From cd607716f8d0c573e2d3b8e04a79f171c5a7d4a1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 10:14:38 -0300 Subject: [PATCH 001/103] fix(super-editor): persist data URI images set via setPresetContent Register data URI image sources as DOCX media parts during export so the relationship target resolves correctly. Map the svg+xml MIME subtype to a .svg extension when deriving the media filename. --- .../super-converter/helpers/mediaHelpers.js | 2 +- .../wp/helpers/decode-image-node-helpers.js | 30 ++++++++++++++++++- .../helpers/decode-image-node-helpers.test.js | 21 +++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index e01fb502c0..2f698c6933 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -10,7 +10,7 @@ export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => const [prefix] = src.split(';'); const [, maybeType] = prefix.split('/'); - const extension = maybeType?.toLowerCase(); + const extension = maybeType?.toLowerCase() === 'svg+xml' ? 'svg' : maybeType?.toLowerCase(); return extension ? `${fallback}.${extension}` : fallback; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index f6de2ece9e..b22959504a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -10,6 +10,32 @@ const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/d const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'; +function getImageExtensionFromDataUri(src) { + if (typeof src !== 'string' || !src.startsWith('data:')) return null; + + const prefix = src.slice(5, src.indexOf(',') === -1 ? undefined : src.indexOf(',')); + const mime = prefix.split(';')[0]; + const [, subtype] = mime.split('/'); + if (!subtype) return null; + + return subtype.toLowerCase() === 'svg+xml' ? 'svg' : subtype.toLowerCase(); +} + +function createMediaTargetForDataUri(params, attrs, src, imageName) { + const extension = getImageExtensionFromDataUri(src); + if (!extension) return null; + + const preferredBaseName = attrs.alt || imageName || 'image'; + const fileBaseName = sanitizeDocxMediaName(preferredBaseName, 'image'); + const fileName = `${fileBaseName}_${generateDocxRandomId(8)}.${extension}`; + const relationshipTarget = `media/${fileName}`; + + if (!params.media) params.media = {}; + params.media[`word/${relationshipTarget}`] = src; + + return relationshipTarget; +} + /** * Resolve the hyperlink relationship rId for an image, if applicable. * Called once so that both wp:docPr and pic:cNvPr share the same rId. @@ -231,7 +257,9 @@ export const translateImageNode = (params) => { addImageRelationshipForId(params, imageId, path); } } else if (params.node.type === 'image' && !imageId) { - const path = src?.split('word/')[1]; + const path = src?.startsWith('data:') + ? createMediaTargetForDataUri(params, attrs, src, imageName) + : src?.split('word/')[1]; imageId = addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 88c6a6f7e3..4301959eb0 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -112,6 +112,27 @@ describe('translateImageNode', () => { expect(result.elements).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'a:graphic' })])); }); + it('should register data URI image media when rId is missing', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node.attrs = { + src, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }; + + const result = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(baseParams.relationships[0].attributes.Target).toBe('media/Signature_Example_123.svg'); + expect(baseParams.media['word/media/Signature_Example_123.svg']).toBe(src); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); + }); + it('should use clamped fallback size (1 EMU) when attrs.size is empty', () => { baseParams.node.attrs.size = {}; From 94ce66d74909cf5584de8a8e20053063c7ff0525 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 10:45:38 -0300 Subject: [PATCH 002/103] fix(super-editor): register sized SVG data URI images without canvas processing Detect SVG data URIs with known finite sizes and register them in place during browser-path image handling, skipping the canvas-based resize pipeline that strips vector content. Normalize svg+xml extensions to .svg when generating media filenames so the relationship target matches the stored media key. --- .../image/imageHelpers/handleBase64.js | 2 +- .../image/imageHelpers/handleBase64.test.js | 15 +++++ .../imageRegistrationPlugin.browser.test.js | 27 ++++++++- .../imageHelpers/imageRegistrationPlugin.js | 55 ++++++++++++------ .../structured-content-commands.test.js | 58 +++++++++++++++++++ 5 files changed, 138 insertions(+), 19 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index 022001ab9f..1a057203a0 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -47,7 +47,7 @@ const extractBase64Meta = (base64String) => { const mimeType = rawMimeType || DEFAULT_MIME_TYPE; const binaryString = decodeBase64ToBinaryString(payload); const hash = simpleHash(binaryString); - const extension = mimeType.split('/')[1] || 'bin'; + const extension = mimeType === 'image/svg+xml' ? 'svg' : mimeType.split('/')[1] || 'bin'; const filename = `image-${hash}.${extension}`; return { mimeType, binaryString, filename }; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index ab6cbf43df..c5dd64b42d 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -59,6 +59,21 @@ describe('handleBase64', () => { expect(filename).toBe(file.name); }); + it('normalizes svg+xml data URI filenames to .svg', () => { + vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); + + const payload = ''; + const base64 = base64ForPayload(payload, 'image/svg+xml'); + + const { filename, mimeType } = getBase64FileMeta(base64); + const file = base64ToFile(base64); + + expect(mimeType).toBe('image/svg+xml'); + expect(filename).toBe(file.name); + expect(file.name).toMatch(/^image-\d+\.svg$/); + expect(file.type).toBe('image/svg+xml'); + }); + it('defaults metadata when mime data is missing', () => { vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index b5ff363ee4..d30710c957 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -63,8 +63,9 @@ vi.mock('./fileNameUtils.js', () => ({ // ── Imports (after mocks) ───────────────────────────────────────────── import { Decoration } from 'prosemirror-view'; import { handleBrowserPath } from './imageRegistrationPlugin.js'; +import { getBase64FileMeta } from './handleBase64'; import { urlToFile, validateUrlAccessibility } from './handleUrl'; -import { addImageRelationship } from './startImageUpload'; +import { addImageRelationship, checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; // ── Helpers ─────────────────────────────────────────────────────────── const createImageNode = (attrs) => ({ @@ -164,6 +165,30 @@ describe('handleBrowserPath', () => { const [secondPos] = tr.delete.mock.calls[1]; expect(firstPos).toBeGreaterThan(secondPos); }); + + it('registers sized SVG data URI images in place without canvas processing', () => { + const svgDataUri = 'data:image/svg+xml;base64,PHN2Zy8+'; + const id = {}; + const imageNode = createImageNode({ + src: svgDataUri, + size: { width: 200, height: 50 }, + }); + getBase64FileMeta.mockReturnValueOnce({ filename: 'image-123.svg', mimeType: 'image/svg+xml' }); + + handleBrowserPath([{ node: imageNode, pos: 20, id }], editor, view, state); + + expect(checkAndProcessImage).not.toHaveBeenCalled(); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + expect(addImageRelationship).toHaveBeenCalledWith({ + editor, + path: expect.stringMatching(/^media\/image-\d+\.svg$/), + }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith(20, undefined, { + ...imageNode.attrs, + src: expect.stringMatching(/^word\/media\/image-\d+\.svg$/), + rId: 'rId99', + }); + }); }); describe('registerRelativeImages (via handleBrowserPath)', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index 5db1c2fdca..52804739ce 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -193,6 +193,11 @@ const parseSizeFromImageUrl = (src) => { const hasFinitePositiveSize = (size) => Number.isFinite(size?.width) && size.width > 0 && Number.isFinite(size?.height) && size.height > 0; +const isSvgFile = (file) => file?.type === 'image/svg+xml'; + +const shouldRegisterInPlace = (node) => + node.attrs?.src?.startsWith('data:image/svg+xml') && hasFinitePositiveSize(node.attrs?.size); + const getOrInitMediaStore = (editor) => { if (!editor?.storage?.image?.media) { editor.storage.image.media = {}; @@ -214,6 +219,11 @@ const getOrInitMediaStore = (editor) => { */ export const handleNodePath = (foundImages, editor, state) => { const { tr } = state; + registerImagesInTransaction(foundImages, editor, tr); + return tr; +}; + +const registerImagesInTransaction = (foundImages, editor, tr) => { const { mediaStore, existingFileNames } = getOrInitMediaStore(editor); foundImages.forEach(({ node, pos }) => { @@ -244,8 +254,6 @@ export const handleNodePath = (foundImages, editor, state) => { rId, }); }); - - return tr; }; /** @@ -264,19 +272,28 @@ export const handleBrowserPath = (foundImages, editor, view, state) => { // Relative paths are resolved by the browser natively for display. // Register them in the background for export without removing from the document. const relativeImages = foundImages.filter(({ node }) => isRelativeUrl(node.attrs?.src)); - const imagesToProcess = foundImages.filter(({ node }) => !isRelativeUrl(node.attrs?.src)); + const inPlaceImages = foundImages.filter( + ({ node }) => !isRelativeUrl(node.attrs?.src) && shouldRegisterInPlace(node), + ); + const imagesToProcess = foundImages.filter( + ({ node }) => !isRelativeUrl(node.attrs?.src) && !shouldRegisterInPlace(node), + ); if (relativeImages.length > 0) { registerRelativeImages(relativeImages, editor, view); } - if (imagesToProcess.length === 0) return null; + const tr = state.tr; + if (inPlaceImages.length > 0) { + registerImagesInTransaction(inPlaceImages, editor, tr); + } + + if (imagesToProcess.length === 0) return tr.docChanged ? tr : null; // Register the images. (async process). registerImages(imagesToProcess, 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. @@ -487,20 +504,24 @@ const registerImages = async (foundImages, editor, view) => { } try { - const process = await checkAndProcessImage({ - getMaxContentSize: () => editor.getMaxContentSize(), - file, - }); + if (isSvgFile(file) && hasFinitePositiveSize(image.node.attrs?.size)) { + await uploadAndInsertImage({ editor, view, file, size: image.node.attrs.size, id }); + } else { + const process = await checkAndProcessImage({ + getMaxContentSize: () => editor.getMaxContentSize(), + file, + }); + + if (!process.file) { + // Processing failed, remove placeholder + const tr = view.state.tr; + removeImagePlaceholder(view.state, tr, id); + view.dispatch(tr); + return; + } - 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 }); } - - 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 diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.test.js index a582ab4feb..ea0caf5275 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.test.js @@ -40,6 +40,18 @@ function findFirstTextNode(node) { return found; } +function findFirstNodeByType(node, typeName) { + let found = null; + node.descendants((child) => { + if (child.type.name === typeName) { + found = child; + return false; + } + return true; + }); + return found; +} + describe('StructuredContentTableCommands', () => { let editor; let schema; @@ -890,6 +902,52 @@ describe('StructuredContent ID Validation', () => { }); describe('insertStructuredContentBlock', () => { + it('preserves preset image content when inserting an sdtLocked block', () => { + const signatureSrc = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; + + const didInsert = editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215856', + tag: '{"fieldType":"signer"}', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: signatureSrc, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }); + + expect(didInsert).toBe(true); + + const insertedBlock = findFirstNodeByType(editor.state.doc, 'structuredContentBlock'); + expect(insertedBlock).not.toBeNull(); + expect(insertedBlock.attrs).toMatchObject({ + id: '1299215856', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }); + + const insertedImage = findFirstNodeByType(insertedBlock, 'image'); + expect(insertedImage).not.toBeNull(); + expect(insertedImage.attrs).toMatchObject({ + src: expect.stringMatching(/^word\/media\/image-\d+\.svg$/), + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }); + expect(editor.storage.image.media[insertedImage.attrs.src]).toBe(signatureSrc); + }); + it('accepts valid integer string IDs', () => { expect(() => { editor.commands.insertStructuredContentBlock({ From c4d3b8e031bf52b0d14a5772bf9cbdb0f8c93f04 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 10:49:20 -0300 Subject: [PATCH 003/103] fix(pm-adapter): scope inline SDT placeholder to structuredContent metadata Only emit the empty-inline-SDT placeholder when the resolved metadata describes a structuredContent node, so other inline SDT variants aren't collapsed into a placeholder text run when their content is empty. --- .../src/converters/inline-converters/structured-content.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts index 87ed9ec1e1..664a38d70a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts @@ -19,7 +19,11 @@ export function structuredContentNodeToBlocks({ const inlineMetadata = resolveNodeSdtMetadata(node, 'structuredContent'); const nextSdt = inlineMetadata ?? sdtMetadata; - if (inlineMetadata?.scope === 'inline' && (!node.content || node.content.length === 0)) { + if ( + inlineMetadata?.type === 'structuredContent' && + inlineMetadata.scope === 'inline' && + (!node.content || node.content.length === 0) + ) { const pos = positions.get(node); const contentPos = pos ? pos.start + 1 : undefined; const placeholder: TextRun = { From c6e5483d7323ded8b77ca1f69550e9a27de6a84d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 11:39:38 -0300 Subject: [PATCH 004/103] fix(super-editor): support non-base64 data URI images in registration Parse data URIs by inspecting the meta header rather than assuming base64 encoding. URL-encoded payloads (e.g. SVG with charset=utf-8) are now decoded as text and written through as-is, while base64 payloads continue through atob/binary conversion. Adds coverage for the non-base64 SVG path in handleBase64 and the browser registration plugin. --- .../image/imageHelpers/handleBase64.js | 61 +++++++++++++------ .../image/imageHelpers/handleBase64.test.js | 14 +++++ .../imageRegistrationPlugin.browser.test.js | 34 +++++++++-- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index 1a057203a0..63289d890d 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -35,38 +35,63 @@ const decodeBase64ToBinaryString = (data) => { throw new Error('Unable to decode base64 payload in the current environment.'); }; +const decodeDataUriText = (data) => { + try { + return decodeURIComponent(data); + } catch { + return data; + } +}; + +const binaryStringToBytes = (binaryString) => { + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; + +const splitDataUri = (dataUri) => { + const separatorIndex = dataUri.indexOf(','); + if (separatorIndex === -1) { + return { meta: dataUri, payload: '' }; + } + + return { + meta: dataUri.slice(0, separatorIndex), + payload: dataUri.slice(separatorIndex + 1), + }; +}; + /** - * Extract metadata from a base64-encoded string. - * @param {string} base64String - The base64-encoded string. + * Extract metadata from a data URI string. + * @param {string} dataUri - The data URI string. * @returns {Object} An object containing mimeType, binaryString, and filename. */ -const extractBase64Meta = (base64String) => { - const [meta = '', payload = ''] = base64String.split(','); - const mimeMatch = meta.match(/:(.*?);/); - const rawMimeType = mimeMatch ? mimeMatch[1] : ''; +const extractBase64Meta = (dataUri) => { + const { meta = '', payload = '' } = splitDataUri(dataUri); + const metaParts = meta.startsWith('data:') ? meta.slice(5).split(';') : []; + const rawMimeType = metaParts[0] || ''; const mimeType = rawMimeType || DEFAULT_MIME_TYPE; - const binaryString = decodeBase64ToBinaryString(payload); + const isBase64 = metaParts.some((part) => part.toLowerCase() === 'base64'); + const binaryString = isBase64 ? decodeBase64ToBinaryString(payload) : decodeDataUriText(payload); const hash = simpleHash(binaryString); const extension = mimeType === 'image/svg+xml' ? 'svg' : mimeType.split('/')[1] || 'bin'; const filename = `image-${hash}.${extension}`; - return { mimeType, binaryString, filename }; + return { mimeType, binaryString, filename, isBase64 }; }; -export const getBase64FileMeta = (base64String) => { - const { mimeType, filename } = extractBase64Meta(base64String); +export const getBase64FileMeta = (dataUri) => { + const { mimeType, filename } = extractBase64Meta(dataUri); return { mimeType, filename }; }; -export const base64ToFile = (base64String) => { - const { mimeType, binaryString, filename } = extractBase64Meta(base64String); +export const base64ToFile = (dataUri) => { + const { mimeType, binaryString, filename, isBase64 } = extractBase64Meta(dataUri); const fileType = mimeType || DEFAULT_MIME_TYPE; - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - const blob = new Blob([bytes], { type: fileType }); + const data = isBase64 ? binaryStringToBytes(binaryString) : binaryString; + const blob = new Blob([data], { type: fileType }); return new File([blob], filename, { type: fileType }); }; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index c5dd64b42d..b294cfe7a2 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -74,6 +74,20 @@ describe('handleBase64', () => { expect(file.type).toBe('image/svg+xml'); }); + it('handles non-base64 svg data URI filenames', async () => { + const payload = ''; + const dataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(payload)}`; + + const { filename, mimeType } = getBase64FileMeta(dataUri); + const file = base64ToFile(dataUri); + + expect(mimeType).toBe('image/svg+xml'); + expect(filename).toBe(file.name); + expect(file.name).toMatch(/^image-\d+\.svg$/); + expect(file.type).toBe('image/svg+xml'); + await expect(file.text()).resolves.toBe(payload); + }); + it('defaults metadata when mime data is missing', () => { vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index d30710c957..f947888e07 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -39,10 +39,14 @@ vi.mock('prosemirror-transform', () => ({ })); // ── Image helper mocks ─────────────────────────────────────────────── -vi.mock('./handleBase64', () => ({ - base64ToFile: vi.fn(() => null), - getBase64FileMeta: vi.fn(() => ({ filename: 'image.png' })), -})); +vi.mock('./handleBase64', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + base64ToFile: vi.fn(() => null), + getBase64FileMeta: vi.fn(actual.getBase64FileMeta), + }; +}); vi.mock('./handleUrl', () => ({ urlToFile: vi.fn(() => Promise.resolve(null)), @@ -189,6 +193,28 @@ describe('handleBrowserPath', () => { rId: 'rId99', }); }); + + it('registers sized non-base64 SVG data URI images in place', () => { + const svgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`; + const imageNode = createImageNode({ + src: svgDataUri, + size: { width: 200, height: 50 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + expect(checkAndProcessImage).not.toHaveBeenCalled(); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + expect(addImageRelationship).toHaveBeenCalledWith({ + editor, + path: expect.stringMatching(/^media\/image-\d+\.svg$/), + }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith(20, undefined, { + ...imageNode.attrs, + src: expect.stringMatching(/^word\/media\/image-\d+\.svg$/), + rId: 'rId99', + }); + }); }); describe('registerRelativeImages (via handleBrowserPath)', () => { From a9d63cd3b486757868bcb6464bf0e040bbefc309 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 11:44:57 -0300 Subject: [PATCH 005/103] fix(super-editor): reuse data URI image exports --- .../wp/helpers/decode-image-node-helpers.js | 40 +++++-- .../helpers/decode-image-node-helpers.test.js | 50 +++++++- .../image/imageHelpers/handleBase64.js | 3 +- .../image/imageHelpers/handleBase64.test.js | 15 +++ ...structured-content-image-roundtrip.test.js | 110 ++++++++++++++++++ 5 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index b22959504a..81a6043998 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -10,6 +10,16 @@ const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/d const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'; +function 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; + } + return Math.abs(hash).toString(); +} + function getImageExtensionFromDataUri(src) { if (typeof src !== 'string' || !src.startsWith('data:')) return null; @@ -21,17 +31,26 @@ function getImageExtensionFromDataUri(src) { return subtype.toLowerCase() === 'svg+xml' ? 'svg' : subtype.toLowerCase(); } -function createMediaTargetForDataUri(params, attrs, src, imageName) { +function createMediaTargetForDataUri(params, src) { const extension = getImageExtensionFromDataUri(src); if (!extension) return null; - const preferredBaseName = attrs.alt || imageName || 'image'; - const fileBaseName = sanitizeDocxMediaName(preferredBaseName, 'image'); - const fileName = `${fileBaseName}_${generateDocxRandomId(8)}.${extension}`; + if (!params.media) params.media = {}; + const existingEntry = Object.entries(params.media).find(([, value]) => value === src); + if (existingEntry?.[0]?.startsWith('word/')) { + return existingEntry[0].slice(5); + } + + const fileBaseName = sanitizeDocxMediaName(`image-${simpleHash(src)}`, 'image'); + let fileName = `${fileBaseName}.${extension}`; + let packagePath = `word/media/${fileName}`; + if (params.media[packagePath] && params.media[packagePath] !== src) { + fileName = `${fileBaseName}_${generateDocxRandomId(8)}.${extension}`; + packagePath = `word/media/${fileName}`; + } const relationshipTarget = `media/${fileName}`; - if (!params.media) params.media = {}; - params.media[`word/${relationshipTarget}`] = src; + params.media[packagePath] = src; return relationshipTarget; } @@ -244,7 +263,7 @@ export const translateImageNode = (params) => { } if (imageId) { - const path = src?.split('word/')[1]; + const path = src?.startsWith('data:') ? createMediaTargetForDataUri(params, src) : src?.split('word/')[1]; const relationships = params.isHeaderFooter ? params.existingRelationships : getDocumentRelationships(params); const existingRelation = findImageRelationship(relationships, { id: imageId, @@ -257,10 +276,9 @@ export const translateImageNode = (params) => { addImageRelationshipForId(params, imageId, path); } } else if (params.node.type === 'image' && !imageId) { - const path = src?.startsWith('data:') - ? createMediaTargetForDataUri(params, attrs, src, imageName) - : src?.split('word/')[1]; - imageId = addNewImageRelationship(params, path); + const path = src?.startsWith('data:') ? createMediaTargetForDataUri(params, src) : src?.split('word/')[1]; + const existingRelation = findImageRelationship(params.relationships, { target: path }); + imageId = existingRelation?.attributes?.Id ?? addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. const type = src?.split(';')[0].split('/')[1]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 4301959eb0..723bb933d5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -123,8 +123,9 @@ describe('translateImageNode', () => { const result = translateImageNode(baseParams); expect(baseParams.relationships).toHaveLength(1); - expect(baseParams.relationships[0].attributes.Target).toBe('media/Signature_Example_123.svg'); - expect(baseParams.media['word/media/Signature_Example_123.svg']).toBe(src); + const target = baseParams.relationships[0].attributes.Target; + expect(target).toMatch(/^media\/image-\d+\.svg$/); + expect(baseParams.media[`word/${target}`]).toBe(src); const blip = result.elements .find((e) => e.name === 'a:graphic') @@ -133,6 +134,51 @@ describe('translateImageNode', () => { expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); }); + it('should reuse data URI image media and relationship for duplicate payloads', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node.attrs = { + src, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }; + + const firstResult = translateImageNode(baseParams); + const secondResult = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(Object.keys(baseParams.media)).toEqual([`word/${baseParams.relationships[0].attributes.Target}`]); + + const firstBlip = firstResult.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + const secondBlip = secondResult.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(secondBlip.attributes['r:embed']).toBe(firstBlip.attributes['r:embed']); + }); + + it('should create a media target when a data URI image already has an rId', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node.attrs = { + src, + rId: 'rIdExisting', + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }; + + translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(baseParams.relationships[0].attributes).toMatchObject({ + Id: 'rIdExisting', + Target: expect.stringMatching(/^media\/.+\.svg$/), + }); + expect(baseParams.relationships[0].attributes.Target).not.toBeUndefined(); + expect(baseParams.media[`word/${baseParams.relationships[0].attributes.Target}`]).toBe(src); + }); + it('should use clamped fallback size (1 EMU) when attrs.size is empty', () => { baseParams.node.attrs.size = {}; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index 63289d890d..64c2fe1a2c 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -76,7 +76,8 @@ const extractBase64Meta = (dataUri) => { const isBase64 = metaParts.some((part) => part.toLowerCase() === 'base64'); const binaryString = isBase64 ? decodeBase64ToBinaryString(payload) : decodeDataUriText(payload); const hash = simpleHash(binaryString); - const extension = mimeType === 'image/svg+xml' ? 'svg' : mimeType.split('/')[1] || 'bin'; + const normalizedMimeType = mimeType.toLowerCase(); + const extension = normalizedMimeType === 'image/svg+xml' ? 'svg' : normalizedMimeType.split('/')[1] || 'bin'; const filename = `image-${hash}.${extension}`; return { mimeType, binaryString, filename, isBase64 }; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index b294cfe7a2..98111e2525 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -74,6 +74,21 @@ describe('handleBase64', () => { expect(file.type).toBe('image/svg+xml'); }); + it('normalizes svg+xml MIME casing for filenames', () => { + vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); + + const payload = ''; + const base64 = base64ForPayload(payload, 'Image/SVG+XML'); + + const { filename, mimeType } = getBase64FileMeta(base64); + const file = base64ToFile(base64); + + expect(mimeType).toBe('Image/SVG+XML'); + expect(filename).toBe(file.name); + expect(file.name).toMatch(/^image-\d+\.svg$/); + expect(file.type).toBe('image/svg+xml'); + }); + it('handles non-base64 svg data URI filenames', async () => { const payload = ''; const dataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(payload)}`; diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js new file mode 100644 index 0000000000..5963c5b22b --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js @@ -0,0 +1,110 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Editor } from '@core/Editor.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const SIGNATURE_SRC = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; + +const findFirstNodeByType = (node, typeName) => { + let found = null; + node.descendants((child) => { + if (child.type.name === typeName) { + found = child; + return false; + } + return true; + }); + return found; +}; + +const collectElementsByName = (node, name, result = []) => { + if (!node || typeof node !== 'object') return result; + if (node.name === name) result.push(node); + (node.elements || []).forEach((child) => collectElementsByName(child, name, result)); + return result; +}; + +const getChildElement = (node, name) => node?.elements?.find((child) => child.name === name); + +const hasDescendantNamed = (node, name) => collectElementsByName(node, name).length > 0; + +describe('SD-3116 structured content image round-trip', () => { + let editor; + let reopened; + + afterEach(() => { + editor?.destroy(); + reopened?.destroy(); + editor = null; + reopened = null; + }); + + it('exports and reopens a block SDT containing preset image content', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const didInsert = editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215856', + tag: '{"fieldType":"signer"}', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: SIGNATURE_SRC, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }); + + expect(didInsert).toBe(true); + + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true, isFinalDoc: false }); + const documentXml = parseXmlToJson(updatedDocs['word/document.xml']); + const sdt = collectElementsByName(documentXml, 'w:sdt').find((candidate) => { + const sdtPr = getChildElement(candidate, 'w:sdtPr'); + return sdtPr?.elements?.some((el) => el.name === 'w:id' && el.attributes?.['w:val'] === '1299215856'); + }); + + expect(sdt).toBeDefined(); + const sdtContent = getChildElement(sdt, 'w:sdtContent'); + expect(sdtContent).toBeDefined(); + expect(hasDescendantNamed(sdtContent, 'a:blip')).toBe(true); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const [roundTripDocx, roundTripMedia, roundTripMediaFiles, roundTripFonts] = await Editor.loadXmlData( + exported, + true, + ); + ({ editor: reopened } = initTestEditor({ + content: roundTripDocx, + media: roundTripMedia, + mediaFiles: roundTripMediaFiles, + fonts: roundTripFonts, + isNewFile: false, + })); + + const reopenedBlock = findFirstNodeByType(reopened.state.doc, 'structuredContentBlock'); + expect(reopenedBlock?.attrs).toMatchObject({ + id: '1299215856', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }); + + const reopenedImage = findFirstNodeByType(reopenedBlock, 'image'); + expect(reopenedImage?.attrs).toMatchObject({ + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }); + expect(reopenedImage?.attrs.src).toMatch(/^word\/media\/.+\.svg$/); + }); +}); From c17124b7d3c38bcb7defb2a324982cf9c505153d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 11:55:37 -0300 Subject: [PATCH 006/103] fix(super-editor): extract shared hash helpers --- .../v2/importer/documentCommentsImporter.js | 18 ++---------------- .../wp/helpers/decode-image-node-helpers.js | 13 ++----------- .../src/editors/v1/core/utilities/hash.js | 18 ++++++++++++++++++ .../src/editors/v1/core/utilities/index.js | 1 + .../v1/core/utilities/tests/utilities.test.js | 12 ++++++++++++ .../image/imageHelpers/handleBase64.js | 19 +++---------------- 6 files changed, 38 insertions(+), 43 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/utilities/hash.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js index 35827ba02d..6057f1f5b3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { defaultNodeListHandler } from './docxImporter'; +import { stableHexHash } from '@core/utilities/hash.js'; /** * Parse comments.xml into SuperDoc-ready comments @@ -601,21 +602,6 @@ const applyParentRelationships = (comments, parentMap, trackedChangeParentMap = }); }; -/** - * Lightweight, non-cryptographic FNV-1a 32-bit hash for stable identifiers. - * - * @param {string} input - * @returns {string} 8-char hex string - */ -const simpleHash = (input) => { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); - } - return (hash >>> 0).toString(16).padStart(8, '0'); -}; - /** * Resolve a stable comment ID for imported comments. * - Prefer the explicit internal ID when present. @@ -625,6 +611,6 @@ const simpleHash = (input) => { const getCommentId = (internalId, importedId, createdTime) => { if (internalId != null) return internalId; if (importedId == null || !Number.isFinite(createdTime)) return uuidv4(); - const hash = simpleHash(`${importedId}-${createdTime}`); + const hash = stableHexHash(`${importedId}-${createdTime}`); return `imported-${hash}`; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 81a6043998..8f3da054f5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -4,22 +4,13 @@ import { prepareTextAnnotation } from '@converter/v3/handlers/w/sdt/helpers/tran import { wrapTextInRun } from '@converter/exporter.js'; import { generateDocxRandomId } from '@core/helpers/index.js'; import { readImageDimensionsFromDataUri } from '@converter/image-dimensions.js'; +import { simpleStringHash } from '@core/utilities/hash.js'; const DECORATIVE_EXT_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}'; const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/decorative'; const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'; -function 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; - } - return Math.abs(hash).toString(); -} - function getImageExtensionFromDataUri(src) { if (typeof src !== 'string' || !src.startsWith('data:')) return null; @@ -41,7 +32,7 @@ function createMediaTargetForDataUri(params, src) { return existingEntry[0].slice(5); } - const fileBaseName = sanitizeDocxMediaName(`image-${simpleHash(src)}`, 'image'); + const fileBaseName = sanitizeDocxMediaName(`image-${simpleStringHash(src)}`, 'image'); let fileName = `${fileBaseName}.${extension}`; let packagePath = `word/media/${fileName}`; if (params.media[packagePath] && params.media[packagePath] !== src) { diff --git a/packages/super-editor/src/editors/v1/core/utilities/hash.js b/packages/super-editor/src/editors/v1/core/utilities/hash.js new file mode 100644 index 0000000000..0cc26e74e1 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/utilities/hash.js @@ -0,0 +1,18 @@ +export const simpleStringHash = (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; + } + return Math.abs(hash).toString(); +}; + +export const stableHexHash = (input) => { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +}; diff --git a/packages/super-editor/src/editors/v1/core/utilities/index.js b/packages/super-editor/src/editors/v1/core/utilities/index.js index ae4f05fcbe..a2cd3c1ed8 100644 --- a/packages/super-editor/src/editors/v1/core/utilities/index.js +++ b/packages/super-editor/src/editors/v1/core/utilities/index.js @@ -9,3 +9,4 @@ export * from './parseSizeUnit.js'; export * from './minMax.js'; export * from './clipboardUtils.js'; export * from './cssColorToHex.js'; +export * from './hash.js'; diff --git a/packages/super-editor/src/editors/v1/core/utilities/tests/utilities.test.js b/packages/super-editor/src/editors/v1/core/utilities/tests/utilities.test.js index 8cad9bdc07..ff4a19f544 100644 --- a/packages/super-editor/src/editors/v1/core/utilities/tests/utilities.test.js +++ b/packages/super-editor/src/editors/v1/core/utilities/tests/utilities.test.js @@ -5,6 +5,7 @@ import { carbonCopy } from '../carbonCopy.js'; import { createStyleTag } from '../createStyleTag.js'; import { deleteProps } from '../deleteProps.js'; import { getMediaObjectUrls } from '../imageBlobs.js'; +import { simpleStringHash, stableHexHash } from '../hash.js'; import { isEmptyObject } from '../isEmptyObject.js'; import { isIOS } from '../isIOS.js'; import { isMacOS } from '../isMacOS.js'; @@ -106,6 +107,17 @@ describe('core utilities', () => { }); }); + describe('hash utilities', () => { + it('preserves the simple string hash used by image filenames', () => { + expect(simpleStringHash('fake-image-payload')).toBe('1287114076'); + expect(simpleStringHash('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=')).toBe('1253334850'); + }); + + it('preserves the stable hex hash used by imported comment IDs', () => { + expect(stableHexHash('1-1707568200000')).toBe('58b122b1'); + }); + }); + describe('createStyleTag', () => { it('creates a new style tag when absent', () => { const style = createStyleTag('.foo { color: red; }'); diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index 64c2fe1a2c..c8f1aedcf2 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -1,20 +1,7 @@ // @ts-check -const DEFAULT_MIME_TYPE = 'application/octet-stream'; +import { simpleStringHash } from '@core/utilities/hash.js'; -/** - * Generates a simple hash from a string. - * @param {string} str - The input string. - * @returns {string} The generated hash. - */ -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(); -}; +const DEFAULT_MIME_TYPE = 'application/octet-stream'; /** * Decodes a base64-encoded string into a binary string. @@ -75,7 +62,7 @@ const extractBase64Meta = (dataUri) => { const mimeType = rawMimeType || DEFAULT_MIME_TYPE; const isBase64 = metaParts.some((part) => part.toLowerCase() === 'base64'); const binaryString = isBase64 ? decodeBase64ToBinaryString(payload) : decodeDataUriText(payload); - const hash = simpleHash(binaryString); + const hash = simpleStringHash(binaryString); const normalizedMimeType = mimeType.toLowerCase(); const extension = normalizedMimeType === 'image/svg+xml' ? 'svg' : normalizedMimeType.split('/')[1] || 'bin'; const filename = `image-${hash}.${extension}`; From f962681b6bd2bb51df6204b5d52879512f2b33f9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 12:03:08 -0300 Subject: [PATCH 007/103] fix(super-editor): decode non-base64 data URI exports --- .../v1/core/super-converter/helpers.js | 38 ++++++++++++++++-- .../v1/core/super-converter/helpers.test.js | 15 +++++++ ...structured-content-image-roundtrip.test.js | 39 +++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js index 059ccf8e53..adf9ec163a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js @@ -33,6 +33,20 @@ function base64ToUint8Array(base64) { return bytes; } +function stringToUtf8ArrayBuffer(value) { + const encoded = encodeURIComponent(value); + const bytes = []; + for (let i = 0; i < encoded.length; i++) { + if (encoded[i] === '%') { + bytes.push(parseInt(encoded.slice(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(encoded.charCodeAt(i)); + } + } + return new Uint8Array(bytes).buffer; +} + /** * Convert a base64 string or data URI to an ArrayBuffer. * Accepts ArrayBuffer, TypedArray, data URI, or raw base64 string. @@ -52,8 +66,23 @@ function dataUriToArrayBuffer(data) { if (data.startsWith('data:')) { const commaIndex = data.indexOf(','); if (commaIndex === -1) { - throw new Error('Invalid data URI: missing base64 content'); + throw new Error('Invalid data URI: missing content'); } + const meta = data.slice(0, commaIndex); + const payload = data.substring(commaIndex + 1); + const isBase64 = meta + .slice(5) + .split(';') + .some((part) => part.toLowerCase() === 'base64'); + + if (!isBase64) { + try { + return stringToUtf8ArrayBuffer(decodeURIComponent(payload)); + } catch { + return stringToUtf8ArrayBuffer(payload); + } + } + base64 = data.substring(commaIndex + 1); } @@ -351,10 +380,11 @@ const getArrayBufferFromUrl = async (input) => { return await response.arrayBuffer(); } - // If this is a data URI we need only the payload portion - const base64Payload = isDataUri ? trimmed.split(',', 2)[1] : trimmed.replace(/\s/g, ''); + if (isDataUri) { + return dataUriToArrayBuffer(trimmed); + } - return base64ToUint8Array(base64Payload).buffer; + return base64ToUint8Array(trimmed.replace(/\s/g, '')).buffer; }; const getContentTypesFromXml = (contentTypesXml) => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js index 60ac1a898c..fa66229f99 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js @@ -335,6 +335,15 @@ describe('getArrayBufferFromUrl', () => { expect(Array.from(new Uint8Array(result))).toEqual(Array.from(bytes)); }); + it('decodes non-base64 data URIs into an ArrayBuffer', async () => { + const svg = ''; + const dataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + + const result = await getArrayBufferFromUrl(dataUri); + + expect(new TextDecoder().decode(result)).toBe(svg); + }); + it('decodes bare base64 strings into an ArrayBuffer', async () => { const bytes = new Uint8Array([55, 66, 77]); const base64 = Buffer.from(bytes).toString('base64'); @@ -407,6 +416,12 @@ describe('dataUriToArrayBuffer', () => { expect(Array.from(new Uint8Array(result))).toEqual([11, 22, 33]); }); + it('decodes a non-base64 data URI string', () => { + const svg = ''; + const result = dataUriToArrayBuffer(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`); + expect(new TextDecoder().decode(result)).toBe(svg); + }); + it('decodes a raw base64 string', () => { const bytes = new Uint8Array([55, 66, 77]); const base64 = Buffer.from(bytes).toString('base64'); diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js index 5963c5b22b..ebaca2d767 100644 --- a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js @@ -4,6 +4,8 @@ import { parseXmlToJson } from '@converter/v2/docxHelper.js'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; const SIGNATURE_SRC = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; +const ENCODED_SIGNATURE_SVG = ''; +const ENCODED_SIGNATURE_SRC = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(ENCODED_SIGNATURE_SVG)}`; const findFirstNodeByType = (node, typeName) => { let found = null; @@ -107,4 +109,41 @@ describe('SD-3116 structured content image round-trip', () => { }); expect(reopenedImage?.attrs.src).toMatch(/^word\/media\/.+\.svg$/); }); + + it('exports non-base64 SVG preset image content as decoded media bytes', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const didInsert = editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215857', + tag: '{"fieldType":"signer"}', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: ENCODED_SIGNATURE_SRC, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }); + + expect(didInsert).toBe(true); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const [, , exportedMediaFiles] = await Editor.loadXmlData(exported, true); + const svgMediaEntry = Object.entries(exportedMediaFiles).find(([path]) => path.endsWith('.svg')); + + expect(svgMediaEntry).toBeDefined(); + expect(Buffer.from(svgMediaEntry[1], 'base64').toString('utf8')).toBe(ENCODED_SIGNATURE_SVG); + }); }); From a49193b30f9d26f35dd49a0935ef62ffd42260c5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 12:07:44 -0300 Subject: [PATCH 008/103] fix(super-editor): mirror in-place image media to parent --- .../imageRegistrationPlugin.browser.test.js | 18 ++++++++++++++++++ .../imageHelpers/imageRegistrationPlugin.js | 18 ++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index f947888e07..a6314664bd 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -215,6 +215,24 @@ describe('handleBrowserPath', () => { rId: 'rId99', }); }); + + it('mirrors in-place SVG media to the parent editor media store', () => { + const svgDataUri = 'data:image/svg+xml;base64,PHN2Zy8+'; + const parentEditor = { storage: { image: { media: {} } } }; + editor.options.parentEditor = parentEditor; + editor.options.isHeaderOrFooter = true; + const imageNode = createImageNode({ + src: svgDataUri, + size: { width: 200, height: 50 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + const mediaPath = Object.keys(editor.storage.image.media)[0]; + expect(mediaPath).toMatch(/^word\/media\/image-\d+\.svg$/); + expect(editor.storage.image.media[mediaPath]).toBe(svgDataUri); + expect(parentEditor.storage.image.media[mediaPath]).toBe(svgDataUri); + }); }); describe('registerRelativeImages (via handleBrowserPath)', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index 52804739ce..bc76d44f0c 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -204,9 +204,17 @@ const getOrInitMediaStore = (editor) => { } const mediaStore = editor.storage.image.media; - const existingFileNames = new Set(Object.keys(mediaStore).map((k) => k.split('/').pop())); + const parentMediaStore = editor?.options?.parentEditor?.storage?.image?.media; + const mediaStores = [mediaStore]; + if (parentMediaStore && parentMediaStore !== mediaStore) { + mediaStores.push(parentMediaStore); + } + const existingFileNames = new Set(); + mediaStores.forEach((store) => { + Object.keys(store).forEach((k) => existingFileNames.add(k.split('/').pop())); + }); - return { mediaStore, existingFileNames }; + return { mediaStore, mediaStores, existingFileNames }; }; /** @@ -224,7 +232,7 @@ export const handleNodePath = (foundImages, editor, state) => { }; const registerImagesInTransaction = (foundImages, editor, tr) => { - const { mediaStore, existingFileNames } = getOrInitMediaStore(editor); + const { mediaStores, existingFileNames } = getOrInitMediaStore(editor); foundImages.forEach(({ node, pos }) => { const { src } = node.attrs; @@ -233,7 +241,9 @@ const registerImagesInTransaction = (foundImages, editor, tr) => { existingFileNames.add(uniqueFileName); const mediaPath = buildMediaPath(uniqueFileName); - mediaStore[mediaPath] = src; + mediaStores.forEach((store) => { + store[mediaPath] = src; + }); // Sync image data to Y.Doc media map so other collab clients can access it. // We write directly to the Y.Doc map instead of using editor.commands because From cab65c29a9dc9d63f762823805e2a5eec83cdb37 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 12:39:54 -0300 Subject: [PATCH 009/103] fix(layout-engine): allow non-base64 SVG data URLs in image rendering Replace the base64-only data URL regex with an allowlist-based validator that accepts URL-encoded SVG payloads while still restricting raster image MIME types to base64. Applies to both inline image runs and field annotation images, and adds tests for the SVG, raster, and non-image cases. --- .../painters/dom/src/index.test.ts | 86 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 54 ++++++++---- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 8988662ac7..331654690e 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2269,6 +2269,49 @@ describe('DomPainter', () => { expect(annotation?.style.fontSize).toBe('14pt'); }); + it('renders field annotation images with non-base64 SVG data URLs', () => { + const svg = ''; + const imageSrc = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + const block: FlowBlock = { + kind: 'paragraph', + id: 'fa-svg-image', + runs: [ + { + kind: 'fieldAnnotation', + variant: 'signature', + displayLabel: 'Signature', + fieldId: 'F1', + fieldType: 'signer', + fieldColor: '#980043', + imageSrc, + pmStart: 0, + pmEnd: 1, + }, + ], + }; + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + const testLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'fa-svg-image', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 }], + }, + ], + }; + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(testLayout, mount); + + const img = mount.querySelector('.annotation img') as HTMLImageElement | null; + expect(img).toBeTruthy(); + expect(img?.src).toBe(imageSrc); + expect(img?.alt).toBe('Signature'); + }); + it('sets explicit fontSize on math run wrapper', () => { const block: FlowBlock = { kind: 'paragraph', @@ -7529,6 +7572,49 @@ describe('DomPainter', () => { expect(img?.height).toBe(100); }); + it('renders img element with non-base64 SVG data URL', () => { + const svg = + 'Signature'; + const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + + renderInlineImageRun({ + kind: 'image', + src: svgDataUrl, + width: 100, + height: 50, + }); + + const img = mount.querySelector('img'); + expect(img).toBeTruthy(); + expect(img?.src).toBe(svgDataUrl); + expect(img?.width).toBe(100); + expect(img?.height).toBe(50); + }); + + it('rejects non-base64 raster data URLs', () => { + renderInlineImageRun({ + kind: 'image', + src: 'data:image/png,not-base64', + width: 100, + height: 100, + }); + + const img = mount.querySelector('img'); + expect(img).toBeNull(); + }); + + it('rejects non-image data URLs without requiring base64', () => { + renderInlineImageRun({ + kind: 'image', + src: 'data:text/html;charset=utf-8,%3Cscript%3Ealert(1)%3C%2Fscript%3E', + width: 100, + height: 100, + }); + + const img = mount.querySelector('img'); + expect(img).toBeNull(); + }); + it('renders DrawingML luminance using percentage units', () => { const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 35463af5eb..fbc62f12b2 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -930,12 +930,39 @@ const SAFE_ANCHOR_PATTERN = /^[A-Za-z0-9._-]+$/; */ const MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; // 10MB -/** - * Regular expression to validate data URL format for images. - * Only allows common, safe image MIME types with base64 encoding. - * Prevents XSS and malformed data URL attacks. - */ -const VALID_IMAGE_DATA_URL = /^data:image\/(png|jpeg|jpg|gif|svg\+xml|webp|bmp|ico|tiff?);base64,/i; +const VALID_IMAGE_DATA_URL_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/bmp', + 'image/ico', + 'image/tif', + 'image/tiff', +]); + +function isValidImageDataUrl(src: string): boolean { + if (!src.startsWith('data:') || src.length > MAX_DATA_URL_LENGTH) { + return false; + } + + const metadataEnd = src.indexOf(','); + if (metadataEnd === -1) { + return false; + } + + const metadata = src.slice('data:'.length, metadataEnd); + const [rawMimeType = '', ...rawParameters] = metadata.split(';'); + const mimeType = rawMimeType.toLowerCase(); + if (!VALID_IMAGE_DATA_URL_MIME_TYPES.has(mimeType)) { + return false; + } + + const isBase64 = rawParameters.some((parameter) => parameter.toLowerCase() === 'base64'); + return isBase64 || mimeType === 'image/svg+xml'; +} const SVG_NS = 'http://www.w3.org/2000/svg'; const WORDART_LINE_FILL_RATIO = 0.9; @@ -5973,9 +6000,9 @@ export class DomPainter { * Renders an ImageRun as an inline element. * * SECURITY NOTES: - * - Data URLs are validated against VALID_IMAGE_DATA_URL regex to ensure proper format + * - Data URLs are validated against an allowlist of image MIME types * - Size limit (MAX_DATA_URL_LENGTH) prevents DoS attacks from extremely large images - * - Only allows safe image MIME types (png, jpeg, gif, etc.) with base64 encoding + * - Only allows safe image MIME types; non-base64 data URLs are limited to SVG * - Non-data URLs are sanitized through sanitizeUrl to prevent XSS * * METADATA ATTRIBUTE: @@ -6023,13 +6050,8 @@ export class DomPainter { // but are safe for elements when properly validated const isDataUrl = typeof run.src === 'string' && run.src.startsWith('data:'); if (isDataUrl) { - // SECURITY: Validate data URL format and size - if (run.src.length > MAX_DATA_URL_LENGTH) { - // Reject data URLs that are too large (DoS prevention) - return null; - } - if (!VALID_IMAGE_DATA_URL.test(run.src)) { - // Reject data URLs with invalid MIME types or encoding + // SECURITY: Validate data URL MIME type, encoding, and size. + if (!isValidImageDataUrl(run.src)) { return null; } img.src = run.src; @@ -6386,7 +6408,7 @@ export class DomPainter { // SECURITY: Validate data URLs const isDataUrl = run.imageSrc.startsWith('data:'); if (isDataUrl) { - if (run.imageSrc.length <= MAX_DATA_URL_LENGTH && VALID_IMAGE_DATA_URL.test(run.imageSrc)) { + if (isValidImageDataUrl(run.imageSrc)) { img.src = run.imageSrc; } else { // Invalid data URL - fall back to displayLabel From d0adb9d3566643beff3cde8e222dd8eaaef95d97 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 12:47:01 -0300 Subject: [PATCH 010/103] fix(super-editor): export field annotation svgs as svg --- .../wp/helpers/decode-image-node-helpers.js | 4 +-- .../helpers/decode-image-node-helpers.test.js | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 8f3da054f5..1715616151 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -215,7 +215,7 @@ export const translateImageNode = (params) => { // For fieldAnnotations without a recognizable MIME type, fall back to text // annotation before attempting size resolution (they have no image data). if (params.node.type === 'fieldAnnotation' && !imageId) { - const type = src?.split(';')[0].split('/')[1]; + const type = getImageExtensionFromDataUri(src) ?? src?.split(';')[0].split('/')[1]; if (!type) { return prepareTextAnnotation(params); } @@ -272,7 +272,7 @@ export const translateImageNode = (params) => { imageId = existingRelation?.attributes?.Id ?? addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. - const type = src?.split(';')[0].split('/')[1]; + const type = getImageExtensionFromDataUri(src) ?? src?.split(';')[0].split('/')[1]; const sanitizedHash = sanitizeDocxMediaName(attrs.hash, generateDocxRandomId(4)); const fileName = `${imageName}_${sanitizedHash}.${type}`; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 723bb933d5..745d2d959c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -273,6 +273,31 @@ describe('translateImageNode', () => { expect(result).toEqual({ type: 'text', text: 'annotation' }); }); + it('should export fieldAnnotation SVG data URI media with svg extension', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node = { + type: 'fieldAnnotation', + attrs: { + fieldId: 'signatureField', + hash: 'signatureHash', + src, + size: { width: 200, height: 50 }, + }, + }; + + const result = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(baseParams.relationships[0].attributes.Target).toBe('media/signatureField_signatureHash.svg'); + expect(baseParams.media['word/media/signatureField_signatureHash.svg']).toBe(src); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); + }); + it('should resize images inside tableCell to maxWidth', () => { baseParams.node.attrs.size = { width: 500, height: 500 }; baseParams.tableCell = { From 7e00837fbc119157929b2a6dd20302e003579442 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 13:34:45 -0300 Subject: [PATCH 011/103] fix(super-editor): guard non-base64 data uri exports --- .../editors/v1/core/super-converter/helpers.js | 17 ++++++----------- .../v1/core/super-converter/helpers.test.js | 11 +++++++++++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js index adf9ec163a..2d18fd425d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js @@ -34,17 +34,7 @@ function base64ToUint8Array(base64) { } function stringToUtf8ArrayBuffer(value) { - const encoded = encodeURIComponent(value); - const bytes = []; - for (let i = 0; i < encoded.length; i++) { - if (encoded[i] === '%') { - bytes.push(parseInt(encoded.slice(i + 1, i + 3), 16)); - i += 2; - } else { - bytes.push(encoded.charCodeAt(i)); - } - } - return new Uint8Array(bytes).buffer; + return new globalThis.TextEncoder().encode(value).buffer; } /** @@ -70,12 +60,17 @@ function dataUriToArrayBuffer(data) { } const meta = data.slice(0, commaIndex); const payload = data.substring(commaIndex + 1); + const mimeType = meta.slice(5).split(';')[0].toLowerCase(); const isBase64 = meta .slice(5) .split(';') .some((part) => part.toLowerCase() === 'base64'); if (!isBase64) { + if (mimeType !== 'image/svg+xml') { + throw new Error(`Unsupported non-base64 data URI media type: ${mimeType || 'unknown'}`); + } + try { return stringToUtf8ArrayBuffer(decodeURIComponent(payload)); } catch { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js index fa66229f99..401981b79d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js @@ -422,6 +422,17 @@ describe('dataUriToArrayBuffer', () => { expect(new TextDecoder().decode(result)).toBe(svg); }); + it('does not double-encode malformed non-base64 SVG payloads', () => { + const result = dataUriToArrayBuffer('data:image/svg+xml,%'); + expect(new TextDecoder().decode(result)).toBe('%'); + }); + + it('rejects non-base64 raster data URI strings', () => { + expect(() => dataUriToArrayBuffer('data:image/png,not-base64')).toThrow( + 'Unsupported non-base64 data URI media type', + ); + }); + it('decodes a raw base64 string', () => { const bytes = new Uint8Array([55, 66, 77]); const base64 = Buffer.from(bytes).toString('base64'); From 3e8ff74043267cd20ec139a10efe67f232ccc817 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 13:35:44 -0300 Subject: [PATCH 012/103] fix(super-editor): validate in-place svg image data --- .../imageRegistrationPlugin.browser.test.js | 14 ++++++++++++++ .../imageHelpers/imageRegistrationPlugin.js | 17 +++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index a6314664bd..2313f391d6 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -216,6 +216,20 @@ describe('handleBrowserPath', () => { }); }); + it('does not register malformed sized SVG data URI images in place', () => { + const imageNode = createImageNode({ + src: 'data:image/svg+xml', + size: { width: 200, height: 50 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + expect(Object.keys(editor.storage.image.media)).toHaveLength(0); + expect(addImageRelationship).not.toHaveBeenCalled(); + expect(tr.setNodeMarkup).not.toHaveBeenCalled(); + expect(tr.delete).toHaveBeenCalledWith(20, 21); + }); + it('mirrors in-place SVG media to the parent editor media store', () => { const svgDataUri = 'data:image/svg+xml;base64,PHN2Zy8+'; const parentEditor = { storage: { image: { media: {} } } }; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index bc76d44f0c..5ece5199d2 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -8,6 +8,7 @@ import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js'; import { addImageRelationship } from '@extensions/image/imageHelpers/startImageUpload.js'; import { isRelativeUrl } from '@superdoc/url-validation'; const key = new PluginKey('ImageRegistration'); +const MAX_IN_PLACE_DATA_URL_LENGTH = 10 * 1024 * 1024; /** * Determines whether an image node still needs to go through the registration flow. @@ -195,8 +196,20 @@ const hasFinitePositiveSize = (size) => const isSvgFile = (file) => file?.type === 'image/svg+xml'; -const shouldRegisterInPlace = (node) => - node.attrs?.src?.startsWith('data:image/svg+xml') && hasFinitePositiveSize(node.attrs?.size); +const isValidSvgDataUri = (src) => { + if (typeof src !== 'string' || !src.startsWith('data:') || src.length > MAX_IN_PLACE_DATA_URL_LENGTH) { + return false; + } + + const metadataEnd = src.indexOf(','); + if (metadataEnd === -1) return false; + + const metadata = src.slice('data:'.length, metadataEnd); + const mimeType = metadata.split(';')[0].toLowerCase(); + return mimeType === 'image/svg+xml'; +}; + +const shouldRegisterInPlace = (node) => isValidSvgDataUri(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); const getOrInitMediaStore = (editor) => { if (!editor?.storage?.image?.media) { From 63f0b3997985e327e093253ea0fd0990beba286f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 13:36:24 -0300 Subject: [PATCH 013/103] fix(super-editor): normalize svg data uri filenames --- .../editors/v1/core/super-converter/helpers.test.js | 10 ++++++++++ .../v1/core/super-converter/helpers/mediaHelpers.js | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js index 401981b79d..1f47cfb376 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js @@ -11,6 +11,7 @@ import { detectImageType, eighthPointsToPixels, } from './helpers.js'; +import { getFallbackImageNameFromDataUri } from './helpers/mediaHelpers.js'; describe('polygonToObj', () => { it('should return null for null input', () => { @@ -450,6 +451,15 @@ describe('dataUriToArrayBuffer', () => { }); }); +describe('getFallbackImageNameFromDataUri', () => { + it('normalizes SVG extension when the data URI has no parameters', () => { + const svg = ''; + const dataUri = `data:image/svg+xml,${encodeURIComponent(svg)}`; + + expect(getFallbackImageNameFromDataUri(dataUri)).toBe('image.svg'); + }); +}); + describe('detectImageType', () => { it('detects PNG from magic bytes', () => { // PNG signature: 89 50 4E 47 0D 0A 1A 0A diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index 2f698c6933..d962a83e79 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -8,7 +8,8 @@ export const sanitizeDocxMediaName = (value, fallback = 'image') => { export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => { if (!src || typeof src !== 'string') return fallback; - const [prefix] = src.split(';'); + const [metadata] = src.split(','); + const [prefix] = metadata.split(';'); const [, maybeType] = prefix.split('/'); const extension = maybeType?.toLowerCase() === 'svg+xml' ? 'svg' : maybeType?.toLowerCase(); From 4fc5dcb9eb18d6ebbc885be41eaceeda12da3357 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 13:37:48 -0300 Subject: [PATCH 014/103] fix(super-editor): skip invalid data uri image targets --- .../wp/helpers/decode-image-node-helpers.js | 16 ++++++- .../helpers/decode-image-node-helpers.test.js | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 1715616151..c6d5b716f1 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -46,6 +46,14 @@ function createMediaTargetForDataUri(params, src) { return relationshipTarget; } +function getMediaTargetForImageSrc(params, src) { + return src?.startsWith('data:') ? createMediaTargetForDataUri(params, src) : src?.split('word/')[1]; +} + +function fallbackForMissingMediaTarget(params) { + return params.node.type === 'fieldAnnotation' ? prepareTextAnnotation(params) : null; +} + /** * Resolve the hyperlink relationship rId for an image, if applicable. * Called once so that both wp:docPr and pic:cNvPr share the same rId. @@ -254,7 +262,9 @@ export const translateImageNode = (params) => { } if (imageId) { - const path = src?.startsWith('data:') ? createMediaTargetForDataUri(params, src) : src?.split('word/')[1]; + const path = getMediaTargetForImageSrc(params, src); + if (src?.startsWith('data:') && !path) return fallbackForMissingMediaTarget(params); + const relationships = params.isHeaderFooter ? params.existingRelationships : getDocumentRelationships(params); const existingRelation = findImageRelationship(relationships, { id: imageId, @@ -267,7 +277,9 @@ export const translateImageNode = (params) => { addImageRelationshipForId(params, imageId, path); } } else if (params.node.type === 'image' && !imageId) { - const path = src?.startsWith('data:') ? createMediaTargetForDataUri(params, src) : src?.split('word/')[1]; + const path = getMediaTargetForImageSrc(params, src); + if (src?.startsWith('data:') && !path) return fallbackForMissingMediaTarget(params); + const existingRelation = findImageRelationship(params.relationships, { target: path }); imageId = existingRelation?.attributes?.Id ?? addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 745d2d959c..7221f44dd0 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -179,6 +179,54 @@ describe('translateImageNode', () => { expect(baseParams.media[`word/${baseParams.relationships[0].attributes.Target}`]).toBe(src); }); + it('should skip data URI image export when no media target can be created', () => { + baseParams.node.attrs = { + src: 'data:,payload', + size: { width: 200, height: 50 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + + it('should not add an existing image rId relationship when data URI media target is invalid', () => { + baseParams.node.attrs = { + src: 'data:,payload', + rId: 'rIdInvalidData', + size: { width: 200, height: 50 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + + it('should fall back to text for fieldAnnotation with rId and invalid data URI media target', () => { + const params = { + ...baseParams, + node: { + type: 'fieldAnnotation', + attrs: { + src: 'data:,payload', + rId: 'rIdInvalidData', + size: { width: 200, height: 50 }, + }, + }, + }; + + const result = translateImageNode(params); + + expect(annotationHelpers.prepareTextAnnotation).toHaveBeenCalledWith(params); + expect(result).toEqual({ type: 'text', text: 'annotation' }); + expect(params.relationships).toHaveLength(0); + expect(params.media).toEqual({}); + }); + it('should use clamped fallback size (1 EMU) when attrs.size is empty', () => { baseParams.node.attrs.size = {}; From 15f060d8105893646c40a4c273ba676e43097ef9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 13:42:07 -0300 Subject: [PATCH 015/103] fix(super-editor): share data uri media parsing --- .../v1/core/super-converter/helpers.js | 26 ++++++-------- .../super-converter/helpers/mediaHelpers.js | 36 +++++++++++++++---- .../wp/helpers/decode-image-node-helpers.js | 23 +++++------- .../image/imageHelpers/handleBase64.js | 24 ++++--------- .../imageHelpers/imageRegistrationPlugin.js | 9 ++--- .../v1/tests/helpers/mediaHelpers.test.js | 30 ++++++++++++++++ 6 files changed, 87 insertions(+), 61 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js index 2d18fd425d..8f136ea305 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js @@ -1,5 +1,6 @@ import { parseSizeUnit } from '../utilities/index.js'; import { xml2js } from 'xml-js'; +import { getDataUriMetadata } from './helpers/mediaHelpers.js'; // --- Browser-compatible CRC32 (replaces buffer-crc32 to avoid Node.js Buffer dependency) --- const CRC32_TABLE = new Uint32Array(256); @@ -54,31 +55,24 @@ function dataUriToArrayBuffer(data) { let base64 = data; if (data.startsWith('data:')) { - const commaIndex = data.indexOf(','); - if (commaIndex === -1) { + const metadata = getDataUriMetadata(data); + if (!metadata?.hasPayloadSeparator) { throw new Error('Invalid data URI: missing content'); } - const meta = data.slice(0, commaIndex); - const payload = data.substring(commaIndex + 1); - const mimeType = meta.slice(5).split(';')[0].toLowerCase(); - const isBase64 = meta - .slice(5) - .split(';') - .some((part) => part.toLowerCase() === 'base64'); - - if (!isBase64) { - if (mimeType !== 'image/svg+xml') { - throw new Error(`Unsupported non-base64 data URI media type: ${mimeType || 'unknown'}`); + + if (!metadata.isBase64) { + if (metadata.mimeType !== 'image/svg+xml') { + throw new Error(`Unsupported non-base64 data URI media type: ${metadata.mimeType || 'unknown'}`); } try { - return stringToUtf8ArrayBuffer(decodeURIComponent(payload)); + return stringToUtf8ArrayBuffer(decodeURIComponent(metadata.payload)); } catch { - return stringToUtf8ArrayBuffer(payload); + return stringToUtf8ArrayBuffer(metadata.payload); } } - base64 = data.substring(commaIndex + 1); + base64 = metadata.payload; } return base64ToUint8Array(base64).buffer; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index d962a83e79..9dd2227006 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -5,13 +5,37 @@ export const sanitizeDocxMediaName = (value, fallback = 'image') => { return sanitized || fallback; }; -export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => { - if (!src || typeof src !== 'string') return fallback; +export const getImageExtensionFromMimeType = (mimeType) => { + const [, subtype] = String(mimeType || '').split('/'); + if (!subtype) return null; + + return subtype.toLowerCase() === 'svg+xml' ? 'svg' : subtype.toLowerCase(); +}; + +export const getDataUriMetadata = (src = '') => { + if (typeof src !== 'string' || !src.startsWith('data:')) return null; - const [metadata] = src.split(','); - const [prefix] = metadata.split(';'); - const [, maybeType] = prefix.split('/'); - const extension = maybeType?.toLowerCase() === 'svg+xml' ? 'svg' : maybeType?.toLowerCase(); + const commaIndex = src.indexOf(','); + const hasPayloadSeparator = commaIndex !== -1; + const metadata = src.slice(5, hasPayloadSeparator ? commaIndex : undefined); + const payload = hasPayloadSeparator ? src.slice(commaIndex + 1) : ''; + const [rawMimeType = '', ...parameters] = metadata.split(';'); + const mimeType = rawMimeType.toLowerCase(); + + return { + hasPayloadSeparator, + metadata, + payload, + rawMimeType, + mimeType, + parameters, + isBase64: parameters.some((part) => part.toLowerCase() === 'base64'), + extension: getImageExtensionFromMimeType(mimeType), + }; +}; + +export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => { + const extension = getDataUriMetadata(src)?.extension; return extension ? `${fallback}.${extension}` : fallback; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index c6d5b716f1..6e023d190a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -1,5 +1,9 @@ import { emuToPixels, pixelsToEmu, degreesToRot } from '@converter/helpers.js'; -import { getFallbackImageNameFromDataUri, sanitizeDocxMediaName } from '@converter/helpers/mediaHelpers.js'; +import { + getDataUriMetadata, + getFallbackImageNameFromDataUri, + sanitizeDocxMediaName, +} from '@converter/helpers/mediaHelpers.js'; import { prepareTextAnnotation } from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js'; import { wrapTextInRun } from '@converter/exporter.js'; import { generateDocxRandomId } from '@core/helpers/index.js'; @@ -11,19 +15,8 @@ const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/d const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'; -function getImageExtensionFromDataUri(src) { - if (typeof src !== 'string' || !src.startsWith('data:')) return null; - - const prefix = src.slice(5, src.indexOf(',') === -1 ? undefined : src.indexOf(',')); - const mime = prefix.split(';')[0]; - const [, subtype] = mime.split('/'); - if (!subtype) return null; - - return subtype.toLowerCase() === 'svg+xml' ? 'svg' : subtype.toLowerCase(); -} - function createMediaTargetForDataUri(params, src) { - const extension = getImageExtensionFromDataUri(src); + const extension = getDataUriMetadata(src)?.extension; if (!extension) return null; if (!params.media) params.media = {}; @@ -223,7 +216,7 @@ export const translateImageNode = (params) => { // For fieldAnnotations without a recognizable MIME type, fall back to text // annotation before attempting size resolution (they have no image data). if (params.node.type === 'fieldAnnotation' && !imageId) { - const type = getImageExtensionFromDataUri(src) ?? src?.split(';')[0].split('/')[1]; + const type = getDataUriMetadata(src)?.extension; if (!type) { return prepareTextAnnotation(params); } @@ -284,7 +277,7 @@ export const translateImageNode = (params) => { imageId = existingRelation?.attributes?.Id ?? addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. - const type = getImageExtensionFromDataUri(src) ?? src?.split(';')[0].split('/')[1]; + const type = getDataUriMetadata(src)?.extension; const sanitizedHash = sanitizeDocxMediaName(attrs.hash, generateDocxRandomId(4)); const fileName = `${imageName}_${sanitizedHash}.${type}`; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index c8f1aedcf2..7cb9465706 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -1,4 +1,5 @@ // @ts-check +import { getDataUriMetadata } from '@converter/helpers/mediaHelpers.js'; import { simpleStringHash } from '@core/utilities/hash.js'; const DEFAULT_MIME_TYPE = 'application/octet-stream'; @@ -38,33 +39,20 @@ const binaryStringToBytes = (binaryString) => { return bytes; }; -const splitDataUri = (dataUri) => { - const separatorIndex = dataUri.indexOf(','); - if (separatorIndex === -1) { - return { meta: dataUri, payload: '' }; - } - - return { - meta: dataUri.slice(0, separatorIndex), - payload: dataUri.slice(separatorIndex + 1), - }; -}; - /** * Extract metadata from a data URI string. * @param {string} dataUri - The data URI string. * @returns {Object} An object containing mimeType, binaryString, and filename. */ const extractBase64Meta = (dataUri) => { - const { meta = '', payload = '' } = splitDataUri(dataUri); - const metaParts = meta.startsWith('data:') ? meta.slice(5).split(';') : []; - const rawMimeType = metaParts[0] || ''; + const metadata = getDataUriMetadata(dataUri); + const rawMimeType = metadata?.rawMimeType || ''; const mimeType = rawMimeType || DEFAULT_MIME_TYPE; - const isBase64 = metaParts.some((part) => part.toLowerCase() === 'base64'); + const isBase64 = Boolean(metadata?.isBase64); + const payload = metadata?.payload || ''; const binaryString = isBase64 ? decodeBase64ToBinaryString(payload) : decodeDataUriText(payload); const hash = simpleStringHash(binaryString); - const normalizedMimeType = mimeType.toLowerCase(); - const extension = normalizedMimeType === 'image/svg+xml' ? 'svg' : normalizedMimeType.split('/')[1] || 'bin'; + const extension = metadata?.extension || 'bin'; const filename = `image-${hash}.${extension}`; return { mimeType, binaryString, filename, isBase64 }; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index 5ece5199d2..1041ef1871 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -6,6 +6,7 @@ import { urlToFile, validateUrlAccessibility } from './handleUrl'; import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js'; import { addImageRelationship } from '@extensions/image/imageHelpers/startImageUpload.js'; +import { getDataUriMetadata } from '@converter/helpers/mediaHelpers.js'; import { isRelativeUrl } from '@superdoc/url-validation'; const key = new PluginKey('ImageRegistration'); const MAX_IN_PLACE_DATA_URL_LENGTH = 10 * 1024 * 1024; @@ -201,12 +202,8 @@ const isValidSvgDataUri = (src) => { return false; } - const metadataEnd = src.indexOf(','); - if (metadataEnd === -1) return false; - - const metadata = src.slice('data:'.length, metadataEnd); - const mimeType = metadata.split(';')[0].toLowerCase(); - return mimeType === 'image/svg+xml'; + const metadata = getDataUriMetadata(src); + return metadata?.hasPayloadSeparator === true && metadata.mimeType === 'image/svg+xml'; }; const shouldRegisterInPlace = (node) => isValidSvgDataUri(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); diff --git a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js index 680c77569c..b6ebba6f1b 100644 --- a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js +++ b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getDataUriMetadata, getFallbackImageNameFromDataUri, sanitizeDocxMediaName, } from '../../core/super-converter/helpers/mediaHelpers.js'; @@ -25,6 +26,35 @@ describe('sanitizeDocxMediaName', () => { }); }); +describe('getDataUriMetadata', () => { + it('extracts MIME type, base64 flag, payload, and normalized extension', () => { + const result = getDataUriMetadata('data:image/svg+xml;charset=utf-8;base64,PHN2Zy8+'); + + expect(result).toMatchObject({ + hasPayloadSeparator: true, + rawMimeType: 'image/svg+xml', + mimeType: 'image/svg+xml', + isBase64: true, + payload: 'PHN2Zy8+', + extension: 'svg', + }); + }); + + it('handles no-parameter SVG data URIs without including the payload in the extension', () => { + const result = getDataUriMetadata('data:image/svg+xml,%3Csvg%2F%3E'); + + expect(result).toMatchObject({ + mimeType: 'image/svg+xml', + payload: '%3Csvg%2F%3E', + extension: 'svg', + }); + }); + + it('returns null for non-data URI input', () => { + expect(getDataUriMetadata('word/media/image.png')).toBeNull(); + }); +}); + describe('getFallbackImageNameFromDataUri', () => { it('returns a filename with extension extracted from data URI', () => { const dataUri = 'data:image/png;base64,AAAA'; From 28634009be94dc5ecc7ccc7be3d0f46c16939f1f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 13:45:42 -0300 Subject: [PATCH 016/103] test(super-editor): cover structured content image edges --- .../wp/helpers/decode-image-node-helpers.js | 4 +- .../helpers/decode-image-node-helpers.test.js | 36 ++++++ .../image/imageHelpers/handleBase64.test.js | 22 ++++ ...structured-content-image-roundtrip.test.js | 122 ++++++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 6e023d190a..75977ed51e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -256,7 +256,7 @@ export const translateImageNode = (params) => { if (imageId) { const path = getMediaTargetForImageSrc(params, src); - if (src?.startsWith('data:') && !path) return fallbackForMissingMediaTarget(params); + if (!path) return fallbackForMissingMediaTarget(params); const relationships = params.isHeaderFooter ? params.existingRelationships : getDocumentRelationships(params); const existingRelation = findImageRelationship(relationships, { @@ -271,7 +271,7 @@ export const translateImageNode = (params) => { } } else if (params.node.type === 'image' && !imageId) { const path = getMediaTargetForImageSrc(params, src); - if (src?.startsWith('data:') && !path) return fallbackForMissingMediaTarget(params); + if (!path) return fallbackForMissingMediaTarget(params); const existingRelation = findImageRelationship(params.relationships, { target: path }); imageId = existingRelation?.attributes?.Id ?? addNewImageRelationship(params, path); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 7221f44dd0..2302ccefad 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -179,6 +179,42 @@ describe('translateImageNode', () => { expect(baseParams.media[`word/${baseParams.relationships[0].attributes.Target}`]).toBe(src); }); + it('should register raster data URI image media when rId is missing', () => { + const src = 'data:image/png;base64,iVBORw0KGgo='; + baseParams.node.attrs = { + src, + alt: 'Raster Example', + size: { width: 20, height: 10 }, + }; + + const result = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + const target = baseParams.relationships[0].attributes.Target; + expect(target).toMatch(/^media\/image-\d+\.png$/); + expect(baseParams.media[`word/${target}`]).toBe(src); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); + }); + + it('should not create a corrupt relationship when image src is null', () => { + baseParams.node.attrs = { + src: null, + rId: 'rIdMissingSrc', + size: { width: 200, height: 50 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + it('should skip data URI image export when no media target can be created', () => { baseParams.node.attrs = { src: 'data:,payload', diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index 98111e2525..c65dd46a9c 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -103,6 +103,28 @@ describe('handleBase64', () => { await expect(file.text()).resolves.toBe(payload); }); + it('falls back to raw data URI text when percent decoding fails', async () => { + const dataUri = 'data:image/svg+xml,%'; + + const file = base64ToFile(dataUri); + + expect(file.name).toMatch(/^image-\d+\.svg$/); + expect(file.type).toBe('image/svg+xml'); + await expect(file.text()).resolves.toBe('%'); + }); + + it('handles data URIs without a comma as empty payloads', () => { + vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); + + const { filename, mimeType } = getBase64FileMeta('data:image/png;base64'); + const file = base64ToFile('data:image/png;base64'); + + expect(mimeType).toBe('image/png'); + expect(filename).toBe(file.name); + expect(file.name).toMatch(/^image-\d+\.png$/); + expect(file.size).toBe(0); + }); + it('defaults metadata when mime data is missing', () => { vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js index ebaca2d767..aa4befa537 100644 --- a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { DOMSerializer } from 'prosemirror-model'; import { Editor } from '@core/Editor.js'; import { parseXmlToJson } from '@converter/v2/docxHelper.js'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; @@ -19,6 +20,18 @@ const findFirstNodeByType = (node, typeName) => { return found; }; +const findNodeByTypeAndId = (node, typeName, id) => { + let found = null; + node.descendants((child) => { + if (child.type.name === typeName && child.attrs?.id === id) { + found = child; + return false; + } + return true; + }); + return found; +}; + const collectElementsByName = (node, name, result = []) => { if (!node || typeof node !== 'object') return result; if (node.name === name) result.push(node); @@ -33,12 +46,15 @@ const hasDescendantNamed = (node, name) => collectElementsByName(node, name).len describe('SD-3116 structured content image round-trip', () => { let editor; let reopened; + let repainted; afterEach(() => { editor?.destroy(); reopened?.destroy(); + repainted?.destroy(); editor = null; reopened = null; + repainted = null; }); it('exports and reopens a block SDT containing preset image content', async () => { @@ -110,6 +126,112 @@ describe('SD-3116 structured content image round-trip', () => { expect(reopenedImage?.attrs.src).toMatch(/^word\/media\/.+\.svg$/); }); + it('repaints preset image content from a saved document model without export and re-import', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const didInsert = editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215860', + tag: '{"fieldType":"signer"}', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: SIGNATURE_SRC, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }); + + expect(didInsert).toBe(true); + + const savedModel = editor.getJSON(); + const savedMedia = { ...editor.storage.image.media }; + const savedImage = findFirstNodeByType(editor.state.doc, 'image'); + expect(savedImage?.attrs.src).toMatch(/^word\/media\/image-\d+\.svg$/); + expect(savedMedia[savedImage.attrs.src]).toBe(SIGNATURE_SRC); + + ({ editor: repainted } = initTestEditor({ jsonOverride: savedModel })); + repainted.storage.image.media = { ...savedMedia }; + + const fragment = DOMSerializer.fromSchema(repainted.schema).serializeFragment(repainted.state.doc.content, { + document, + }); + const img = fragment.querySelector('img'); + + expect(img?.getAttribute('src')).toBe(SIGNATURE_SRC); + expect(img?.getAttribute('alt')).toBe('Signature Example'); + }); + + it('round-trips inline text SDTs and block plain-text SDTs', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + expect( + editor.commands.insertStructuredContentInline({ + attrs: { + id: '1299215861', + tag: 'inline_text_sdt', + alias: 'Inline text TEST', + lockMode: 'sdtLocked', + }, + text: 'Inline plain text', + }), + ).toBe(true); + + expect( + editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215862', + tag: 'block_text_sdt', + alias: 'Block text TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [{ type: 'text', text: 'Block plain text' }], + }, + }), + ).toBe(true); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const [roundTripDocx, roundTripMedia, roundTripMediaFiles, roundTripFonts] = await Editor.loadXmlData( + exported, + true, + ); + ({ editor: reopened } = initTestEditor({ + content: roundTripDocx, + media: roundTripMedia, + mediaFiles: roundTripMediaFiles, + fonts: roundTripFonts, + isNewFile: false, + })); + + const inlineSdt = findNodeByTypeAndId(reopened.state.doc, 'structuredContent', '1299215861'); + expect(inlineSdt?.attrs).toMatchObject({ + alias: 'Inline text TEST', + lockMode: 'sdtLocked', + }); + expect(inlineSdt?.textContent).toBe('Inline plain text'); + + const blockSdt = findNodeByTypeAndId(reopened.state.doc, 'structuredContentBlock', '1299215862'); + expect(blockSdt?.attrs).toMatchObject({ + alias: 'Block text TEST', + lockMode: 'sdtLocked', + }); + expect(blockSdt?.textContent).toBe('Block plain text'); + }); + it('exports non-base64 SVG preset image content as decoded media bytes', async () => { const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); From e20942431b478c7e4b936a301e5243491eb2583e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:19:13 -0300 Subject: [PATCH 017/103] fix(super-editor): read svg data uri dimensions --- .../core/super-converter/image-dimensions.js | 37 ++++++++++++++++--- .../super-converter/image-dimensions.test.js | 6 +++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js index 0e1a4f5b01..896044d9b5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js @@ -1,4 +1,5 @@ import { base64ToUint8Array } from './helpers.js'; +import { getDataUriMetadata } from './helpers/mediaHelpers.js'; /** * Read intrinsic image dimensions from raw binary headers. @@ -144,6 +145,24 @@ function readWebpDimensions(bytes) { return null; } +function readSvgDimensions(svgText) { + if (typeof svgText !== 'string') return null; + + const svgMatch = svgText.match(/]*>/i); + if (!svgMatch) return null; + + const widthMatch = svgMatch[0].match(/\bwidth=(["']?)([0-9.]+)(?:px)?\1/i); + const heightMatch = svgMatch[0].match(/\bheight=(["']?)([0-9.]+)(?:px)?\1/i); + const width = widthMatch ? Number.parseFloat(widthMatch[2]) : NaN; + const height = heightMatch ? Number.parseFloat(heightMatch[2]) : NaN; + + if (Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0) { + return { width, height }; + } + + return null; +} + /** * Extract dimensions from a data URI's base64 payload. * @@ -153,14 +172,22 @@ function readWebpDimensions(bytes) { export function readImageDimensionsFromDataUri(dataUri) { if (typeof dataUri !== 'string' || !dataUri.startsWith('data:')) return null; - const commaIndex = dataUri.indexOf(','); - if (commaIndex === -1) return null; + const metadata = getDataUriMetadata(dataUri); + if (!metadata?.hasPayloadSeparator || !metadata.payload) return null; - const base64Payload = dataUri.slice(commaIndex + 1); - if (!base64Payload) return null; + if (metadata.mimeType === 'image/svg+xml') { + try { + const svgText = metadata.isBase64 ? atob(metadata.payload) : decodeURIComponent(metadata.payload); + return readSvgDimensions(svgText); + } catch { + return null; + } + } try { - const bytes = base64ToUint8Array(base64Payload); + const bytes = metadata.isBase64 + ? base64ToUint8Array(metadata.payload) + : new globalThis.TextEncoder().encode(metadata.payload); return readImageDimensions(bytes); } catch { return null; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js index 2afbb1a044..e70835b03c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js @@ -216,6 +216,12 @@ describe('readImageDimensionsFromDataUri', () => { expect(readImageDimensionsFromDataUri(uri)).toEqual({ width: 320, height: 240 }); }); + it('reads dimensions from non-base64 SVG data URI', () => { + const svg = ''; + const uri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + expect(readImageDimensionsFromDataUri(uri)).toEqual({ width: 200, height: 50 }); + }); + it('returns null for non-data-URI string', () => { expect(readImageDimensionsFromDataUri('https://example.com/image.png')).toBeNull(); }); From 75fcd9a00b7f1e41d19bcd6162e33f60362cd797 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:19:45 -0300 Subject: [PATCH 018/103] fix(super-editor): block non-image data uri exports --- .../wp/helpers/decode-image-node-helpers.js | 17 ++++++++++++++++- .../helpers/decode-image-node-helpers.test.js | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 75977ed51e..ce8f8683c1 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -14,9 +14,24 @@ const DECORATIVE_EXT_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}'; const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/decorative'; const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'; +const EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/bmp', + 'image/ico', + 'image/tif', + 'image/tiff', +]); function createMediaTargetForDataUri(params, src) { - const extension = getDataUriMetadata(src)?.extension; + const metadata = getDataUriMetadata(src); + if (!metadata || !EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES.has(metadata.mimeType)) return null; + + const extension = metadata.extension; if (!extension) return null; if (!params.media) params.media = {}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 2302ccefad..d51fbf8e8e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -201,6 +201,20 @@ describe('translateImageNode', () => { expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); }); + it('should not export non-image data URI media', () => { + baseParams.node.attrs = { + src: 'data:text/html,%3Cscript%3Ealert(1)%3C%2Fscript%3E', + alt: 'HTML Example', + size: { width: 20, height: 10 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + it('should not create a corrupt relationship when image src is null', () => { baseParams.node.attrs = { src: null, From be7053f3fb128f8824ee74195ebead38c1821d9b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:21:09 -0300 Subject: [PATCH 019/103] fix(super-editor): reject separatorless data uri files --- .../v1/extensions/image/imageHelpers/handleBase64.js | 12 ++++++++++-- .../image/imageHelpers/handleBase64.test.js | 12 +++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index 7cb9465706..e0bb4d60c0 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -46,6 +46,8 @@ const binaryStringToBytes = (binaryString) => { */ const extractBase64Meta = (dataUri) => { const metadata = getDataUriMetadata(dataUri); + if (!metadata?.hasPayloadSeparator) return null; + const rawMimeType = metadata?.rawMimeType || ''; const mimeType = rawMimeType || DEFAULT_MIME_TYPE; const isBase64 = Boolean(metadata?.isBase64); @@ -59,12 +61,18 @@ const extractBase64Meta = (dataUri) => { }; export const getBase64FileMeta = (dataUri) => { - const { mimeType, filename } = extractBase64Meta(dataUri); + const meta = extractBase64Meta(dataUri); + if (!meta) return { mimeType: DEFAULT_MIME_TYPE, filename: 'image-0.bin' }; + + const { mimeType, filename } = meta; return { mimeType, filename }; }; export const base64ToFile = (dataUri) => { - const { mimeType, binaryString, filename, isBase64 } = extractBase64Meta(dataUri); + const meta = extractBase64Meta(dataUri); + if (!meta) return null; + + const { mimeType, binaryString, filename, isBase64 } = meta; const fileType = mimeType || DEFAULT_MIME_TYPE; const data = isBase64 ? binaryStringToBytes(binaryString) : binaryString; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index c65dd46a9c..d92431b4e0 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -113,16 +113,10 @@ describe('handleBase64', () => { await expect(file.text()).resolves.toBe('%'); }); - it('handles data URIs without a comma as empty payloads', () => { - vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); - - const { filename, mimeType } = getBase64FileMeta('data:image/png;base64'); - const file = base64ToFile('data:image/png;base64'); + it('returns null for data URIs without a payload separator', () => { + const file = base64ToFile('data:image/svg+xml'); - expect(mimeType).toBe('image/png'); - expect(filename).toBe(file.name); - expect(file.name).toMatch(/^image-\d+\.png$/); - expect(file.size).toBe(0); + expect(file).toBeNull(); }); it('defaults metadata when mime data is missing', () => { From 2a3856efbb77170623730beae0b0423363d02236 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:21:50 -0300 Subject: [PATCH 020/103] fix(super-editor): warn on skipped image exports --- .../v3/handlers/wp/helpers/decode-image-node-helpers.js | 8 +++++++- .../handlers/wp/helpers/decode-image-node-helpers.test.js | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index ce8f8683c1..ffbdd4269f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -59,7 +59,13 @@ function getMediaTargetForImageSrc(params, src) { } function fallbackForMissingMediaTarget(params) { - return params.node.type === 'fieldAnnotation' ? prepareTextAnnotation(params) : null; + if (params.node.type === 'fieldAnnotation') return prepareTextAnnotation(params); + + console.warn('Skipping image export because media target could not be resolved.', { + nodeType: params.node.type, + src: params.node.attrs?.src, + }); + return null; } /** diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index d51fbf8e8e..2e8c23c91d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -230,6 +230,7 @@ describe('translateImageNode', () => { }); it('should skip data URI image export when no media target can be created', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); baseParams.node.attrs = { src: 'data:,payload', size: { width: 200, height: 50 }, @@ -240,6 +241,11 @@ describe('translateImageNode', () => { expect(result).toBeNull(); expect(baseParams.relationships).toHaveLength(0); expect(baseParams.media).toEqual({}); + expect(warn).toHaveBeenCalledWith( + 'Skipping image export because media target could not be resolved.', + expect.objectContaining({ nodeType: 'image', src: 'data:,payload' }), + ); + warn.mockRestore(); }); it('should not add an existing image rId relationship when data URI media target is invalid', () => { From 3a804f759bc5bd5294b11764ba0eb8aafe96216e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:22:27 -0300 Subject: [PATCH 021/103] fix(super-editor): reject malformed svg data uri payloads --- .../src/editors/v1/core/super-converter/helpers.js | 2 +- .../src/editors/v1/core/super-converter/helpers.test.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js index 8f136ea305..0acc0d3941 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js @@ -68,7 +68,7 @@ function dataUriToArrayBuffer(data) { try { return stringToUtf8ArrayBuffer(decodeURIComponent(metadata.payload)); } catch { - return stringToUtf8ArrayBuffer(metadata.payload); + throw new Error('Invalid non-base64 data URI payload'); } } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js index 1f47cfb376..9fac98c166 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js @@ -423,9 +423,8 @@ describe('dataUriToArrayBuffer', () => { expect(new TextDecoder().decode(result)).toBe(svg); }); - it('does not double-encode malformed non-base64 SVG payloads', () => { - const result = dataUriToArrayBuffer('data:image/svg+xml,%'); - expect(new TextDecoder().decode(result)).toBe('%'); + it('rejects malformed non-base64 SVG payloads', () => { + expect(() => dataUriToArrayBuffer('data:image/svg+xml,%')).toThrow('Invalid non-base64 data URI payload'); }); it('rejects non-base64 raster data URI strings', () => { From 024080bfca2ae9a47d6dcc7d215c583338a29d2e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:41:58 -0300 Subject: [PATCH 022/103] fix(super-editor): block raw raster data uri exports --- .../wp/helpers/decode-image-node-helpers.js | 1 + .../wp/helpers/decode-image-node-helpers.test.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index ffbdd4269f..dc98faca94 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -30,6 +30,7 @@ const EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES = new Set([ function createMediaTargetForDataUri(params, src) { const metadata = getDataUriMetadata(src); if (!metadata || !EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES.has(metadata.mimeType)) return null; + if (!metadata.isBase64 && metadata.mimeType !== 'image/svg+xml') return null; const extension = metadata.extension; if (!extension) return null; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 2e8c23c91d..2c083c6691 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -201,6 +201,20 @@ describe('translateImageNode', () => { expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); }); + it('should not export non-base64 raster data URI media', () => { + baseParams.node.attrs = { + src: 'data:image/png,not-base64', + alt: 'Raster Example', + size: { width: 20, height: 10 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + it('should not export non-image data URI media', () => { baseParams.node.attrs = { src: 'data:text/html,%3Cscript%3Ealert(1)%3C%2Fscript%3E', From 4d432721b2c7572ad7b7f23228a90b83fd51fa4a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:43:11 -0300 Subject: [PATCH 023/103] fix(super-editor): avoid duplicate image rids --- .../wp/helpers/decode-image-node-helpers.js | 5 ++++- .../helpers/decode-image-node-helpers.test.js | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index dc98faca94..c6f789d1be 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -280,7 +280,10 @@ export const translateImageNode = (params) => { const path = getMediaTargetForImageSrc(params, src); if (!path) return fallbackForMissingMediaTarget(params); - const relationships = params.isHeaderFooter ? params.existingRelationships : getDocumentRelationships(params); + const relationships = [ + ...(params.relationships || []), + ...(params.isHeaderFooter ? params.existingRelationships || [] : getDocumentRelationships(params)), + ]; const existingRelation = findImageRelationship(relationships, { id: imageId, target: path, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 2c083c6691..043a5f7297 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -179,6 +179,25 @@ describe('translateImageNode', () => { expect(baseParams.media[`word/${baseParams.relationships[0].attributes.Target}`]).toBe(src); }); + it('should not add duplicate relationships for repeated data URI image rIds', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node.attrs = { + src, + rId: 'rIdExisting', + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }; + + translateImageNode(baseParams); + translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(baseParams.relationships[0].attributes).toMatchObject({ + Id: 'rIdExisting', + Target: expect.stringMatching(/^media\/.+\.svg$/), + }); + }); + it('should register raster data URI image media when rId is missing', () => { const src = 'data:image/png;base64,iVBORw0KGgo='; baseParams.node.attrs = { From b40c97b02a8bd608b52b00b7ffec9c62955e734d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:43:34 -0300 Subject: [PATCH 024/103] docs(super-editor): clarify image registration comments --- .../extensions/image/imageHelpers/imageRegistrationPlugin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index 1041ef1871..3c9780592e 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -283,7 +283,7 @@ const registerImagesInTransaction = (foundImages, editor, tr) => { * @param {Object} editor - The editor instance. * @param {import('prosemirror-view').EditorView} view - The editor view instance. * @param {import('prosemirror-state').EditorState} state - The current editor state. - * @returns {import('prosemirror-state').Transaction} - The updated transaction with image nodes replaced by placeholders and registration process initiated. + * @returns {import('prosemirror-state').Transaction} - The updated transaction with in-place registrations and placeholders for images that require async processing. * @internal Exported for testing only. */ export const handleBrowserPath = (foundImages, editor, view, state) => { @@ -313,7 +313,7 @@ export const handleBrowserPath = (foundImages, editor, view, state) => { // Register the images. (async process). registerImages(imagesToProcess, editor, view); - // Remove all the images that were found. These will eventually be replaced by the updated images. + // Remove only images that require async processing. These will eventually be replaced by updated images. // We need to delete the image nodes and replace them with decorations. This will change their positions. From 02210546024f5e6e30cf64a06286074989ed6e8f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 14:46:02 -0300 Subject: [PATCH 025/103] test(super-editor): repaint saved sdt images through painter --- ...structured-content-image-roundtrip.test.js | 101 ++++++++++++++++-- 1 file changed, 92 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js index aa4befa537..a2ee5bd669 100644 --- a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js @@ -1,5 +1,7 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { DOMSerializer } from 'prosemirror-model'; +import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { createDomPainter } from '@superdoc/painter-dom'; +import { resolveLayout } from '@superdoc/layout-resolved'; import { Editor } from '@core/Editor.js'; import { parseXmlToJson } from '@converter/v2/docxHelper.js'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; @@ -43,18 +45,96 @@ const getChildElement = (node, name) => node?.elements?.find((child) => child.na const hasDescendantNamed = (node, name) => collectElementsByName(node, name).length > 0; +const DEFAULT_CONVERTER_CONTEXT = { + docx: {}, + translatedLinkedStyles: { + docDefaults: {}, + latentStyles: {}, + styles: {}, + }, + translatedNumbering: { + abstracts: {}, + definitions: {}, + }, +}; + +const TEST_PAGE = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, +}; + +const paintSavedModel = (pmDoc, mediaFiles) => { + const { blocks } = toFlowBlocks(pmDoc, { + converterContext: DEFAULT_CONVERTER_CONTEXT, + mediaFiles, + }); + const contentWidth = TEST_PAGE.pageSize.w - TEST_PAGE.margins.left - TEST_PAGE.margins.right; + const measures = blocks.map((block) => { + const imageRun = block.runs?.find((run) => run.kind === 'image'); + const lineHeight = imageRun?.height ?? 20; + + return { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: Math.max((block.runs?.length ?? 1) - 1, 0), + toChar: 0, + width: imageRun?.width ?? contentWidth, + ascent: lineHeight, + descent: 0, + lineHeight, + }, + ], + totalHeight: lineHeight, + }; + }); + + let y = TEST_PAGE.margins.top; + const fragments = blocks.flatMap((block, index) => { + const measure = measures[index]; + if (block.kind !== 'paragraph') return []; + + const fragment = { + kind: 'para', + blockId: block.id, + fromLine: 0, + toLine: measure.lines?.length ?? 1, + x: TEST_PAGE.margins.left, + y, + width: contentWidth, + }; + y += measure.totalHeight ?? 20; + return [fragment]; + }); + + const layout = { + pageSize: TEST_PAGE.pageSize, + pages: [{ number: 1, fragments }], + }; + const mount = document.createElement('div'); + document.body.appendChild(mount); + + const painter = createDomPainter({}); + const resolvedLayout = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + painter.paint({ resolvedLayout }, mount); + + return { mount, blocks }; +}; + describe('SD-3116 structured content image round-trip', () => { let editor; let reopened; - let repainted; + let paintMount; afterEach(() => { editor?.destroy(); reopened?.destroy(); - repainted?.destroy(); + paintMount?.remove(); editor = null; reopened = null; - repainted = null; + paintMount = null; }); it('exports and reopens a block SDT containing preset image content', async () => { @@ -161,14 +241,17 @@ describe('SD-3116 structured content image round-trip', () => { expect(savedImage?.attrs.src).toMatch(/^word\/media\/image-\d+\.svg$/); expect(savedMedia[savedImage.attrs.src]).toBe(SIGNATURE_SRC); - ({ editor: repainted } = initTestEditor({ jsonOverride: savedModel })); - repainted.storage.image.media = { ...savedMedia }; + const painted = paintSavedModel(savedModel, savedMedia); + paintMount = painted.mount; - const fragment = DOMSerializer.fromSchema(repainted.schema).serializeFragment(repainted.state.doc.content, { - document, + expect(painted.blocks).toHaveLength(1); + expect(painted.blocks[0].attrs?.sdt).toMatchObject({ + type: 'structuredContent', + scope: 'block', + id: '1299215860', }); - const img = fragment.querySelector('img'); + const img = paintMount.querySelector('img'); expect(img?.getAttribute('src')).toBe(SIGNATURE_SRC); expect(img?.getAttribute('alt')).toBe('Signature Example'); }); From 95140d4150938987d2b4caeb6af22dfa5559a51f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:27:53 -0300 Subject: [PATCH 026/103] fix(super-editor): reject malformed data uri files --- .../v1/extensions/image/imageHelpers/handleBase64.js | 4 +++- .../v1/extensions/image/imageHelpers/handleBase64.test.js | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index e0bb4d60c0..a4df1411a5 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -27,7 +27,7 @@ const decodeDataUriText = (data) => { try { return decodeURIComponent(data); } catch { - return data; + return null; } }; @@ -53,6 +53,8 @@ const extractBase64Meta = (dataUri) => { const isBase64 = Boolean(metadata?.isBase64); const payload = metadata?.payload || ''; const binaryString = isBase64 ? decodeBase64ToBinaryString(payload) : decodeDataUriText(payload); + if (binaryString == null) return null; + const hash = simpleStringHash(binaryString); const extension = metadata?.extension || 'bin'; const filename = `image-${hash}.${extension}`; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index d92431b4e0..a7f12a665d 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -103,14 +103,12 @@ describe('handleBase64', () => { await expect(file.text()).resolves.toBe(payload); }); - it('falls back to raw data URI text when percent decoding fails', async () => { + it('returns null when non-base64 payload percent decoding fails', () => { const dataUri = 'data:image/svg+xml,%'; const file = base64ToFile(dataUri); - expect(file.name).toMatch(/^image-\d+\.svg$/); - expect(file.type).toBe('image/svg+xml'); - await expect(file.text()).resolves.toBe('%'); + expect(file).toBeNull(); }); it('returns null for data URIs without a payload separator', () => { From ba43f9c6c5edc8c45150fc501d8bed57dd186e11 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:28:54 -0300 Subject: [PATCH 027/103] fix(super-editor): validate field annotation data uri exports --- .../wp/helpers/decode-image-node-helpers.js | 21 +++++++-- .../helpers/decode-image-node-helpers.test.js | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index c6f789d1be..7dd2938fc6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -27,10 +27,22 @@ const EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES = new Set([ 'image/tiff', ]); +function isExportableDataUriMetadata(metadata) { + if (!metadata?.hasPayloadSeparator || !EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES.has(metadata.mimeType)) return false; + if (metadata.isBase64) return true; + if (metadata.mimeType !== 'image/svg+xml') return false; + + try { + decodeURIComponent(metadata.payload); + return true; + } catch { + return false; + } +} + function createMediaTargetForDataUri(params, src) { const metadata = getDataUriMetadata(src); - if (!metadata || !EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES.has(metadata.mimeType)) return null; - if (!metadata.isBase64 && metadata.mimeType !== 'image/svg+xml') return null; + if (!isExportableDataUriMetadata(metadata)) return null; const extension = metadata.extension; if (!extension) return null; @@ -302,7 +314,10 @@ export const translateImageNode = (params) => { imageId = existingRelation?.attributes?.Id ?? addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. - const type = getDataUriMetadata(src)?.extension; + const metadata = getDataUriMetadata(src); + if (!isExportableDataUriMetadata(metadata)) return prepareTextAnnotation(params); + + const type = metadata.extension; const sanitizedHash = sanitizeDocxMediaName(attrs.hash, generateDocxRandomId(4)); const fileName = `${imageName}_${sanitizedHash}.${type}`; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 043a5f7297..d7cc73909e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -435,6 +435,50 @@ describe('translateImageNode', () => { expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); }); + it('should fall back to text for fieldAnnotation with non-base64 raster data URI', () => { + const params = { + ...baseParams, + node: { + type: 'fieldAnnotation', + attrs: { + fieldId: 'signatureField', + hash: 'signatureHash', + src: 'data:image/png,not-base64', + size: { width: 200, height: 50 }, + }, + }, + }; + + const result = translateImageNode(params); + + expect(annotationHelpers.prepareTextAnnotation).toHaveBeenCalledWith(params); + expect(result).toEqual({ type: 'text', text: 'annotation' }); + expect(params.relationships).toHaveLength(0); + expect(params.media).toEqual({}); + }); + + it('should fall back to text for fieldAnnotation with malformed non-base64 SVG data URI', () => { + const params = { + ...baseParams, + node: { + type: 'fieldAnnotation', + attrs: { + fieldId: 'signatureField', + hash: 'signatureHash', + src: 'data:image/svg+xml,%', + size: { width: 200, height: 50 }, + }, + }, + }; + + const result = translateImageNode(params); + + expect(annotationHelpers.prepareTextAnnotation).toHaveBeenCalledWith(params); + expect(result).toEqual({ type: 'text', text: 'annotation' }); + expect(params.relationships).toHaveLength(0); + expect(params.media).toEqual({}); + }); + it('should resize images inside tableCell to maxWidth', () => { baseParams.node.attrs.size = { width: 500, height: 500 }; baseParams.tableCell = { From 3b2d73ea766a77f6df8db5db01dc6ecd6c5fa3c3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:29:37 -0300 Subject: [PATCH 028/103] fix(super-editor): validate in-place svg payloads --- .../imageRegistrationPlugin.browser.test.js | 14 ++++++++++++++ .../image/imageHelpers/imageRegistrationPlugin.js | 10 +++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index 2313f391d6..9861b1f251 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -230,6 +230,20 @@ describe('handleBrowserPath', () => { expect(tr.delete).toHaveBeenCalledWith(20, 21); }); + it('does not register percent-malformed sized SVG data URI images in place', () => { + const imageNode = createImageNode({ + src: 'data:image/svg+xml,%', + size: { width: 200, height: 50 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + expect(Object.keys(editor.storage.image.media)).toHaveLength(0); + expect(addImageRelationship).not.toHaveBeenCalled(); + expect(tr.setNodeMarkup).not.toHaveBeenCalled(); + expect(tr.delete).toHaveBeenCalledWith(20, 21); + }); + it('mirrors in-place SVG media to the parent editor media store', () => { const svgDataUri = 'data:image/svg+xml;base64,PHN2Zy8+'; const parentEditor = { storage: { image: { media: {} } } }; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index 3c9780592e..e011be880d 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -203,7 +203,15 @@ const isValidSvgDataUri = (src) => { } const metadata = getDataUriMetadata(src); - return metadata?.hasPayloadSeparator === true && metadata.mimeType === 'image/svg+xml'; + if (metadata?.hasPayloadSeparator !== true || metadata.mimeType !== 'image/svg+xml') return false; + if (metadata.isBase64) return true; + + try { + decodeURIComponent(metadata.payload); + return true; + } catch { + return false; + } }; const shouldRegisterInPlace = (node) => isValidSvgDataUri(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); From 89509a3cb00cd8a1d532ed8e4dbd8eed3eed1a45 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:30:28 -0300 Subject: [PATCH 029/103] fix(super-editor): reuse target image relationships --- .../wp/helpers/decode-image-node-helpers.js | 6 ++++- .../helpers/decode-image-node-helpers.test.js | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 7dd2938fc6..c2015ca886 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -310,7 +310,11 @@ export const translateImageNode = (params) => { const path = getMediaTargetForImageSrc(params, src); if (!path) return fallbackForMissingMediaTarget(params); - const existingRelation = findImageRelationship(params.relationships, { target: path }); + const relationships = [ + ...(params.relationships || []), + ...(params.isHeaderFooter ? params.existingRelationships || [] : getDocumentRelationships(params)), + ]; + const existingRelation = findImageRelationship(relationships, { target: path }); imageId = existingRelation?.attributes?.Id ?? addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index d7cc73909e..28044ae075 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -220,6 +220,31 @@ describe('translateImageNode', () => { expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); }); + it('should reuse document relationship by target when image rId is missing', () => { + baseParams.node.attrs = { + src: 'word/media/test.png', + size: { width: 100, height: 50 }, + }; + baseParams.converter.convertedXml['word/_rels/document.xml.rels'].elements[0].elements.push({ + type: 'element', + name: 'Relationship', + attributes: { + Id: 'rIdDocumentImage', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: 'media/test.png', + }, + }); + + const result = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(0); + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(blip.attributes['r:embed']).toBe('rIdDocumentImage'); + }); + it('should not export non-base64 raster data URI media', () => { baseParams.node.attrs = { src: 'data:image/png,not-base64', From 08ae36697efc4250c6f031c0d365481101bf1f4c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:33:12 -0300 Subject: [PATCH 030/103] fix(shared): centralize image data url policy --- .../painters/dom/src/renderer.ts | 32 ++++++------------- .../wp/helpers/decode-image-node-helpers.js | 16 ++-------- .../imageHelpers/imageRegistrationPlugin.js | 5 ++- shared/url-validation/index.d.ts | 4 +++ shared/url-validation/index.js | 22 +++++++++++++ shared/url-validation/index.test.js | 18 ++++++++++- 6 files changed, 56 insertions(+), 41 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index fbc62f12b2..ea98dac458 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -78,7 +78,12 @@ import { import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; import { toCssFontFamily } from '@superdoc/font-utils'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; +import { + encodeTooltip, + IMAGE_DATA_URL_MIME_TYPES, + MAX_IMAGE_DATA_URL_LENGTH, + sanitizeHref, +} from '@superdoc/url-validation'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; import { @@ -924,27 +929,8 @@ const MAX_HREF_LENGTH = 2048; const SAFE_ANCHOR_PATTERN = /^[A-Za-z0-9._-]+$/; -/** - * Maximum allowed length for data URLs (10MB). - * Prevents denial of service attacks from extremely large embedded images. - */ -const MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; // 10MB - -const VALID_IMAGE_DATA_URL_MIME_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/svg+xml', - 'image/webp', - 'image/bmp', - 'image/ico', - 'image/tif', - 'image/tiff', -]); - function isValidImageDataUrl(src: string): boolean { - if (!src.startsWith('data:') || src.length > MAX_DATA_URL_LENGTH) { + if (!src.startsWith('data:') || src.length > MAX_IMAGE_DATA_URL_LENGTH) { return false; } @@ -956,7 +942,7 @@ function isValidImageDataUrl(src: string): boolean { const metadata = src.slice('data:'.length, metadataEnd); const [rawMimeType = '', ...rawParameters] = metadata.split(';'); const mimeType = rawMimeType.toLowerCase(); - if (!VALID_IMAGE_DATA_URL_MIME_TYPES.has(mimeType)) { + if (!IMAGE_DATA_URL_MIME_TYPES.includes(mimeType)) { return false; } @@ -6001,7 +5987,7 @@ export class DomPainter { * * SECURITY NOTES: * - Data URLs are validated against an allowlist of image MIME types - * - Size limit (MAX_DATA_URL_LENGTH) prevents DoS attacks from extremely large images + * - Size limit prevents DoS attacks from extremely large images * - Only allows safe image MIME types; non-base64 data URLs are limited to SVG * - Non-data URLs are sanitized through sanitizeUrl to prevent XSS * diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index c2015ca886..b4fedb53b5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -9,26 +9,14 @@ import { wrapTextInRun } from '@converter/exporter.js'; import { generateDocxRandomId } from '@core/helpers/index.js'; import { readImageDimensionsFromDataUri } from '@converter/image-dimensions.js'; import { simpleStringHash } from '@core/utilities/hash.js'; +import { IMAGE_DATA_URL_MIME_TYPES } from '@superdoc/url-validation'; const DECORATIVE_EXT_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}'; const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/decorative'; const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'; -const EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/svg+xml', - 'image/webp', - 'image/bmp', - 'image/ico', - 'image/tif', - 'image/tiff', -]); - function isExportableDataUriMetadata(metadata) { - if (!metadata?.hasPayloadSeparator || !EXPORTABLE_IMAGE_DATA_URI_MIME_TYPES.has(metadata.mimeType)) return false; + if (!metadata?.hasPayloadSeparator || !IMAGE_DATA_URL_MIME_TYPES.includes(metadata.mimeType)) return false; if (metadata.isBase64) return true; if (metadata.mimeType !== 'image/svg+xml') return false; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index e011be880d..741abca7ab 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -7,9 +7,8 @@ import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js'; import { addImageRelationship } from '@extensions/image/imageHelpers/startImageUpload.js'; import { getDataUriMetadata } from '@converter/helpers/mediaHelpers.js'; -import { isRelativeUrl } from '@superdoc/url-validation'; +import { isRelativeUrl, MAX_IMAGE_DATA_URL_LENGTH } from '@superdoc/url-validation'; const key = new PluginKey('ImageRegistration'); -const MAX_IN_PLACE_DATA_URL_LENGTH = 10 * 1024 * 1024; /** * Determines whether an image node still needs to go through the registration flow. @@ -198,7 +197,7 @@ const hasFinitePositiveSize = (size) => const isSvgFile = (file) => file?.type === 'image/svg+xml'; const isValidSvgDataUri = (src) => { - if (typeof src !== 'string' || !src.startsWith('data:') || src.length > MAX_IN_PLACE_DATA_URL_LENGTH) { + if (typeof src !== 'string' || !src.startsWith('data:') || src.length > MAX_IMAGE_DATA_URL_LENGTH) { return false; } diff --git a/shared/url-validation/index.d.ts b/shared/url-validation/index.d.ts index 39a49ad26c..02b41fcc81 100644 --- a/shared/url-validation/index.d.ts +++ b/shared/url-validation/index.d.ts @@ -87,6 +87,10 @@ export function encodeTooltip(raw: string | null | undefined, maxLength?: number export const DEFAULT_TOOLTIP_MAX_LENGTH: number; +export const MAX_IMAGE_DATA_URL_LENGTH: number; + +export const IMAGE_DATA_URL_MIME_TYPES: readonly string[]; + export const UrlValidationConstants: { DEFAULT_ALLOWED_PROTOCOLS: string[]; OPTIONAL_PROTOCOLS: string[]; diff --git a/shared/url-validation/index.js b/shared/url-validation/index.js index 1f88860d17..7d840ad670 100644 --- a/shared/url-validation/index.js +++ b/shared/url-validation/index.js @@ -64,6 +64,28 @@ const BLOCKED_PROTOCOLS = ['javascript', 'data', 'vbscript', 'file', 'ssh', 'ws' */ const DEFAULT_MAX_LENGTH = 2048; +/** + * Maximum allowed length for image data URLs. + * Prevents resource exhaustion from extremely large embedded images. + */ +export const MAX_IMAGE_DATA_URL_LENGTH = 10 * 1024 * 1024; + +/** + * Canonical set of image data URL MIME types supported by rendering and export. + */ +export const IMAGE_DATA_URL_MIME_TYPES = Object.freeze([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/bmp', + 'image/ico', + 'image/tif', + 'image/tiff', +]); + /** * Default maximum tooltip length in characters. * diff --git a/shared/url-validation/index.test.js b/shared/url-validation/index.test.js index 247b5d97c2..78a401790b 100644 --- a/shared/url-validation/index.test.js +++ b/shared/url-validation/index.test.js @@ -1,7 +1,23 @@ import { describe, expect, it, beforeEach, afterEach, spyOn } from 'bun:test'; -import { sanitizeHref, encodeTooltip, UrlValidationConstants, buildAllowedProtocols, isRelativeUrl } from './index.js'; +import { + sanitizeHref, + encodeTooltip, + UrlValidationConstants, + buildAllowedProtocols, + isRelativeUrl, + IMAGE_DATA_URL_MIME_TYPES, + MAX_IMAGE_DATA_URL_LENGTH, +} from './index.js'; describe('url-validation', () => { + describe('image data URL policy', () => { + it('exports the shared MIME allowlist and size cap', () => { + expect(IMAGE_DATA_URL_MIME_TYPES).toContain('image/svg+xml'); + expect(IMAGE_DATA_URL_MIME_TYPES).toContain('image/png'); + expect(MAX_IMAGE_DATA_URL_LENGTH).toBe(10 * 1024 * 1024); + }); + }); + describe('sanitizeHref', () => { it('allows fully-qualified https URLs', () => { const result = sanitizeHref('https://example.com'); From 419a77869c6c5af22c672a1ce492a753cab5237c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:33:41 -0300 Subject: [PATCH 031/103] perf(super-editor): avoid scanning data uri media --- .../v3/handlers/wp/helpers/decode-image-node-helpers.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index b4fedb53b5..30cfa4e112 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -36,14 +36,14 @@ function createMediaTargetForDataUri(params, src) { if (!extension) return null; if (!params.media) params.media = {}; - const existingEntry = Object.entries(params.media).find(([, value]) => value === src); - if (existingEntry?.[0]?.startsWith('word/')) { - return existingEntry[0].slice(5); - } const fileBaseName = sanitizeDocxMediaName(`image-${simpleStringHash(src)}`, 'image'); let fileName = `${fileBaseName}.${extension}`; let packagePath = `word/media/${fileName}`; + if (params.media[packagePath] === src) { + return `media/${fileName}`; + } + if (params.media[packagePath] && params.media[packagePath] !== src) { fileName = `${fileBaseName}_${generateDocxRandomId(8)}.${extension}`; packagePath = `word/media/${fileName}`; From 79ec4f765f1058bfa4a80156b52cc0f644179a40 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:34:24 -0300 Subject: [PATCH 032/103] fix(super-editor): normalize image data uri extensions --- .../v1/core/super-converter/helpers.test.js | 6 ++++++ .../super-converter/helpers/mediaHelpers.js | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js index 9fac98c166..e86cdab2e5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js @@ -457,6 +457,12 @@ describe('getFallbackImageNameFromDataUri', () => { expect(getFallbackImageNameFromDataUri(dataUri)).toBe('image.svg'); }); + + it('normalizes MIME aliases to Word-compatible image extensions', () => { + expect(getFallbackImageNameFromDataUri('data:image/jpeg;base64,abc')).toBe('image.jpg'); + expect(getFallbackImageNameFromDataUri('data:image/tiff;base64,abc')).toBe('image.tif'); + expect(getFallbackImageNameFromDataUri('data:image/x-icon;base64,abc')).toBe('image.ico'); + }); }); describe('detectImageType', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index 9dd2227006..5dfe62cb13 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -5,11 +5,25 @@ export const sanitizeDocxMediaName = (value, fallback = 'image') => { return sanitized || fallback; }; +const MIME_TYPE_TO_EXTENSION = { + 'image/svg+xml': 'svg', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/tiff': 'tif', + 'image/tif': 'tif', + 'image/x-icon': 'ico', + 'image/vnd.microsoft.icon': 'ico', + 'image/ico': 'ico', +}; + export const getImageExtensionFromMimeType = (mimeType) => { - const [, subtype] = String(mimeType || '').split('/'); + const normalizedMimeType = String(mimeType || '').toLowerCase(); + if (MIME_TYPE_TO_EXTENSION[normalizedMimeType]) return MIME_TYPE_TO_EXTENSION[normalizedMimeType]; + + const [, subtype] = normalizedMimeType.split('/'); if (!subtype) return null; - return subtype.toLowerCase() === 'svg+xml' ? 'svg' : subtype.toLowerCase(); + return subtype; }; export const getDataUriMetadata = (src = '') => { From 37060e96d7b78eecad3da83eb30d2b88afb808a4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:35:06 -0300 Subject: [PATCH 033/103] refactor(super-editor): trim data uri metadata fields --- .../editors/v1/core/super-converter/helpers/mediaHelpers.js | 2 -- .../src/editors/v1/tests/helpers/mediaHelpers.test.js | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index 5dfe62cb13..a2390f5feb 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -38,11 +38,9 @@ export const getDataUriMetadata = (src = '') => { return { hasPayloadSeparator, - metadata, payload, rawMimeType, mimeType, - parameters, isBase64: parameters.some((part) => part.toLowerCase() === 'base64'), extension: getImageExtensionFromMimeType(mimeType), }; diff --git a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js index b6ebba6f1b..dbbbd6606f 100644 --- a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js +++ b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js @@ -30,7 +30,7 @@ describe('getDataUriMetadata', () => { it('extracts MIME type, base64 flag, payload, and normalized extension', () => { const result = getDataUriMetadata('data:image/svg+xml;charset=utf-8;base64,PHN2Zy8+'); - expect(result).toMatchObject({ + expect(result).toEqual({ hasPayloadSeparator: true, rawMimeType: 'image/svg+xml', mimeType: 'image/svg+xml', @@ -63,7 +63,7 @@ describe('getFallbackImageNameFromDataUri', () => { it('normalises the extension casing', () => { const dataUri = 'data:image/JPEG;base64,AAAA'; - expect(getFallbackImageNameFromDataUri(dataUri)).toBe('image.jpeg'); + expect(getFallbackImageNameFromDataUri(dataUri)).toBe('image.jpg'); }); it('returns fallback when type cannot be derived', () => { From 5cb63da80953506b21b72419f5d4e36e879ab66b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:36:20 -0300 Subject: [PATCH 034/103] refactor(super-editor): share data uri text decoding --- .../src/editors/v1/core/super-converter/helpers.js | 9 +++++---- .../v1/core/super-converter/helpers/mediaHelpers.js | 8 ++++++++ .../v1/core/super-converter/image-dimensions.js | 5 +++-- .../handlers/wp/helpers/decode-image-node-helpers.js | 8 ++------ .../v1/extensions/image/imageHelpers/handleBase64.js | 12 ++---------- .../image/imageHelpers/handleBase64.test.js | 2 +- .../image/imageHelpers/imageRegistrationPlugin.js | 9 ++------- .../editors/v1/tests/helpers/mediaHelpers.test.js | 11 +++++++++++ 8 files changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js index 0acc0d3941..8e71a016bf 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js @@ -1,6 +1,6 @@ import { parseSizeUnit } from '../utilities/index.js'; import { xml2js } from 'xml-js'; -import { getDataUriMetadata } from './helpers/mediaHelpers.js'; +import { getDataUriMetadata, tryDecodeDataUriText } from './helpers/mediaHelpers.js'; // --- Browser-compatible CRC32 (replaces buffer-crc32 to avoid Node.js Buffer dependency) --- const CRC32_TABLE = new Uint32Array(256); @@ -65,11 +65,12 @@ function dataUriToArrayBuffer(data) { throw new Error(`Unsupported non-base64 data URI media type: ${metadata.mimeType || 'unknown'}`); } - try { - return stringToUtf8ArrayBuffer(decodeURIComponent(metadata.payload)); - } catch { + const decodedPayload = tryDecodeDataUriText(metadata.payload); + if (decodedPayload == null) { throw new Error('Invalid non-base64 data URI payload'); } + + return stringToUtf8ArrayBuffer(decodedPayload); } base64 = metadata.payload; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index a2390f5feb..125a694702 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -46,6 +46,14 @@ export const getDataUriMetadata = (src = '') => { }; }; +export const tryDecodeDataUriText = (payload = '') => { + try { + return decodeURIComponent(payload); + } catch { + return null; + } +}; + export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => { const extension = getDataUriMetadata(src)?.extension; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js index 896044d9b5..0ffec8a284 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js @@ -1,5 +1,5 @@ import { base64ToUint8Array } from './helpers.js'; -import { getDataUriMetadata } from './helpers/mediaHelpers.js'; +import { getDataUriMetadata, tryDecodeDataUriText } from './helpers/mediaHelpers.js'; /** * Read intrinsic image dimensions from raw binary headers. @@ -177,7 +177,8 @@ export function readImageDimensionsFromDataUri(dataUri) { if (metadata.mimeType === 'image/svg+xml') { try { - const svgText = metadata.isBase64 ? atob(metadata.payload) : decodeURIComponent(metadata.payload); + const svgText = metadata.isBase64 ? atob(metadata.payload) : tryDecodeDataUriText(metadata.payload); + if (svgText == null) return null; return readSvgDimensions(svgText); } catch { return null; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 30cfa4e112..07053c5373 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -3,6 +3,7 @@ import { getDataUriMetadata, getFallbackImageNameFromDataUri, sanitizeDocxMediaName, + tryDecodeDataUriText, } from '@converter/helpers/mediaHelpers.js'; import { prepareTextAnnotation } from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js'; import { wrapTextInRun } from '@converter/exporter.js'; @@ -20,12 +21,7 @@ function isExportableDataUriMetadata(metadata) { if (metadata.isBase64) return true; if (metadata.mimeType !== 'image/svg+xml') return false; - try { - decodeURIComponent(metadata.payload); - return true; - } catch { - return false; - } + return tryDecodeDataUriText(metadata.payload) != null; } function createMediaTargetForDataUri(params, src) { diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index a4df1411a5..2363216805 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -1,5 +1,5 @@ // @ts-check -import { getDataUriMetadata } from '@converter/helpers/mediaHelpers.js'; +import { getDataUriMetadata, tryDecodeDataUriText } from '@converter/helpers/mediaHelpers.js'; import { simpleStringHash } from '@core/utilities/hash.js'; const DEFAULT_MIME_TYPE = 'application/octet-stream'; @@ -23,14 +23,6 @@ const decodeBase64ToBinaryString = (data) => { throw new Error('Unable to decode base64 payload in the current environment.'); }; -const decodeDataUriText = (data) => { - try { - return decodeURIComponent(data); - } catch { - return null; - } -}; - const binaryStringToBytes = (binaryString) => { const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { @@ -52,7 +44,7 @@ const extractBase64Meta = (dataUri) => { const mimeType = rawMimeType || DEFAULT_MIME_TYPE; const isBase64 = Boolean(metadata?.isBase64); const payload = metadata?.payload || ''; - const binaryString = isBase64 ? decodeBase64ToBinaryString(payload) : decodeDataUriText(payload); + const binaryString = isBase64 ? decodeBase64ToBinaryString(payload) : tryDecodeDataUriText(payload); if (binaryString == null) return null; const hash = simpleStringHash(binaryString); diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index a7f12a665d..f25e20bfe9 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -33,7 +33,7 @@ describe('handleBase64', () => { const file = base64ToFile(base64); expect(file.type).toBe('image/jpeg'); - expect(file.name).toMatch(/^image-\d+\.jpeg$/); + expect(file.name).toMatch(/^image-\d+\.jpg$/); expect(file.size).toBe(Buffer.byteLength(payload)); }); diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index 741abca7ab..46bbac48b8 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -6,7 +6,7 @@ import { urlToFile, validateUrlAccessibility } from './handleUrl'; import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js'; import { addImageRelationship } from '@extensions/image/imageHelpers/startImageUpload.js'; -import { getDataUriMetadata } from '@converter/helpers/mediaHelpers.js'; +import { getDataUriMetadata, tryDecodeDataUriText } from '@converter/helpers/mediaHelpers.js'; import { isRelativeUrl, MAX_IMAGE_DATA_URL_LENGTH } from '@superdoc/url-validation'; const key = new PluginKey('ImageRegistration'); @@ -205,12 +205,7 @@ const isValidSvgDataUri = (src) => { if (metadata?.hasPayloadSeparator !== true || metadata.mimeType !== 'image/svg+xml') return false; if (metadata.isBase64) return true; - try { - decodeURIComponent(metadata.payload); - return true; - } catch { - return false; - } + return tryDecodeDataUriText(metadata.payload) != null; }; const shouldRegisterInPlace = (node) => isValidSvgDataUri(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); diff --git a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js index dbbbd6606f..538e933801 100644 --- a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js +++ b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js @@ -3,6 +3,7 @@ import { getDataUriMetadata, getFallbackImageNameFromDataUri, sanitizeDocxMediaName, + tryDecodeDataUriText, } from '../../core/super-converter/helpers/mediaHelpers.js'; describe('sanitizeDocxMediaName', () => { @@ -71,3 +72,13 @@ describe('getFallbackImageNameFromDataUri', () => { expect(getFallbackImageNameFromDataUri('', 'custom')).toBe('custom'); }); }); + +describe('tryDecodeDataUriText', () => { + it('decodes percent-encoded data URI text payloads', () => { + expect(tryDecodeDataUriText('%3Csvg%2F%3E')).toBe(''); + }); + + it('returns null for malformed percent escapes', () => { + expect(tryDecodeDataUriText('%')).toBeNull(); + }); +}); From c60f750f5f63d15817b44ed68a723e98bd2a111a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:37:45 -0300 Subject: [PATCH 035/103] fix(pm-adapter): narrow sdt metadata overrides --- .../inline-converters/field-annotation.ts | 4 +-- .../inline-converters/structured-content.ts | 6 +--- .../pm-adapter/src/sdt/metadata.test.ts | 32 +++++++++++++++++-- .../pm-adapter/src/sdt/metadata.ts | 28 ++++++++++++++-- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts index 9f0e1ddf68..c5195c9655 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts @@ -1,4 +1,4 @@ -import type { FieldAnnotationRun, FieldAnnotationMetadata } from '@superdoc/contracts'; +import type { FieldAnnotationRun } from '@superdoc/contracts'; import type { PMNode } from '../../types.js'; import { type InlineConverterParams } from './common'; import { resolveNodeSdtMetadata } from '../../sdt/index.js'; @@ -16,7 +16,7 @@ import { resolveNodeSdtMetadata } from '../../sdt/index.js'; * @returns FieldAnnotationRun object with all extracted properties */ export function fieldAnnotationNodeToRun({ node, positions }: InlineConverterParams): FieldAnnotationRun { - const fieldMetadata = resolveNodeSdtMetadata(node, 'fieldAnnotation') as FieldAnnotationMetadata | null; + const fieldMetadata = resolveNodeSdtMetadata(node, 'fieldAnnotation'); // If there's inner content, extract text to use as displayLabel override let contentText: string | undefined; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts index 664a38d70a..87ed9ec1e1 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts @@ -19,11 +19,7 @@ export function structuredContentNodeToBlocks({ const inlineMetadata = resolveNodeSdtMetadata(node, 'structuredContent'); const nextSdt = inlineMetadata ?? sdtMetadata; - if ( - inlineMetadata?.type === 'structuredContent' && - inlineMetadata.scope === 'inline' && - (!node.content || node.content.length === 0) - ) { + if (inlineMetadata?.scope === 'inline' && (!node.content || node.content.length === 0)) { const pos = positions.get(node); const contentPos = pos ? pos.start + 1 : undefined; const placeholder: TextRun = { diff --git a/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts b/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts index 5205f1c172..8d5514a1d8 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts @@ -2,7 +2,7 @@ * Tests for SDT Metadata Module */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, expectTypeOf } from 'vitest'; import { hasInstruction, getNodeInstruction, @@ -14,7 +14,16 @@ import { applySdtMetadataToListBlock, } from './metadata.js'; import type { PMNode } from '../types.js'; -import type { ParagraphBlock, TableBlock, ListBlock, SdtMetadata } from '@superdoc/contracts'; +import type { + ParagraphBlock, + TableBlock, + ListBlock, + SdtMetadata, + FieldAnnotationMetadata, + StructuredContentMetadata, + DocumentSectionMetadata, + DocPartMetadata, +} from '@superdoc/contracts'; describe('metadata', () => { describe('hasInstruction', () => { @@ -176,6 +185,25 @@ describe('metadata', () => { // Both calls should return the same cached object expect(result1).toBe(result2); }); + + it('narrows the return type when a literal override is provided', () => { + const node = { type: 'fieldAnnotation', attrs: { fieldId: 'field-1' } } as PMNode; + + expectTypeOf(resolveNodeSdtMetadata(node)).toEqualTypeOf(); + expectTypeOf(resolveNodeSdtMetadata(node, 'fieldAnnotation')).toEqualTypeOf< + FieldAnnotationMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'structuredContent')).toEqualTypeOf< + StructuredContentMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'structuredContentBlock')).toEqualTypeOf< + StructuredContentMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'documentSection')).toEqualTypeOf< + DocumentSectionMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'docPartObject')).toEqualTypeOf(); + }); }); describe('applySdtMetadataToParagraphBlocks', () => { diff --git a/packages/layout-engine/pm-adapter/src/sdt/metadata.ts b/packages/layout-engine/pm-adapter/src/sdt/metadata.ts index 3953190518..322bad72e5 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/metadata.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/metadata.ts @@ -6,10 +6,29 @@ * document sections, TOC entries, structured content blocks, etc. */ -import type { FlowBlock, TableBlock, ListBlock, SdtMetadata } from '@superdoc/contracts'; +import type { + FlowBlock, + TableBlock, + ListBlock, + SdtMetadata, + FieldAnnotationMetadata, + StructuredContentMetadata, + DocumentSectionMetadata, + DocPartMetadata, +} from '@superdoc/contracts'; import type { PMNode } from '../types.js'; import { resolveSdtMetadata } from '@superdoc/style-engine'; +type SdtMetadataForOverride = TOverride extends 'fieldAnnotation' + ? FieldAnnotationMetadata + : TOverride extends 'structuredContent' | 'structuredContentBlock' + ? StructuredContentMetadata + : TOverride extends 'documentSection' + ? DocumentSectionMetadata + : TOverride extends 'docPartObject' + ? DocPartMetadata + : SdtMetadata; + /** * Type guard to check if a node has instruction attribute. */ @@ -57,7 +76,10 @@ export function getDocPartObjectId(node: PMNode): string | undefined { * @param overrideType - Optional type override (e.g., 'documentSection', 'docPartObject') * @returns Resolved SDT metadata, or undefined if none */ -export function resolveNodeSdtMetadata(node: PMNode, overrideType?: string): SdtMetadata | undefined { +export function resolveNodeSdtMetadata( + node: PMNode, + overrideType?: TOverride, +): SdtMetadataForOverride | undefined { const attrs = node.attrs; if (!attrs) return undefined; const nodeType = overrideType ?? node.type; @@ -74,7 +96,7 @@ export function resolveNodeSdtMetadata(node: PMNode, overrideType?: string): Sdt nodeType, attrs, cacheKey, - }); + }) as SdtMetadataForOverride | undefined; } /** From e0ecf260e87fd099391a613eae6980a5461871d0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 15:58:53 -0300 Subject: [PATCH 036/103] fix(super-editor): register preset raster data uris in place --- .../imageRegistrationPlugin.browser.test.js | 24 +++++++++++++++++++ .../imageHelpers/imageRegistrationPlugin.js | 10 ++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index 9861b1f251..710af05006 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -156,6 +156,30 @@ describe('handleBrowserPath', () => { expect(tr.delete).toHaveBeenCalledTimes(2); }); + it('registers sized raster data URI images in place without placeholder deletion', () => { + const pngDataUri = 'data:image/png;base64,iVBORw0KGgo='; + const imageNode = createImageNode({ + src: pngDataUri, + size: { width: 20, height: 10 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + expect(Decoration.widget).not.toHaveBeenCalled(); + expect(tr.delete).not.toHaveBeenCalled(); + expect(checkAndProcessImage).not.toHaveBeenCalled(); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + expect(addImageRelationship).toHaveBeenCalledWith({ + editor, + path: expect.stringMatching(/^media\/image-\d+\.png$/), + }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith(20, undefined, { + ...imageNode.attrs, + src: expect.stringMatching(/^word\/media\/image-\d+\.png$/), + rId: 'rId99', + }); + }); + it('deletes non-relative image nodes in descending position order', () => { const foundImages = [ { node: createImageNode({ src: 'https://a.com/1.png' }), pos: 5, id: {} }, diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index 46bbac48b8..ae7e703bbf 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -7,7 +7,7 @@ import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js'; import { addImageRelationship } from '@extensions/image/imageHelpers/startImageUpload.js'; import { getDataUriMetadata, tryDecodeDataUriText } from '@converter/helpers/mediaHelpers.js'; -import { isRelativeUrl, MAX_IMAGE_DATA_URL_LENGTH } from '@superdoc/url-validation'; +import { IMAGE_DATA_URL_MIME_TYPES, isRelativeUrl, MAX_IMAGE_DATA_URL_LENGTH } from '@superdoc/url-validation'; const key = new PluginKey('ImageRegistration'); /** @@ -196,19 +196,21 @@ const hasFinitePositiveSize = (size) => const isSvgFile = (file) => file?.type === 'image/svg+xml'; -const isValidSvgDataUri = (src) => { +const isValidInPlaceDataUri = (src) => { if (typeof src !== 'string' || !src.startsWith('data:') || src.length > MAX_IMAGE_DATA_URL_LENGTH) { return false; } const metadata = getDataUriMetadata(src); - if (metadata?.hasPayloadSeparator !== true || metadata.mimeType !== 'image/svg+xml') return false; + if (metadata?.hasPayloadSeparator !== true || !IMAGE_DATA_URL_MIME_TYPES.includes(metadata.mimeType)) return false; if (metadata.isBase64) return true; + if (metadata.mimeType !== 'image/svg+xml') return false; return tryDecodeDataUriText(metadata.payload) != null; }; -const shouldRegisterInPlace = (node) => isValidSvgDataUri(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); +const shouldRegisterInPlace = (node) => + isValidInPlaceDataUri(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); const getOrInitMediaStore = (editor) => { if (!editor?.storage?.image?.media) { From 71fb7d8b61f57e4ac7fb14bf234545fc257338d2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:00:15 -0300 Subject: [PATCH 037/103] fix(super-editor): validate oversized async svg images --- .../imageRegistrationPlugin.browser.test.js | 25 ++++++++++++++++++- .../imageHelpers/imageRegistrationPlugin.js | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index 710af05006..6f34ec03db 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -67,7 +67,7 @@ vi.mock('./fileNameUtils.js', () => ({ // ── Imports (after mocks) ───────────────────────────────────────────── import { Decoration } from 'prosemirror-view'; import { handleBrowserPath } from './imageRegistrationPlugin.js'; -import { getBase64FileMeta } from './handleBase64'; +import { base64ToFile, getBase64FileMeta } from './handleBase64'; import { urlToFile, validateUrlAccessibility } from './handleUrl'; import { addImageRelationship, checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; @@ -268,6 +268,29 @@ describe('handleBrowserPath', () => { expect(tr.delete).toHaveBeenCalledWith(20, 21); }); + it('runs oversized async SVG files through image validation before upload', async () => { + const oversizedSvgDataUri = `data:image/svg+xml;base64,${'A'.repeat(10 * 1024 * 1024 + 1)}`; + const oversizedSvgFile = new File(['x'.repeat(10 * 1024 * 1024 + 1)], 'too-large.svg', { + type: 'image/svg+xml', + }); + const imageNode = createImageNode({ + src: oversizedSvgDataUri, + size: { width: 200, height: 50 }, + }); + base64ToFile.mockReturnValueOnce(oversizedSvgFile); + checkAndProcessImage.mockResolvedValueOnce({ file: null, size: { width: 0, height: 0 } }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + await flushPromises(); + + expect(checkAndProcessImage).toHaveBeenCalledWith({ + getMaxContentSize: expect.any(Function), + file: oversizedSvgFile, + }); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + expect(view.dispatch).toHaveBeenCalled(); + }); + it('mirrors in-place SVG media to the parent editor media store', () => { const svgDataUri = 'data:image/svg+xml;base64,PHN2Zy8+'; const parentEditor = { storage: { image: { media: {} } } }; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index ae7e703bbf..a530388213 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -528,7 +528,7 @@ const registerImages = async (foundImages, editor, view) => { } try { - if (isSvgFile(file) && hasFinitePositiveSize(image.node.attrs?.size)) { + if (isSvgFile(file) && hasFinitePositiveSize(image.node.attrs?.size) && file.size <= MAX_IMAGE_DATA_URL_LENGTH) { await uploadAndInsertImage({ editor, view, file, size: image.node.attrs.size, id }); } else { const process = await checkAndProcessImage({ From 2d9c2d6267d81354f0d8b86a9b279129397c6cb9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:00:56 -0300 Subject: [PATCH 038/103] fix(super-editor): reject raw raster data uri dimensions --- .../v1/core/super-converter/image-dimensions.js | 6 +++--- .../core/super-converter/image-dimensions.test.js | 14 +++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js index 0ffec8a284..5b735f3637 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js @@ -185,10 +185,10 @@ export function readImageDimensionsFromDataUri(dataUri) { } } + if (!metadata.isBase64) return null; + try { - const bytes = metadata.isBase64 - ? base64ToUint8Array(metadata.payload) - : new globalThis.TextEncoder().encode(metadata.payload); + const bytes = base64ToUint8Array(metadata.payload); return readImageDimensions(bytes); } catch { return null; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js index e70835b03c..828c090c90 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js @@ -1,6 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { readImageDimensions, readImageDimensionsFromDataUri } from './image-dimensions.js'; +afterEach(() => { + vi.unstubAllGlobals(); +}); + // --------------------------------------------------------------------------- // Helpers to build minimal valid headers // --------------------------------------------------------------------------- @@ -222,6 +226,14 @@ describe('readImageDimensionsFromDataUri', () => { expect(readImageDimensionsFromDataUri(uri)).toEqual({ width: 200, height: 50 }); }); + it('rejects non-base64 raster data URIs without encoding payload text as bytes', () => { + const textEncoderConstructor = vi.fn(() => ({ encode: vi.fn(() => new Uint8Array()) })); + vi.stubGlobal('TextEncoder', textEncoderConstructor); + + expect(readImageDimensionsFromDataUri('data:image/png,not-base64')).toBeNull(); + expect(textEncoderConstructor).not.toHaveBeenCalled(); + }); + it('returns null for non-data-URI string', () => { expect(readImageDimensionsFromDataUri('https://example.com/image.png')).toBeNull(); }); From 31c3c326d766e2ee251912ea35b3351c34019e84 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:01:30 -0300 Subject: [PATCH 039/103] fix(super-editor): avoid non-image data uri extensions --- .../editors/v1/core/super-converter/helpers/mediaHelpers.js | 4 ++-- .../src/editors/v1/tests/helpers/mediaHelpers.test.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index 125a694702..d5dd80ca26 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -20,8 +20,8 @@ export const getImageExtensionFromMimeType = (mimeType) => { const normalizedMimeType = String(mimeType || '').toLowerCase(); if (MIME_TYPE_TO_EXTENSION[normalizedMimeType]) return MIME_TYPE_TO_EXTENSION[normalizedMimeType]; - const [, subtype] = normalizedMimeType.split('/'); - if (!subtype) return null; + const [type, subtype] = normalizedMimeType.split('/'); + if (type !== 'image' || !subtype) return null; return subtype; }; diff --git a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js index 538e933801..34bddf0125 100644 --- a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js +++ b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js @@ -69,6 +69,7 @@ describe('getFallbackImageNameFromDataUri', () => { it('returns fallback when type cannot be derived', () => { expect(getFallbackImageNameFromDataUri('data:,')).toBe('image'); + expect(getFallbackImageNameFromDataUri('data:text/html,%3Cp%3Ebad%3C%2Fp%3E')).toBe('image'); expect(getFallbackImageNameFromDataUri('', 'custom')).toBe('custom'); }); }); From 4e6670a4c3942e3da32e331585e427417938e501 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:03:09 -0300 Subject: [PATCH 040/103] refactor(shared): centralize image data uri parsing --- .../painters/dom/src/renderer.ts | 27 +------------ .../super-converter/helpers/mediaHelpers.js | 28 ++++--------- .../imageHelpers/imageRegistrationPlugin.js | 19 +-------- shared/url-validation/index.d.ts | 14 +++++++ shared/url-validation/index.js | 40 +++++++++++++++++++ shared/url-validation/index.test.js | 20 ++++++++++ 6 files changed, 84 insertions(+), 64 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ea98dac458..7a3d6bb090 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -78,12 +78,7 @@ import { import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; import { toCssFontFamily } from '@superdoc/font-utils'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import { - encodeTooltip, - IMAGE_DATA_URL_MIME_TYPES, - MAX_IMAGE_DATA_URL_LENGTH, - sanitizeHref, -} from '@superdoc/url-validation'; +import { encodeTooltip, isValidImageDataUrl, sanitizeHref } from '@superdoc/url-validation'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; import { @@ -929,26 +924,6 @@ const MAX_HREF_LENGTH = 2048; const SAFE_ANCHOR_PATTERN = /^[A-Za-z0-9._-]+$/; -function isValidImageDataUrl(src: string): boolean { - if (!src.startsWith('data:') || src.length > MAX_IMAGE_DATA_URL_LENGTH) { - return false; - } - - const metadataEnd = src.indexOf(','); - if (metadataEnd === -1) { - return false; - } - - const metadata = src.slice('data:'.length, metadataEnd); - const [rawMimeType = '', ...rawParameters] = metadata.split(';'); - const mimeType = rawMimeType.toLowerCase(); - if (!IMAGE_DATA_URL_MIME_TYPES.includes(mimeType)) { - return false; - } - - const isBase64 = rawParameters.some((parameter) => parameter.toLowerCase() === 'base64'); - return isBase64 || mimeType === 'image/svg+xml'; -} const SVG_NS = 'http://www.w3.org/2000/svg'; const WORDART_LINE_FILL_RATIO = 0.9; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index d5dd80ca26..8021ccd0a0 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -1,3 +1,5 @@ +import { getDataUriMetadata as getSharedDataUriMetadata, tryDecodeDataUriText } from '@superdoc/url-validation'; + export const sanitizeDocxMediaName = (value, fallback = 'image') => { if (!value) return fallback; @@ -27,32 +29,16 @@ export const getImageExtensionFromMimeType = (mimeType) => { }; export const getDataUriMetadata = (src = '') => { - if (typeof src !== 'string' || !src.startsWith('data:')) return null; - - const commaIndex = src.indexOf(','); - const hasPayloadSeparator = commaIndex !== -1; - const metadata = src.slice(5, hasPayloadSeparator ? commaIndex : undefined); - const payload = hasPayloadSeparator ? src.slice(commaIndex + 1) : ''; - const [rawMimeType = '', ...parameters] = metadata.split(';'); - const mimeType = rawMimeType.toLowerCase(); + const metadata = getSharedDataUriMetadata(src); + if (!metadata) return null; return { - hasPayloadSeparator, - payload, - rawMimeType, - mimeType, - isBase64: parameters.some((part) => part.toLowerCase() === 'base64'), - extension: getImageExtensionFromMimeType(mimeType), + ...metadata, + extension: getImageExtensionFromMimeType(metadata.mimeType), }; }; -export const tryDecodeDataUriText = (payload = '') => { - try { - return decodeURIComponent(payload); - } catch { - return null; - } -}; +export { tryDecodeDataUriText }; export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => { const extension = getDataUriMetadata(src)?.extension; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index a530388213..ff9177ff2d 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -6,8 +6,7 @@ import { urlToFile, validateUrlAccessibility } from './handleUrl'; import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js'; import { addImageRelationship } from '@extensions/image/imageHelpers/startImageUpload.js'; -import { getDataUriMetadata, tryDecodeDataUriText } from '@converter/helpers/mediaHelpers.js'; -import { IMAGE_DATA_URL_MIME_TYPES, isRelativeUrl, MAX_IMAGE_DATA_URL_LENGTH } from '@superdoc/url-validation'; +import { isRelativeUrl, isValidImageDataUrl, MAX_IMAGE_DATA_URL_LENGTH } from '@superdoc/url-validation'; const key = new PluginKey('ImageRegistration'); /** @@ -196,21 +195,7 @@ const hasFinitePositiveSize = (size) => const isSvgFile = (file) => file?.type === 'image/svg+xml'; -const isValidInPlaceDataUri = (src) => { - if (typeof src !== 'string' || !src.startsWith('data:') || src.length > MAX_IMAGE_DATA_URL_LENGTH) { - return false; - } - - const metadata = getDataUriMetadata(src); - if (metadata?.hasPayloadSeparator !== true || !IMAGE_DATA_URL_MIME_TYPES.includes(metadata.mimeType)) return false; - if (metadata.isBase64) return true; - if (metadata.mimeType !== 'image/svg+xml') return false; - - return tryDecodeDataUriText(metadata.payload) != null; -}; - -const shouldRegisterInPlace = (node) => - isValidInPlaceDataUri(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); +const shouldRegisterInPlace = (node) => isValidImageDataUrl(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); const getOrInitMediaStore = (editor) => { if (!editor?.storage?.image?.media) { diff --git a/shared/url-validation/index.d.ts b/shared/url-validation/index.d.ts index 02b41fcc81..ab21b0152d 100644 --- a/shared/url-validation/index.d.ts +++ b/shared/url-validation/index.d.ts @@ -91,6 +91,20 @@ export const MAX_IMAGE_DATA_URL_LENGTH: number; export const IMAGE_DATA_URL_MIME_TYPES: readonly string[]; +export type DataUriMetadata = { + hasPayloadSeparator: boolean; + payload: string; + rawMimeType: string; + mimeType: string; + isBase64: boolean; +}; + +export function getDataUriMetadata(src?: string): DataUriMetadata | null; + +export function tryDecodeDataUriText(payload?: string): string | null; + +export function isValidImageDataUrl(src: unknown): boolean; + export const UrlValidationConstants: { DEFAULT_ALLOWED_PROTOCOLS: string[]; OPTIONAL_PROTOCOLS: string[]; diff --git a/shared/url-validation/index.js b/shared/url-validation/index.js index 7d840ad670..0fc0955dcb 100644 --- a/shared/url-validation/index.js +++ b/shared/url-validation/index.js @@ -86,6 +86,46 @@ export const IMAGE_DATA_URL_MIME_TYPES = Object.freeze([ 'image/tiff', ]); +export const getDataUriMetadata = (src = '') => { + if (typeof src !== 'string' || !src.startsWith('data:')) return null; + + const commaIndex = src.indexOf(','); + const hasPayloadSeparator = commaIndex !== -1; + const metadata = src.slice(5, hasPayloadSeparator ? commaIndex : undefined); + const payload = hasPayloadSeparator ? src.slice(commaIndex + 1) : ''; + const [rawMimeType = '', ...parameters] = metadata.split(';'); + const mimeType = rawMimeType.toLowerCase(); + + return { + hasPayloadSeparator, + payload, + rawMimeType, + mimeType, + isBase64: parameters.some((part) => part.toLowerCase() === 'base64'), + }; +}; + +export const tryDecodeDataUriText = (payload = '') => { + try { + return decodeURIComponent(payload); + } catch { + return null; + } +}; + +export const isValidImageDataUrl = (src) => { + if (typeof src !== 'string' || !src.startsWith('data:') || src.length > MAX_IMAGE_DATA_URL_LENGTH) { + return false; + } + + const metadata = getDataUriMetadata(src); + if (!metadata?.hasPayloadSeparator || !IMAGE_DATA_URL_MIME_TYPES.includes(metadata.mimeType)) return false; + if (metadata.isBase64) return true; + if (metadata.mimeType !== 'image/svg+xml') return false; + + return tryDecodeDataUriText(metadata.payload) != null; +}; + /** * Default maximum tooltip length in characters. * diff --git a/shared/url-validation/index.test.js b/shared/url-validation/index.test.js index 78a401790b..f3f149a88f 100644 --- a/shared/url-validation/index.test.js +++ b/shared/url-validation/index.test.js @@ -7,6 +7,8 @@ import { isRelativeUrl, IMAGE_DATA_URL_MIME_TYPES, MAX_IMAGE_DATA_URL_LENGTH, + getDataUriMetadata, + isValidImageDataUrl, } from './index.js'; describe('url-validation', () => { @@ -16,6 +18,24 @@ describe('url-validation', () => { expect(IMAGE_DATA_URL_MIME_TYPES).toContain('image/png'); expect(MAX_IMAGE_DATA_URL_LENGTH).toBe(10 * 1024 * 1024); }); + + it('parses data URI metadata once for shared consumers', () => { + expect(getDataUriMetadata('data:image/svg+xml;charset=utf-8;base64,PHN2Zy8+')).toEqual({ + hasPayloadSeparator: true, + payload: 'PHN2Zy8+', + rawMimeType: 'image/svg+xml', + mimeType: 'image/svg+xml', + isBase64: true, + }); + }); + + it('validates image data URLs with the shared renderer/export policy', () => { + expect(isValidImageDataUrl('data:image/png;base64,abc')).toBe(true); + expect(isValidImageDataUrl('data:image/svg+xml,%3Csvg%2F%3E')).toBe(true); + expect(isValidImageDataUrl('data:image/png,not-base64')).toBe(false); + expect(isValidImageDataUrl('data:text/html,%3Cp%3Ebad%3C%2Fp%3E')).toBe(false); + expect(isValidImageDataUrl('data:image/svg+xml,%')).toBe(false); + }); }); describe('sanitizeHref', () => { From 3368c735f020487fe42b9c3f3db83bda73e27e49 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:03:48 -0300 Subject: [PATCH 041/103] refactor(super-editor): share image relationship export lookup --- .../wp/helpers/decode-image-node-helpers.js | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 07053c5373..a3914a9b53 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -272,34 +272,14 @@ export const translateImageNode = (params) => { if (w && h) size = { w, h }; } - if (imageId) { + if (imageId || params.node.type === 'image') { const path = getMediaTargetForImageSrc(params, src); if (!path) return fallbackForMissingMediaTarget(params); - const relationships = [ - ...(params.relationships || []), - ...(params.isHeaderFooter ? params.existingRelationships || [] : getDocumentRelationships(params)), - ]; - const existingRelation = findImageRelationship(relationships, { + imageId = resolveImageRelationshipId(params, { id: imageId, - target: path, + path, }); - - if (existingRelation) { - imageId = existingRelation.attributes.Id; - } else { - addImageRelationshipForId(params, imageId, path); - } - } else if (params.node.type === 'image' && !imageId) { - const path = getMediaTargetForImageSrc(params, src); - if (!path) return fallbackForMissingMediaTarget(params); - - const relationships = [ - ...(params.relationships || []), - ...(params.isHeaderFooter ? params.existingRelationships || [] : getDocumentRelationships(params)), - ]; - const existingRelation = findImageRelationship(relationships, { target: path }); - imageId = existingRelation?.attributes?.Id ?? addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. const metadata = getDataUriMetadata(src); @@ -580,6 +560,7 @@ function addImageRelationshipForId(params, id, imagePath) { }, }; params.relationships.push(newRel); + return id; } function getDocumentRelationships(params) { @@ -588,6 +569,24 @@ function getDocumentRelationships(params) { return rels?.elements?.find((el) => el.name === 'Relationships')?.elements ?? []; } +function getImageRelationshipLookup(params) { + return [ + ...(params.relationships || []), + ...(params.isHeaderFooter ? params.existingRelationships || [] : getDocumentRelationships(params)), + ]; +} + +function resolveImageRelationshipId(params, { id, path }) { + const existingRelation = findImageRelationship(getImageRelationshipLookup(params), { + ...(id ? { id } : {}), + target: path, + }); + + if (existingRelation) return existingRelation.attributes.Id; + if (id) return addImageRelationshipForId(params, id, path); + return addNewImageRelationship(params, path); +} + function findImageRelationship(relationships = [], { id, target }) { return relationships.find((rel) => { if (rel?.attributes?.Type !== IMAGE_REL_TYPE) return false; From 3f0360f76e4ed1c8afeb738a1c6e78c2add9e833 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:27:34 -0300 Subject: [PATCH 042/103] fix(super-editor): enforce upload byte cap for data uris --- .../imageRegistrationPlugin.browser.test.js | 31 +++++++++++++++++-- .../imageHelpers/imageRegistrationPlugin.js | 29 ++++++++++++++--- .../image/imageHelpers/startImageUpload.js | 6 ++-- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index 6f34ec03db..31d68ef95e 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -54,6 +54,7 @@ vi.mock('./handleUrl', () => ({ })); vi.mock('./startImageUpload', () => ({ + MAX_IMAGE_FILE_BYTES: 5 * 1024 * 1024, checkAndProcessImage: vi.fn(), uploadAndInsertImage: vi.fn(), addImageRelationship: vi.fn(() => 'rId99'), @@ -180,6 +181,30 @@ describe('handleBrowserPath', () => { }); }); + it('runs oversized sized raster data URI images through image validation', async () => { + const oversizedRasterDataUri = `data:image/png;base64,${'A'.repeat(7 * 1024 * 1024)}`; + const oversizedRasterFile = new File(['x'.repeat(5 * 1024 * 1024 + 1)], 'too-large.png', { + type: 'image/png', + }); + const imageNode = createImageNode({ + src: oversizedRasterDataUri, + size: { width: 20, height: 10 }, + }); + base64ToFile.mockReturnValueOnce(oversizedRasterFile); + checkAndProcessImage.mockResolvedValueOnce({ file: null, size: { width: 0, height: 0 } }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + await flushPromises(); + + expect(Decoration.widget).toHaveBeenCalled(); + expect(tr.delete).toHaveBeenCalledWith(20, 21); + expect(checkAndProcessImage).toHaveBeenCalledWith({ + getMaxContentSize: expect.any(Function), + file: oversizedRasterFile, + }); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + }); + it('deletes non-relative image nodes in descending position order', () => { const foundImages = [ { node: createImageNode({ src: 'https://a.com/1.png' }), pos: 5, id: {} }, @@ -268,9 +293,9 @@ describe('handleBrowserPath', () => { expect(tr.delete).toHaveBeenCalledWith(20, 21); }); - it('runs oversized async SVG files through image validation before upload', async () => { - const oversizedSvgDataUri = `data:image/svg+xml;base64,${'A'.repeat(10 * 1024 * 1024 + 1)}`; - const oversizedSvgFile = new File(['x'.repeat(10 * 1024 * 1024 + 1)], 'too-large.svg', { + it('runs SVG files over the upload byte budget through image validation before upload', async () => { + const oversizedSvgDataUri = `data:image/svg+xml;base64,${'A'.repeat(7 * 1024 * 1024)}`; + const oversizedSvgFile = new File(['x'.repeat(5 * 1024 * 1024 + 1)], 'too-large.svg', { type: 'image/svg+xml', }); const imageNode = createImageNode({ diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index ff9177ff2d..1e85489736 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -3,10 +3,10 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; import { ReplaceStep, ReplaceAroundStep } from 'prosemirror-transform'; import { base64ToFile, getBase64FileMeta } from './handleBase64'; import { urlToFile, validateUrlAccessibility } from './handleUrl'; -import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; +import { checkAndProcessImage, MAX_IMAGE_FILE_BYTES, uploadAndInsertImage } from './startImageUpload'; import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js'; import { addImageRelationship } from '@extensions/image/imageHelpers/startImageUpload.js'; -import { isRelativeUrl, isValidImageDataUrl, MAX_IMAGE_DATA_URL_LENGTH } from '@superdoc/url-validation'; +import { getDataUriMetadata, isRelativeUrl, isValidImageDataUrl, tryDecodeDataUriText } from '@superdoc/url-validation'; const key = new PluginKey('ImageRegistration'); /** @@ -195,7 +195,28 @@ const hasFinitePositiveSize = (size) => const isSvgFile = (file) => file?.type === 'image/svg+xml'; -const shouldRegisterInPlace = (node) => isValidImageDataUrl(node.attrs?.src) && hasFinitePositiveSize(node.attrs?.size); +const getBase64PayloadByteLength = (payload = '') => { + const normalized = payload.replace(/\s/g, ''); + if (!normalized) return 0; + const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0; + return Math.floor((normalized.length * 3) / 4) - padding; +}; + +const getDataUriDecodedByteLength = (src) => { + const metadata = getDataUriMetadata(src); + if (!metadata?.hasPayloadSeparator) return null; + + if (metadata.isBase64) return getBase64PayloadByteLength(metadata.payload); + + const decoded = tryDecodeDataUriText(metadata.payload); + if (decoded == null) return null; + return new globalThis.TextEncoder().encode(decoded).byteLength; +}; + +const shouldRegisterInPlace = (node) => + isValidImageDataUrl(node.attrs?.src) && + hasFinitePositiveSize(node.attrs?.size) && + getDataUriDecodedByteLength(node.attrs.src) <= MAX_IMAGE_FILE_BYTES; const getOrInitMediaStore = (editor) => { if (!editor?.storage?.image?.media) { @@ -513,7 +534,7 @@ const registerImages = async (foundImages, editor, view) => { } try { - if (isSvgFile(file) && hasFinitePositiveSize(image.node.attrs?.size) && file.size <= MAX_IMAGE_DATA_URL_LENGTH) { + if (isSvgFile(file) && hasFinitePositiveSize(image.node.attrs?.size) && file.size <= MAX_IMAGE_FILE_BYTES) { await uploadAndInsertImage({ editor, view, file, size: image.node.attrs.size, id }); } else { const process = await checkAndProcessImage({ diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js index ba5b33c992..5d7fa062fb 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js @@ -6,10 +6,10 @@ import { generateDocxRandomId } from '@core/helpers/index.js'; import { findOrCreateRelationship } from '@core/parts/adapters/relationships-mutation.js'; import { resolveHeaderFooterRelsPartIdFromRefId } from '@core/parts/adapters/header-footer-sync.js'; -const fileTooLarge = (file) => { - let fileSizeMb = Number((file.size / (1024 * 1024)).toFixed(4)); +export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; - if (fileSizeMb > 5) { +const fileTooLarge = (file) => { + if (file.size > MAX_IMAGE_FILE_BYTES) { window.alert('Image size must be less than 5MB'); return true; } From 0b62c1dec7553ff8446c05564c4c84c469375d27 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:28:07 -0300 Subject: [PATCH 043/103] refactor(super-editor): reuse shared data uri export policy --- .../wp/helpers/decode-image-node-helpers.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index a3914a9b53..95b70b4ed0 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -3,30 +3,23 @@ import { getDataUriMetadata, getFallbackImageNameFromDataUri, sanitizeDocxMediaName, - tryDecodeDataUriText, } from '@converter/helpers/mediaHelpers.js'; import { prepareTextAnnotation } from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js'; import { wrapTextInRun } from '@converter/exporter.js'; import { generateDocxRandomId } from '@core/helpers/index.js'; import { readImageDimensionsFromDataUri } from '@converter/image-dimensions.js'; import { simpleStringHash } from '@core/utilities/hash.js'; -import { IMAGE_DATA_URL_MIME_TYPES } from '@superdoc/url-validation'; +import { isValidImageDataUrl } from '@superdoc/url-validation'; const DECORATIVE_EXT_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}'; const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/decorative'; const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'; -function isExportableDataUriMetadata(metadata) { - if (!metadata?.hasPayloadSeparator || !IMAGE_DATA_URL_MIME_TYPES.includes(metadata.mimeType)) return false; - if (metadata.isBase64) return true; - if (metadata.mimeType !== 'image/svg+xml') return false; - - return tryDecodeDataUriText(metadata.payload) != null; -} function createMediaTargetForDataUri(params, src) { + if (!isValidImageDataUrl(src)) return null; + const metadata = getDataUriMetadata(src); - if (!isExportableDataUriMetadata(metadata)) return null; const extension = metadata.extension; if (!extension) return null; @@ -282,8 +275,9 @@ export const translateImageNode = (params) => { }); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. + if (!isValidImageDataUrl(src)) return prepareTextAnnotation(params); + const metadata = getDataUriMetadata(src); - if (!isExportableDataUriMetadata(metadata)) return prepareTextAnnotation(params); const type = metadata.extension; From 5d8339e6bd8e8ccbd380c0ec2951e86d6382a713 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:28:24 -0300 Subject: [PATCH 044/103] test(shared): cover image data uri length boundary --- shared/url-validation/index.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shared/url-validation/index.test.js b/shared/url-validation/index.test.js index f3f149a88f..f9b9a96392 100644 --- a/shared/url-validation/index.test.js +++ b/shared/url-validation/index.test.js @@ -36,6 +36,14 @@ describe('url-validation', () => { expect(isValidImageDataUrl('data:text/html,%3Cp%3Ebad%3C%2Fp%3E')).toBe(false); expect(isValidImageDataUrl('data:image/svg+xml,%')).toBe(false); }); + + it('accepts image data URLs at the maximum length and rejects one byte over', () => { + const prefix = 'data:image/svg+xml,'; + const payload = 'a'.repeat(MAX_IMAGE_DATA_URL_LENGTH - prefix.length); + + expect(isValidImageDataUrl(`${prefix}${payload}`)).toBe(true); + expect(isValidImageDataUrl(`${prefix}${payload}a`)).toBe(false); + }); }); describe('sanitizeHref', () => { From 1e403b38cfa14888cdb0cf4ec2df1f30489cec94 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:29:46 -0300 Subject: [PATCH 045/103] fix(super-editor): reuse colliding data uri media targets --- .../wp/helpers/decode-image-node-helpers.js | 7 ++++ .../helpers/decode-image-node-helpers.test.js | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 95b70b4ed0..6be57550c8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -25,11 +25,17 @@ function createMediaTargetForDataUri(params, src) { if (!extension) return null; if (!params.media) params.media = {}; + if (!params.dataUriMediaTargets) params.dataUriMediaTargets = new Map(); + const cachedPackagePath = params.dataUriMediaTargets.get(src); + if (cachedPackagePath && params.media[cachedPackagePath] === src) { + return cachedPackagePath.slice('word/'.length); + } const fileBaseName = sanitizeDocxMediaName(`image-${simpleStringHash(src)}`, 'image'); let fileName = `${fileBaseName}.${extension}`; let packagePath = `word/media/${fileName}`; if (params.media[packagePath] === src) { + params.dataUriMediaTargets.set(src, packagePath); return `media/${fileName}`; } @@ -40,6 +46,7 @@ function createMediaTargetForDataUri(params, src) { const relationshipTarget = `media/${fileName}`; params.media[packagePath] = src; + params.dataUriMediaTargets.set(src, packagePath); return relationshipTarget; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 28044ae075..d64f48f57b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -4,6 +4,7 @@ import { } from '@converter/v3/handlers/wp/helpers/decode-image-node-helpers.js'; import * as helpers from '@converter/helpers.js'; import * as annotationHelpers from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js'; +import * as coreHelpers from '@core/helpers/index.js'; vi.mock('@converter/helpers.js', async (importOriginal) => { const actual = await importOriginal(); @@ -38,6 +39,10 @@ vi.mock(import('@core/helpers/index.js'), async (importOriginal) => { }; }); +vi.mock('@core/utilities/hash.js', () => ({ + simpleStringHash: vi.fn(() => '123'), +})); + describe('translateImageNode', () => { let baseParams; @@ -159,6 +164,34 @@ describe('translateImageNode', () => { expect(secondBlip.attributes['r:embed']).toBe(firstBlip.attributes['r:embed']); }); + it('should reuse the same collision media target for repeated data URI payloads', () => { + const firstSrc = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + const collidingSrc = 'data:image/svg+xml;base64,PHN2ZyBpZD0iMiI+PC9zdmc+'; + baseParams.node.attrs = { + src: firstSrc, + alt: 'First Image', + size: { width: 20, height: 10 }, + }; + translateImageNode(baseParams); + + vi.mocked(coreHelpers.generateDocxRandomId).mockClear(); + baseParams.node.attrs = { + src: collidingSrc, + alt: 'Colliding Image', + size: { width: 20, height: 10 }, + }; + + translateImageNode(baseParams); + translateImageNode(baseParams); + + expect(Object.keys(baseParams.media).sort()).toEqual(['word/media/image-123.svg', 'word/media/image-123_123.svg']); + expect(baseParams.relationships.map((rel) => rel.attributes.Target)).toEqual([ + 'media/image-123.svg', + 'media/image-123_123.svg', + ]); + expect(vi.mocked(coreHelpers.generateDocxRandomId).mock.calls.filter(([length]) => length === 8)).toHaveLength(1); + }); + it('should create a media target when a data URI image already has an rId', () => { const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; baseParams.node.attrs = { From 522173601c90011c1b143a75ef4584bb58403197 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:31:20 -0300 Subject: [PATCH 046/103] test(super-editor): roundtrip mixed image block sdts --- ...structured-content-image-roundtrip.test.js | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js index a2ee5bd669..9ebe678741 100644 --- a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js @@ -9,6 +9,8 @@ import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpe const SIGNATURE_SRC = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; const ENCODED_SIGNATURE_SVG = ''; const ENCODED_SIGNATURE_SRC = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(ENCODED_SIGNATURE_SVG)}`; +const PNG_SRC = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/Ur/9wAAAABJRU5ErkJggg=='; const findFirstNodeByType = (node, typeName) => { let found = null; @@ -34,6 +36,15 @@ const findNodeByTypeAndId = (node, typeName, id) => { return found; }; +const collectNodesByType = (node, typeName) => { + const found = []; + node.descendants((child) => { + if (child.type.name === typeName) found.push(child); + return true; + }); + return found; +}; + const collectElementsByName = (node, name, result = []) => { if (!node || typeof node !== 'object') return result; if (node.name === name) result.push(node); @@ -351,4 +362,92 @@ describe('SD-3116 structured content image round-trip', () => { expect(svgMediaEntry).toBeDefined(); expect(Buffer.from(svgMediaEntry[1], 'base64').toString('utf8')).toBe(ENCODED_SIGNATURE_SVG); }); + + it('round-trips two block SDTs with different preset image types in one document', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + expect( + editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215863', + tag: 'svg_signature_sdt', + alias: 'SVG Signature', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: SIGNATURE_SRC, + alt: 'SVG Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }), + ).toBe(true); + + expect( + editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215864', + tag: 'png_signature_sdt', + alias: 'PNG Signature', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: PNG_SRC, + alt: 'PNG Signature Example', + size: { width: 20, height: 10 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }), + ).toBe(true); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const [roundTripDocx, roundTripMedia, roundTripMediaFiles, roundTripFonts] = await Editor.loadXmlData( + exported, + true, + ); + ({ editor: reopened } = initTestEditor({ + content: roundTripDocx, + media: roundTripMedia, + mediaFiles: roundTripMediaFiles, + fonts: roundTripFonts, + isNewFile: false, + })); + + const reopenedSvgBlock = findNodeByTypeAndId(reopened.state.doc, 'structuredContentBlock', '1299215863'); + const reopenedPngBlock = findNodeByTypeAndId(reopened.state.doc, 'structuredContentBlock', '1299215864'); + const reopenedSvgImage = findFirstNodeByType(reopenedSvgBlock, 'image'); + const reopenedPngImage = findFirstNodeByType(reopenedPngBlock, 'image'); + + expect(reopenedSvgImage?.attrs).toMatchObject({ + alt: 'SVG Signature Example', + size: { width: 200, height: 50 }, + }); + expect(reopenedPngImage?.attrs).toMatchObject({ + alt: 'PNG Signature Example', + size: { width: 20, height: 10 }, + }); + expect(reopenedSvgImage?.attrs.src).toMatch(/^word\/media\/.+\.svg$/); + expect(reopenedPngImage?.attrs.src).toMatch(/^word\/media\/.+\.png$/); + expect( + new Set(collectNodesByType(reopened.state.doc, 'image').map((node) => node.attrs.src)).size, + ).toBeGreaterThanOrEqual(2); + expect(Object.keys(roundTripMediaFiles).filter((path) => /\.(svg|png)$/.test(path))).toHaveLength(2); + }); }); From c977f3022dcb5dbbe854b9b1e49c794a13fe09bc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:31:57 -0300 Subject: [PATCH 047/103] docs(shared): document image data uri helpers --- shared/url-validation/index.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/shared/url-validation/index.js b/shared/url-validation/index.js index 0fc0955dcb..f0b3bd5dfe 100644 --- a/shared/url-validation/index.js +++ b/shared/url-validation/index.js @@ -86,6 +86,16 @@ export const IMAGE_DATA_URL_MIME_TYPES = Object.freeze([ 'image/tiff', ]); +/** + * Parse a data URI into the MIME type, payload, and base64 flag used by image + * validation and DOCX export. Returns null for non-data URI strings. + * + * The payload separator is tracked separately so callers can reject malformed + * values like `data:image/svg+xml` instead of treating them as empty files. + * + * @param {string} src + * @returns {{hasPayloadSeparator: boolean, payload: string, rawMimeType: string, mimeType: string, isBase64: boolean}|null} + */ export const getDataUriMetadata = (src = '') => { if (typeof src !== 'string' || !src.startsWith('data:')) return null; @@ -105,6 +115,12 @@ export const getDataUriMetadata = (src = '') => { }; }; +/** + * Percent-decode a non-base64 data URI payload. + * + * @param {string} payload + * @returns {string|null} Decoded text, or null when the payload has invalid percent escapes. + */ export const tryDecodeDataUriText = (payload = '') => { try { return decodeURIComponent(payload); @@ -113,6 +129,17 @@ export const tryDecodeDataUriText = (payload = '') => { } }; +/** + * Validate an image data URL for rendering and export. + * + * The URL must use a supported image MIME type, include a comma payload + * separator, and stay within MAX_IMAGE_DATA_URL_LENGTH. Base64 payloads are + * accepted for all supported image MIME types. Non-base64 payloads are accepted + * only for SVG, where the percent-encoded text must decode successfully. + * + * @param {unknown} src + * @returns {boolean} + */ export const isValidImageDataUrl = (src) => { if (typeof src !== 'string' || !src.startsWith('data:') || src.length > MAX_IMAGE_DATA_URL_LENGTH) { return false; From e184217b239a0ada291925a1c501a88ccf8919bb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 16:32:05 -0300 Subject: [PATCH 048/103] docs(super-editor): clarify data uri buffer conversion --- .../src/editors/v1/core/super-converter/helpers.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js index 8e71a016bf..a18381b3d8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js @@ -39,8 +39,11 @@ function stringToUtf8ArrayBuffer(value) { } /** - * Convert a base64 string or data URI to an ArrayBuffer. - * Accepts ArrayBuffer, TypedArray, data URI, or raw base64 string. + * Convert media data to an ArrayBuffer for DOCX packaging. + * + * Accepts ArrayBuffer, TypedArray, raw base64 strings, base64 data URIs, and + * percent-encoded non-base64 SVG data URIs. Other non-base64 data URI MIME + * types are rejected, and malformed percent-encoded SVG payloads throw. * * @param {string|ArrayBuffer|Uint8Array} data * @returns {ArrayBuffer} From 881cda97260c47e32feb4b0f6d87b6b25e398a6a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 17:22:17 -0300 Subject: [PATCH 049/103] refactor(super-editor): wrap shared tryDecodeDataUriText re-export Wrap the imported helper in a local function so the public API remains a stable indirection rather than a direct re-export. --- .../v1/core/super-converter/helpers/mediaHelpers.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index 8021ccd0a0..c48596675a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -1,4 +1,7 @@ -import { getDataUriMetadata as getSharedDataUriMetadata, tryDecodeDataUriText } from '@superdoc/url-validation'; +import { + getDataUriMetadata as getSharedDataUriMetadata, + tryDecodeDataUriText as tryDecodeSharedDataUriText, +} from '@superdoc/url-validation'; export const sanitizeDocxMediaName = (value, fallback = 'image') => { if (!value) return fallback; @@ -38,7 +41,7 @@ export const getDataUriMetadata = (src = '') => { }; }; -export { tryDecodeDataUriText }; +export const tryDecodeDataUriText = (payload) => tryDecodeSharedDataUriText(payload); export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => { const extension = getDataUriMetadata(src)?.extension; From f92cf9be57aafee266ae8d4717d3cf0f833c007d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 18:02:23 -0300 Subject: [PATCH 050/103] fix(shared): reject malformed base64 image data URIs Validate base64 payloads in isValidImageDataUrl before treating them as renderable or exportable. Previously any data:image/*;base64 URL was accepted regardless of payload contents, so malformed sources could reach the DOCX exporter and produce unreadable media parts. --- .../helpers/decode-image-node-helpers.test.js | 14 ++++++++++++++ shared/url-validation/index.js | 19 ++++++++++++++++++- shared/url-validation/index.test.js | 10 ++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index d64f48f57b..4034cfac60 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -292,6 +292,20 @@ describe('translateImageNode', () => { expect(baseParams.media).toEqual({}); }); + it('should not export malformed base64 image data URI media', () => { + baseParams.node.attrs = { + src: 'data:image/png;base64,%%%', + alt: 'Malformed Image', + size: { width: 20, height: 10 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + it('should not export non-image data URI media', () => { baseParams.node.attrs = { src: 'data:text/html,%3Cscript%3Ealert(1)%3C%2Fscript%3E', diff --git a/shared/url-validation/index.js b/shared/url-validation/index.js index f0b3bd5dfe..98af73cfa9 100644 --- a/shared/url-validation/index.js +++ b/shared/url-validation/index.js @@ -129,6 +129,23 @@ export const tryDecodeDataUriText = (payload = '') => { } }; +const BASE64_PAYLOAD_PATTERN = /^[A-Za-z0-9+/]+$/; + +const isValidBase64Payload = (payload = '') => { + const normalizedPayload = payload.replace(/\s/g, ''); + if (!normalizedPayload || normalizedPayload.length % 4 === 1) return false; + + const padding = normalizedPayload.match(/=+$/)?.[0] || ''; + if (padding.length > 2) return false; + + const body = padding ? normalizedPayload.slice(0, -padding.length) : normalizedPayload; + if (!body || !BASE64_PAYLOAD_PATTERN.test(body) || body.includes('=')) return false; + if (!padding) return true; + if (normalizedPayload.length % 4 !== 0) return false; + + return body.length % 4 === (padding.length === 1 ? 3 : 2); +}; + /** * Validate an image data URL for rendering and export. * @@ -147,7 +164,7 @@ export const isValidImageDataUrl = (src) => { const metadata = getDataUriMetadata(src); if (!metadata?.hasPayloadSeparator || !IMAGE_DATA_URL_MIME_TYPES.includes(metadata.mimeType)) return false; - if (metadata.isBase64) return true; + if (metadata.isBase64) return isValidBase64Payload(metadata.payload); if (metadata.mimeType !== 'image/svg+xml') return false; return tryDecodeDataUriText(metadata.payload) != null; diff --git a/shared/url-validation/index.test.js b/shared/url-validation/index.test.js index f9b9a96392..bbbed9ad28 100644 --- a/shared/url-validation/index.test.js +++ b/shared/url-validation/index.test.js @@ -37,6 +37,16 @@ describe('url-validation', () => { expect(isValidImageDataUrl('data:image/svg+xml,%')).toBe(false); }); + it('rejects malformed base64 image data URL payloads before export', () => { + expect(isValidImageDataUrl('data:image/png;base64,%%%')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,a')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,ab=c')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,==')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,abc=')).toBe(true); + expect(isValidImageDataUrl('data:image/png;base64,YWJjZA==')).toBe(true); + }); + it('accepts image data URLs at the maximum length and rejects one byte over', () => { const prefix = 'data:image/svg+xml,'; const payload = 'a'.repeat(MAX_IMAGE_DATA_URL_LENGTH - prefix.length); From 01da84e3db1fb067ce180d209d1a7c6b21bda525 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 17:23:03 -0300 Subject: [PATCH 051/103] feat(super-editor): disable mutation toolbar controls inside content-locked SDTs Add a hasContentLockedStructuredContentSelection helper that walks the selection's ancestor chain, NodeSelection target, and range, then gate formatting, paragraph, list, table, link, image, and track-changes derivers through a new isMutationCommandDisabled. View controls (undo/redo, ruler, zoom, document-mode, formatting marks) keep their existing rules. Covered by unit tests across lock modes and selection shapes plus a behavior spec. --- .../src/headless-toolbar/helpers/context.ts | 50 +++++ .../src/headless-toolbar/helpers/document.ts | 4 +- .../headless-toolbar/helpers/formatting.ts | 22 +- .../src/headless-toolbar/helpers/general.ts | 8 +- .../src/headless-toolbar/helpers/paragraph.ts | 12 +- .../src/headless-toolbar/helpers/table.ts | 4 +- .../headless-toolbar/helpers/track-changes.ts | 4 +- .../headless-toolbar/toolbar-registry.test.ts | 198 +++++++++++++++++- .../sdt/sdt-content-lock-toolbar.spec.ts | 25 +++ 9 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 tests/behavior/tests/sdt/sdt-content-lock-toolbar.spec.ts diff --git a/packages/super-editor/src/headless-toolbar/helpers/context.ts b/packages/super-editor/src/headless-toolbar/helpers/context.ts index a27ab19c2c..b2dfffd620 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/context.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/context.ts @@ -3,6 +3,9 @@ import { calculateResolvedParagraphProperties } from '../../editors/v1/extension import { NodeSelection } from 'prosemirror-state'; import type { ToolbarContext } from '../types.js'; +const STRUCTURED_CONTENT_NODE_TYPES = new Set(['structuredContent', 'structuredContentBlock']); +const CONTENT_LOCK_MODES = new Set(['contentLocked', 'sdtContentLocked']); + export const resolveStateEditor = (context: ToolbarContext | null) => { if (!context) return null; return context.editor ?? context.presentationEditor?.getActiveEditor() ?? null; @@ -32,3 +35,50 @@ export const isFieldAnnotationSelection = (context: ToolbarContext | null) => { const selection = resolveStateEditor(context)?.state?.selection; return selection instanceof NodeSelection && selection?.node?.type?.name === 'fieldAnnotation'; }; + +const isContentLockedStructuredContentNode = (node: any) => { + return STRUCTURED_CONTENT_NODE_TYPES.has(node?.type?.name) && CONTENT_LOCK_MODES.has(node?.attrs?.lockMode); +}; + +const resolvedPositionHasContentLockedStructuredContent = ($pos: any) => { + if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') return false; + + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (isContentLockedStructuredContentNode($pos.node(depth))) return true; + } + + return false; +}; + +export const hasContentLockedStructuredContentSelection = (context: ToolbarContext | null) => { + const state = resolveStateEditor(context)?.state; + const selection = state?.selection; + const doc = state?.doc; + if (!selection || !doc) return false; + + if (selection instanceof NodeSelection && isContentLockedStructuredContentNode(selection.node)) { + return true; + } + + if ( + resolvedPositionHasContentLockedStructuredContent(selection.$from) || + resolvedPositionHasContentLockedStructuredContent(selection.$to) + ) { + return true; + } + + if (typeof doc.nodesBetween !== 'function' || selection.from == null || selection.to == null) { + return false; + } + + let hasLockedNode = false; + doc.nodesBetween(selection.from, selection.to, (node: any) => { + if (isContentLockedStructuredContentNode(node)) { + hasLockedNode = true; + return false; + } + return !hasLockedNode; + }); + + return hasLockedNode; +}; diff --git a/packages/super-editor/src/headless-toolbar/helpers/document.ts b/packages/super-editor/src/headless-toolbar/helpers/document.ts index bf093fcb0f..958f90e669 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/document.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/document.ts @@ -1,6 +1,6 @@ import { undoDepth, redoDepth } from 'prosemirror-history'; import { yUndoPluginKey } from 'y-prosemirror'; -import { isCommandDisabled } from './general.js'; +import { isCommandDisabled, isMutationCommandDisabled } from './general.js'; import { resolveStateEditor } from './context.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; @@ -67,7 +67,7 @@ export const getCurrentRedoDepth = (context: ToolbarContext | null) => { export const createDocumentOperationCapabilityStateDeriver = (operationId: string) => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - if (isCommandDisabled(context)) { + if (isMutationCommandDisabled(context)) { return { active: false, disabled: true, diff --git a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts index 87d5347e76..d4104951b1 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts @@ -4,7 +4,7 @@ import { getActiveFormatting } from '../../editors/v1/core/helpers/getActiveForm import { getFileOpener, processAndInsertImageFile } from '../../editors/v1/extensions/image/imageHelpers/index.js'; import { TextSelection, Selection } from 'prosemirror-state'; import { getCurrentResolvedParagraphProperties, isFieldAnnotationSelection, resolveStateEditor } from './context.js'; -import { createDirectCommandExecute, isCommandDisabled } from './general.js'; +import { createDirectCommandExecute, isMutationCommandDisabled } from './general.js'; import type { ToolbarContext } from '../types.js'; /** @@ -104,7 +104,7 @@ export const createBoldStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -129,7 +129,7 @@ export const createItalicStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -151,7 +151,7 @@ export const createUnderlineStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -173,7 +173,7 @@ export const createStrikethroughStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -195,7 +195,7 @@ export const createCopyFormatStateDeriver = ({ context }: { context: ToolbarContext | null }) => { return { active: hasStoredCopyFormat(context), - disabled: isCommandDisabled(context), + disabled: isMutationCommandDisabled(context), }; }; @@ -204,7 +204,7 @@ export const createFontSizeStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -242,7 +242,7 @@ export const createFontFamilyStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -284,7 +284,7 @@ export const createTextColorStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -313,7 +313,7 @@ export const createHighlightColorStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -342,7 +342,7 @@ export const createLinkStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { diff --git a/packages/super-editor/src/headless-toolbar/helpers/general.ts b/packages/super-editor/src/headless-toolbar/helpers/general.ts index a0d925f462..ed814d288e 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/general.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/general.ts @@ -1,4 +1,4 @@ -import { resolveStateEditor } from './context.js'; +import { hasContentLockedStructuredContentSelection, resolveStateEditor } from './context.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; export const isCommandDisabled = (context: ToolbarContext | null) => { @@ -8,10 +8,14 @@ export const isCommandDisabled = (context: ToolbarContext | null) => { return documentMode === 'viewing'; }; +export const isMutationCommandDisabled = (context: ToolbarContext | null) => { + return isCommandDisabled(context) || hasContentLockedStructuredContentSelection(context); +}; + export const createDisabledStateDeriver = (options?: { withValue?: boolean }) => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const disabled = isCommandDisabled(context); + const disabled = isMutationCommandDisabled(context); if (options?.withValue) { return { diff --git a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts index 3d15a6849d..282505cbae 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts @@ -5,7 +5,7 @@ import { twipsToLines } from '../../editors/v1/core/super-converter/helpers.js'; import { getQuickFormatList } from '../../editors/v1/extensions/linked-styles/index.js'; import { mapStoredJustificationToDisplayAlignment } from '../../editors/v1/core/helpers/paragraph-alignment.js'; import { getCurrentParagraphParent, getCurrentResolvedParagraphProperties, resolveStateEditor } from './context.js'; -import { createDirectCommandExecute, isCommandDisabled } from './general.js'; +import { createDirectCommandExecute, isMutationCommandDisabled } from './general.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; const getCurrentParagraphJustification = (context: ToolbarContext | null) => { @@ -18,7 +18,7 @@ const getCurrentParagraphJustification = (context: ToolbarContext | null) => { export const createParagraphDirectionStateDeriver = (direction: 'ltr' | 'rtl') => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) return { active: false, disabled: true, value: null }; const rightToLeft = getCurrentResolvedParagraphProperties(context)?.rightToLeft; @@ -47,7 +47,7 @@ export const createParagraphDirectionExecute = export const createTextAlignStateDeriver = () => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -69,7 +69,7 @@ export const createTextAlignStateDeriver = export const createLineHeightStateDeriver = () => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -93,7 +93,7 @@ export const createLineHeightStateDeriver = export const createLinkedStyleStateDeriver = () => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); const stateEditor = resolveStateEditor(context); if (isDisabled || !stateEditor) { @@ -126,7 +126,7 @@ export const createLinkedStyleStateDeriver = export const createListStateDeriver = (numberingType: 'bullet' | 'ordered') => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { diff --git a/packages/super-editor/src/headless-toolbar/helpers/table.ts b/packages/super-editor/src/headless-toolbar/helpers/table.ts index 3b9f324102..df6dd80cfe 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/table.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/table.ts @@ -1,6 +1,6 @@ import { isInTable } from '../../editors/v1/core/helpers/isInTable.js'; import { resolveStateEditor } from './context.js'; -import { isCommandDisabled } from './general.js'; +import { isMutationCommandDisabled } from './general.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; export const createTableActionsStateDeriver = @@ -8,7 +8,7 @@ export const createTableActionsStateDeriver = ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { const editor = resolveStateEditor(context); const inTable = editor?.state?.selection?.$head ? isInTable(editor.state) : false; - const disabled = isCommandDisabled(context) || !inTable; + const disabled = isMutationCommandDisabled(context) || !inTable; return { active: false, diff --git a/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts b/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts index 3a1899c720..74f31c6499 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts @@ -3,7 +3,7 @@ import { isTrackedChangeActionAllowed, } from '../../editors/v1/extensions/track-changes/permission-helpers.js'; import { resolveStateEditor } from './context.js'; -import { isCommandDisabled } from './general.js'; +import { isMutationCommandDisabled } from './general.js'; import type { ToolbarContext } from '../types.js'; // SD-3213f: prefer the narrow `superdoc.getComment(id)` method when @@ -43,7 +43,7 @@ const enrichTrackedChanges = (trackedChanges: Array> = [], s export const createTrackChangesSelectionActionStateDeriver = (action: 'accept' | 'reject') => ({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }) => { - if (isCommandDisabled(context)) { + if (isMutationCommandDisabled(context)) { return { active: false, disabled: true, diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 281a91074b..02c3950c4e 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { historyKey } from 'prosemirror-history'; -import { PluginKey } from 'prosemirror-state'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; const getActiveFormattingMock = vi.hoisted(() => vi.fn(() => [])); const getYUndoPluginStateMock = vi.hoisted(() => vi.fn(() => undefined)); @@ -47,6 +48,116 @@ const createContext = (): ToolbarContext => ({ editor: {} as any, }); +const sdtSchema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + parseDOM: [{ tag: 'p' }], + }, + text: { group: 'inline' }, + structuredContent: { + group: 'inline', + inline: true, + content: 'inline*', + attrs: { + id: { default: null }, + lockMode: { default: 'unlocked' }, + }, + toDOM: () => ['span', 0], + parseDOM: [{ tag: 'span' }], + }, + structuredContentBlock: { + group: 'block', + content: 'block+', + attrs: { + id: { default: null }, + lockMode: { default: 'unlocked' }, + }, + toDOM: () => ['div', 0], + parseDOM: [{ tag: 'div' }], + }, + }, +}); + +const makeToolbarContextWithSelection = (state: EditorState): ToolbarContext => ({ + ...createContext(), + editor: { + state, + options: { + documentMode: 'editing', + }, + } as any, +}); + +const findNodeById = (doc: any, id: string) => { + let result: { node: any; pos: number } | null = null; + doc.descendants((node: any, pos: number) => { + if (result) return false; + if (String(node.attrs?.id) === id) { + result = { node, pos }; + return false; + } + return true; + }); + if (!result) throw new Error(`Missing test node "${id}"`); + return result; +}; + +const findTextPos = (doc: any, text: string) => { + let result: number | null = null; + doc.descendants((node: any, pos: number) => { + if (result != null) return false; + if (node.isText && node.text?.includes(text)) { + result = pos + node.text.indexOf(text); + return false; + } + return true; + }); + if (result == null) throw new Error(`Missing test text "${text}"`); + return result; +}; + +const makeInlineSdtState = (lockMode: string, selectionKind: 'inside' | 'node' | 'span' = 'inside') => { + const doc = sdtSchema.node('doc', null, [ + sdtSchema.node('paragraph', null, [ + sdtSchema.text('A '), + sdtSchema.node('structuredContent', { id: 'inline-sdt', lockMode }, [sdtSchema.text('Field')]), + sdtSchema.text(' Z'), + ]), + ]); + + const baseState = EditorState.create({ schema: sdtSchema, doc }); + const inlineSdt = findNodeById(doc, 'inline-sdt'); + + if (selectionKind === 'node') { + return baseState.apply(baseState.tr.setSelection(NodeSelection.create(doc, inlineSdt.pos))); + } + + if (selectionKind === 'span') { + return baseState.apply( + baseState.tr.setSelection(TextSelection.create(doc, findTextPos(doc, 'A'), findTextPos(doc, 'Z') + 1)), + ); + } + + return baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, findTextPos(doc, 'Field') + 1))); +}; + +const makeBlockSdtState = (lockMode: string) => { + const doc = sdtSchema.node('doc', null, [ + sdtSchema.node('paragraph', null, [sdtSchema.text('Before')]), + sdtSchema.node('structuredContentBlock', { id: 'block-sdt', lockMode }, [ + sdtSchema.node('paragraph', null, [sdtSchema.text('Block field')]), + ]), + sdtSchema.node('paragraph', null, [sdtSchema.text('After')]), + ]); + + const baseState = EditorState.create({ schema: sdtSchema, doc }); + return baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, findTextPos(doc, 'Block field') + 1))); +}; + describe('createToolbarRegistry', () => { afterEach(() => { vi.clearAllMocks(); @@ -1226,6 +1337,91 @@ describe('createToolbarRegistry', () => { }); }); + it.each(['contentLocked', 'sdtContentLocked'])( + 'disables representative mutation commands inside a %s inline SDT', + (lockMode) => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeInlineSdtState(lockMode)); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry.italic?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry.underline?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry.link?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry.image?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['table-insert']?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['clear-formatting']?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['copy-format']?.state({ context, superdoc: {} })?.disabled).toBe(true); + }, + ); + + it.each(['unlocked', 'sdtLocked'])('does not disable mutation commands from %s SDTs alone', (lockMode) => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeInlineSdtState(lockMode)); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry.link?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry['table-insert']?.state({ context, superdoc: {} })?.disabled).toBe(false); + }); + + it('leaves document controls governed by their existing rules inside content-locked SDTs', () => { + getYUndoPluginStateMock.mockReturnValue({ + undoManager: { + undoStack: [1], + redoStack: [1], + }, + }); + + const registry = createToolbarRegistry(); + const baseContext = makeToolbarContextWithSelection(makeInlineSdtState('contentLocked')); + const context = { + ...baseContext, + editor: { + ...baseContext.editor, + options: { + ydoc: {}, + documentMode: 'editing', + }, + } as any, + }; + + expect(registry.undo?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry.redo?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry.ruler?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry.zoom?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry['document-mode']?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect( + registry['formatting-marks']?.state({ + context, + superdoc: { + toggleFormattingMarks: vi.fn(), + }, + })?.disabled, + ).toBe(false); + }); + + it('disables mutation commands for a collapsed cursor inside a locked block SDT paragraph', () => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeBlockSdtState('contentLocked')); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['text-align']?.state({ context, superdoc: {} })?.disabled).toBe(true); + }); + + it('disables mutation commands for a NodeSelection on a locked SDT', () => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeInlineSdtState('sdtContentLocked', 'node')); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); + }); + + it('disables mutation commands for a range spanning locked SDT content', () => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeInlineSdtState('contentLocked', 'span')); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['bullet-list']?.state({ context, superdoc: {} })?.disabled).toBe(true); + }); + // ------------------------------------------------------------------------- // PR-2873 (SD-2527) — full coverage of bullet + ordered style derivation // diff --git a/tests/behavior/tests/sdt/sdt-content-lock-toolbar.spec.ts b/tests/behavior/tests/sdt/sdt-content-lock-toolbar.spec.ts new file mode 100644 index 0000000000..6bb89528d0 --- /dev/null +++ b/tests/behavior/tests/sdt/sdt-content-lock-toolbar.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +test('default toolbar disables mutation controls inside content-locked SDT content', async ({ superdoc }) => { + await superdoc.type('Before '); + await superdoc.waitForStable(); + + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { id: '6201', alias: 'Toolbar Lock', lockMode: 'contentLocked' }, + text: 'Locked value', + }); + }); + await superdoc.waitForStable(); + + const lockedTextPos = await superdoc.findTextPos('Locked value'); + await superdoc.setTextSelection(lockedTextPos + 1); + await superdoc.waitForStable(); + + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/disabled/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/disabled/); + await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/disabled/); + await expect(superdoc.page.locator('[data-item="btn-link"]')).toHaveClass(/disabled/); +}); From 5f623249a375b220e1486af5a449ed4c9c27c929 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 17:28:27 -0300 Subject: [PATCH 052/103] fix(super-editor): block locked sdt toolbar execution --- .../create-headless-toolbar.test.ts | 52 ++++++++++++++++++- .../create-headless-toolbar.ts | 5 ++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts index ef94b5ebb5..6c25867c41 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { historyKey } from 'prosemirror-history'; -import { NodeSelection } from 'prosemirror-state'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; const getActiveFormattingMock = vi.hoisted(() => vi.fn(() => [])); @@ -87,6 +88,55 @@ describe('createHeadlessToolbar', () => { controller.destroy(); }); + it('does not execute commands that are currently reported disabled', () => { + const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + parseDOM: [{ tag: 'p' }], + }, + text: { group: 'inline' }, + structuredContent: { + group: 'inline', + inline: true, + content: 'inline*', + attrs: { + lockMode: { default: 'unlocked' }, + }, + toDOM: () => ['span', 0], + parseDOM: [{ tag: 'span' }], + }, + }, + }); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [ + schema.text('A '), + schema.node('structuredContent', { lockMode: 'contentLocked' }, [schema.text('Locked')]), + ]), + ]); + const baseState = EditorState.create({ schema, doc }); + const state = baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, 5))); + const toggleBold = vi.fn(() => true); + const superdoc = createActiveEditorHost({ + commands: { toggleBold }, + state, + }); + + const controller = createHeadlessToolbar({ + superdoc, + commands: ['bold'], + }); + + expect(controller.getSnapshot().commands.bold?.disabled).toBe(true); + expect(controller.execute?.('bold')).toBe(false); + expect(toggleBold).not.toHaveBeenCalled(); + + controller.destroy(); + }); + it('executes track-changes accept-selection through the registry direct command path', () => { const acceptTrackedChangeFromToolbar = vi.fn(() => true); const superdoc = createActiveEditorHost({ diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts index 5abca58d52..32c318e8b9 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts @@ -6,6 +6,7 @@ import type { ToolbarSubscriptionEvent, } from './types.js'; import { createToolbarSnapshot } from './create-toolbar-snapshot.js'; +import { hasContentLockedStructuredContentSelection } from './helpers/context.js'; import { subscribeToolbarEvents } from './subscribe-toolbar-events.js'; import { createToolbarRegistry } from './toolbar-registry.js'; import type { BuiltInToolbarRegistryEntry } from './internal-types.js'; @@ -107,6 +108,10 @@ export const createHeadlessToolbar = (options: CreateHeadlessToolbarOptions): He }, execute(id: PublicToolbarItemId, payload?: unknown) { + if (snapshot.commands[id]?.disabled && hasContentLockedStructuredContentSelection(snapshot.context)) { + return false; + } + const result = executeRegistryCommand(id, options.superdoc, snapshot, toolbarRegistry, payload); if (result && !destroyed) { refreshControllerState(); From 97632fe3386f9b0d7a9da7f660690e563dac196a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 26 May 2026 17:32:40 -0300 Subject: [PATCH 053/103] fix(super-editor): guard unlisted locked toolbar commands --- .../create-headless-toolbar.test.ts | 5 ++- .../create-headless-toolbar.ts | 34 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts index 6c25867c41..4ee951781c 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts @@ -120,8 +120,9 @@ describe('createHeadlessToolbar', () => { const baseState = EditorState.create({ schema, doc }); const state = baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, 5))); const toggleBold = vi.fn(() => true); + const insertTable = vi.fn(() => true); const superdoc = createActiveEditorHost({ - commands: { toggleBold }, + commands: { toggleBold, insertTable }, state, }); @@ -133,6 +134,8 @@ describe('createHeadlessToolbar', () => { expect(controller.getSnapshot().commands.bold?.disabled).toBe(true); expect(controller.execute?.('bold')).toBe(false); expect(toggleBold).not.toHaveBeenCalled(); + expect(controller.execute?.('table-insert', { rows: 1, cols: 1 })).toBe(false); + expect(insertTable).not.toHaveBeenCalled(); controller.destroy(); }); diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts index 32c318e8b9..1549f29718 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts @@ -46,6 +46,38 @@ const executeRegistryCommand = ( return executeDirectCommand(id, snapshot, toolbarRegistry, payload); }; +const CONTENT_LOCK_EXECUTION_EXEMPT_IDS = new Set([ + 'undo', + 'redo', + 'ruler', + 'formatting-marks', + 'zoom', + 'document-mode', +]); + +const isContentLockExecutionBlocked = ( + id: PublicToolbarItemId, + superdoc: CreateHeadlessToolbarOptions['superdoc'], + snapshot: ToolbarSnapshot, + toolbarRegistry: Partial>, +): boolean => { + if (CONTENT_LOCK_EXECUTION_EXEMPT_IDS.has(id) || !hasContentLockedStructuredContentSelection(snapshot.context)) { + return false; + } + + const snapshotState = snapshot.commands[id]; + if (snapshotState) return snapshotState.disabled; + + const entry = toolbarRegistry[id]; + if (!entry) return false; + + try { + return entry.state({ context: snapshot.context, superdoc }).disabled; + } catch { + return true; + } +}; + export const createHeadlessToolbar = (options: CreateHeadlessToolbarOptions): HeadlessToolbarController => { const listeners = new Set<(event: ToolbarSubscriptionEvent) => void>(); const toolbarRegistry = createToolbarRegistry(); @@ -108,7 +140,7 @@ export const createHeadlessToolbar = (options: CreateHeadlessToolbarOptions): He }, execute(id: PublicToolbarItemId, payload?: unknown) { - if (snapshot.commands[id]?.disabled && hasContentLockedStructuredContentSelection(snapshot.context)) { + if (isContentLockExecutionBlocked(id, options.superdoc, snapshot, toolbarRegistry)) { return false; } From 50b6982ce830a0a4d1785172231f211d3ddd8e2e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 09:23:30 -0300 Subject: [PATCH 054/103] fix(super-editor): block disabled toolbar execution --- .../create-headless-toolbar.test.ts | 29 +++++++++++++++---- .../create-headless-toolbar.ts | 10 +++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts index 4ee951781c..987333845c 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts @@ -140,6 +140,29 @@ describe('createHeadlessToolbar', () => { controller.destroy(); }); + it('does not execute disabled mutation commands in viewing mode', () => { + const toggleBold = vi.fn(() => true); + const superdoc = createActiveEditorHost({ + commands: { toggleBold }, + extra: { + options: { + documentMode: 'viewing', + }, + }, + }); + + const controller = createHeadlessToolbar({ + superdoc, + commands: ['bold'], + }); + + expect(controller.getSnapshot().commands.bold?.disabled).toBe(true); + expect(controller.execute?.('bold')).toBe(false); + expect(toggleBold).not.toHaveBeenCalled(); + + controller.destroy(); + }); + it('executes track-changes accept-selection through the registry direct command path', () => { const acceptTrackedChangeFromToolbar = vi.fn(() => true); const superdoc = createActiveEditorHost({ @@ -148,7 +171,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['track-changes-accept-selection'], }); expect(controller.execute?.('track-changes-accept-selection')).toBe(true); @@ -165,7 +187,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['track-changes-reject-selection'], }); expect(controller.execute?.('track-changes-reject-selection')).toBe(true); @@ -746,7 +767,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['undo'], }); expect(controller.execute?.('undo')).toBe(true); @@ -773,7 +793,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['redo'], }); expect(controller.execute?.('redo')).toBe(true); @@ -991,7 +1010,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['linked-style'], }); expect(controller.execute?.('linked-style', { id: 'Heading1' })).toBe(true); @@ -1050,7 +1068,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: [id], }); expect(controller.execute?.(id)).toBe(true); diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts index 1549f29718..60ce20b564 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts @@ -55,19 +55,19 @@ const CONTENT_LOCK_EXECUTION_EXEMPT_IDS = new Set([ 'document-mode', ]); -const isContentLockExecutionBlocked = ( +const isToolbarCommandExecutionDisabled = ( id: PublicToolbarItemId, superdoc: CreateHeadlessToolbarOptions['superdoc'], snapshot: ToolbarSnapshot, toolbarRegistry: Partial>, ): boolean => { + const snapshotState = snapshot.commands[id]; + if (snapshotState) return snapshotState.disabled; + if (CONTENT_LOCK_EXECUTION_EXEMPT_IDS.has(id) || !hasContentLockedStructuredContentSelection(snapshot.context)) { return false; } - const snapshotState = snapshot.commands[id]; - if (snapshotState) return snapshotState.disabled; - const entry = toolbarRegistry[id]; if (!entry) return false; @@ -140,7 +140,7 @@ export const createHeadlessToolbar = (options: CreateHeadlessToolbarOptions): He }, execute(id: PublicToolbarItemId, payload?: unknown) { - if (isContentLockExecutionBlocked(id, options.superdoc, snapshot, toolbarRegistry)) { + if (isToolbarCommandExecutionDisabled(id, options.superdoc, snapshot, toolbarRegistry)) { return false; } From d2ebafc5736dcf01fe82d6b68dbfb1c3f3b7ff50 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 09:24:18 -0300 Subject: [PATCH 055/103] refactor(super-editor): share structured content predicates --- .../getViewModeSelectionWithoutStructuredContent.js | 7 +++---- .../v1/extensions/structured-content/nodeTypes.js | 5 +++++ .../extensions/structured-content/nodeTypes.test.js | 13 +++++++++++++ .../src/headless-toolbar/helpers/context.ts | 7 +++---- 4 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.js create mode 100644 packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.test.js diff --git a/packages/super-editor/src/editors/v1/core/helpers/getViewModeSelectionWithoutStructuredContent.js b/packages/super-editor/src/editors/v1/core/helpers/getViewModeSelectionWithoutStructuredContent.js index 9e159eb082..189dd311b2 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/getViewModeSelectionWithoutStructuredContent.js +++ b/packages/super-editor/src/editors/v1/core/helpers/getViewModeSelectionWithoutStructuredContent.js @@ -1,11 +1,10 @@ import { NodeSelection, Selection } from 'prosemirror-state'; - -const STRUCTURED_CONTENT_NODE_TYPES = new Set(['structuredContent', 'structuredContentBlock']); +import { isStructuredContentNodeType } from '../../extensions/structured-content/nodeTypes.js'; function findEnclosingStructuredContentPosition($pos) { for (let depth = $pos.depth; depth > 0; depth--) { const node = $pos.node(depth); - if (STRUCTURED_CONTENT_NODE_TYPES.has(node.type.name)) { + if (isStructuredContentNodeType(node.type.name)) { return $pos.before(depth); } } @@ -16,7 +15,7 @@ function findEnclosingStructuredContentPosition($pos) { export function getViewModeSelectionWithoutStructuredContent(state) { const { selection, doc } = state; - if (selection instanceof NodeSelection && STRUCTURED_CONTENT_NODE_TYPES.has(selection.node.type.name)) { + if (selection instanceof NodeSelection && isStructuredContentNodeType(selection.node.type.name)) { const candidate = Selection.near(doc.resolve(selection.from), -1); const candidatePos = findEnclosingStructuredContentPosition(candidate.$from); if (candidatePos !== null) return null; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.js b/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.js new file mode 100644 index 0000000000..a10e146904 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.js @@ -0,0 +1,5 @@ +export const STRUCTURED_CONTENT_NODE_TYPES = new Set(['structuredContent', 'structuredContentBlock']); + +export function isStructuredContentNodeType(nodeTypeName) { + return STRUCTURED_CONTENT_NODE_TYPES.has(nodeTypeName); +} diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.test.js new file mode 100644 index 0000000000..39ad0c74f5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.test.js @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { STRUCTURED_CONTENT_NODE_TYPES, isStructuredContentNodeType } from './nodeTypes.js'; + +describe('structured content node types', () => { + it('recognizes inline and block structured content node types', () => { + expect(STRUCTURED_CONTENT_NODE_TYPES).toEqual(new Set(['structuredContent', 'structuredContentBlock'])); + expect(isStructuredContentNodeType('structuredContent')).toBe(true); + expect(isStructuredContentNodeType('structuredContentBlock')).toBe(true); + expect(isStructuredContentNodeType('paragraph')).toBe(false); + expect(isStructuredContentNodeType(null)).toBe(false); + }); +}); diff --git a/packages/super-editor/src/headless-toolbar/helpers/context.ts b/packages/super-editor/src/headless-toolbar/helpers/context.ts index b2dfffd620..7f62746139 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/context.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/context.ts @@ -1,11 +1,10 @@ import { findParentNode } from '../../editors/v1/core/helpers/findParentNode.js'; import { calculateResolvedParagraphProperties } from '../../editors/v1/extensions/paragraph/resolvedPropertiesCache.js'; +import { isContentLockedMode } from '../../editors/v1/extensions/structured-content/lockModes.js'; +import { isStructuredContentNodeType } from '../../editors/v1/extensions/structured-content/nodeTypes.js'; import { NodeSelection } from 'prosemirror-state'; import type { ToolbarContext } from '../types.js'; -const STRUCTURED_CONTENT_NODE_TYPES = new Set(['structuredContent', 'structuredContentBlock']); -const CONTENT_LOCK_MODES = new Set(['contentLocked', 'sdtContentLocked']); - export const resolveStateEditor = (context: ToolbarContext | null) => { if (!context) return null; return context.editor ?? context.presentationEditor?.getActiveEditor() ?? null; @@ -37,7 +36,7 @@ export const isFieldAnnotationSelection = (context: ToolbarContext | null) => { }; const isContentLockedStructuredContentNode = (node: any) => { - return STRUCTURED_CONTENT_NODE_TYPES.has(node?.type?.name) && CONTENT_LOCK_MODES.has(node?.attrs?.lockMode); + return isStructuredContentNodeType(node?.type?.name) && isContentLockedMode(node?.attrs?.lockMode); }; const resolvedPositionHasContentLockedStructuredContent = ($pos: any) => { From 1550433cf9398d805c36854cff0ef80f4009d23d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 14:15:56 -0300 Subject: [PATCH 056/103] fix(super-editor): keep text-align enabled in locked SDT paragraphs Text alignment is a read-only state indicator that should remain visible even when the selection sits inside a content-locked SDT. Switch the text-align state deriver to isCommandDisabled so it only disables on a truly read-only context, not on the mutation-blocked one. --- .../super-editor/src/headless-toolbar/helpers/paragraph.ts | 4 ++-- .../src/headless-toolbar/toolbar-registry.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts index 282505cbae..061f9c4949 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts @@ -5,7 +5,7 @@ import { twipsToLines } from '../../editors/v1/core/super-converter/helpers.js'; import { getQuickFormatList } from '../../editors/v1/extensions/linked-styles/index.js'; import { mapStoredJustificationToDisplayAlignment } from '../../editors/v1/core/helpers/paragraph-alignment.js'; import { getCurrentParagraphParent, getCurrentResolvedParagraphProperties, resolveStateEditor } from './context.js'; -import { createDirectCommandExecute, isMutationCommandDisabled } from './general.js'; +import { createDirectCommandExecute, isCommandDisabled, isMutationCommandDisabled } from './general.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; const getCurrentParagraphJustification = (context: ToolbarContext | null) => { @@ -47,7 +47,7 @@ export const createParagraphDirectionExecute = export const createTextAlignStateDeriver = () => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isMutationCommandDisabled(context); + const isDisabled = isCommandDisabled(context); if (isDisabled) { return { diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 02c3950c4e..541e886596 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -1399,12 +1399,12 @@ describe('createToolbarRegistry', () => { ).toBe(false); }); - it('disables mutation commands for a collapsed cursor inside a locked block SDT paragraph', () => { + it('keeps text-align available when mutation commands are disabled inside a locked block SDT paragraph', () => { const registry = createToolbarRegistry(); const context = makeToolbarContextWithSelection(makeBlockSdtState('contentLocked')); expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); - expect(registry['text-align']?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['text-align']?.state({ context, superdoc: {} })?.disabled).toBe(false); }); it('disables mutation commands for a NodeSelection on a locked SDT', () => { From f288be61de4c6a1da1837dbb61eb3ef1c71c583d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 09:43:52 -0300 Subject: [PATCH 057/103] fix(super-editor): exclude sdt chrome labels from caret position lookup Structured-content chrome labels share pm-start/pm-end ranges with their underlying content. Without exclusion, DomPositionIndex could return the label element for caret lookups, breaking hover and click-to-place interactions over block SDTs. --- .../v1/dom-observer/DomPositionIndex.test.ts | 43 +++++++++++++++++++ .../v1/dom-observer/DomPositionIndex.ts | 6 ++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.test.ts diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.test.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.test.ts new file mode 100644 index 0000000000..2c81f474b9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; + +import { DomPositionIndex } from './DomPositionIndex.ts'; + +describe('DomPositionIndex', () => { + it('excludes structured-content chrome labels from caret position lookup', () => { + const container = document.createElement('div'); + + const fragment = document.createElement('div'); + fragment.className = `${DOM_CLASS_NAMES.FRAGMENT} ${DOM_CLASS_NAMES.BLOCK_SDT} ${DOM_CLASS_NAMES.TABLE_FRAGMENT}`; + fragment.dataset.pmStart = '16'; + fragment.dataset.pmEnd = '44'; + + const label = document.createElement('div'); + label.className = DOM_CLASS_NAMES.BLOCK_SDT_LABEL; + label.dataset.pmStart = '16'; + label.dataset.pmEnd = '44'; + label.textContent = 'Block With Table'; + + const line = document.createElement('div'); + line.className = DOM_CLASS_NAMES.LINE; + line.dataset.pmStart = '16'; + line.dataset.pmEnd = '18'; + + const span = document.createElement('span'); + span.dataset.pmStart = '16'; + span.dataset.pmEnd = '18'; + span.textContent = 'A1'; + + line.appendChild(span); + fragment.append(label, line); + container.appendChild(fragment); + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.findEntryAtPosition(16)?.el).toBe(span); + }); +}); diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts index 386340a327..06351e19ee 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts @@ -1,4 +1,4 @@ -import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { DOM_CLASS_NAMES, STRUCTURED_CONTENT_CHROME_LABEL_CLASS_NAMES } from '@superdoc/dom-contract'; import { sortedIndexBy } from 'lodash'; import { debugLog, getSelectionDebugConfig } from '../core/presentation-editor/selection/SelectionDebug.js'; @@ -30,6 +30,10 @@ export type DomPositionIndexEntry = { }; function isExcludedFromBodyDomIndex(node: HTMLElement): boolean { + if (STRUCTURED_CONTENT_CHROME_LABEL_CLASS_NAMES.some((className) => node.classList.contains(className))) { + return true; + } + if (node.closest('.superdoc-page-header, .superdoc-page-footer')) { return true; } From e0b7e5512696220e64a3e00c6420e321dafa4769 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 10:04:07 -0300 Subject: [PATCH 058/103] feat(super-editor): move caret into preceding block sdt on backspace Adds moveIntoBlockSdtBeforeTextBlockStart to the backspace chain so that Backspace at the start of a textblock following a block SDT moves the caret to the last text position inside the SDT instead of deleting into protected content. Lifts findFirstTextPosInNode / findLastTextPosInNode out of the table boundary navigation plugin into a shared helper module. --- .../v1/core/commands/core-command-map.d.ts | 1 + .../v1/core/commands/core-command-map.test.ts | 1 + .../v1/core/commands/helpers/textPositions.js | 39 ++++++ .../src/editors/v1/core/commands/index.js | 1 + .../moveIntoBlockSdtBeforeTextBlockStart.js | 45 +++++++ ...veIntoBlockSdtBeforeTextBlockStart.test.js | 113 ++++++++++++++++++ .../extensions/keymap-backspace-chain.test.js | 3 + .../src/editors/v1/core/extensions/keymap.js | 1 + .../tableHelpers/tableBoundaryNavigation.js | 37 +----- .../sdt/sd-3237-sdt-interactions.spec.ts | 87 ++++++++++++++ 10 files changed, 292 insertions(+), 36 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js create mode 100644 packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js create mode 100644 packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts index bfd3b47778..07f2843b7a 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts @@ -65,6 +65,7 @@ type CoreCommandNames = | 'selectInlineSdtBeforeRunStart' | 'selectInlineSdtAfterRunEnd' | 'deleteBlockSdtAtTextBlockStart' + | 'moveIntoBlockSdtBeforeTextBlockStart' | 'deleteSkipEmptyRun' | 'deleteNextToRun' | 'deleteAtomAfter' diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts index ef8c702ac0..b2a735fa50 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts @@ -11,5 +11,6 @@ describe('core command map types', () => { expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'"); expect(declaration).toContain("| 'selectInlineSdtAfterRunEnd'"); + expect(declaration).toContain("| 'moveIntoBlockSdtBeforeTextBlockStart'"); }); }); diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js new file mode 100644 index 0000000000..5462dfc311 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -0,0 +1,39 @@ +/** + * Finds the first text cursor position inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +export function findFirstTextPosInNode(node, nodePos) { + if (node.isText) return nodePos; + + for (let index = 0, offset = 0; index < node.childCount; index += 1) { + const child = node.child(index); + const childPos = nodePos + 1 + offset; + const found = findFirstTextPosInNode(child, childPos); + if (found != null) return found; + offset += child.nodeSize; + } + + return null; +} + +/** + * Finds the last text cursor position inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +export function findLastTextPosInNode(node, nodePos) { + if (node.isText) return nodePos + (node.text?.length ?? 0); + + for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { + const child = node.child(index); + offset -= child.nodeSize; + const childPos = nodePos + 1 + offset; + const found = findLastTextPosInNode(child, childPos); + if (found != null) return found; + } + + return null; +} diff --git a/packages/super-editor/src/editors/v1/core/commands/index.js b/packages/super-editor/src/editors/v1/core/commands/index.js index 541c2c15af..a1ce59a165 100644 --- a/packages/super-editor/src/editors/v1/core/commands/index.js +++ b/packages/super-editor/src/editors/v1/core/commands/index.js @@ -54,6 +54,7 @@ export * from './backspaceAcrossRuns.js'; export * from './backspaceAtomBefore.js'; export * from './selectInlineSdtBeforeRunStart.js'; export * from './deleteBlockSdtAtTextBlockStart.js'; +export * from './moveIntoBlockSdtBeforeTextBlockStart.js'; export * from './deleteSkipEmptyRun.js'; export * from './deleteNextToRun.js'; export * from './deleteAtomAfter.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js new file mode 100644 index 0000000000..d71f3abd18 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js @@ -0,0 +1,45 @@ +import { TextSelection } from 'prosemirror-state'; +import { findFirstTextPosInNode, findLastTextPosInNode } from './helpers/textPositions.js'; + +function findAncestorDepth($pos, predicate) { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (predicate($pos.node(depth))) return depth; + } + return null; +} + +/** + * Moves the caret into the previous block SDT when Backspace is pressed at the + * start of the following textblock. + * + * @returns {import('@core/commands/types').Command} + */ +export const moveIntoBlockSdtBeforeTextBlockStart = + () => + ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const { $from } = selection; + const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); + if (textblockDepth == null) return false; + + const textblock = $from.node(textblockDepth); + const textblockPos = $from.before(textblockDepth); + const firstTextPos = findFirstTextPosInNode(textblock, textblockPos); + if (firstTextPos !== $from.pos) return false; + + const boundary = state.doc.resolve(textblockPos); + const previousNode = boundary.nodeBefore; + if (previousNode?.type.name !== 'structuredContentBlock') return false; + + const previousNodePos = textblockPos - previousNode.nodeSize; + const targetPos = findLastTextPosInNode(previousNode, previousNodePos); + if (targetPos == null) return false; + + if (dispatch) { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, targetPos)).scrollIntoView()); + } + + return true; + }; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js new file mode 100644 index 0000000000..df8ceac2c9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { moveIntoBlockSdtBeforeTextBlockStart } from './moveIntoBlockSdtBeforeTextBlockStart.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + structuredContentBlock: { + group: 'block', + content: 'block*', + isolating: true, + attrs: { + lockMode: { default: 'unlocked' }, + }, + }, + table: { group: 'block', content: 'tableRow+' }, + tableRow: { content: 'tableCell+' }, + tableCell: { content: 'block+' }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); +const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); + +const makeDoc = (schema) => + schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.table.create(null, [ + schema.nodes.tableRow.create(null, [ + schema.nodes.tableCell.create(null, paragraph(schema, 'A1')), + schema.nodes.tableCell.create(null, paragraph(schema, 'B1')), + ]), + schema.nodes.tableRow.create(null, [ + schema.nodes.tableCell.create(null, paragraph(schema, 'A2')), + schema.nodes.tableCell.create(null, paragraph(schema, 'B2')), + ]), + ]), + ]), + paragraph(schema, 'After'), + ]); + +const findTextPos = (doc, text, offset = 0) => { + let found = null; + doc.descendants((node, pos) => { + if (!node.isText || found != null) return found == null; + const index = node.text.indexOf(text); + if (index === -1) return true; + found = pos + index + offset; + return false; + }); + expect(found).not.toBeNull(); + return found; +}; + +describe('moveIntoBlockSdtBeforeTextBlockStart', () => { + it('moves from the start of the following paragraph to the last text position inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const afterStart = findTextPos(doc, 'After'); + const b2End = findTextPos(doc, 'B2', 2); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.doc.textContent).toBe('BeforeA1B1A2B2After'); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(b2End); + expect(dispatched.selection.to).toBe(b2End); + }); + + it('returns false when the caret is not at the visible textblock start', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'After', 1)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the previous sibling is not a block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [paragraph(schema, 'Before'), paragraph(schema, 'After')]); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, findTextPos(doc, 'After')) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js index f653d6e360..0e90c1c0ec 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js @@ -38,6 +38,7 @@ describe('handleBackspace chain ordering', () => { undoInputRule: make('undoInputRule'), deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'), + moveIntoBlockSdtBeforeTextBlockStart: make('moveIntoBlockSdtBeforeTextBlockStart'), backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'), backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'), backspaceAtomBefore: make('backspaceAtomBefore'), @@ -75,6 +76,7 @@ describe('handleBackspace chain ordering', () => { // step 2 sets inputType meta and returns false (no command call) 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtBeforeRunStart', + 'moveIntoBlockSdtBeforeTextBlockStart', 'backspaceEmptyRunParagraph', 'backspaceSkipEmptyRun', 'backspaceAtomBefore', @@ -105,6 +107,7 @@ describe('handleBackspace chain ordering', () => { expect(callLog[0]).toBe('undoInputRule'); expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart'); expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart'); + expect(callLog[3]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); }); it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => { diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap.js b/packages/super-editor/src/editors/v1/core/extensions/keymap.js index c1873ff34f..1e318c6993 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -39,6 +39,7 @@ export const handleBackspace = (editor) => { }, () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtBeforeRunStart(), + () => commands.moveIntoBlockSdtBeforeTextBlockStart(), () => commands.backspaceEmptyRunParagraph(), () => commands.backspaceSkipEmptyRun(), () => commands.backspaceAtomBefore(), diff --git a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/tableBoundaryNavigation.js b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/tableBoundaryNavigation.js index 9afc16779a..1f7056f668 100644 --- a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/tableBoundaryNavigation.js +++ b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/tableBoundaryNavigation.js @@ -2,6 +2,7 @@ import { Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state'; import { CellSelection, TableMap } from 'prosemirror-tables'; import { getTableVisualDirection } from '@superdoc/contracts'; +import { findFirstTextPosInNode, findLastTextPosInNode } from '@core/commands/helpers/textPositions.js'; const TABLE_CELL_ROLES = new Set(['cell', 'header_cell']); @@ -214,42 +215,6 @@ function isFirstCellInTable(context) { return rect.left === 0 && rect.top === 0; } -/** - * Finds the first text position inside a node. - * @param {import('prosemirror-model').Node} node - * @param {number} nodePos - * @returns {number | null} - */ -function findFirstTextPosInNode(node, nodePos) { - if (node.isText) return nodePos; - for (let index = 0, offset = 0; index < node.childCount; index += 1) { - const child = node.child(index); - const childPos = nodePos + 1 + offset; - const found = findFirstTextPosInNode(child, childPos); - if (found != null) return found; - offset += child.nodeSize; - } - return null; -} - -/** - * Finds the last text position inside a node. - * @param {import('prosemirror-model').Node} node - * @param {number} nodePos - * @returns {number | null} - */ -function findLastTextPosInNode(node, nodePos) { - if (node.isText) return nodePos + (node.text?.length ?? 0); - for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { - const child = node.child(index); - offset -= child.nodeSize; - const childPos = nodePos + 1 + offset; - const found = findLastTextPosInNode(child, childPos); - if (found != null) return found; - } - return null; -} - /** * Finds the first text position after a boundary, or null if no text node exists. * @param {import('prosemirror-state').EditorState} state diff --git a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts index d9084282dd..f5da136958 100644 --- a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts +++ b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts @@ -265,6 +265,57 @@ async function isLabelVisible(page: Page, blockSelector: string): Promise { + return page.evaluate(() => { + const editor = (window as any).editor; + const { schema } = editor; + const paragraph = (text: string) => + schema.nodes.paragraph.create(null, schema.nodes.run.create(null, schema.text(text))); + const cell = (text: string) => schema.nodes.tableCell.create(null, paragraph(text)); + + const blockSdt = schema.nodes.structuredContentBlock.create( + { + id: 'sd3237-block-table', + alias: 'Block With Table', + tag: 'block-table', + lockMode: 'unlocked', + controlType: 'richText', + }, + [ + schema.nodes.table.create( + { + tableLayout: 'fixed', + tableProperties: { tableLayout: 'fixed', tableWidth: { value: 0, type: 'auto' } }, + grid: [{ col: 4680 }, { col: 4680 }], + }, + [ + schema.nodes.tableRow.create(null, [cell('A1'), cell('B1')]), + schema.nodes.tableRow.create(null, [cell('A2'), cell('B2')]), + ], + ), + ], + ); + + const doc = schema.nodes.doc.create(null, [paragraph('Before'), blockSdt, paragraph('After')]); + editor.view.dispatch(editor.state.tr.replaceWith(0, editor.state.doc.content.size, doc.content)); + + let afterStart: number | null = null; + let b2End: number | null = null; + editor.state.doc.descendants((node: any, pos: number) => { + if (!node.isText || !node.text) return true; + if (node.text === 'After') afterStart = pos; + if (node.text === 'B2') b2End = pos + node.text.length; + return true; + }); + + if (afterStart == null || b2End == null) { + throw new Error('Failed to build block SDT table fixture'); + } + + return { afterStart, b2End }; + }); +} + test.describe('SD-3237 structured content interactions', () => { test.beforeEach(async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); @@ -455,4 +506,40 @@ test.describe('SD-3237 structured content interactions', () => { to: inlineRange.nodeEnd, }); }); + + test('Backspace at paragraph after block SDT table moves into SDT without deleting following text', async ({ + superdoc, + }) => { + const { afterStart, b2End } = await loadBlockSdtTableBackspaceFixture(superdoc.page); + await superdoc.waitForStable(); + + await superdoc.setTextSelection(afterStart); + await superdoc.page.evaluate(() => (window as any).editor.view.focus()); + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => { + const { state } = (window as any).editor; + const { selection } = state; + const parentTypes: string[] = []; + for (let depth = selection.$from.depth; depth > 0; depth -= 1) { + parentTypes.push(selection.$from.node(depth).type.name); + } + return { + text: state.doc.textContent, + from: selection.from, + to: selection.to, + empty: selection.empty, + parentTypes, + }; + }); + + expect(result).toMatchObject({ + text: 'BeforeA1B1A2B2After', + from: b2End, + to: b2End, + empty: true, + }); + expect(result.parentTypes).toContain('structuredContentBlock'); + }); }); From 2da50a094f5f2e7442854c68d1c8f3abcf3e58c8 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 10:06:56 -0300 Subject: [PATCH 059/103] feat(super-editor): move caret into following block sdt on delete Adds moveIntoBlockSdtAfterTextBlockEnd to the delete chain so that Delete at the end of a textblock preceding a block SDT moves the caret to the first text position inside the SDT instead of deleting into protected content. Mirrors the existing backspace-side handler. --- .../v1/core/commands/core-command-map.d.ts | 1 + .../v1/core/commands/core-command-map.test.ts | 1 + .../src/editors/v1/core/commands/index.js | 1 + .../moveIntoBlockSdtAfterTextBlockEnd.js | 45 +++++++ .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 117 ++++++++++++++++++ .../extensions/keymap-backspace-chain.test.js | 2 + .../src/editors/v1/core/extensions/keymap.js | 1 + .../sdt/sd-3237-sdt-interactions.spec.ts | 48 ++++++- 8 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js create mode 100644 packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts index 07f2843b7a..1a2e13e361 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts @@ -66,6 +66,7 @@ type CoreCommandNames = | 'selectInlineSdtAfterRunEnd' | 'deleteBlockSdtAtTextBlockStart' | 'moveIntoBlockSdtBeforeTextBlockStart' + | 'moveIntoBlockSdtAfterTextBlockEnd' | 'deleteSkipEmptyRun' | 'deleteNextToRun' | 'deleteAtomAfter' diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts index b2a735fa50..29aeb75e6a 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts @@ -12,5 +12,6 @@ describe('core command map types', () => { expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'"); expect(declaration).toContain("| 'selectInlineSdtAfterRunEnd'"); expect(declaration).toContain("| 'moveIntoBlockSdtBeforeTextBlockStart'"); + expect(declaration).toContain("| 'moveIntoBlockSdtAfterTextBlockEnd'"); }); }); diff --git a/packages/super-editor/src/editors/v1/core/commands/index.js b/packages/super-editor/src/editors/v1/core/commands/index.js index a1ce59a165..82ee55614d 100644 --- a/packages/super-editor/src/editors/v1/core/commands/index.js +++ b/packages/super-editor/src/editors/v1/core/commands/index.js @@ -55,6 +55,7 @@ export * from './backspaceAtomBefore.js'; export * from './selectInlineSdtBeforeRunStart.js'; export * from './deleteBlockSdtAtTextBlockStart.js'; export * from './moveIntoBlockSdtBeforeTextBlockStart.js'; +export * from './moveIntoBlockSdtAfterTextBlockEnd.js'; export * from './deleteSkipEmptyRun.js'; export * from './deleteNextToRun.js'; export * from './deleteAtomAfter.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js new file mode 100644 index 0000000000..db42e53e72 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js @@ -0,0 +1,45 @@ +import { TextSelection } from 'prosemirror-state'; +import { findFirstTextPosInNode, findLastTextPosInNode } from './helpers/textPositions.js'; + +function findAncestorDepth($pos, predicate) { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (predicate($pos.node(depth))) return depth; + } + return null; +} + +/** + * Moves the caret into the next block SDT when Delete is pressed at the end of + * the preceding textblock. + * + * @returns {import('@core/commands/types').Command} + */ +export const moveIntoBlockSdtAfterTextBlockEnd = + () => + ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const { $from } = selection; + const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); + if (textblockDepth == null) return false; + + const textblock = $from.node(textblockDepth); + const textblockPos = $from.before(textblockDepth); + const lastTextPos = findLastTextPosInNode(textblock, textblockPos); + if (lastTextPos !== $from.pos) return false; + + const boundaryPos = $from.after(textblockDepth); + const boundary = state.doc.resolve(boundaryPos); + const nextNode = boundary.nodeAfter; + if (nextNode?.type.name !== 'structuredContentBlock') return false; + + const targetPos = findFirstTextPosInNode(nextNode, boundaryPos); + if (targetPos == null) return false; + + if (dispatch) { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, targetPos)).scrollIntoView()); + } + + return true; + }; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js new file mode 100644 index 0000000000..17c37f5df7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { moveIntoBlockSdtAfterTextBlockEnd } from './moveIntoBlockSdtAfterTextBlockEnd.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + structuredContentBlock: { + group: 'block', + content: 'block*', + isolating: true, + attrs: { + lockMode: { default: 'unlocked' }, + }, + }, + table: { group: 'block', content: 'tableRow+' }, + tableRow: { content: 'tableCell+' }, + tableCell: { content: 'block+' }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); +const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); + +const makeDoc = (schema) => + schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.table.create(null, [ + schema.nodes.tableRow.create(null, [ + schema.nodes.tableCell.create(null, paragraph(schema, 'A1')), + schema.nodes.tableCell.create(null, paragraph(schema, 'B1')), + ]), + schema.nodes.tableRow.create(null, [ + schema.nodes.tableCell.create(null, paragraph(schema, 'A2')), + schema.nodes.tableCell.create(null, paragraph(schema, 'B2')), + ]), + ]), + ]), + paragraph(schema, 'After'), + ]); + +const findTextPos = (doc, text, offset = 0) => { + let found = null; + doc.descendants((node, pos) => { + if (!node.isText || found != null) return found == null; + const index = node.text.indexOf(text); + if (index === -1) return true; + found = pos + index + offset; + return false; + }); + expect(found).not.toBeNull(); + return found; +}; + +describe('moveIntoBlockSdtAfterTextBlockEnd', () => { + it('moves from the end of the preceding paragraph to the first text position inside a following block SDT', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const beforeEnd = findTextPos(doc, 'Before', 6); + const a1Start = findTextPos(doc, 'A1'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.doc.textContent).toBe('BeforeA1B1A2B2After'); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(a1Start); + expect(dispatched.selection.to).toBe(a1Start); + }); + + it('returns false when the caret is not at the visible textblock end', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 5)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the next sibling is not a block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [paragraph(schema, 'Before'), paragraph(schema, 'After')]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 6)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js index 0e90c1c0ec..98538477e7 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js @@ -179,6 +179,7 @@ describe('handleDelete chain ordering', () => { const commands = { deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'), + moveIntoBlockSdtAfterTextBlockEnd: make('moveIntoBlockSdtAfterTextBlockEnd'), deleteSkipEmptyRun: make('deleteSkipEmptyRun'), deleteAtomAfter: make('deleteAtomAfter'), deleteNextToRun: make('deleteNextToRun'), @@ -211,6 +212,7 @@ describe('handleDelete chain ordering', () => { expect(callLog).toEqual([ 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtAfterRunEnd', + 'moveIntoBlockSdtAfterTextBlockEnd', 'deleteSkipEmptyRun', 'deleteAtomAfter', 'deleteNextToRun', diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap.js b/packages/super-editor/src/editors/v1/core/extensions/keymap.js index 1e318c6993..20422b5383 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -61,6 +61,7 @@ export const handleDelete = (editor) => { return editor.commands.first(({ commands }) => [ () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtAfterRunEnd(), + () => commands.moveIntoBlockSdtAfterTextBlockEnd(), () => commands.deleteSkipEmptyRun(), () => commands.deleteAtomAfter(), () => commands.deleteNextToRun(), diff --git a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts index f5da136958..f2224500df 100644 --- a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts +++ b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts @@ -265,7 +265,9 @@ async function isLabelVisible(page: Page, blockSelector: string): Promise { +async function loadBlockSdtTableBackspaceFixture( + page: Page, +): Promise<{ beforeEnd: number; afterStart: number; a1Start: number; b2End: number }> { return page.evaluate(() => { const editor = (window as any).editor; const { schema } = editor; @@ -300,19 +302,23 @@ async function loadBlockSdtTableBackspaceFixture(page: Page): Promise<{ afterSta editor.view.dispatch(editor.state.tr.replaceWith(0, editor.state.doc.content.size, doc.content)); let afterStart: number | null = null; + let beforeEnd: number | null = null; + let a1Start: number | null = null; let b2End: number | null = null; editor.state.doc.descendants((node: any, pos: number) => { if (!node.isText || !node.text) return true; + if (node.text === 'Before') beforeEnd = pos + node.text.length; if (node.text === 'After') afterStart = pos; + if (node.text === 'A1') a1Start = pos; if (node.text === 'B2') b2End = pos + node.text.length; return true; }); - if (afterStart == null || b2End == null) { + if (beforeEnd == null || afterStart == null || a1Start == null || b2End == null) { throw new Error('Failed to build block SDT table fixture'); } - return { afterStart, b2End }; + return { beforeEnd, afterStart, a1Start, b2End }; }); } @@ -542,4 +548,40 @@ test.describe('SD-3237 structured content interactions', () => { }); expect(result.parentTypes).toContain('structuredContentBlock'); }); + + test('Delete at paragraph before block SDT table moves into SDT without deleting preceding text', async ({ + superdoc, + }) => { + const { beforeEnd, a1Start } = await loadBlockSdtTableBackspaceFixture(superdoc.page); + await superdoc.waitForStable(); + + await superdoc.setTextSelection(beforeEnd); + await superdoc.page.evaluate(() => (window as any).editor.view.focus()); + await superdoc.press('Delete'); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => { + const { state } = (window as any).editor; + const { selection } = state; + const parentTypes: string[] = []; + for (let depth = selection.$from.depth; depth > 0; depth -= 1) { + parentTypes.push(selection.$from.node(depth).type.name); + } + return { + text: state.doc.textContent, + from: selection.from, + to: selection.to, + empty: selection.empty, + parentTypes, + }; + }); + + expect(result).toMatchObject({ + text: 'BeforeA1B1A2B2After', + from: a1Start, + to: a1Start, + empty: true, + }); + expect(result.parentTypes).toContain('structuredContentBlock'); + }); }); From 5c1cbfe6ece2a29b6ee1ef6fd70a64134752493b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 10:44:09 -0300 Subject: [PATCH 060/103] fix(super-editor): avoid restoring dragged block to its source position resolveInsertionBoundary now sorts candidate boundaries by distance from the requested target and falls back to side-of-bias only as a tie-break. The drop path passes the mapped source start as a position to avoid, so the moved node lands on the next nearest valid boundary instead of snapping back to where it came from. --- .../input/internal-node-move.test.ts | 61 +++++++++++++++++++ .../input/internal-node-move.ts | 43 +++++++++---- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts index fb5c61a54b..5c77da3e7e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts @@ -156,4 +156,65 @@ describe('createInternalNodeMoveTransaction', () => { expect(canInsertAt).toHaveBeenCalledWith(tr.doc, 70, sourceNode); expect(tr.insert).toHaveBeenCalledWith(70, sourceNode); }); + + it('moves a block node to the after boundary when dropped near the end of text while dragging upward', () => { + const { doc, tr, sourceNode } = createMoveState({ mappedTarget: 88 }); + doc.nodeAt.mockImplementation((pos: number) => (pos === 120 ? sourceNode : null)); + tr.doc = { + content: { size: 200 }, + resolve: vi.fn(() => ({ + depth: 1, + before: vi.fn(() => 70), + after: vi.fn(() => 90), + })), + } as never; + const canInsertAt = vi.fn((_doc, pos: number) => pos === 70 || pos === 90); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 120, + sourceEnd: 130, + targetPos: 88, + canInsertAt, + }, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.mappedTarget).toBe(90); + } + expect(tr.insert).toHaveBeenCalledWith(90, sourceNode); + }); + + it('avoids restoring a moved block to its original boundary when another fallback boundary is valid', () => { + const { doc, tr, sourceNode } = createMoveState({ sourceNodeSize: 40 }); + doc.nodeAt.mockImplementation((pos: number) => (pos === 19 ? sourceNode : null)); + tr.doc = { + content: { size: 59 }, + resolve: vi.fn(() => ({ + depth: 1, + before: vi.fn(() => 10), + after: vi.fn(() => 19), + })), + } as never; + tr.mapping.map = vi.fn((pos: number) => pos); + const canInsertAt = vi.fn((_doc, pos: number) => pos === 10 || pos === 19); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 19, + sourceEnd: 59, + targetPos: 17, + canInsertAt, + }, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.mappedTarget).toBe(10); + } + expect(tr.insert).toHaveBeenCalledWith(10, sourceNode); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts index 5da4ea8646..cf56fb2039 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts @@ -19,6 +19,10 @@ type InternalMoveState = { }; type TargetBias = 'before' | 'after'; +type BoundaryCandidate = { + pos: number; + side: TargetBias; +}; export function canInsertNodeAtPosition(doc: ProseMirrorNode, pos: number, node: ProseMirrorNode): boolean { try { @@ -42,26 +46,41 @@ function resolveInsertionBoundary( node: ProseMirrorNode, canInsertAt: InternalMoveRequest['canInsertAt'], bias: TargetBias, + avoidPos?: number, ): number | null { try { const resolvedPos = doc.resolve(pos); - const candidates: number[] = []; + const candidates: BoundaryCandidate[] = []; for (let depth = resolvedPos.depth; depth > 0; depth--) { - const before = resolvedPos.before(depth); - const after = resolvedPos.after(depth); - if (bias === 'before') { - candidates.push(before, after); - } else { - candidates.push(after, before); - } + candidates.push( + { pos: resolvedPos.before(depth), side: 'before' }, + { pos: resolvedPos.after(depth), side: 'after' }, + ); } + candidates.sort((a, b) => { + const distanceDelta = Math.abs(a.pos - pos) - Math.abs(b.pos - pos); + if (distanceDelta !== 0) return distanceDelta; + if (a.side === bias && b.side !== bias) return -1; + if (b.side === bias && a.side !== bias) return 1; + return 0; + }); + + let avoidedCandidate: number | null = null; for (const candidate of candidates) { - if (candidate < 0 || candidate > doc.content.size) continue; - if (candidate === pos) continue; - if (canInsertAt(doc, candidate, node)) return candidate; + const candidatePos = candidate.pos; + if (candidatePos < 0 || candidatePos > doc.content.size) continue; + if (candidatePos === pos) continue; + if (!canInsertAt(doc, candidatePos, node)) continue; + if (avoidPos != null && candidatePos === avoidPos) { + avoidedCandidate = candidatePos; + continue; + } + return candidatePos; } + + return avoidedCandidate; } catch { return null; } @@ -92,6 +111,7 @@ export function createInternalNodeMoveTransaction( tr.delete(sourceStart, sourceEnd); const mappedTarget = tr.mapping.map(targetPos); + const mappedSourceStart = tr.mapping.map(sourceStart); if (mappedTarget < 0 || mappedTarget > tr.doc.content.size) { return { ok: false, reason: 'invalid-target' }; } @@ -104,6 +124,7 @@ export function createInternalNodeMoveTransaction( sourceNode, canInsertAt, targetPos <= sourceStart ? 'before' : 'after', + mappedSourceStart, ); if (boundaryTarget == null) { return { ok: false, reason: 'invalid-target' }; From 6482205e15b739b08daf3ea3fa6c7b391ad74876 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 11:26:43 -0300 Subject: [PATCH 061/103] refactor(layout-engine): share box model between block and inline sdt labels Consolidates structured content label rules so block and inline SDT labels share a single declaration for size, padding, border, background, and a new drag-handle ::before indicator. Scope-specific rules now only carry positioning, border-radius, and the inline display: inline-flex override. --- .../painters/dom/src/styles.test.ts | 45 ++++++++++++++++ .../layout-engine/painters/dom/src/styles.ts | 53 +++++++++++-------- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 9a7b019a44..24e70b1d26 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -65,6 +65,51 @@ describe('ensureSdtContainerStyles', () => { expect(emptyRule).not.toContain('vertical-align'); }); + it('uses the same label box model for block and inline SDTs', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const sharedLabelRule = + cssText.match( + /\.superdoc-structured-content__label,\s*\.superdoc-structured-content-inline__label\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const inlineSelectedRule = + cssText.match( + /\.superdoc-structured-content-inline\.ProseMirror-selectednode \.superdoc-structured-content-inline__label\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const sharedLabelDragHandleRule = + cssText.match( + /\.superdoc-structured-content__label::before,\s*\.superdoc-structured-content-inline__label::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const inlineLabelRule = + [...cssText.matchAll(/\.superdoc-structured-content-inline__label\s*\{([^}]*)\}/g)] + .map((match) => match[1] ?? '') + .find((rule) => rule.includes('bottom: calc(100% + 1px);')) ?? ''; + const blockLabelRule = cssText.match(/\.superdoc-structured-content__label\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(sharedLabelRule).toContain('height: 18px;'); + expect(sharedLabelRule).toContain('padding: 0 4px;'); + expect(sharedLabelRule).toContain('border: 1px solid var(--sd-content-controls-label-border, #629be7);'); + expect(sharedLabelRule).toContain('box-sizing: border-box;'); + expect(sharedLabelRule).toContain('align-items: center;'); + expect(sharedLabelRule).toContain('justify-content: center;'); + expect(sharedLabelDragHandleRule).toContain("content: '';"); + expect(sharedLabelDragHandleRule).toContain('height: 8px;'); + expect(sharedLabelDragHandleRule).toContain( + 'radial-gradient(circle, currentColor 1px, transparent 1px) center 0 / 2px 2px no-repeat,', + ); + expect(sharedLabelDragHandleRule).toContain('center 3px / 2px 2px no-repeat,'); + expect(sharedLabelDragHandleRule).toContain('center 6px / 2px 2px no-repeat;'); + expect(inlineSelectedRule).toContain('display: inline-flex;'); + expect(inlineLabelRule).toContain('border-radius: 4px 4px 0 0;'); + expect(blockLabelRule).toContain('white-space: nowrap;'); + expect(blockLabelRule).toContain('top: -18px;'); + expect(blockLabelRule).not.toContain('width:'); + expect(blockLabelRule).not.toContain('max-width:'); + expect(cssText).toContain('bottom: calc(100% + 1px);'); + }); + it('reserves empty inline SDT width without adding line-box height', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 85d9fcf512..cf36f76721 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -554,32 +554,48 @@ const SDT_CONTAINER_STYLES = ` border-color: var(--sd-content-controls-block-border, #629be7); } -/* Structured content drag handle/label - positioned above */ -.superdoc-structured-content__label { +/* Structured content labels - shared box model; positioning differs by scope. */ +.superdoc-structured-content__label, +.superdoc-structured-content-inline__label { font-size: 11px; align-items: center; justify-content: center; - position: absolute; - left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); - top: -19px; - width: calc(var(--sd-sdt-chrome-width, 100%) - 4px); - max-width: 130px; - min-width: 0; height: 18px; padding: 0 4px; border: 1px solid var(--sd-content-controls-label-border, #629be7); - border-bottom: none; - border-radius: 6px 6px 0 0; background-color: var(--sd-content-controls-label-bg, #629be7ee); color: var(--sd-content-controls-label-text, #ffffff); box-sizing: border-box; - z-index: 10; display: none; pointer-events: auto; cursor: pointer; user-select: none; } +.superdoc-structured-content__label::before, +.superdoc-structured-content-inline__label::before { + content: ''; + width: 2px; + height: 8px; + margin-right: 4px; + background: + radial-gradient(circle, currentColor 1px, transparent 1px) center 0 / 2px 2px no-repeat, + radial-gradient(circle, currentColor 1px, transparent 1px) center 3px / 2px 2px no-repeat, + radial-gradient(circle, currentColor 1px, transparent 1px) center 6px / 2px 2px no-repeat; + flex: 0 0 auto; +} + +/* Structured content drag handle/label - positioned above */ +.superdoc-structured-content__label { + position: absolute; + left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); + top: -18px; + border-bottom: none; + border-radius: 6px 6px 0 0; + white-space: nowrap; + z-index: 10; +} + .superdoc-structured-content__label span { max-width: 100%; overflow: hidden; @@ -681,25 +697,16 @@ const SDT_CONTAINER_STYLES = ` /* Inline structured content label - shown when active */ .superdoc-structured-content-inline__label { position: absolute; - bottom: calc(100% + 2px); + bottom: calc(100% + 1px); left: 50%; transform: translateX(-50%); - font-size: 11px; - padding: 0 4px; - border: 1px solid var(--sd-content-controls-label-border, #629be7); - background-color: var(--sd-content-controls-label-bg, #629be7ee); - color: var(--sd-content-controls-label-text, #ffffff); - border-radius: 4px; + border-radius: 4px 4px 0 0; white-space: nowrap; z-index: 100; - display: none; - pointer-events: auto; - cursor: pointer; - user-select: none; } .superdoc-structured-content-inline.ProseMirror-selectednode .superdoc-structured-content-inline__label { - display: block; + display: inline-flex; } .superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover .superdoc-structured-content-inline__label { From 2ec58fb0e48b9c59db57f364130fcc637dc624b6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 11:34:12 -0300 Subject: [PATCH 062/103] fix(super-editor): handle empty block sdt navigation --- .../moveIntoBlockSdtAfterTextBlockEnd.js | 12 ++-- .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 66 +++++++++++++++++++ .../moveIntoBlockSdtBeforeTextBlockStart.js | 12 ++-- ...veIntoBlockSdtBeforeTextBlockStart.test.js | 66 +++++++++++++++++++ 4 files changed, 148 insertions(+), 8 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js index db42e53e72..ad1fa6077f 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js @@ -1,4 +1,4 @@ -import { TextSelection } from 'prosemirror-state'; +import { Selection, TextSelection } from 'prosemirror-state'; import { findFirstTextPosInNode, findLastTextPosInNode } from './helpers/textPositions.js'; function findAncestorDepth($pos, predicate) { @@ -26,7 +26,7 @@ export const moveIntoBlockSdtAfterTextBlockEnd = const textblock = $from.node(textblockDepth); const textblockPos = $from.before(textblockDepth); - const lastTextPos = findLastTextPosInNode(textblock, textblockPos); + const lastTextPos = findLastTextPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth); if (lastTextPos !== $from.pos) return false; const boundaryPos = $from.after(textblockDepth); @@ -35,10 +35,14 @@ export const moveIntoBlockSdtAfterTextBlockEnd = if (nextNode?.type.name !== 'structuredContentBlock') return false; const targetPos = findFirstTextPosInNode(nextNode, boundaryPos); - if (targetPos == null) return false; if (dispatch) { - dispatch(state.tr.setSelection(TextSelection.create(state.doc, targetPos)).scrollIntoView()); + const targetSelection = + targetPos != null + ? TextSelection.create(state.doc, targetPos) + : (Selection.findFrom(state.doc.resolve(boundaryPos), 1, true) ?? + Selection.near(state.doc.resolve(boundaryPos), 1)); + dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); } return true; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 17c37f5df7..c43747c509 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -27,6 +27,7 @@ const makeSchema = () => const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); +const emptyParagraph = (schema) => schema.nodes.paragraph.create(); const makeDoc = (schema) => schema.node('doc', null, [ @@ -59,6 +60,19 @@ const findTextPos = (doc, text, offset = 0) => { return found; }; +const findEmptyParagraphTextPos = (doc) => { + let found = null; + doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.childCount === 0 && found == null) { + found = pos + 1; + return false; + } + return true; + }); + expect(found).not.toBeNull(); + return found; +}; + describe('moveIntoBlockSdtAfterTextBlockEnd', () => { it('moves from the end of the preceding paragraph to the first text position inside a following block SDT', () => { const schema = makeSchema(); @@ -83,6 +97,58 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.to).toBe(a1Start); }); + it('moves from an empty preceding paragraph to the first text position inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + emptyParagraph(schema), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findEmptyParagraphTextPos(doc); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(innerStart); + expect(dispatched.selection.to).toBe(innerStart); + }); + + it('moves into a following block SDT that only contains an empty paragraph', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [emptyParagraph(schema)]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const targetPos = findEmptyParagraphTextPos(doc); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(targetPos); + expect(dispatched.selection.to).toBe(targetPos); + }); + it('returns false when the caret is not at the visible textblock end', () => { const schema = makeSchema(); const doc = makeDoc(schema); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js index d71f3abd18..cad8f43a26 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js @@ -1,4 +1,4 @@ -import { TextSelection } from 'prosemirror-state'; +import { Selection, TextSelection } from 'prosemirror-state'; import { findFirstTextPosInNode, findLastTextPosInNode } from './helpers/textPositions.js'; function findAncestorDepth($pos, predicate) { @@ -26,7 +26,7 @@ export const moveIntoBlockSdtBeforeTextBlockStart = const textblock = $from.node(textblockDepth); const textblockPos = $from.before(textblockDepth); - const firstTextPos = findFirstTextPosInNode(textblock, textblockPos); + const firstTextPos = findFirstTextPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth); if (firstTextPos !== $from.pos) return false; const boundary = state.doc.resolve(textblockPos); @@ -35,10 +35,14 @@ export const moveIntoBlockSdtBeforeTextBlockStart = const previousNodePos = textblockPos - previousNode.nodeSize; const targetPos = findLastTextPosInNode(previousNode, previousNodePos); - if (targetPos == null) return false; if (dispatch) { - dispatch(state.tr.setSelection(TextSelection.create(state.doc, targetPos)).scrollIntoView()); + const targetSelection = + targetPos != null + ? TextSelection.create(state.doc, targetPos) + : (Selection.findFrom(state.doc.resolve(textblockPos), -1, true) ?? + Selection.near(state.doc.resolve(textblockPos), -1)); + dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); } return true; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index df8ceac2c9..3395caf7b1 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -27,6 +27,7 @@ const makeSchema = () => const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); +const emptyParagraph = (schema) => schema.nodes.paragraph.create(); const makeDoc = (schema) => schema.node('doc', null, [ @@ -59,6 +60,19 @@ const findTextPos = (doc, text, offset = 0) => { return found; }; +const findEmptyParagraphTextPos = (doc) => { + let found = null; + doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.childCount === 0 && found == null) { + found = pos + 1; + return false; + } + return true; + }); + expect(found).not.toBeNull(); + return found; +}; + describe('moveIntoBlockSdtBeforeTextBlockStart', () => { it('moves from the start of the following paragraph to the last text position inside a previous block SDT', () => { const schema = makeSchema(); @@ -83,6 +97,58 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.to).toBe(b2End); }); + it('moves from an empty following paragraph to the last text position inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + emptyParagraph(schema), + ]); + const afterStart = findEmptyParagraphTextPos(doc); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(innerEnd); + expect(dispatched.selection.to).toBe(innerEnd); + }); + + it('moves into a previous block SDT that only contains an empty paragraph', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [emptyParagraph(schema)]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const targetPos = findEmptyParagraphTextPos(doc); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(targetPos); + expect(dispatched.selection.to).toBe(targetPos); + }); + it('returns false when the caret is not at the visible textblock start', () => { const schema = makeSchema(); const doc = makeDoc(schema); From 5e6aa8912f5e9f82a74924dae38d1246d8cb3584 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 11:38:10 -0300 Subject: [PATCH 063/103] fix(super-editor): respect inline atoms in sdt navigation --- .../v1/core/commands/helpers/textPositions.js | 41 +++++++++++++++++++ .../moveIntoBlockSdtAfterTextBlockEnd.js | 6 +-- .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 22 ++++++++++ .../moveIntoBlockSdtBeforeTextBlockStart.js | 6 +-- ...veIntoBlockSdtBeforeTextBlockStart.test.js | 18 ++++++++ 5 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index 5462dfc311..2ffd69cc08 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -18,6 +18,26 @@ export function findFirstTextPosInNode(node, nodePos) { return null; } +/** + * Finds the first cursor position for visible content inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +export function findFirstContentCursorPosInNode(node, nodePos) { + if (node.isText || node.isAtom) return nodePos; + + for (let index = 0, offset = 0; index < node.childCount; index += 1) { + const child = node.child(index); + const childPos = nodePos + 1 + offset; + const found = findFirstContentCursorPosInNode(child, childPos); + if (found != null) return found; + offset += child.nodeSize; + } + + return null; +} + /** * Finds the last text cursor position inside a node. * @param {import('prosemirror-model').Node} node @@ -37,3 +57,24 @@ export function findLastTextPosInNode(node, nodePos) { return null; } + +/** + * Finds the last cursor position for visible content inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +export function findLastContentCursorPosInNode(node, nodePos) { + if (node.isText) return nodePos + (node.text?.length ?? 0); + if (node.isAtom) return nodePos + node.nodeSize; + + for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { + const child = node.child(index); + offset -= child.nodeSize; + const childPos = nodePos + 1 + offset; + const found = findLastContentCursorPosInNode(child, childPos); + if (found != null) return found; + } + + return null; +} diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js index ad1fa6077f..ca59fd832c 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js @@ -1,5 +1,5 @@ import { Selection, TextSelection } from 'prosemirror-state'; -import { findFirstTextPosInNode, findLastTextPosInNode } from './helpers/textPositions.js'; +import { findFirstTextPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js'; function findAncestorDepth($pos, predicate) { for (let depth = $pos.depth; depth > 0; depth -= 1) { @@ -26,8 +26,8 @@ export const moveIntoBlockSdtAfterTextBlockEnd = const textblock = $from.node(textblockDepth); const textblockPos = $from.before(textblockDepth); - const lastTextPos = findLastTextPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth); - if (lastTextPos !== $from.pos) return false; + const lastContentPos = findLastContentCursorPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth); + if (lastContentPos !== $from.pos) return false; const boundaryPos = $from.after(textblockDepth); const boundary = state.doc.resolve(boundaryPos); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index c43747c509..504b119240 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -20,12 +20,14 @@ const makeSchema = () => table: { group: 'block', content: 'tableRow+' }, tableRow: { content: 'tableCell+' }, tableCell: { content: 'block+' }, + noBreakHyphen: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, marks: {}, }); const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); +const atomRun = (schema, nodeName) => schema.nodes.run.create(null, schema.nodes[nodeName].create()); const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); const emptyParagraph = (schema) => schema.nodes.paragraph.create(); @@ -149,6 +151,26 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.to).toBe(targetPos); }); + it('returns false when inline atom content appears after the last text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Before'), atomRun(schema, 'noBreakHyphen')]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 6)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('returns false when the caret is not at the visible textblock end', () => { const schema = makeSchema(); const doc = makeDoc(schema); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js index cad8f43a26..dd330c134d 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js @@ -1,5 +1,5 @@ import { Selection, TextSelection } from 'prosemirror-state'; -import { findFirstTextPosInNode, findLastTextPosInNode } from './helpers/textPositions.js'; +import { findFirstContentCursorPosInNode, findLastTextPosInNode } from './helpers/textPositions.js'; function findAncestorDepth($pos, predicate) { for (let depth = $pos.depth; depth > 0; depth -= 1) { @@ -26,8 +26,8 @@ export const moveIntoBlockSdtBeforeTextBlockStart = const textblock = $from.node(textblockDepth); const textblockPos = $from.before(textblockDepth); - const firstTextPos = findFirstTextPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth); - if (firstTextPos !== $from.pos) return false; + const firstContentPos = findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth); + if (firstContentPos !== $from.pos) return false; const boundary = state.doc.resolve(textblockPos); const previousNode = boundary.nodeBefore; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index 3395caf7b1..711488cf93 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -20,12 +20,14 @@ const makeSchema = () => table: { group: 'block', content: 'tableRow+' }, tableRow: { content: 'tableCell+' }, tableCell: { content: 'block+' }, + noBreakHyphen: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, marks: {}, }); const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); +const atomRun = (schema, nodeName) => schema.nodes.run.create(null, schema.nodes[nodeName].create()); const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); const emptyParagraph = (schema) => schema.nodes.paragraph.create(); @@ -149,6 +151,22 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.to).toBe(targetPos); }); + it('returns false when inline atom content appears before the first text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [atomRun(schema, 'noBreakHyphen'), run(schema, 'After')]), + ]); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, findTextPos(doc, 'After')) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('returns false when the caret is not at the visible textblock start', () => { const schema = makeSchema(); const doc = makeDoc(schema); From eaffbf7f720b2fac16d16435fc2ec14dff6e7858 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 11:42:25 -0300 Subject: [PATCH 064/103] fix(super-editor): target nearest sdt cursor position --- .../v1/core/commands/helpers/textPositions.js | 2 ++ .../moveIntoBlockSdtAfterTextBlockEnd.js | 4 +-- .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 26 +++++++++++++++++++ .../moveIntoBlockSdtBeforeTextBlockStart.js | 4 +-- ...veIntoBlockSdtBeforeTextBlockStart.test.js | 26 +++++++++++++++++++ 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index 2ffd69cc08..f89f44c57f 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -26,6 +26,7 @@ export function findFirstTextPosInNode(node, nodePos) { */ export function findFirstContentCursorPosInNode(node, nodePos) { if (node.isText || node.isAtom) return nodePos; + if (node.isTextblock && node.childCount === 0) return nodePos + 1; for (let index = 0, offset = 0; index < node.childCount; index += 1) { const child = node.child(index); @@ -67,6 +68,7 @@ export function findLastTextPosInNode(node, nodePos) { export function findLastContentCursorPosInNode(node, nodePos) { if (node.isText) return nodePos + (node.text?.length ?? 0); if (node.isAtom) return nodePos + node.nodeSize; + if (node.isTextblock && node.childCount === 0) return nodePos + 1; for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { const child = node.child(index); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js index ca59fd832c..3ffd6a6439 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js @@ -1,5 +1,5 @@ import { Selection, TextSelection } from 'prosemirror-state'; -import { findFirstTextPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js'; +import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js'; function findAncestorDepth($pos, predicate) { for (let depth = $pos.depth; depth > 0; depth -= 1) { @@ -34,7 +34,7 @@ export const moveIntoBlockSdtAfterTextBlockEnd = const nextNode = boundary.nodeAfter; if (nextNode?.type.name !== 'structuredContentBlock') return false; - const targetPos = findFirstTextPosInNode(nextNode, boundaryPos); + const targetPos = findFirstContentCursorPosInNode(nextNode, boundaryPos); if (dispatch) { const targetSelection = diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 504b119240..8356b3ce1c 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -151,6 +151,32 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.to).toBe(targetPos); }); + it('moves into the leading empty paragraph of a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [emptyParagraph(schema), paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const targetPos = findEmptyParagraphTextPos(doc); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(targetPos); + expect(dispatched.selection.to).toBe(targetPos); + }); + it('returns false when inline atom content appears after the last text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js index dd330c134d..3b43563186 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js @@ -1,5 +1,5 @@ import { Selection, TextSelection } from 'prosemirror-state'; -import { findFirstContentCursorPosInNode, findLastTextPosInNode } from './helpers/textPositions.js'; +import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js'; function findAncestorDepth($pos, predicate) { for (let depth = $pos.depth; depth > 0; depth -= 1) { @@ -34,7 +34,7 @@ export const moveIntoBlockSdtBeforeTextBlockStart = if (previousNode?.type.name !== 'structuredContentBlock') return false; const previousNodePos = textblockPos - previousNode.nodeSize; - const targetPos = findLastTextPosInNode(previousNode, previousNodePos); + const targetPos = findLastContentCursorPosInNode(previousNode, previousNodePos); if (dispatch) { const targetSelection = diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index 711488cf93..13a05fbbd4 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -151,6 +151,32 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.to).toBe(targetPos); }); + it('moves into the trailing empty paragraph of a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner'), emptyParagraph(schema)]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const targetPos = findEmptyParagraphTextPos(doc); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(targetPos); + expect(dispatched.selection.to).toBe(targetPos); + }); + it('returns false when inline atom content appears before the first text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From 9c228eb0c11d0d64cf8fd364f47f51b0e17013a0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 11:46:32 -0300 Subject: [PATCH 065/103] fix(super-editor): skip hidden sdt navigation markers --- .../v1/core/commands/helpers/textPositions.js | 2 + .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 54 ++++++++++++++++++- ...veIntoBlockSdtBeforeTextBlockStart.test.js | 54 ++++++++++++++++++- 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index f89f44c57f..8f8668cc6b 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -25,6 +25,7 @@ export function findFirstTextPosInNode(node, nodePos) { * @returns {number | null} */ export function findFirstContentCursorPosInNode(node, nodePos) { + if (node.isAtom && node.isInline && node.textContent === '') return null; if (node.isText || node.isAtom) return nodePos; if (node.isTextblock && node.childCount === 0) return nodePos + 1; @@ -66,6 +67,7 @@ export function findLastTextPosInNode(node, nodePos) { * @returns {number | null} */ export function findLastContentCursorPosInNode(node, nodePos) { + if (node.isAtom && node.isInline && node.textContent === '') return null; if (node.isText) return nodePos + (node.text?.length ?? 0); if (node.isAtom) return nodePos + node.nodeSize; if (node.isTextblock && node.childCount === 0) return nodePos + 1; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 8356b3ce1c..b49b623791 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -20,7 +20,8 @@ const makeSchema = () => table: { group: 'block', content: 'tableRow+' }, tableRow: { content: 'tableCell+' }, tableCell: { content: 'block+' }, - noBreakHyphen: { inline: true, group: 'inline', atom: true }, + noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '‑' }, + bookmarkEnd: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, marks: {}, @@ -28,6 +29,7 @@ const makeSchema = () => const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); const atomRun = (schema, nodeName) => schema.nodes.run.create(null, schema.nodes[nodeName].create()); +const marker = (schema, nodeName) => schema.nodes[nodeName].create({ id: 'marker-id' }); const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); const emptyParagraph = (schema) => schema.nodes.paragraph.create(); @@ -177,6 +179,56 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.to).toBe(targetPos); }); + it('ignores trailing inline markers when checking the preceding paragraph end', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Before'), marker(schema, 'bookmarkEnd')]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('ignores leading inline markers when targeting a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [marker(schema, 'bookmarkEnd'), run(schema, 'Inner')]), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + it('returns false when inline atom content appears after the last text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index 13a05fbbd4..c638a93f86 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -20,7 +20,8 @@ const makeSchema = () => table: { group: 'block', content: 'tableRow+' }, tableRow: { content: 'tableCell+' }, tableCell: { content: 'block+' }, - noBreakHyphen: { inline: true, group: 'inline', atom: true }, + noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '‑' }, + bookmarkEnd: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, marks: {}, @@ -28,6 +29,7 @@ const makeSchema = () => const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); const atomRun = (schema, nodeName) => schema.nodes.run.create(null, schema.nodes[nodeName].create()); +const marker = (schema, nodeName) => schema.nodes[nodeName].create({ id: 'marker-id' }); const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); const emptyParagraph = (schema) => schema.nodes.paragraph.create(); @@ -177,6 +179,56 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.to).toBe(targetPos); }); + it('ignores leading inline markers when checking the following paragraph start', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [marker(schema, 'bookmarkEnd'), run(schema, 'After')]), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('ignores trailing inline markers when targeting a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Inner'), marker(schema, 'bookmarkEnd')]), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + it('returns false when inline atom content appears before the first text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From 840a42bbeb6d8e262e7f809cf86697cdf80c0c9c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 11:51:01 -0300 Subject: [PATCH 066/103] fix(super-editor): keep visible atoms in sdt navigation --- .../v1/core/commands/helpers/textPositions.js | 18 +++++- .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 62 ++++++++++++++++++- ...veIntoBlockSdtBeforeTextBlockStart.test.js | 58 ++++++++++++++++- 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index 8f8668cc6b..193729b949 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -1,3 +1,17 @@ +const ZERO_WIDTH_INLINE_MARKER_NODE_NAMES = new Set([ + 'bookmarkStart', + 'bookmarkEnd', + 'commentRangeStart', + 'commentRangeEnd', + 'commentReference', + 'permStart', + 'permEnd', +]); + +function isZeroWidthInlineMarker(node) { + return node.isInline && ZERO_WIDTH_INLINE_MARKER_NODE_NAMES.has(node.type.name); +} + /** * Finds the first text cursor position inside a node. * @param {import('prosemirror-model').Node} node @@ -25,7 +39,7 @@ export function findFirstTextPosInNode(node, nodePos) { * @returns {number | null} */ export function findFirstContentCursorPosInNode(node, nodePos) { - if (node.isAtom && node.isInline && node.textContent === '') return null; + if (isZeroWidthInlineMarker(node)) return null; if (node.isText || node.isAtom) return nodePos; if (node.isTextblock && node.childCount === 0) return nodePos + 1; @@ -67,7 +81,7 @@ export function findLastTextPosInNode(node, nodePos) { * @returns {number | null} */ export function findLastContentCursorPosInNode(node, nodePos) { - if (node.isAtom && node.isInline && node.textContent === '') return null; + if (isZeroWidthInlineMarker(node)) return null; if (node.isText) return nodePos + (node.text?.length ?? 0); if (node.isAtom) return nodePos + node.nodeSize; if (node.isTextblock && node.childCount === 0) return nodePos + 1; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index b49b623791..2df64e0683 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -20,8 +20,9 @@ const makeSchema = () => table: { group: 'block', content: 'tableRow+' }, tableRow: { content: 'tableCell+' }, tableCell: { content: 'block+' }, - noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '‑' }, + noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '-' }, bookmarkEnd: { inline: true, group: 'inline', atom: true }, + image: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, marks: {}, @@ -77,6 +78,19 @@ const findEmptyParagraphTextPos = (doc) => { return found; }; +const findNodePos = (doc, nodeName) => { + let found = null; + doc.descendants((node, pos) => { + if (node.type.name === nodeName && found == null) { + found = pos; + return false; + } + return true; + }); + expect(found).not.toBeNull(); + return found; +}; + describe('moveIntoBlockSdtAfterTextBlockEnd', () => { it('moves from the end of the preceding paragraph to the first text position inside a following block SDT', () => { const schema = makeSchema(); @@ -229,6 +243,52 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.from).toBe(innerStart); }); + it('returns false when visible inline atom content appears after the last text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Before'), schema.nodes.image.create()]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 6)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('targets a visible leading inline atom inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [schema.nodes.image.create(), run(schema, 'Inner')]), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const imageStart = findNodePos(doc, 'image'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(imageStart); + }); + it('returns false when inline atom content appears after the last text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index c638a93f86..ee91446438 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -20,8 +20,9 @@ const makeSchema = () => table: { group: 'block', content: 'tableRow+' }, tableRow: { content: 'tableCell+' }, tableCell: { content: 'block+' }, - noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '‑' }, + noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '-' }, bookmarkEnd: { inline: true, group: 'inline', atom: true }, + image: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, marks: {}, @@ -77,6 +78,19 @@ const findEmptyParagraphTextPos = (doc) => { return found; }; +const findNodePos = (doc, nodeName) => { + let found = null; + doc.descendants((node, pos) => { + if (node.type.name === nodeName && found == null) { + found = pos; + return false; + } + return true; + }); + expect(found).not.toBeNull(); + return found; +}; + describe('moveIntoBlockSdtBeforeTextBlockStart', () => { it('moves from the start of the following paragraph to the last text position inside a previous block SDT', () => { const schema = makeSchema(); @@ -229,6 +243,48 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.from).toBe(innerEnd); }); + it('returns false when visible inline atom content appears before the first text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [schema.nodes.image.create(), run(schema, 'After')]), + ]); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, findTextPos(doc, 'After')) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('targets a visible trailing inline atom inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Inner'), schema.nodes.image.create()]), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const imageEnd = findNodePos(doc, 'image') + schema.nodes.image.create().nodeSize; + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(imageEnd); + }); + it('returns false when inline atom content appears before the first text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From a88c66be3560178b89ef932af6cda299a48b1aa0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 11:56:21 -0300 Subject: [PATCH 067/103] fix(super-editor): handle marker-only sdt paragraphs --- .../v1/core/commands/helpers/textPositions.js | 4 +++ .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 27 +++++++++++++++++++ ...veIntoBlockSdtBeforeTextBlockStart.test.js | 27 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index 193729b949..23617fdd1f 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -51,6 +51,8 @@ export function findFirstContentCursorPosInNode(node, nodePos) { offset += child.nodeSize; } + if (node.isTextblock) return nodePos + 1; + return null; } @@ -94,5 +96,7 @@ export function findLastContentCursorPosInNode(node, nodePos) { if (found != null) return found; } + if (node.isTextblock) return nodePos + 1; + return null; } diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 2df64e0683..4ce5539234 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -243,6 +243,33 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.from).toBe(innerStart); }); + it('targets a marker-only leading paragraph inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [marker(schema, 'bookmarkEnd')]), + paragraph(schema, 'Inner'), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const targetPos = findNodePos(doc, 'bookmarkEnd'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(targetPos); + }); + it('returns false when visible inline atom content appears after the last text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index ee91446438..0e8df09428 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -243,6 +243,33 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.from).toBe(innerEnd); }); + it('targets a marker-only trailing paragraph inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + paragraph(schema, 'Inner'), + schema.nodes.paragraph.create(null, [marker(schema, 'bookmarkEnd')]), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const targetPos = findNodePos(doc, 'bookmarkEnd'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(targetPos); + }); + it('returns false when visible inline atom content appears before the first text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From dd82661f5c663cc95a3e6492c0b970777c65502e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 12:02:20 -0300 Subject: [PATCH 068/103] fix(super-editor): skip hidden block sdt markers --- .../v1/core/commands/helpers/textPositions.js | 12 ++++---- .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 28 +++++++++++++++++++ ...veIntoBlockSdtBeforeTextBlockStart.test.js | 28 +++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index 23617fdd1f..9ef03cff8b 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -1,4 +1,4 @@ -const ZERO_WIDTH_INLINE_MARKER_NODE_NAMES = new Set([ +const ZERO_WIDTH_MARKER_NODE_NAMES = new Set([ 'bookmarkStart', 'bookmarkEnd', 'commentRangeStart', @@ -6,10 +6,12 @@ const ZERO_WIDTH_INLINE_MARKER_NODE_NAMES = new Set([ 'commentReference', 'permStart', 'permEnd', + 'permStartBlock', + 'permEndBlock', ]); -function isZeroWidthInlineMarker(node) { - return node.isInline && ZERO_WIDTH_INLINE_MARKER_NODE_NAMES.has(node.type.name); +function isZeroWidthMarker(node) { + return ZERO_WIDTH_MARKER_NODE_NAMES.has(node.type.name); } /** @@ -39,7 +41,7 @@ export function findFirstTextPosInNode(node, nodePos) { * @returns {number | null} */ export function findFirstContentCursorPosInNode(node, nodePos) { - if (isZeroWidthInlineMarker(node)) return null; + if (isZeroWidthMarker(node)) return null; if (node.isText || node.isAtom) return nodePos; if (node.isTextblock && node.childCount === 0) return nodePos + 1; @@ -83,7 +85,7 @@ export function findLastTextPosInNode(node, nodePos) { * @returns {number | null} */ export function findLastContentCursorPosInNode(node, nodePos) { - if (isZeroWidthInlineMarker(node)) return null; + if (isZeroWidthMarker(node)) return null; if (node.isText) return nodePos + (node.text?.length ?? 0); if (node.isAtom) return nodePos + node.nodeSize; if (node.isTextblock && node.childCount === 0) return nodePos + 1; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 4ce5539234..483f385b20 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -17,6 +17,7 @@ const makeSchema = () => lockMode: { default: 'unlocked' }, }, }, + permStartBlock: { group: 'block', atom: true }, table: { group: 'block', content: 'tableRow+' }, tableRow: { content: 'tableCell+' }, tableCell: { content: 'block+' }, @@ -270,6 +271,33 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.from).toBe(targetPos); }); + it('skips leading hidden block markers when targeting a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.permStartBlock.create(), + paragraph(schema, 'Inner'), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + it('returns false when visible inline atom content appears after the last text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index 0e8df09428..2fa840ec89 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -17,6 +17,7 @@ const makeSchema = () => lockMode: { default: 'unlocked' }, }, }, + permEndBlock: { group: 'block', atom: true }, table: { group: 'block', content: 'tableRow+' }, tableRow: { content: 'tableCell+' }, tableCell: { content: 'block+' }, @@ -270,6 +271,33 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.from).toBe(targetPos); }); + it('skips trailing hidden block markers when targeting a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + paragraph(schema, 'Inner'), + schema.nodes.permEndBlock.create(), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + it('returns false when visible inline atom content appears before the first text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From 25616b39c64d14447bd0ebfa6f892f90bd27cb6b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 12:06:05 -0300 Subject: [PATCH 069/103] fix(super-editor): skip hidden metadata sdt markers --- .../v1/core/commands/helpers/textPositions.js | 5 ++ .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 53 +++++++++++++++++++ ...veIntoBlockSdtBeforeTextBlockStart.test.js | 53 +++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index 9ef03cff8b..65e3dda1d8 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -8,6 +8,11 @@ const ZERO_WIDTH_MARKER_NODE_NAMES = new Set([ 'permEnd', 'permStartBlock', 'permEndBlock', + 'tableOfContentsEntry', + 'indexEntry', + 'authorityEntry', + 'passthroughInline', + 'passthroughBlock', ]); function isZeroWidthMarker(node) { diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 483f385b20..20cbcd4753 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -23,6 +23,8 @@ const makeSchema = () => tableCell: { content: 'block+' }, noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '-' }, bookmarkEnd: { inline: true, group: 'inline', atom: true }, + tableOfContentsEntry: { inline: true, group: 'inline', atom: true }, + passthroughBlock: { group: 'block', atom: true }, image: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, @@ -298,6 +300,57 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.from).toBe(innerStart); }); + it('ignores trailing hidden metadata atoms when checking the preceding paragraph end', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Before'), schema.nodes.tableOfContentsEntry.create()]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('skips leading hidden metadata atoms when targeting a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.passthroughBlock.create(), + paragraph(schema, 'Inner'), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + it('returns false when visible inline atom content appears after the last text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index 2fa840ec89..a2de193154 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -23,6 +23,8 @@ const makeSchema = () => tableCell: { content: 'block+' }, noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '-' }, bookmarkEnd: { inline: true, group: 'inline', atom: true }, + tableOfContentsEntry: { inline: true, group: 'inline', atom: true }, + passthroughBlock: { group: 'block', atom: true }, image: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, @@ -298,6 +300,57 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.from).toBe(innerEnd); }); + it('ignores leading hidden metadata atoms when checking the following paragraph start', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [schema.nodes.tableOfContentsEntry.create(), run(schema, 'After')]), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('skips trailing hidden metadata atoms when targeting a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + paragraph(schema, 'Inner'), + schema.nodes.passthroughBlock.create(), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + it('returns false when visible inline atom content appears before the first text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From 209c2c32f8714613784469d86236c2342c2e8d55 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 12:10:11 -0300 Subject: [PATCH 070/103] fix(super-editor): skip hidden field annotations in sdt navigation --- .../v1/core/commands/helpers/textPositions.js | 1 + .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 85 +++++++++++++++++++ ...veIntoBlockSdtBeforeTextBlockStart.test.js | 81 ++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index 65e3dda1d8..e13f7ee54f 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -16,6 +16,7 @@ const ZERO_WIDTH_MARKER_NODE_NAMES = new Set([ ]); function isZeroWidthMarker(node) { + if (node.type.name === 'fieldAnnotation' && node.attrs?.hidden === true) return true; return ZERO_WIDTH_MARKER_NODE_NAMES.has(node.type.name); } diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 20cbcd4753..832b6da7c4 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -25,6 +25,12 @@ const makeSchema = () => bookmarkEnd: { inline: true, group: 'inline', atom: true }, tableOfContentsEntry: { inline: true, group: 'inline', atom: true }, passthroughBlock: { group: 'block', atom: true }, + fieldAnnotation: { + inline: true, + group: 'inline', + atom: true, + attrs: { hidden: { default: false } }, + }, image: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, @@ -351,6 +357,85 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.from).toBe(innerStart); }); + it('ignores trailing hidden field annotations when checking the preceding paragraph end', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [ + run(schema, 'Before'), + schema.nodes.fieldAnnotation.create({ hidden: true }), + ]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('targets text after a hidden leading field annotation inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [ + schema.nodes.fieldAnnotation.create({ hidden: true }), + run(schema, 'Inner'), + ]), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('returns false when visible field annotation content appears after the last text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [ + run(schema, 'Before'), + schema.nodes.fieldAnnotation.create({ hidden: false }), + ]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 6)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('returns false when visible inline atom content appears after the last text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index a2de193154..154ccfb52c 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -25,6 +25,12 @@ const makeSchema = () => bookmarkEnd: { inline: true, group: 'inline', atom: true }, tableOfContentsEntry: { inline: true, group: 'inline', atom: true }, passthroughBlock: { group: 'block', atom: true }, + fieldAnnotation: { + inline: true, + group: 'inline', + atom: true, + attrs: { hidden: { default: false } }, + }, image: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, @@ -351,6 +357,81 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.from).toBe(innerEnd); }); + it('ignores leading hidden field annotations when checking the following paragraph start', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [ + schema.nodes.fieldAnnotation.create({ hidden: true }), + run(schema, 'After'), + ]), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('targets text before a hidden trailing field annotation inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [ + run(schema, 'Inner'), + schema.nodes.fieldAnnotation.create({ hidden: true }), + ]), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('returns false when visible field annotation content appears before the first text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [ + schema.nodes.fieldAnnotation.create({ hidden: false }), + run(schema, 'After'), + ]), + ]); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, findTextPos(doc, 'After')) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('returns false when visible inline atom content appears before the first text position', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From bbaa1798398af2bc440f38a38def38f88b79d97e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 12:16:02 -0300 Subject: [PATCH 071/103] fix(super-editor): handle sdt marker gaps and block atoms --- .../v1/core/commands/helpers/textPositions.js | 4 +- .../moveIntoBlockSdtAfterTextBlockEnd.js | 34 +++++++++--- .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 53 ++++++++++++++++++- .../moveIntoBlockSdtBeforeTextBlockStart.js | 34 +++++++++--- ...veIntoBlockSdtBeforeTextBlockStart.test.js | 53 ++++++++++++++++++- 5 files changed, 162 insertions(+), 16 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index e13f7ee54f..1e1c859909 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -15,7 +15,7 @@ const ZERO_WIDTH_MARKER_NODE_NAMES = new Set([ 'passthroughBlock', ]); -function isZeroWidthMarker(node) { +export function isZeroWidthMarker(node) { if (node.type.name === 'fieldAnnotation' && node.attrs?.hidden === true) return true; return ZERO_WIDTH_MARKER_NODE_NAMES.has(node.type.name); } @@ -93,7 +93,7 @@ export function findLastTextPosInNode(node, nodePos) { export function findLastContentCursorPosInNode(node, nodePos) { if (isZeroWidthMarker(node)) return null; if (node.isText) return nodePos + (node.text?.length ?? 0); - if (node.isAtom) return nodePos + node.nodeSize; + if (node.isAtom) return node.isInline ? nodePos + node.nodeSize : nodePos; if (node.isTextblock && node.childCount === 0) return nodePos + 1; for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js index 3ffd6a6439..a7d0caf3f0 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js @@ -1,5 +1,9 @@ -import { Selection, TextSelection } from 'prosemirror-state'; -import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js'; +import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, + isZeroWidthMarker, +} from './helpers/textPositions.js'; function findAncestorDepth($pos, predicate) { for (let depth = $pos.depth; depth > 0; depth -= 1) { @@ -8,6 +12,25 @@ function findAncestorDepth($pos, predicate) { return null; } +function findNextNodeAfterHiddenMarkers(doc, pos) { + let currentPos = pos; + let node = doc.resolve(currentPos).nodeAfter; + + while (node && isZeroWidthMarker(node)) { + currentPos += node.nodeSize; + node = doc.resolve(currentPos).nodeAfter; + } + + return { node, pos: currentPos }; +} + +function createSelectionAtContentPos(doc, pos, bias) { + const $pos = doc.resolve(pos); + if ($pos.parent.inlineContent) return TextSelection.create(doc, pos); + if ($pos.nodeAfter && NodeSelection.isSelectable($pos.nodeAfter)) return NodeSelection.create(doc, pos); + return Selection.near($pos, bias); +} + /** * Moves the caret into the next block SDT when Delete is pressed at the end of * the preceding textblock. @@ -30,16 +53,15 @@ export const moveIntoBlockSdtAfterTextBlockEnd = if (lastContentPos !== $from.pos) return false; const boundaryPos = $from.after(textblockDepth); - const boundary = state.doc.resolve(boundaryPos); - const nextNode = boundary.nodeAfter; + const { node: nextNode, pos: nextNodePos } = findNextNodeAfterHiddenMarkers(state.doc, boundaryPos); if (nextNode?.type.name !== 'structuredContentBlock') return false; - const targetPos = findFirstContentCursorPosInNode(nextNode, boundaryPos); + const targetPos = findFirstContentCursorPosInNode(nextNode, nextNodePos); if (dispatch) { const targetSelection = targetPos != null - ? TextSelection.create(state.doc, targetPos) + ? createSelectionAtContentPos(state.doc, targetPos, 1) : (Selection.findFrom(state.doc.resolve(boundaryPos), 1, true) ?? Selection.near(state.doc.resolve(boundaryPos), 1)); dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 832b6da7c4..994dc8cbc7 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { Schema } from 'prosemirror-model'; -import { EditorState, TextSelection } from 'prosemirror-state'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; import { moveIntoBlockSdtAfterTextBlockEnd } from './moveIntoBlockSdtAfterTextBlockEnd.js'; const makeSchema = () => @@ -25,6 +25,7 @@ const makeSchema = () => bookmarkEnd: { inline: true, group: 'inline', atom: true }, tableOfContentsEntry: { inline: true, group: 'inline', atom: true }, passthroughBlock: { group: 'block', atom: true }, + mathBlock: { group: 'block', atom: true }, fieldAnnotation: { inline: true, group: 'inline', @@ -306,6 +307,31 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.from).toBe(innerStart); }); + it('skips hidden block markers between the preceding paragraph and following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.permStartBlock.create(), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + it('ignores trailing hidden metadata atoms when checking the preceding paragraph end', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ @@ -357,6 +383,31 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.from).toBe(innerStart); }); + it('selects a visible leading block atom inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [schema.nodes.mathBlock.create(), paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const mathBlockStart = findNodePos(doc, 'mathBlock'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(mathBlockStart); + }); + it('ignores trailing hidden field annotations when checking the preceding paragraph end', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js index 3b43563186..ae10af6977 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js @@ -1,5 +1,9 @@ -import { Selection, TextSelection } from 'prosemirror-state'; -import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js'; +import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, + isZeroWidthMarker, +} from './helpers/textPositions.js'; function findAncestorDepth($pos, predicate) { for (let depth = $pos.depth; depth > 0; depth -= 1) { @@ -8,6 +12,25 @@ function findAncestorDepth($pos, predicate) { return null; } +function findPreviousNodeBeforeHiddenMarkers(doc, pos) { + let currentPos = pos; + let node = doc.resolve(currentPos).nodeBefore; + + while (node && isZeroWidthMarker(node)) { + currentPos -= node.nodeSize; + node = doc.resolve(currentPos).nodeBefore; + } + + return { node, boundaryPos: currentPos }; +} + +function createSelectionAtContentPos(doc, pos, bias) { + const $pos = doc.resolve(pos); + if ($pos.parent.inlineContent) return TextSelection.create(doc, pos); + if ($pos.nodeAfter && NodeSelection.isSelectable($pos.nodeAfter)) return NodeSelection.create(doc, pos); + return Selection.near($pos, bias); +} + /** * Moves the caret into the previous block SDT when Backspace is pressed at the * start of the following textblock. @@ -29,17 +52,16 @@ export const moveIntoBlockSdtBeforeTextBlockStart = const firstContentPos = findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth); if (firstContentPos !== $from.pos) return false; - const boundary = state.doc.resolve(textblockPos); - const previousNode = boundary.nodeBefore; + const { node: previousNode, boundaryPos } = findPreviousNodeBeforeHiddenMarkers(state.doc, textblockPos); if (previousNode?.type.name !== 'structuredContentBlock') return false; - const previousNodePos = textblockPos - previousNode.nodeSize; + const previousNodePos = boundaryPos - previousNode.nodeSize; const targetPos = findLastContentCursorPosInNode(previousNode, previousNodePos); if (dispatch) { const targetSelection = targetPos != null - ? TextSelection.create(state.doc, targetPos) + ? createSelectionAtContentPos(state.doc, targetPos, -1) : (Selection.findFrom(state.doc.resolve(textblockPos), -1, true) ?? Selection.near(state.doc.resolve(textblockPos), -1)); dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index 154ccfb52c..0a37cc3039 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { Schema } from 'prosemirror-model'; -import { EditorState, TextSelection } from 'prosemirror-state'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; import { moveIntoBlockSdtBeforeTextBlockStart } from './moveIntoBlockSdtBeforeTextBlockStart.js'; const makeSchema = () => @@ -25,6 +25,7 @@ const makeSchema = () => bookmarkEnd: { inline: true, group: 'inline', atom: true }, tableOfContentsEntry: { inline: true, group: 'inline', atom: true }, passthroughBlock: { group: 'block', atom: true }, + mathBlock: { group: 'block', atom: true }, fieldAnnotation: { inline: true, group: 'inline', @@ -306,6 +307,31 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.from).toBe(innerEnd); }); + it('skips hidden block markers between the previous block SDT and following paragraph', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.permEndBlock.create(), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + it('ignores leading hidden metadata atoms when checking the following paragraph start', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ @@ -357,6 +383,31 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.from).toBe(innerEnd); }); + it('selects a visible trailing block atom inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner'), schema.nodes.mathBlock.create()]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const mathBlockStart = findNodePos(doc, 'mathBlock'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(mathBlockStart); + }); + it('ignores leading hidden field annotations when checking the following paragraph start', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From 6482915cf5c284ef0fce1600c30c172ae7a56264 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 13:31:58 -0300 Subject: [PATCH 072/103] fix(layout-engine): cap block sdt label width --- packages/layout-engine/painters/dom/src/styles.test.ts | 5 +++-- packages/layout-engine/painters/dom/src/styles.ts | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 24e70b1d26..6d34d66e10 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -105,8 +105,9 @@ describe('ensureSdtContainerStyles', () => { expect(inlineLabelRule).toContain('border-radius: 4px 4px 0 0;'); expect(blockLabelRule).toContain('white-space: nowrap;'); expect(blockLabelRule).toContain('top: -18px;'); - expect(blockLabelRule).not.toContain('width:'); - expect(blockLabelRule).not.toContain('max-width:'); + expect(blockLabelRule).toContain('width: calc(var(--sd-sdt-chrome-width, 100%) - 4px);'); + expect(blockLabelRule).toContain('max-width: 130px;'); + expect(blockLabelRule).toContain('min-width: 0;'); expect(cssText).toContain('bottom: calc(100% + 1px);'); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index cf36f76721..9b54796144 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -590,6 +590,9 @@ const SDT_CONTAINER_STYLES = ` position: absolute; left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); top: -18px; + width: calc(var(--sd-sdt-chrome-width, 100%) - 4px); + max-width: 130px; + min-width: 0; border-bottom: none; border-radius: 6px 6px 0 0; white-space: nowrap; From 6a0ebfebfd71b9cf1b5eda949c6f7fd806e68f01 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 13:32:54 -0300 Subject: [PATCH 073/103] fix(super-editor): ignore empty block sdt key targets --- .../moveIntoBlockSdtAfterTextBlockEnd.js | 7 ++----- .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 17 +++++++++++++++++ .../moveIntoBlockSdtBeforeTextBlockStart.js | 7 ++----- ...moveIntoBlockSdtBeforeTextBlockStart.test.js | 17 +++++++++++++++++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js index a7d0caf3f0..ac261c7a70 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js @@ -57,13 +57,10 @@ export const moveIntoBlockSdtAfterTextBlockEnd = if (nextNode?.type.name !== 'structuredContentBlock') return false; const targetPos = findFirstContentCursorPosInNode(nextNode, nextNodePos); + if (targetPos == null) return false; if (dispatch) { - const targetSelection = - targetPos != null - ? createSelectionAtContentPos(state.doc, targetPos, 1) - : (Selection.findFrom(state.doc.resolve(boundaryPos), 1, true) ?? - Selection.near(state.doc.resolve(boundaryPos), 1)); + const targetSelection = createSelectionAtContentPos(state.doc, targetPos, 1); dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); } diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index 994dc8cbc7..ed88358603 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -177,6 +177,23 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.to).toBe(targetPos); }); + it('returns false for a following block SDT with no cursor target', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('moves into the leading empty paragraph of a following block SDT', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js index ae10af6977..5f28a1afd5 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js @@ -57,13 +57,10 @@ export const moveIntoBlockSdtBeforeTextBlockStart = const previousNodePos = boundaryPos - previousNode.nodeSize; const targetPos = findLastContentCursorPosInNode(previousNode, previousNodePos); + if (targetPos == null) return false; if (dispatch) { - const targetSelection = - targetPos != null - ? createSelectionAtContentPos(state.doc, targetPos, -1) - : (Selection.findFrom(state.doc.resolve(textblockPos), -1, true) ?? - Selection.near(state.doc.resolve(textblockPos), -1)); + const targetSelection = createSelectionAtContentPos(state.doc, targetPos, -1); dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); } diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index 0a37cc3039..ae09270077 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -177,6 +177,23 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.to).toBe(targetPos); }); + it('returns false for a previous block SDT with no cursor target', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('moves into the trailing empty paragraph of a previous block SDT', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From 9c3c1732431d3d9fcf2d1a6cf4aeff8908ca88be Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 13:55:01 -0300 Subject: [PATCH 074/103] fix(super-editor): target marker-only textblock end --- .../src/editors/v1/core/commands/helpers/textPositions.js | 2 +- .../commands/moveIntoBlockSdtBeforeTextBlockStart.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js index 1e1c859909..e5b4df09f2 100644 --- a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -104,7 +104,7 @@ export function findLastContentCursorPosInNode(node, nodePos) { if (found != null) return found; } - if (node.isTextblock) return nodePos + 1; + if (node.isTextblock) return nodePos + node.nodeSize - 1; return null; } diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index ae09270077..2da32de5c0 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -270,7 +270,7 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.from).toBe(innerEnd); }); - it('targets a marker-only trailing paragraph inside a previous block SDT', () => { + it('targets the end of a marker-only trailing paragraph inside a previous block SDT', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ paragraph(schema, 'Before'), @@ -281,7 +281,7 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { paragraph(schema, 'After'), ]); const afterStart = findTextPos(doc, 'After'); - const targetPos = findNodePos(doc, 'bookmarkEnd'); + const targetPos = findNodePos(doc, 'bookmarkEnd') + schema.nodes.bookmarkEnd.create().nodeSize; const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); let dispatched; From 168ab05e72a6357314ab4cb475c70636e184f82e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 13:55:27 -0300 Subject: [PATCH 075/103] fix(super-editor): drop unreachable move fallback --- .../v1/core/presentation-editor/input/internal-node-move.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts index cf56fb2039..385430510a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts @@ -84,8 +84,6 @@ function resolveInsertionBoundary( } catch { return null; } - - return null; } export function createInternalNodeMoveTransaction( From aed0d45da49d41912a5806aa84c8b82dcdf07eb2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 13:56:32 -0300 Subject: [PATCH 076/103] refactor(super-editor): share block sdt navigation helpers --- .../moveIntoBlockSdtAfterTextBlockEnd.js | 69 +-------------- .../moveIntoBlockSdtAtTextBlockBoundary.js | 88 +++++++++++++++++++ .../moveIntoBlockSdtBeforeTextBlockStart.js | 69 +-------------- 3 files changed, 90 insertions(+), 136 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAtTextBlockBoundary.js diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js index ac261c7a70..d2bc26a07a 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js @@ -1,68 +1 @@ -import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; -import { - findFirstContentCursorPosInNode, - findLastContentCursorPosInNode, - isZeroWidthMarker, -} from './helpers/textPositions.js'; - -function findAncestorDepth($pos, predicate) { - for (let depth = $pos.depth; depth > 0; depth -= 1) { - if (predicate($pos.node(depth))) return depth; - } - return null; -} - -function findNextNodeAfterHiddenMarkers(doc, pos) { - let currentPos = pos; - let node = doc.resolve(currentPos).nodeAfter; - - while (node && isZeroWidthMarker(node)) { - currentPos += node.nodeSize; - node = doc.resolve(currentPos).nodeAfter; - } - - return { node, pos: currentPos }; -} - -function createSelectionAtContentPos(doc, pos, bias) { - const $pos = doc.resolve(pos); - if ($pos.parent.inlineContent) return TextSelection.create(doc, pos); - if ($pos.nodeAfter && NodeSelection.isSelectable($pos.nodeAfter)) return NodeSelection.create(doc, pos); - return Selection.near($pos, bias); -} - -/** - * Moves the caret into the next block SDT when Delete is pressed at the end of - * the preceding textblock. - * - * @returns {import('@core/commands/types').Command} - */ -export const moveIntoBlockSdtAfterTextBlockEnd = - () => - ({ state, dispatch }) => { - const { selection } = state; - if (!selection.empty) return false; - - const { $from } = selection; - const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); - if (textblockDepth == null) return false; - - const textblock = $from.node(textblockDepth); - const textblockPos = $from.before(textblockDepth); - const lastContentPos = findLastContentCursorPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth); - if (lastContentPos !== $from.pos) return false; - - const boundaryPos = $from.after(textblockDepth); - const { node: nextNode, pos: nextNodePos } = findNextNodeAfterHiddenMarkers(state.doc, boundaryPos); - if (nextNode?.type.name !== 'structuredContentBlock') return false; - - const targetPos = findFirstContentCursorPosInNode(nextNode, nextNodePos); - if (targetPos == null) return false; - - if (dispatch) { - const targetSelection = createSelectionAtContentPos(state.doc, targetPos, 1); - dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); - } - - return true; - }; +export { moveIntoBlockSdtAfterTextBlockEnd } from './moveIntoBlockSdtAtTextBlockBoundary.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAtTextBlockBoundary.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAtTextBlockBoundary.js new file mode 100644 index 0000000000..e145481720 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAtTextBlockBoundary.js @@ -0,0 +1,88 @@ +import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, + isZeroWidthMarker, +} from './helpers/textPositions.js'; + +function findAncestorDepth($pos, predicate) { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (predicate($pos.node(depth))) return depth; + } + return null; +} + +function findSiblingAcrossHiddenMarkers(doc, pos, direction) { + let currentPos = pos; + let node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter; + + while (node && isZeroWidthMarker(node)) { + currentPos += direction === 'before' ? -node.nodeSize : node.nodeSize; + node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter; + } + + return { + node, + nodePos: direction === 'before' && node ? currentPos - node.nodeSize : currentPos, + }; +} + +function createSelectionAtContentPos(doc, pos, bias) { + const $pos = doc.resolve(pos); + if ($pos.parent.inlineContent) return TextSelection.create(doc, pos); + if ($pos.nodeAfter && NodeSelection.isSelectable($pos.nodeAfter)) return NodeSelection.create(doc, pos); + return Selection.near($pos, bias); +} + +function moveIntoAdjacentBlockSdt(direction) { + return ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const { $from } = selection; + const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); + if (textblockDepth == null) return false; + + const textblock = $from.node(textblockDepth); + const textblockPos = $from.before(textblockDepth); + + const atTextblockBoundary = + direction === 'before' + ? (findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth)) + : (findLastContentCursorPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth)); + if (atTextblockBoundary !== $from.pos) return false; + + const siblingBoundaryPos = direction === 'before' ? textblockPos : $from.after(textblockDepth); + const { node, nodePos } = findSiblingAcrossHiddenMarkers(state.doc, siblingBoundaryPos, direction); + if (node?.type.name !== 'structuredContentBlock') return false; + + const targetPos = + direction === 'before' + ? findLastContentCursorPosInNode(node, nodePos) + : findFirstContentCursorPosInNode(node, nodePos); + if (targetPos == null) return false; + + if (dispatch) { + const targetSelection = createSelectionAtContentPos(state.doc, targetPos, direction === 'before' ? -1 : 1); + dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); + } + + return true; + }; +} + +/** + * Moves the caret into the previous block SDT when Backspace is pressed at the + * start of the following textblock. + * + * @returns {import('@core/commands/types').Command} + */ +export const moveIntoBlockSdtBeforeTextBlockStart = () => moveIntoAdjacentBlockSdt('before'); + +/** + * Moves the caret into the next block SDT when Delete is pressed at the end of + * the preceding textblock. + * + * @returns {import('@core/commands/types').Command} + */ +export const moveIntoBlockSdtAfterTextBlockEnd = () => moveIntoAdjacentBlockSdt('after'); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js index 5f28a1afd5..c4a54d55dd 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js @@ -1,68 +1 @@ -import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; -import { - findFirstContentCursorPosInNode, - findLastContentCursorPosInNode, - isZeroWidthMarker, -} from './helpers/textPositions.js'; - -function findAncestorDepth($pos, predicate) { - for (let depth = $pos.depth; depth > 0; depth -= 1) { - if (predicate($pos.node(depth))) return depth; - } - return null; -} - -function findPreviousNodeBeforeHiddenMarkers(doc, pos) { - let currentPos = pos; - let node = doc.resolve(currentPos).nodeBefore; - - while (node && isZeroWidthMarker(node)) { - currentPos -= node.nodeSize; - node = doc.resolve(currentPos).nodeBefore; - } - - return { node, boundaryPos: currentPos }; -} - -function createSelectionAtContentPos(doc, pos, bias) { - const $pos = doc.resolve(pos); - if ($pos.parent.inlineContent) return TextSelection.create(doc, pos); - if ($pos.nodeAfter && NodeSelection.isSelectable($pos.nodeAfter)) return NodeSelection.create(doc, pos); - return Selection.near($pos, bias); -} - -/** - * Moves the caret into the previous block SDT when Backspace is pressed at the - * start of the following textblock. - * - * @returns {import('@core/commands/types').Command} - */ -export const moveIntoBlockSdtBeforeTextBlockStart = - () => - ({ state, dispatch }) => { - const { selection } = state; - if (!selection.empty) return false; - - const { $from } = selection; - const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); - if (textblockDepth == null) return false; - - const textblock = $from.node(textblockDepth); - const textblockPos = $from.before(textblockDepth); - const firstContentPos = findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth); - if (firstContentPos !== $from.pos) return false; - - const { node: previousNode, boundaryPos } = findPreviousNodeBeforeHiddenMarkers(state.doc, textblockPos); - if (previousNode?.type.name !== 'structuredContentBlock') return false; - - const previousNodePos = boundaryPos - previousNode.nodeSize; - const targetPos = findLastContentCursorPosInNode(previousNode, previousNodePos); - if (targetPos == null) return false; - - if (dispatch) { - const targetSelection = createSelectionAtContentPos(state.doc, targetPos, -1); - dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); - } - - return true; - }; +export { moveIntoBlockSdtBeforeTextBlockStart } from './moveIntoBlockSdtAtTextBlockBoundary.js'; From f3fe907fd7b35f34b577ecd3a62608420c2e2ac4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 13:57:14 -0300 Subject: [PATCH 077/103] test(super-editor): cover nested block sdt navigation --- .../moveIntoBlockSdtAfterTextBlockEnd.test.js | 27 +++++++++++++++++++ ...veIntoBlockSdtBeforeTextBlockStart.test.js | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js index ed88358603..0f80f2b1a7 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -151,6 +151,33 @@ describe('moveIntoBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.to).toBe(innerStart); }); + it('moves from a nested preceding paragraph to a sibling block SDT inside the same parent SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.structuredContentBlock.create(null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + ]), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(innerStart); + expect(dispatched.selection.to).toBe(innerStart); + }); + it('moves into a following block SDT that only contains an empty paragraph', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js index 2da32de5c0..12aa6855d1 100644 --- a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -151,6 +151,33 @@ describe('moveIntoBlockSdtBeforeTextBlockStart', () => { expect(dispatched.selection.to).toBe(innerEnd); }); + it('moves from a nested following paragraph to a sibling block SDT inside the same parent SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(innerEnd); + expect(dispatched.selection.to).toBe(innerEnd); + }); + it('moves into a previous block SDT that only contains an empty paragraph', () => { const schema = makeSchema(); const doc = schema.node('doc', null, [ From 83b1ba832dd070643f57c82082cc970b8468c48a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 14:56:21 -0300 Subject: [PATCH 078/103] fix(super-editor): allow history transactions through sdt lock Undo/redo transactions were being blocked by the structured-content lock plugin, preventing recovery of SDT content after deletion. Bypass the lock guard when the transaction is a history undo or redo. --- .../structured-content-lock-plugin.js | 5 ++++ .../structured-content-lock-plugin.test.js | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js index 1e70ddbb25..5acb6dc9c2 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js @@ -229,6 +229,11 @@ export function createStructuredContentLockPlugin() { return true; } + const inputType = tr.getMeta?.('inputType'); + if (inputType === 'historyUndo' || inputType === 'historyRedo') { + return true; + } + if (tr.getMeta?.(BLOCK_NODE_METADATA_UPDATE_META)) { return true; } diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js index 8b7aa3f431..68009aad7d 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js @@ -790,6 +790,33 @@ describe('StructuredContentLockPlugin', () => { expect(finalState.doc.textContent).not.toBe(originalContent); expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(true); }); + + it('sdtLocked: undo restores inline SDT content deleted by Backspace', () => { + const leadingRun = schema.nodes.run.create(null, schema.text('Lead ')); + const sdtRun = schema.nodes.run.create(null, schema.text('inline value')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'sdtLocked' }, sdtRun); + const trailingRun = schema.nodes.run.create(null, schema.text('ail.')); + const paragraph = schema.nodes.paragraph.create(null, [leadingRun, sdt, trailingRun]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + placeCaretAt(state, sdtInfo.end); + handleBackspace(editor); + handleBackspace(editor); + + let sdtAfterDelete = findSDTNode(editor.state.doc, 'structuredContent'); + expect(sdtAfterDelete).not.toBeNull(); + expect(sdtAfterDelete.node.textContent).toBe(''); + + expect(editor.commands.undo()).toBe(true); + + sdtAfterDelete = findSDTNode(editor.state.doc, 'structuredContent'); + expect(sdtAfterDelete).not.toBeNull(); + expect(sdtAfterDelete.node.attrs.lockMode).toBe('sdtLocked'); + expect(sdtAfterDelete.node.textContent).toBe('inline value'); + expect(editor.state.doc.textContent).toBe('Lead inline valueail.'); + }); }); }); From 25e3d518798ee5d9c9d3a86828f20273b1315630 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 15:17:06 -0300 Subject: [PATCH 079/103] fix(super-editor): collapse selection on sdtContentLocked delete When backspace or delete targets an sdtContentLocked structured-content SDT, collapse the selection to the wrapper boundary instead of letting the keystroke fall through. Prevents the locked inline content from being mutated while keeping the caret in a usable position for the next edit. --- .../structured-content-lock-plugin.js | 9 ++++- .../structured-content-lock-plugin.test.js | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js index 5acb6dc9c2..2227af5611 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js @@ -1,4 +1,4 @@ -import { NodeSelection, Plugin, PluginKey } from 'prosemirror-state'; +import { NodeSelection, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { ySyncPluginKey } from 'y-prosemirror'; import { BLOCK_NODE_METADATA_UPDATE_META } from '../block-node/block-node.js'; @@ -122,6 +122,13 @@ export function createStructuredContentLockPlugin() { exactContentSDT.lockMode === 'contentLocked' || exactContentSDT.lockMode === 'sdtContentLocked'; const isWrapperDeletable = exactContentSDT.lockMode !== 'sdtLocked' && exactContentSDT.lockMode !== 'sdtContentLocked'; + const isFullyLocked = exactContentSDT.lockMode === 'sdtContentLocked'; + if (isFullyLocked && exactContentSDT.type === 'structuredContent' && (isBackspace || isDelete)) { + const collapsePos = isBackspace ? exactContentSDT.pos : exactContentSDT.end; + view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, collapsePos))); + event.preventDefault(); + return true; + } if (isContentLocked && isWrapperDeletable) { if (isCut) { const tr = state.tr.setSelection(NodeSelection.create(state.doc, exactContentSDT.pos)); diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js index 68009aad7d..f807ce63de 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js @@ -650,6 +650,11 @@ describe('StructuredContentLockPlugin', () => { if (shouldDeleteWrapper) { expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); + } else if (lockMode === 'sdtContentLocked') { + const sel = editor.state.selection; + expect(sel).toBeInstanceOf(TextSelection); + expect(sel.empty).toBe(true); + expect(sel.from).toBe(sdtInfo.pos); } else { // No wrapper deletion: selection unchanged. const sel = editor.state.selection; @@ -696,6 +701,36 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); }); + it('sdtContentLocked: exact content selection + Backspace collapses before inline SDT, then deletes preceding text', () => { + const leadingRun = schema.nodes.run.create(null, schema.text('Lead ')); + const sdtRun = schema.nodes.run.create(null, schema.text('inline value')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'sdtContentLocked' }, sdtRun); + const trailingRun = schema.nodes.run.create(null, schema.text('ail.')); + const paragraph = schema.nodes.paragraph.create(null, [leadingRun, sdt, trailingRun]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + setSelection(state, TextSelection.create(state.doc, sdtInfo.pos + 1, sdtInfo.end - 1)); + + const result = invokeLockHandleKeyDown('Backspace'); + + expect(result.handled).toBe(true); + expect(result.prevented).toBe(true); + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(sdtInfo.pos); + expect(findSDTNode(editor.state.doc, 'structuredContent').node.textContent).toBe('inline value'); + + handleBackspace(editor); + + const sdtAfter = findSDTNode(editor.state.doc, 'structuredContent'); + expect(sdtAfter).not.toBeNull(); + expect(sdtAfter.node.attrs.lockMode).toBe('sdtContentLocked'); + expect(sdtAfter.node.textContent).toBe('inline value'); + expect(editor.state.doc.textContent).toBe('Leadinline valueail.'); + }); + it.each([ ['unlocked', false, true], ['sdtLocked', false, true], @@ -719,6 +754,11 @@ describe('StructuredContentLockPlugin', () => { } else { expect(sdtAfter).not.toBeNull(); expect(sdtAfter.node.textContent === '').toBe(deletesContent); + if (lockMode === 'sdtContentLocked') { + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(sdtAfter.end); + } } }, ); From 70679ac05cfffc5b75f40c9448944ba64808ad7a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:02:26 -0300 Subject: [PATCH 080/103] feat(super-editor): render placeholder text for empty SDTs Replace the invisible 8px spacer with a full "Click or tap here to enter text" placeholder for both inline and block structured-content controls. The placeholder is layout-only (no document text), styled via CSS pseudo-element, and selected-node highlight inherits the system Highlight color. Wire the new chrome into pointer mapping, caret geometry, and the input manager so clicks on the placeholder land inside the SDT instead of snapping to the wrapper boundary. ArrowLeft from the trailing boundary now re-enters an empty inline SDT, and Backspace/Delete inside an unlocked inline SDT no longer escapes into surrounding text. --- packages/layout-engine/contracts/src/index.ts | 13 +++- .../contracts/src/run-helpers.test.ts | 15 +++- .../contracts/src/run-helpers.ts | 12 ++- .../measuring/dom/src/index.test.ts | 15 ++-- .../layout-engine/measuring/dom/src/index.ts | 19 +++-- .../painters/dom/src/index.test.ts | 76 ++++++++++++++++++- .../painters/dom/src/renderer.ts | 16 +++- .../painters/dom/src/styles.test.ts | 19 +++-- .../layout-engine/painters/dom/src/styles.ts | 23 ++++-- .../src/sdt/structured-content-block.test.ts | 53 ++++++++++--- .../src/sdt/structured-content-block.ts | 35 ++++++++- .../pointer-events/EditorInputManager.ts | 6 +- .../tests/DomSelectionGeometry.test.ts | 34 +++++++++ ...itorInputManager.structuredContent.test.ts | 55 +++++++++++++- .../v1/dom-observer/DomPointerMapping.test.ts | 34 +++++++++ .../v1/dom-observer/DomPointerMapping.ts | 6 ++ .../v1/dom-observer/DomSelectionGeometry.ts | 2 +- .../structured-content-lock-plugin.js | 20 +++++ .../structured-content-lock-plugin.test.js | 35 +++++++++ .../structured-content-select-plugin.js | 32 +++++++- .../structured-content-select-plugin.test.js | 20 +++++ 21 files changed, 483 insertions(+), 57 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index ef180b49a5..f86b624070 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -290,6 +290,10 @@ export type FlowRunLink = { history?: boolean; }; +export const EMPTY_SDT_PLACEHOLDER_TEXT = 'Click or tap here to enter text'; + +export type SdtVisualPlaceholder = 'emptyInlineSdt' | 'emptyBlockSdt'; + /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. @@ -343,7 +347,7 @@ export type TextRun = RunMarks & { dataAttrs?: Record; sdt?: SdtMetadata; /** Layout-only placeholder for visual affordances that do not represent document text. */ - visualPlaceholder?: 'emptyInlineSdt'; + visualPlaceholder?: SdtVisualPlaceholder; link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ token?: 'pageNumber' | 'totalPageCount' | 'pageReference'; @@ -2199,6 +2203,11 @@ export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from // Pure transformations on inline-run shapes (used by pm-adapter, layout-bridge, // and painter-dom). Located in contracts to avoid reverse stage dependencies. -export { expandRunsForInlineNewlines, isEmptyInlineSdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js'; +export { + expandRunsForInlineNewlines, + isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, + sliceRunsForLine, +} from './run-helpers.js'; export * as Engines from './engines/index.js'; diff --git a/packages/layout-engine/contracts/src/run-helpers.test.ts b/packages/layout-engine/contracts/src/run-helpers.test.ts index afefce64c6..3922924bc0 100644 --- a/packages/layout-engine/contracts/src/run-helpers.test.ts +++ b/packages/layout-engine/contracts/src/run-helpers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { FlowBlock, Line, ParagraphBlock, Run, TabRun, TextRun, TrackedChangeMeta } from './index.js'; -import { expandRunsForInlineNewlines, sliceRunsForLine } from './run-helpers.js'; +import { expandRunsForInlineNewlines, isEmptySdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js'; describe('expandRunsForInlineNewlines', () => { const makeRun = (text: string, pmStart = 0): TextRun => ({ @@ -153,4 +153,17 @@ describe('sliceRunsForLine', () => { expect(sliceRunsForLine(block, line)).toEqual([run]); }); + + it('recognizes block SDT visual placeholders', () => { + const run: TextRun = { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 12, + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'sdt-block-1' }, + }; + + expect(isEmptySdtPlaceholderRun(run)).toBe(true); + }); }); diff --git a/packages/layout-engine/contracts/src/run-helpers.ts b/packages/layout-engine/contracts/src/run-helpers.ts index 8b04fa638a..516a53756f 100644 --- a/packages/layout-engine/contracts/src/run-helpers.ts +++ b/packages/layout-engine/contracts/src/run-helpers.ts @@ -9,14 +9,20 @@ import type { FlowBlock, Line, Run, TextRun } from './index.js'; -export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } { +export function isEmptySdtPlaceholderRun( + run: Run, +): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' | 'emptyBlockSdt' } { return ( (run.kind === 'text' || run.kind === undefined) && 'text' in run && - (run as TextRun).visualPlaceholder === 'emptyInlineSdt' + ((run as TextRun).visualPlaceholder === 'emptyInlineSdt' || (run as TextRun).visualPlaceholder === 'emptyBlockSdt') ); } +export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } { + return isEmptySdtPlaceholderRun(run) && run.visualPlaceholder === 'emptyInlineSdt'; +} + /** * Expands text runs that contain inline newlines into multiple runs. * @@ -90,7 +96,7 @@ export function sliceRunsForLine(block: FlowBlock, line: Line): Run[] { } const text = run.text ?? ''; - if (isEmptyInlineSdtPlaceholderRun(run)) { + if (isEmptySdtPlaceholderRun(run)) { result.push(run); continue; } diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index b8e993e1a4..ba8ba9c783 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -685,7 +685,7 @@ describe('measureBlock', () => { }); }); - it('measures empty inline SDT placeholders as a small inline box', async () => { + it('measures empty inline SDT placeholders using the visible placeholder text width', async () => { const block: FlowBlock = { kind: 'paragraph', id: 'empty-inline-sdt', @@ -707,14 +707,11 @@ describe('measureBlock', () => { const measure = expectParagraphMeasure(await measureBlock(block, 1000)); expect(measure.lines).toHaveLength(1); - expect(measure.lines[0]).toMatchObject({ - fromRun: 0, - fromChar: 0, - toRun: 0, - toChar: 0, - width: 8, - segments: [{ runIndex: 0, fromChar: 0, toChar: 0, width: 8 }], - }); + expect(measure.lines[0]).toMatchObject({ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0 }); + expect(measure.lines[0].width).toBeGreaterThan(8); + expect(measure.lines[0].segments).toHaveLength(1); + expect(measure.lines[0].segments[0]).toMatchObject({ runIndex: 0, fromChar: 0, toChar: 0 }); + expect(measure.lines[0].segments[0].width).toBe(measure.lines[0].width); }); }); diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 1ae0f9395e..59afe9290f 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -61,8 +61,9 @@ import { type CellSpacing, type TableBorders, type TableBorderValue, + EMPTY_SDT_PLACEHOLDER_TEXT, effectiveTableCellSpacing, - isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, LeaderDecoration, resolveBaseFontSizeForVerticalText, } from '@superdoc/contracts'; @@ -209,8 +210,6 @@ const FIELD_ANNOTATION_VERTICAL_PADDING = 6; // Vertical padding/border for pill const DEFAULT_FIELD_ANNOTATION_FONT_SIZE = 16; // Default font size for field annotations const DEFAULT_PARAGRAPH_FONT_SIZE = 12; const DEFAULT_PARAGRAPH_FONT_FAMILY = 'Arial'; -const EMPTY_INLINE_SDT_PLACEHOLDER_WIDTH = 8; - const isValidFontSize = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0; @@ -1036,7 +1035,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P const emptyParagraphRun = normalizedRuns.length === 1 && isEmptyTextRun(normalizedRuns[0] as Run) && - !isEmptyInlineSdtPlaceholderRun(normalizedRuns[0] as Run) + !isEmptySdtPlaceholderRun(normalizedRuns[0] as Run) ? (normalizedRuns[0] as TextRun) : null; if (emptyParagraphRun) { @@ -2018,11 +2017,19 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P continue; } - if (isEmptyInlineSdtPlaceholderRun(run)) { + if (isEmptySdtPlaceholderRun(run)) { + const placeholderFont = buildFontString(run).font; + const measuredPlaceholderWidth = getMeasuredTextWidth( + EMPTY_SDT_PLACEHOLDER_TEXT, + placeholderFont, + run.letterSpacing ?? 0, + ctx, + ); + const fallbackPlaceholderWidth = EMPTY_SDT_PLACEHOLDER_TEXT.length * run.fontSize * 0.45; const placeholderWidth = run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? 0 - : EMPTY_INLINE_SDT_PLACEHOLDER_WIDTH; + : Math.max(measuredPlaceholderWidth, fallbackPlaceholderWidth); if (!currentLine) { currentLine = { diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 331654690e..3e9ed42901 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2880,8 +2880,82 @@ describe('DomPainter', () => { expect(wrapper?.dataset.empty).toBe('true'); expect(wrapper?.dataset.pmStart).toBe('8'); expect(wrapper?.dataset.pmEnd).toBe('8'); - expect(wrapper?.querySelector('.superdoc-empty-inline-sdt-placeholder')).toBeTruthy(); + const placeholder = wrapper?.querySelector('.superdoc-empty-inline-sdt-placeholder') as HTMLElement | null; + expect(placeholder).toBeTruthy(); + expect(placeholder?.classList.contains('superdoc-empty-sdt-placeholder')).toBe(true); + expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text'); expect(wrapper?.textContent).not.toContain('old content'); + expect(wrapper?.textContent).not.toContain('Click or tap here to enter text'); + }); + + it('renders placeholder chrome for an empty block SDT without adding document text', () => { + const sdt = { + type: 'structuredContent', + scope: 'block', + id: 'sc-block-empty-1', + alias: 'Empty block', + } as const; + const block: FlowBlock = { + kind: 'paragraph', + id: 'block-sc-empty', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 4, + visualPlaceholder: 'emptyBlockSdt', + sdt, + }, + ], + attrs: { sdt }, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 220, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sc-empty', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 3, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const fragment = mount.querySelector( + '.superdoc-structured-content-block[data-sdt-id="sc-block-empty-1"]', + ) as HTMLElement | null; + const placeholder = fragment?.querySelector('.superdoc-empty-block-sdt-placeholder') as HTMLElement | null; + + expect(fragment).toBeTruthy(); + expect(placeholder).toBeTruthy(); + expect(placeholder?.classList.contains('superdoc-empty-sdt-placeholder')).toBe(true); + expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text'); + expect(placeholder?.dataset.pmStart).toBe('4'); + expect(placeholder?.dataset.pmEnd).toBe('4'); + expect(fragment?.textContent).not.toContain('Click or tap here to enter text'); }); it('keeps inline SDT wrapper font-size in sync when run font-size changes', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 7a3d6bb090..2ee8c61c2b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -60,6 +60,7 @@ import type { } from '@superdoc/contracts'; import { LAYOUT_BOUNDARY_SCHEMA, + EMPTY_SDT_PLACEHOLDER_TEXT, adjustAvailableWidthForTextIndent, buildLayoutSourceIdentityForFragment, calculateJustifySpacing, @@ -68,6 +69,7 @@ import { getCellSpacingPx, getParagraphInlineDirection, isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, normalizeColumnLayout, normalizeBaselineShift, resolveBaseFontSizeForVerticalText, @@ -5705,11 +5707,17 @@ export class DomPainter { } } - private renderEmptyInlineSdtPlaceholderRun(run: TextRun): HTMLElement | null { + private renderEmptySdtPlaceholderRun(run: TextRun): HTMLElement | null { if (!this.doc) return null; const elem = this.doc.createElement('span'); - elem.classList.add('superdoc-empty-inline-sdt-placeholder'); + elem.classList.add('superdoc-empty-sdt-placeholder'); + if (run.visualPlaceholder === 'emptyInlineSdt') { + elem.classList.add('superdoc-empty-inline-sdt-placeholder'); + } else if (run.visualPlaceholder === 'emptyBlockSdt') { + elem.classList.add('superdoc-empty-block-sdt-placeholder'); + } elem.setAttribute('aria-hidden', 'true'); + elem.dataset.placeholderText = EMPTY_SDT_PLACEHOLDER_TEXT; elem.dataset.layoutEpoch = String(this.layoutEpoch); if (run.pmStart != null) elem.dataset.pmStart = String(run.pmStart); if (run.pmEnd != null) elem.dataset.pmEnd = String(run.pmEnd); @@ -5854,8 +5862,8 @@ export class DomPainter { return null; } - if (isEmptyInlineSdtPlaceholderRun(run)) { - return this.renderEmptyInlineSdtPlaceholderRun(run); + if (isEmptySdtPlaceholderRun(run)) { + return this.renderEmptySdtPlaceholderRun(run); } // Handle TextRun diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 6d34d66e10..3fe197e9be 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -111,19 +111,26 @@ describe('ensureSdtContainerStyles', () => { expect(cssText).toContain('bottom: calc(100% + 1px);'); }); - it('reserves empty inline SDT width without adding line-box height', () => { + it('renders empty SDT placeholder text and active selection styling', () => { ensureSdtContainerStyles(document); const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); const cssText = styleEl?.textContent ?? ''; - const placeholderRule = cssText.match(/\.superdoc-empty-inline-sdt-placeholder\s*\{([^}]*)\}/)?.[1] ?? ''; + const placeholderRule = cssText.match(/\.superdoc-empty-sdt-placeholder\s*\{([^}]*)\}/)?.[1] ?? ''; + const placeholderBeforeRule = cssText.match(/\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + const selectedRule = + cssText.match( + /\.superdoc-structured-content-inline\.ProseMirror-selectednode \.superdoc-empty-sdt-placeholder::before,\s*\.superdoc-structured-content-block\.ProseMirror-selectednode \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; expect(placeholderRule).toContain('display: inline-block;'); - expect(placeholderRule).toContain('width: 8px;'); - expect(placeholderRule).toContain('height: 0;'); - expect(placeholderRule).toContain('line-height: 0;'); + expect(placeholderRule).toContain('line-height: normal;'); expect(placeholderRule).toContain('vertical-align: baseline;'); - expect(placeholderRule).not.toContain('height: 1em;'); + expect(placeholderRule).toContain('white-space: nowrap;'); + expect(placeholderBeforeRule).toContain('content: attr(data-placeholder-text);'); + expect(placeholderBeforeRule).toContain('color: var(--sd-content-controls-placeholder-text, #a6a6a6);'); + expect(selectedRule).toContain('background-color: var(--sd-content-controls-placeholder-selected-bg, Highlight);'); + expect(selectedRule).not.toMatch(/(^|\n)\s*color\s*:/); }); it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 9b54796144..cfd3a55a46 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -683,18 +683,31 @@ const SDT_CONTAINER_STYLES = ` border-color: var(--sd-content-controls-inline-border, #629be7); } -.superdoc-empty-inline-sdt-placeholder { +.superdoc-empty-sdt-placeholder { display: inline-block; - width: 8px; - height: 0; - line-height: 0; + line-height: normal; vertical-align: baseline; - overflow: hidden; + white-space: nowrap; +} + +.superdoc-empty-sdt-placeholder::before { + content: attr(data-placeholder-text); + color: var(--sd-content-controls-placeholder-text, #a6a6a6); +} + +.superdoc-structured-content-inline.ProseMirror-selectednode .superdoc-empty-sdt-placeholder::before, +.superdoc-structured-content-block.ProseMirror-selectednode .superdoc-empty-sdt-placeholder::before { + background-color: var(--sd-content-controls-placeholder-selected-bg, Highlight); } .superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder { width: 0; min-width: 0; + overflow: hidden; +} + +.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder::before { + content: ''; } /* Inline structured content label - shown when active */ diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index f9587ff8e1..6324223313 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -31,17 +31,19 @@ describe('structured-content-block', () => { const mockConverterContext = { docx: {} } as never; const scbMetadata: SdtMetadata = { - type: 'structuredContentBlock', + type: 'structuredContent', + scope: 'block', id: 'scb-1', }; beforeEach(() => { vi.clearAllMocks(); + mockPositionMap.clear(); }); // ==================== Basic Functionality Tests ==================== describe('Basic functionality', () => { - it('should return early if node.content is not an array', () => { + it('should emit a placeholder paragraph if node.content is not an array', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -50,6 +52,8 @@ describe('structured-content-block', () => { const blocks: FlowBlock[] = []; const recordBlockKind = vi.fn(); + mockPositionMap.set(node, { start: 10, end: 12 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); const context: NodeHandlerContext = { blocks, @@ -70,8 +74,22 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); - expect(blocks).toHaveLength(0); - expect(recordBlockKind).not.toHaveBeenCalled(); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + id: 'paragraph-test-id', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 11, + pmEnd: 11, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); it('should throw if paragraphToFlowBlocks is not provided', () => { @@ -102,7 +120,7 @@ describe('structured-content-block', () => { expect(() => handleStructuredContentBlockNode(node, context)).toThrow(); }); - it('should handle empty children array', () => { + it('should emit a placeholder paragraph for empty children array', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -133,8 +151,19 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); - expect(blocks).toHaveLength(0); - expect(recordBlockKind).not.toHaveBeenCalled(); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); it('should process a single paragraph child', () => { @@ -735,7 +764,7 @@ describe('structured-content-block', () => { // ==================== Edge Cases ==================== describe('Edge cases', () => { - it('should handle node with null content', () => { + it('should emit a placeholder paragraph for null content', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -744,6 +773,7 @@ describe('structured-content-block', () => { const blocks: FlowBlock[] = []; const recordBlockKind = vi.fn(); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); const context: NodeHandlerContext = { blocks, @@ -764,7 +794,12 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); - expect(blocks).toHaveLength(0); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [{ visualPlaceholder: 'emptyBlockSdt', sdt: scbMetadata }], + }); }); it('should handle converter returning empty array', () => { diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index 9302e06b60..089e31be31 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -5,7 +5,7 @@ * paragraphs and tables while preserving their content structure. */ -import type { ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import type { ParagraphBlock, TableBlock, TextRun } from '@superdoc/contracts'; import type { PMNode, NodeHandlerContext } from '../types.js'; import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js'; @@ -17,13 +17,13 @@ import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMeta * @param context - Shared handler context */ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHandlerContext): void { - if (!Array.isArray(node.content)) return; - const { blocks, recordBlockKind, nextBlockId, positions, + defaultFont, + defaultSize, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -33,7 +33,31 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand themeColors, } = context; const structuredContentMetadata = resolveNodeSdtMetadata(node, 'structuredContentBlock'); - const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; + const paragraphToFlowBlocks = converters?.paragraphToFlowBlocks; + + if (!Array.isArray(node.content) || node.content.length === 0) { + if (!structuredContentMetadata) return; + const pos = positions.get(node); + const contentPos = pos ? pos.start + 1 : undefined; + const placeholderRun: TextRun = { + kind: 'text', + text: '', + fontFamily: defaultFont, + fontSize: defaultSize, + sdt: structuredContentMetadata, + visualPlaceholder: 'emptyBlockSdt', + ...(contentPos != null ? { pmStart: contentPos, pmEnd: contentPos } : {}), + }; + const placeholderBlock: ParagraphBlock = { + kind: 'paragraph', + id: nextBlockId('paragraph'), + runs: [placeholderRun], + attrs: { sdt: structuredContentMetadata }, + }; + blocks.push(placeholderBlock); + recordBlockKind?.(placeholderBlock.kind); + return; + } // SD-1333: a documentPartObject is a transparent SDT wrapper. When it sits // as a direct child of a structuredContentBlock (e.g. a Signature SDT @@ -42,6 +66,9 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand // outer SDT metadata to them. const visitChild = (child: PMNode): void => { if (child.type === 'paragraph') { + if (!paragraphToFlowBlocks) { + throw new Error('paragraphToFlowBlocks converter is required for structuredContentBlock paragraphs'); + } const paragraphBlocks = paragraphToFlowBlocks({ para: child, nextBlockId, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 29dad7095c..1639d5c71d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -72,6 +72,7 @@ const COMMENT_THREAD_HIT_TOLERANCE_PX = 3; const INLINE_SDT_LABEL_SELECTOR = `.${DOM_CLASS_NAMES.INLINE_SDT_LABEL}`; const BLOCK_SDT_LABEL_SELECTOR = `.${DOM_CLASS_NAMES.BLOCK_SDT_LABEL}`; const SDT_LABEL_SELECTOR = `${INLINE_SDT_LABEL_SELECTOR}, ${BLOCK_SDT_LABEL_SELECTOR}`; +const EMPTY_SDT_PLACEHOLDER_SELECTOR = '.superdoc-empty-sdt-placeholder'; const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray = [ [0, 0], [-COMMENT_THREAD_HIT_TOLERANCE_PX, 0], @@ -1719,13 +1720,14 @@ export class EditorInputManager { let nextSelection: Selection; let inlineSdtBoundaryPos: number | null = null; let inlineSdtBoundaryDirection: 'before' | 'after' | null = null; + const clickedEmptySdtPlaceholder = target?.closest?.(EMPTY_SDT_PLACEHOLDER_SELECTOR) != null; const inlineSdt = clickDepth === 1 ? findStructuredContentInlineAtPos(doc, hit.pos) : null; - if (inlineSdt && hit.pos >= inlineSdt.end) { + if (!clickedEmptySdtPlaceholder && inlineSdt && hit.pos >= inlineSdt.end) { const afterInlineSdt = inlineSdt.pos + inlineSdt.node.nodeSize; inlineSdtBoundaryPos = afterInlineSdt; inlineSdtBoundaryDirection = 'after'; nextSelection = TextSelection.create(doc, afterInlineSdt); - } else if (inlineSdt && hit.pos <= inlineSdt.start) { + } else if (!clickedEmptySdtPlaceholder && inlineSdt && hit.pos <= inlineSdt.start) { inlineSdtBoundaryPos = inlineSdt.pos; inlineSdtBoundaryDirection = 'before'; nextSelection = TextSelection.create(doc, inlineSdt.pos); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts index b22985dc67..24faed5bf4 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts @@ -1651,6 +1651,40 @@ describe('computeDomCaretPageLocal', () => { y: 20, }); }); + + it('positions caret at the right edge when it is after an empty inline SDT placeholder', () => { + painterHost.innerHTML = ` +
+
+ Lead + + + + trail. +
+
+ `; + + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const lineEl = painterHost.querySelector('.superdoc-line') as HTMLElement; + const placeholderEl = painterHost.querySelector('.superdoc-empty-inline-sdt-placeholder') as HTMLElement; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + lineEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 250, 16)); + placeholderEl.getBoundingClientRect = vi.fn(() => createRect(60, 20, 205, 16)); + + const options = createCaretOptions(); + const caret = computeDomCaretPageLocal(options, 10); + + expect(caret).not.toBe(null); + expect(caret).toMatchObject({ + pageIndex: 0, + x: 265, + y: 20, + }); + }); }); describe('index rebuild for disconnected elements', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts index fca9af1dba..3f3bac5361 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts @@ -58,7 +58,9 @@ function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent { ); } -function createMockDoc(mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary' | 'nestedInlineInBlock') { +function createMockDoc( + mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary' | 'emptyInlineSdt' | 'nestedInlineInBlock', +) { return { content: { size: 200 }, nodeAt: vi.fn(() => ({ nodeSize: 20 })), @@ -89,6 +91,19 @@ function createMockDoc(mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary end: (depth: number) => (depth === 2 ? 12 : 199), }; } + if (mode === 'emptyInlineSdt') { + return { + depth: 2, + node: (depth: number) => { + if (depth === 2) return { type: { name: 'structuredContent' }, nodeSize: 2 }; + if (depth === 1) return { type: { name: 'paragraph' } }; + return { type: { name: 'doc' } }; + }, + before: (depth: number) => (depth === 2 ? 8 : 0), + start: (depth: number) => (depth === 2 ? 9 : 1), + end: (depth: number) => (depth === 2 ? 9 : 199), + }; + } if (mode === 'nestedInlineInBlock') { return { depth: 3, @@ -175,7 +190,9 @@ describe('EditorInputManager structured content clicks', () => { let getEditor: Mock; let mockHitTestTable: Mock; - function mountWithDoc(mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary' | 'nestedInlineInBlock') { + function mountWithDoc( + mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary' | 'emptyInlineSdt' | 'nestedInlineInBlock', + ) { mockEditor.state.doc = createMockDoc(mode); } @@ -240,7 +257,7 @@ describe('EditorInputManager structured content clicks', () => { getEditor, getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })), getEpochMapper: vi.fn(() => ({ - mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 12, toEpoch: 1 })), + mapPosFromLayoutToCurrentDetailed: vi.fn((pos: number) => ({ ok: true, pos, toEpoch: 1 })), })), getViewportHost: vi.fn(() => viewportHost), getVisibleHost: vi.fn(() => visibleHost), @@ -371,6 +388,38 @@ describe('EditorInputManager structured content clicks', () => { expect(mockNodeSelectionCreate).not.toHaveBeenCalled(); }); + it('keeps placeholder clicks inside an empty inline structured content node', () => { + mountWithDoc('emptyInlineSdt'); + (resolvePointerPositionHit as unknown as Mock).mockReturnValueOnce({ + pos: 9, + layoutEpoch: 1, + pageIndex: 0, + blockId: 'body-1', + column: 0, + lineIndex: 0, + }); + const target = document.createElement('span'); + target.className = 'superdoc-empty-sdt-placeholder superdoc-empty-inline-sdt-placeholder'; + viewportHost.appendChild(target); + + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 28, + clientY: 28, + } as PointerEventInit), + ); + + expect(resolvePointerPositionHit as unknown as Mock).toHaveBeenCalled(); + expect(mockTextSelectionCreate).toHaveBeenCalledWith(mockEditor.state.doc, 9); + expect(mockApplyEditableSlotAtInlineBoundary).not.toHaveBeenCalled(); + expect(mockNodeSelectionCreate).not.toHaveBeenCalled(); + }); + it('selects the whole inline structured content when its label is clicked', () => { mountWithDoc('inlineSdtAfterBoundary'); const wrapper = document.createElement('span'); diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts index 3739307bd7..3029ce1b24 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts @@ -282,6 +282,40 @@ describe('DomPointerMapping', () => { } } }); + + it('maps clicks on empty SDT placeholder chrome to the SDT content position', () => { + container.innerHTML = ` +
+
+
+ +
+
+
+ `; + + const page = container.querySelector('.superdoc-page') as HTMLElement; + const fragment = container.querySelector('.superdoc-fragment') as HTMLElement; + const line = container.querySelector('.superdoc-line') as HTMLElement; + const placeholder = container.querySelector('.superdoc-empty-sdt-placeholder') as HTMLElement; + + mockRect(page, { left: 100, top: 10, width: 300, height: 30 }); + mockRect(fragment, { left: 100, top: 10, width: 300, height: 30 }); + mockRect(line, { left: 110, top: 10, width: 250, height: 20 }); + mockRect(placeholder, { left: 110, top: 10, width: 220, height: 20 }); + + withMockedElementsFromPoint( + [placeholder, line, fragment, page, container, document.body, document.documentElement], + () => { + expect(clickToPositionDom(container, 310, 18)).toBe(11); + }, + ); + }); }); // ----------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts index 483f8d7f63..226180eed8 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts @@ -38,6 +38,7 @@ const CLASS = { line: DOM_CLASS_NAMES.LINE, tableFragment: DOM_CLASS_NAMES.TABLE_FRAGMENT, inlineSdtWrapper: DOM_CLASS_NAMES.INLINE_SDT_WRAPPER, + emptySdtPlaceholder: 'superdoc-empty-sdt-placeholder', } as const; /** Augmented Document type for the `elementsFromPoint` API. */ @@ -488,6 +489,11 @@ function resolvePositionInLine( const { start: spanStart, end: spanEnd } = readPmRange(targetEl); if (!Number.isFinite(spanStart) || !Number.isFinite(spanEnd)) return null; + + if (targetEl.classList.contains(CLASS.emptySdtPlaceholder)) { + return spanStart; + } + const rightCaretBoundary = resolveRightCaretBoundary(spanEls, targetIndex, spanStart, spanEnd); // Non-text or empty element → snap to nearest edge diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts index d05c732615..e9c02b97c3 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts @@ -579,7 +579,7 @@ export function computeDomCaretPageLocal( // For non-text elements (images, math), position caret at the right edge // when pos matches pmEnd (cursor after the element) const isEmptyInlineSdtPlaceholder = targetEl.classList.contains('superdoc-empty-inline-sdt-placeholder'); - const atEnd = !isEmptyInlineSdtPlaceholder && pos >= entry.pmEnd; + const atEnd = isEmptyInlineSdtPlaceholder ? pos > entry.pmEnd : pos >= entry.pmEnd; const lineEl = isEmptyInlineSdtPlaceholder ? (targetEl.closest('.superdoc-line') as HTMLElement | null) : null; const yRect = lineEl?.getBoundingClientRect() ?? elRect; return { diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js index 2227af5611..ec4e8f0d73 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js @@ -166,6 +166,26 @@ export function createStructuredContentLockPlugin() { return true; } + const inlineSdtAncestor = sdtNodes.find( + (s) => s.type === 'structuredContent' && from > s.pos && from < s.end, + ); + const inlineSdtContentEditable = + inlineSdtAncestor && + inlineSdtAncestor.lockMode !== 'contentLocked' && + inlineSdtAncestor.lockMode !== 'sdtContentLocked'; + if (inlineSdtContentEditable && selection.$from.parent.type.name === 'run') { + const deleteFrom = isBackspace ? from - 1 : from; + const deleteTo = isBackspace ? from : from + 1; + const staysInsideInlineSdt = deleteFrom > inlineSdtAncestor.pos && deleteTo < inlineSdtAncestor.end; + const staysInsideRun = isBackspace ? from > selection.$from.start() : from < selection.$from.end(); + + if (staysInsideInlineSdt && staysInsideRun) { + view.dispatch(state.tr.delete(deleteFrom, deleteTo).scrollIntoView()); + event.preventDefault(); + return true; + } + } + if (isBackspace && from > 0) { affectedFrom = from - 1; // Path 2 — caret is exactly at the trailing wrapper boundary of an diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js index f807ce63de..50b7cbdfe3 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js @@ -621,6 +621,41 @@ describe('StructuredContentLockPlugin', () => { expect(result.prevented).toBe(true); expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(!shouldDeleteWrapper); }); + + it('sdtLocked + Delete before typed inline SDT text deletes the text and preserves the wrapper', () => { + const beforeText = schema.text('Before '); + const sdtRun = schema.nodes.run.create(null, schema.text('a')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'sdtLocked' }, sdtRun); + const afterText = schema.text(' After'); + const paragraph = schema.nodes.paragraph.create(null, [beforeText, sdt, afterText]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + let runPos = null; + state.doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent === 'a') { + runPos = pos; + return false; + } + return true; + }); + expect(sdtInfo).not.toBeNull(); + expect(runPos).not.toBeNull(); + + placeCaretAt(state, runPos + 1); + + const result = invokeLockHandleKeyDown('Delete'); + + expect(result.handled).toBe(true); + expect(result.prevented).toBe(true); + const nextSdtInfo = findSDTNode(editor.state.doc, 'structuredContent'); + expect(nextSdtInfo).not.toBeNull(); + expect(nextSdtInfo.node.textContent).toBe(''); + expect(editor.state.doc.textContent).toBe('Before After'); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(nextSdtInfo.pos + 1); + }); }); describe('Path 1 — selection covers SDT content (label selection / triple-click)', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js index 9ff9997977..9747b7ab95 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js @@ -1,4 +1,4 @@ -import { Plugin } from 'prosemirror-state'; +import { Plugin, TextSelection } from 'prosemirror-state'; import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js'; import { SELECT_INLINE_SDT_BEFORE_RUN_START_META } from '@core/commands/selectInlineSdtBeforeRunStart.js'; @@ -24,6 +24,25 @@ export function createStructuredContentSelectPlugin(editor) { const { selection } = state; const isEditableSlotText = (text) => text.replace(/\u200B/g, '').length === 0; + const resolveAdjacentEmptyInlineSdtEntry = () => { + if (!selection.empty) return null; + + let targetPos = null; + state.doc.descendants((node, pos) => { + if (node.type.name !== 'structuredContent') return true; + if (node.content.size !== 0) return true; + + if (event.key === 'ArrowLeft' && selection.from === pos + node.nodeSize) { + targetPos = pos + 1; + return false; + } + + return true; + }); + + return targetPos; + }; + const resolveBoundaryExit = ($pos) => { for (let depth = $pos.depth; depth > 0; depth -= 1) { const node = $pos.node(depth); @@ -62,6 +81,17 @@ export function createStructuredContentSelectPlugin(editor) { return null; }; + const adjacentEmptySdtEntry = resolveAdjacentEmptyInlineSdtEntry(); + if (adjacentEmptySdtEntry != null) { + try { + view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, adjacentEmptySdtEntry))); + event.preventDefault(); + return true; + } catch { + return false; + } + } + const nextPos = resolveBoundaryExit(selection.$from); if (nextPos == null) return false; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js index d1586c2c8e..ec9864682c 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js @@ -292,6 +292,26 @@ describe('StructuredContentSelectPlugin', () => { expect(editor.state.selection.from).toBeGreaterThanOrEqual(sdt.pos + 1); }); + it('moves back inside an empty inline SDT with ArrowLeft from its trailing boundary', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('Lead '), inlineSdt, schema.text(' trail')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const insideSdt = sdt.pos + 1; + const afterSdt = sdt.pos + sdt.node.nodeSize; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, afterSdt))); + + const handled = pressArrow('ArrowLeft'); + + expect(handled).toBe(true); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(insideSdt); + expect(editor.state.selection.to).toBe(insideSdt); + }); + it('does not intercept Shift+ArrowRight near inline SDT boundary', () => { const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]); From cb7b23d33431b29744bc37869ebae421225546cc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:11:28 -0300 Subject: [PATCH 081/103] feat(super-editor): inherit run styles in empty block SDT placeholders Route empty block-SDT paragraphs through paragraphToFlowBlocks so the placeholder run picks up the paragraph's resolved font, size, and color instead of falling back to the document defaults. The painter now applies those run styles to the placeholder span, keeping "Click or tap here to enter text" visually consistent with the surrounding paragraph chrome. --- .../painters/dom/src/index.test.ts | 2 + .../painters/dom/src/renderer.ts | 1 + .../src/sdt/structured-content-block.test.ts | 101 ++++++++++++++++-- .../src/sdt/structured-content-block.ts | 93 +++++++++++++++- 4 files changed, 183 insertions(+), 14 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 3e9ed42901..62af9f95f0 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2955,6 +2955,8 @@ describe('DomPainter', () => { expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text'); expect(placeholder?.dataset.pmStart).toBe('4'); expect(placeholder?.dataset.pmEnd).toBe('4'); + expect(placeholder?.style.fontFamily).toBe('Arial'); + expect(placeholder?.style.fontSize).toBe('16px'); expect(fragment?.textContent).not.toContain('Click or tap here to enter text'); }); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 2ee8c61c2b..75efb7ae03 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5722,6 +5722,7 @@ export class DomPainter { if (run.pmStart != null) elem.dataset.pmStart = String(run.pmStart); if (run.pmEnd != null) elem.dataset.pmEnd = String(run.pmEnd); this.applySdtDataset(elem, run.sdt); + applyRunStyles(elem, run); return elem; } diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index 6324223313..b55567e90c 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -35,6 +35,10 @@ describe('structured-content-block', () => { scope: 'block', id: 'scb-1', }; + const nonEmptyParagraph = (text = 'Text'): PMNode => ({ + type: 'paragraph', + content: [{ type: 'text', text }], + }); beforeEach(() => { vi.clearAllMocks(); @@ -96,7 +100,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -166,6 +170,83 @@ describe('structured-content-block', () => { expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); + it('should emit a placeholder paragraph for a single empty paragraph child', () => { + const emptyParagraph: PMNode = { type: 'paragraph', content: [] }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 14 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 13 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-empty-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + color: '#123456', + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + fontFamily: 'Aptos', + fontSize: 14, + color: '#123456', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(paragraphToFlowBlocks).toHaveBeenCalledWith( + expect.objectContaining({ + para: emptyParagraph, + positions: mockPositionMap, + }), + ); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + it('should process a single paragraph child', () => { const node: PMNode = { type: 'structuredContentBlock', @@ -263,7 +344,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -302,7 +383,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -351,7 +432,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -392,7 +473,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -431,7 +512,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -481,7 +562,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -517,7 +598,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: {}, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -723,7 +804,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -806,7 +887,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index 089e31be31..931076ae82 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -5,10 +5,56 @@ * paragraphs and tables while preserving their content structure. */ -import type { ParagraphBlock, TableBlock, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/contracts'; import type { PMNode, NodeHandlerContext } from '../types.js'; import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js'; +function isEmptyParagraphNode(node: PMNode): boolean { + if (node.type !== 'paragraph') return false; + if (!Array.isArray(node.content) || node.content.length === 0) return true; + + return node.content.every((child) => { + if (child.type === 'run') { + return !Array.isArray(child.content) || child.content.length === 0; + } + if (child.type === 'text') { + return (child.text ?? '').length === 0; + } + return false; + }); +} + +function asEmptyTextRun(run: unknown): TextRun | undefined { + if (!run || typeof run !== 'object') return undefined; + const candidate = run as TextRun; + if (!('text' in candidate) || candidate.text !== '') return undefined; + const kind = (candidate as { kind?: unknown }).kind; + return kind == null || kind === 'text' ? candidate : undefined; +} + +function applyPlaceholderToEmptyParagraphBlocks( + paragraphBlocks: FlowBlock[], + metadata: TextRun['sdt'], + contentPos?: number, +): boolean { + let applied = false; + paragraphBlocks.forEach((block) => { + if (block.kind !== 'paragraph') return; + const run = block.runs.map(asEmptyTextRun).find(Boolean); + if (!run) return; + run.kind = 'text'; + run.text = ''; + run.sdt = metadata; + run.visualPlaceholder = 'emptyBlockSdt'; + if (contentPos != null) { + run.pmStart = contentPos; + run.pmEnd = contentPos; + } + applied = true; + }); + return applied; +} + /** * Handle structured content block nodes. * Processes child paragraphs and tables, applying SDT metadata. @@ -35,10 +81,8 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand const structuredContentMetadata = resolveNodeSdtMetadata(node, 'structuredContentBlock'); const paragraphToFlowBlocks = converters?.paragraphToFlowBlocks; - if (!Array.isArray(node.content) || node.content.length === 0) { + const emitPlaceholderBlock = (contentPos?: number): void => { if (!structuredContentMetadata) return; - const pos = positions.get(node); - const contentPos = pos ? pos.start + 1 : undefined; const placeholderRun: TextRun = { kind: 'text', text: '', @@ -56,6 +100,47 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand }; blocks.push(placeholderBlock); recordBlockKind?.(placeholderBlock.kind); + }; + + if (!Array.isArray(node.content) || node.content.length === 0) { + const pos = positions.get(node); + emitPlaceholderBlock(pos ? pos.start + 1 : undefined); + return; + } + + if (node.content.length === 1 && isEmptyParagraphNode(node.content[0])) { + const paragraphPos = positions.get(node.content[0]); + const blockPos = positions.get(node); + const contentPos = paragraphPos ? paragraphPos.start + 1 : blockPos ? blockPos.start + 1 : undefined; + + if (paragraphToFlowBlocks) { + const convertedBlocks = paragraphToFlowBlocks({ + para: node.content[0], + nextBlockId, + positions, + trackedChangesConfig, + bookmarks, + hyperlinkConfig, + themeColors, + enableComments, + converters, + converterContext, + }); + const paragraphBlocks = Array.isArray(convertedBlocks) ? convertedBlocks : []; + applySdtMetadataToParagraphBlocks( + paragraphBlocks.filter((b) => b.kind === 'paragraph') as ParagraphBlock[], + structuredContentMetadata, + ); + if (applyPlaceholderToEmptyParagraphBlocks(paragraphBlocks, structuredContentMetadata, contentPos)) { + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind?.(block.kind); + }); + return; + } + } + + emitPlaceholderBlock(contentPos); return; } From 200d3e81f395898321695b1ddbc0855cd548fe59 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:16:44 -0300 Subject: [PATCH 082/103] fix(layout-engine): size SDT block labels to content width Use `max-content` with `max-width: 130px` instead of stretching block SDT labels to the chrome width, so short labels no longer span the entire block. Inner span now flexes with `min-width: 0` to keep ellipsis behavior. --- packages/layout-engine/painters/dom/src/styles.test.ts | 5 ++++- packages/layout-engine/painters/dom/src/styles.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 3fe197e9be..9935141e4f 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -105,9 +105,12 @@ describe('ensureSdtContainerStyles', () => { expect(inlineLabelRule).toContain('border-radius: 4px 4px 0 0;'); expect(blockLabelRule).toContain('white-space: nowrap;'); expect(blockLabelRule).toContain('top: -18px;'); - expect(blockLabelRule).toContain('width: calc(var(--sd-sdt-chrome-width, 100%) - 4px);'); + expect(blockLabelRule).toContain('width: max-content;'); expect(blockLabelRule).toContain('max-width: 130px;'); expect(blockLabelRule).toContain('min-width: 0;'); + expect(blockLabelRule).not.toContain('width: calc(var(--sd-sdt-chrome-width, 100%) - 4px);'); + expect(cssText).toContain('.superdoc-structured-content__label span'); + expect(cssText).toContain('flex: 1 1 auto;'); expect(cssText).toContain('bottom: calc(100% + 1px);'); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index cfd3a55a46..f964599b8a 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -590,7 +590,7 @@ const SDT_CONTAINER_STYLES = ` position: absolute; left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); top: -18px; - width: calc(var(--sd-sdt-chrome-width, 100%) - 4px); + width: max-content; max-width: 130px; min-width: 0; border-bottom: none; @@ -600,6 +600,9 @@ const SDT_CONTAINER_STYLES = ` } .superdoc-structured-content__label span { + display: block; + flex: 1 1 auto; + min-width: 0; max-width: 100%; overflow: hidden; white-space: nowrap; From ae5a8907c50cdea618b15ed17e1e96d8fabdf608 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:20:16 -0300 Subject: [PATCH 083/103] fix(layout-engine): use measured width for empty SDT placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop maxing the measured placeholder width with the fallback — for empty SDTs we now trust the measured value and only fall back when measurement returns zero. Also treat empty-SDT placeholder runs as visible content so chrome geometry (--sd-sdt-chrome-left/width) is emitted for them. --- packages/layout-engine/measuring/dom/src/index.ts | 4 +++- packages/layout-engine/painters/dom/src/index.test.ts | 2 ++ packages/layout-engine/painters/dom/src/renderer.ts | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 59afe9290f..7d63d0c860 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2029,7 +2029,9 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P const placeholderWidth = run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? 0 - : Math.max(measuredPlaceholderWidth, fallbackPlaceholderWidth); + : measuredPlaceholderWidth > 0 + ? measuredPlaceholderWidth + : fallbackPlaceholderWidth; if (!currentLine) { currentLine = { diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 62af9f95f0..b0fb6e05eb 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2957,6 +2957,8 @@ describe('DomPainter', () => { expect(placeholder?.dataset.pmEnd).toBe('4'); expect(placeholder?.style.fontFamily).toBe('Arial'); expect(placeholder?.style.fontSize).toBe('16px'); + expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('220px'); expect(fragment?.textContent).not.toContain('Click or tap here to enter text'); }); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 75efb7ae03..cea8e49654 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5537,6 +5537,10 @@ export class DomPainter { let hasVisibleContent = false; for (const run of runsForLine) { if (run.kind === 'lineBreak' || run.kind === 'break') continue; + if (isEmptySdtPlaceholderRun(run)) { + hasVisibleContent = true; + break; + } if ((run.kind === 'text' || run.kind === undefined) && 'text' in run) { if ((run.text ?? '').trim().length === 0) continue; } From dc5f1ad554cdde1a38d1105530558cbe3dc11978 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:24:16 -0300 Subject: [PATCH 084/103] fix(super-editor): align empty block sdt caret --- .../tests/DomSelectionGeometry.test.ts | 32 +++++++++++++++++++ .../v1/dom-observer/DomSelectionGeometry.ts | 9 ++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts index 24faed5bf4..17072dba52 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts @@ -1652,6 +1652,38 @@ describe('computeDomCaretPageLocal', () => { }); }); + it('positions caret at the left edge of an empty block SDT placeholder', () => { + painterHost.innerHTML = ` +
+
+ + + +
+
+ `; + + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const lineEl = painterHost.querySelector('.superdoc-line') as HTMLElement; + const placeholderEl = painterHost.querySelector('.superdoc-empty-block-sdt-placeholder') as HTMLElement; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + lineEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 100, 16)); + placeholderEl.getBoundingClientRect = vi.fn(() => createRect(10, 34, 205, 16)); + + const options = createCaretOptions(); + const caret = computeDomCaretPageLocal(options, 5); + + expect(caret).not.toBe(null); + expect(caret).toMatchObject({ + pageIndex: 0, + x: 10, + y: 20, + }); + }); + it('positions caret at the right edge when it is after an empty inline SDT placeholder', () => { painterHost.innerHTML = `
diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts index e9c02b97c3..3df97b5cb4 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts @@ -578,9 +578,12 @@ export function computeDomCaretPageLocal( const elRect = targetEl.getBoundingClientRect(); // For non-text elements (images, math), position caret at the right edge // when pos matches pmEnd (cursor after the element) - const isEmptyInlineSdtPlaceholder = targetEl.classList.contains('superdoc-empty-inline-sdt-placeholder'); - const atEnd = isEmptyInlineSdtPlaceholder ? pos > entry.pmEnd : pos >= entry.pmEnd; - const lineEl = isEmptyInlineSdtPlaceholder ? (targetEl.closest('.superdoc-line') as HTMLElement | null) : null; + const isEmptySdtPlaceholder = + targetEl.classList.contains('superdoc-empty-sdt-placeholder') || + targetEl.classList.contains('superdoc-empty-inline-sdt-placeholder') || + targetEl.classList.contains('superdoc-empty-block-sdt-placeholder'); + const atEnd = isEmptySdtPlaceholder ? pos > entry.pmEnd : pos >= entry.pmEnd; + const lineEl = isEmptySdtPlaceholder ? (targetEl.closest('.superdoc-line') as HTMLElement | null) : null; const yRect = lineEl?.getBoundingClientRect() ?? elRect; return { pageIndex: Number(page.dataset.pageIndex ?? '0'), From 6f2c40ec886ca1f4676948267e3fd47b7212d3fe Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:25:05 -0300 Subject: [PATCH 085/103] fix(layout-engine): hide empty block sdt placeholder --- .../painters/dom/src/styles.test.ts | 20 +++++++++++++++++++ .../layout-engine/painters/dom/src/styles.ts | 6 ++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 9935141e4f..3dbafeaed3 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -136,6 +136,26 @@ describe('ensureSdtContainerStyles', () => { expect(selectedRule).not.toMatch(/(^|\n)\s*color\s*:/); }); + it('suppresses empty block SDT placeholder text when the SDT appearance is hidden', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const hiddenPlaceholderRule = + cssText.match( + /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const hiddenPlaceholderBeforeRule = + cssText.match( + /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder::before,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + + expect(hiddenPlaceholderRule).toContain('width: 0;'); + expect(hiddenPlaceholderRule).toContain('min-width: 0;'); + expect(hiddenPlaceholderRule).toContain('overflow: hidden;'); + expect(hiddenPlaceholderBeforeRule).toContain("content: '';"); + }); + it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index f964599b8a..afd40d02b3 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -703,13 +703,15 @@ const SDT_CONTAINER_STYLES = ` background-color: var(--sd-content-controls-placeholder-selected-bg, Highlight); } -.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder { +.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder, +.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder { width: 0; min-width: 0; overflow: hidden; } -.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder::before { +.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder::before, +.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder::before { content: ''; } From 917cb8e2599433358f2658e5f9117ad09bfe92e3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:29:46 -0300 Subject: [PATCH 086/103] fix(layout-engine): expose block sdt appearance --- .../painters/dom/src/index.test.ts | 65 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 2 + 2 files changed, 67 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b0fb6e05eb..9cc92903ea 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2962,6 +2962,71 @@ describe('DomPainter', () => { expect(fragment?.textContent).not.toContain('Click or tap here to enter text'); }); + it('marks hidden empty block SDT wrappers so placeholder chrome can be suppressed', () => { + const sdt = { + type: 'structuredContent', + scope: 'block', + id: 'sc-block-hidden-empty-1', + alias: 'Hidden empty block', + appearance: 'hidden', + } as const; + const block: FlowBlock = { + kind: 'paragraph', + id: 'block-sc-hidden-empty', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 4, + visualPlaceholder: 'emptyBlockSdt', + sdt, + }, + ], + attrs: { sdt }, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sc-hidden-empty', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 3, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const fragment = mount.querySelector( + '.superdoc-structured-content-block[data-sdt-id="sc-block-hidden-empty-1"]', + ) as HTMLElement | null; + + expect(fragment).toBeTruthy(); + expect(fragment?.dataset.appearance).toBe('hidden'); + }); + it('keeps inline SDT wrapper font-size in sync when run font-size changes', () => { const block: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index cea8e49654..385ccbec99 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -7622,6 +7622,7 @@ export class DomPainter { 'sdtScope', 'sdtTag', 'sdtAlias', + 'appearance', 'lockMode', 'sdtSectionTitle', 'sdtSectionType', @@ -7752,6 +7753,7 @@ export class DomPainter { this.setDatasetString(el, 'sdtScope', metadata.scope); this.setDatasetString(el, 'sdtTag', metadata.tag); this.setDatasetString(el, 'sdtAlias', metadata.alias); + this.setDatasetString(el, 'appearance', metadata.appearance); // Always set lockMode (defaulting to 'unlocked') so CSS can target all SDTs uniformly. this.setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); } else if (metadata.type === 'documentSection') { From 31e9fc9cfc5d45ba53f3560f620f324f2b108349 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:31:08 -0300 Subject: [PATCH 087/103] fix(super-editor): ignore collapsed inline sdt cut --- .../structured-content-lock-plugin.js | 2 +- .../structured-content-lock-plugin.test.js | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js index ec4e8f0d73..65c2951b3d 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js @@ -173,7 +173,7 @@ export function createStructuredContentLockPlugin() { inlineSdtAncestor && inlineSdtAncestor.lockMode !== 'contentLocked' && inlineSdtAncestor.lockMode !== 'sdtContentLocked'; - if (inlineSdtContentEditable && selection.$from.parent.type.name === 'run') { + if ((isBackspace || isDelete) && inlineSdtContentEditable && selection.$from.parent.type.name === 'run') { const deleteFrom = isBackspace ? from - 1 : from; const deleteTo = isBackspace ? from : from + 1; const staysInsideInlineSdt = deleteFrom > inlineSdtAncestor.pos && deleteTo < inlineSdtAncestor.end; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js index 50b7cbdfe3..432de5e231 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js @@ -656,6 +656,35 @@ describe('StructuredContentLockPlugin', () => { expect(editor.state.selection.empty).toBe(true); expect(editor.state.selection.from).toBe(nextSdtInfo.pos + 1); }); + + it('sdtLocked + collapsed Cmd+X inside typed inline SDT text does not delete content', () => { + const beforeText = schema.text('Before '); + const sdtRun = schema.nodes.run.create(null, schema.text('abc')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'sdtLocked' }, sdtRun); + const afterText = schema.text(' After'); + const paragraph = schema.nodes.paragraph.create(null, [beforeText, sdt, afterText]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const originalText = state.doc.textContent; + + let runPos = null; + state.doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent === 'abc') { + runPos = pos; + return false; + } + return true; + }); + expect(runPos).not.toBeNull(); + + placeCaretAt(state, runPos + 2); + + const result = invokeLockHandleKeyDown('x', { metaKey: true }); + + expect(result.handled).toBe(false); + expect(result.prevented).toBe(false); + expect(editor.state.doc.textContent).toBe(originalText); + }); }); describe('Path 1 — selection covers SDT content (label selection / triple-click)', () => { From e18d0ed3890d2db4bfd9a1246ae89b6e995b8476 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:31:47 -0300 Subject: [PATCH 088/103] fix(layout-engine): hide sdt placeholders in viewing --- .../layout-engine/painters/dom/src/styles.test.ts | 11 +++++++++++ packages/layout-engine/painters/dom/src/styles.ts | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 3dbafeaed3..a1b6ab4c13 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -156,6 +156,17 @@ describe('ensureSdtContainerStyles', () => { expect(hiddenPlaceholderBeforeRule).toContain("content: '';"); }); + it('suppresses empty SDT placeholder text in viewing mode', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const viewingPlaceholderRule = + cssText.match(/\.presentation-editor--viewing \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(viewingPlaceholderRule).toContain("content: '';"); + }); + it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index afd40d02b3..6f8f118331 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -820,6 +820,10 @@ const SDT_CONTAINER_STYLES = ` border: none; } +.presentation-editor--viewing .superdoc-empty-sdt-placeholder::before { + content: ''; +} + .presentation-editor--viewing .superdoc-structured-content__label, .presentation-editor--viewing .superdoc-structured-content-inline__label { display: none !important; From 6cad1e98b9005456a5a25ced33905ca877b34937 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:34:48 -0300 Subject: [PATCH 089/103] fix(layout-engine): hide sdt placeholders in print --- .../layout-engine/painters/dom/src/styles.test.ts | 11 +++++++++++ packages/layout-engine/painters/dom/src/styles.ts | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index a1b6ab4c13..2652618dc4 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -167,6 +167,17 @@ describe('ensureSdtContainerStyles', () => { expect(viewingPlaceholderRule).toContain("content: '';"); }); + it('suppresses empty SDT placeholder text in print mode', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const printPlaceholderRule = + cssText.match(/@media print\s*\{[\s\S]*?\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(printPlaceholderRule).toContain("content: '';"); + }); + it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 6f8f118331..26ebd61657 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -848,6 +848,10 @@ const SDT_CONTAINER_STYLES = ` .superdoc-structured-content-inline__label { display: none !important; } + + .superdoc-empty-sdt-placeholder::before { + content: ''; + } } `; From 813fd30ec0a0c556e068e8bb2939554707e8284c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:44:36 -0300 Subject: [PATCH 090/103] fix(layout-engine): remeasure sdt placeholders --- .../layout-bridge/src/remeasure.ts | 6 +++- .../layout-bridge/test/remeasure.test.ts | 30 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index b58704acb6..ed65c1193d 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -10,7 +10,7 @@ import type { ParagraphIndent, LeaderDecoration, } from '@superdoc/contracts'; -import { Engines } from '@superdoc/contracts'; +import { EMPTY_SDT_PLACEHOLDER_TEXT, Engines, isEmptySdtPlaceholderRun } from '@superdoc/contracts'; import type { WordParagraphLayoutOutput } from '@superdoc/word-layout'; import { LIST_MARKER_GAP as _LIST_MARKER_GAP, @@ -126,6 +126,10 @@ function fontString(run: Run): string { * @returns Text content of the run, or empty string for non-text runs */ function runText(run: Run): string { + if (isEmptySdtPlaceholderRun(run)) { + return run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? '' : EMPTY_SDT_PLACEHOLDER_TEXT; + } + return 'src' in run || run.kind === 'lineBreak' || run.kind === 'break' || diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index 84ec9f746a..04d42b221b 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -11,7 +11,7 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; -import type { ParagraphBlock, Run, TabStop } from '@superdoc/contracts'; +import { EMPTY_SDT_PLACEHOLDER_TEXT, type ParagraphBlock, type Run, type TabStop } from '@superdoc/contracts'; import { remeasureParagraph } from '../src/remeasure.ts'; /** @@ -216,6 +216,34 @@ describe('remeasureParagraph', () => { expect(measure.totalHeight).toBe(0); }); + it('measures visible empty SDT placeholders using the placeholder prompt width', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'empty-block-sdt' }, + }), + ]); + const measure = remeasureParagraph(block, 500); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(EMPTY_SDT_PLACEHOLDER_TEXT.length * CHAR_WIDTH); + }); + + it('keeps hidden empty SDT placeholders zero-width during remeasurement', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'hidden-block-sdt', appearance: 'hidden' }, + }), + ]); + const measure = remeasureParagraph(block, 500); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(0); + }); + it('handles single character per line when maxWidth is very narrow', () => { // With maxWidth=11 (barely fits 1 char at 10px + fudge), each char should be on its own line const block = createBlock([textRun('ABC')]); From b7fd891bb4419e5eb1ff24ad6cd48a0d13ed60b6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:49:50 -0300 Subject: [PATCH 091/103] fix(layout-engine): transform sdt placeholder measure --- .../measuring/dom/src/index.test.ts | 34 +++++++++++++++++++ .../layout-engine/measuring/dom/src/index.ts | 5 +-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index ba8ba9c783..a5a278a18a 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -9,6 +9,7 @@ import type { DrawingBlock, TableMeasure, } from '@superdoc/contracts'; +import { EMPTY_SDT_PLACEHOLDER_TEXT } from '@superdoc/contracts'; const expectParagraphMeasure = (measure: Measure): ParagraphMeasure => { expect(measure.kind).toBe('paragraph'); @@ -713,6 +714,39 @@ describe('measureBlock', () => { expect(measure.lines[0].segments[0]).toMatchObject({ runIndex: 0, fromChar: 0, toChar: 0 }); expect(measure.lines[0].segments[0].width).toBe(measure.lines[0].width); }); + + it('applies textTransform when measuring empty SDT placeholder text', async () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + expect(ctx).not.toBeNull(); + ctx!.font = '16px Arial'; + const transformedPlaceholderText = EMPTY_SDT_PLACEHOLDER_TEXT.toUpperCase(); + const transformedWidth = ctx!.measureText(transformedPlaceholderText).width; + const untransformedWidth = ctx!.measureText(EMPTY_SDT_PLACEHOLDER_TEXT).width; + expect(transformedWidth).not.toBeCloseTo(untransformedWidth, 2); + + const block: FlowBlock = { + kind: 'paragraph', + id: 'empty-inline-sdt-uppercase', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + textTransform: 'uppercase', + visualPlaceholder: 'emptyInlineSdt', + sdt: { type: 'structuredContent', scope: 'inline', id: 'sdt-empty-uppercase' }, + }, + ], + attrs: {}, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 1000)); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBeCloseTo(transformedWidth, 2); + }); }); describe('advanced styling', () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 7d63d0c860..6e6aa87412 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2019,13 +2019,14 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P if (isEmptySdtPlaceholderRun(run)) { const placeholderFont = buildFontString(run).font; + const placeholderText = applyTextTransform(EMPTY_SDT_PLACEHOLDER_TEXT, run); const measuredPlaceholderWidth = getMeasuredTextWidth( - EMPTY_SDT_PLACEHOLDER_TEXT, + placeholderText, placeholderFont, run.letterSpacing ?? 0, ctx, ); - const fallbackPlaceholderWidth = EMPTY_SDT_PLACEHOLDER_TEXT.length * run.fontSize * 0.45; + const fallbackPlaceholderWidth = placeholderText.length * run.fontSize * 0.45; const placeholderWidth = run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? 0 From 17d9807e92d25bb0dacd74c23b1190937ee3089f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 17:57:59 -0300 Subject: [PATCH 092/103] fix(layout-engine): keep remeasured sdt placeholder atomic --- .../layout-engine/layout-bridge/src/remeasure.ts | 11 +++++++++++ .../layout-bridge/test/remeasure.test.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index ed65c1193d..22134eb907 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -1384,6 +1384,17 @@ export function remeasureParagraph( if (text.length > 0 && isTextRun(run)) { lineMaxTextFontSize = Math.max(lineMaxTextFontSize, run.fontSize ?? 16); } + if (isEmptySdtPlaceholderRun(run)) { + const placeholderWidth = text.length > 0 ? measureRunSliceWidth(run, 0, text.length) : 0; + if (width > 0 && width + placeholderWidth > effectiveMaxWidth - WIDTH_FUDGE_PX) { + didBreakInThisLine = true; + break; + } + width += placeholderWidth; + endRun = r; + endChar = text.length > 0 ? text.length : start + 1; + continue; + } for (let c = start; c < text.length; c += 1) { const ch = text[c]; if (ch === '\t') { diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index 04d42b221b..ec9f31a009 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -230,6 +230,22 @@ describe('remeasureParagraph', () => { expect(measure.lines[0].width).toBe(EMPTY_SDT_PLACEHOLDER_TEXT.length * CHAR_WIDTH); }); + it('keeps a visible empty SDT placeholder atomic when it is wider than the line', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'narrow-empty-block-sdt' }, + }), + ]); + const measure = remeasureParagraph(block, 60); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].fromRun).toBe(0); + expect(measure.lines[0].toRun).toBe(0); + expect(measure.lines[0].width).toBe(EMPTY_SDT_PLACEHOLDER_TEXT.length * CHAR_WIDTH); + }); + it('keeps hidden empty SDT placeholders zero-width during remeasurement', () => { const block = createBlock([ textRun('', { From d31d2479e34d3febae54204d6ab582f032d9ff42 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:04:29 -0300 Subject: [PATCH 093/103] fix(layout-engine): suppress hidden block sdt chrome --- packages/layout-engine/painters/dom/src/index.test.ts | 6 +++--- packages/layout-engine/painters/dom/src/styles.test.ts | 10 ++++++---- packages/layout-engine/painters/dom/src/styles.ts | 10 ++++++---- .../painters/dom/src/utils/sdt-helpers.ts | 6 ++++++ 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 9cc92903ea..72e5ffd1c0 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -3019,12 +3019,12 @@ describe('DomPainter', () => { const painter = createTestPainter({ blocks: [block], measures: [measure] }); painter.paint(layout, mount); - const fragment = mount.querySelector( - '.superdoc-structured-content-block[data-sdt-id="sc-block-hidden-empty-1"]', - ) as HTMLElement | null; + const fragment = mount.querySelector('[data-sdt-id="sc-block-hidden-empty-1"]') as HTMLElement | null; expect(fragment).toBeTruthy(); expect(fragment?.dataset.appearance).toBe('hidden'); + expect(fragment?.classList.contains('superdoc-structured-content-block')).toBe(false); + expect(fragment?.querySelector('.superdoc-structured-content__label')).toBeNull(); }); it('keeps inline SDT wrapper font-size in sync when run font-size changes', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 2652618dc4..1b3e1ebc04 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -143,11 +143,11 @@ describe('ensureSdtContainerStyles', () => { const cssText = styleEl?.textContent ?? ''; const hiddenPlaceholderRule = cssText.match( - /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder\s*\{([^}]*)\}/, + /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder,\s*\.superdoc-empty-sdt-placeholder\[data-appearance='hidden'\]\s*\{([^}]*)\}/, )?.[1] ?? ''; const hiddenPlaceholderBeforeRule = cssText.match( - /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder::before,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder::before\s*\{([^}]*)\}/, + /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder::before,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder::before,\s*\.superdoc-empty-sdt-placeholder\[data-appearance='hidden'\]::before\s*\{([^}]*)\}/, )?.[1] ?? ''; expect(hiddenPlaceholderRule).toContain('width: 0;'); @@ -164,7 +164,8 @@ describe('ensureSdtContainerStyles', () => { const viewingPlaceholderRule = cssText.match(/\.presentation-editor--viewing \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; - expect(viewingPlaceholderRule).toContain("content: '';"); + expect(viewingPlaceholderRule).toContain('visibility: hidden;'); + expect(viewingPlaceholderRule).not.toContain('content:'); }); it('suppresses empty SDT placeholder text in print mode', () => { @@ -175,7 +176,8 @@ describe('ensureSdtContainerStyles', () => { const printPlaceholderRule = cssText.match(/@media print\s*\{[\s\S]*?\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; - expect(printPlaceholderRule).toContain("content: '';"); + expect(printPlaceholderRule).toContain('visibility: hidden;'); + expect(printPlaceholderRule).not.toContain('content:'); }); it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 26ebd61657..8a7f2d3f48 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -704,14 +704,16 @@ const SDT_CONTAINER_STYLES = ` } .superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder, -.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder { +.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder, +.superdoc-empty-sdt-placeholder[data-appearance='hidden'] { width: 0; min-width: 0; overflow: hidden; } .superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder::before, -.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder::before { +.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder::before, +.superdoc-empty-sdt-placeholder[data-appearance='hidden']::before { content: ''; } @@ -821,7 +823,7 @@ const SDT_CONTAINER_STYLES = ` } .presentation-editor--viewing .superdoc-empty-sdt-placeholder::before { - content: ''; + visibility: hidden; } .presentation-editor--viewing .superdoc-structured-content__label, @@ -850,7 +852,7 @@ const SDT_CONTAINER_STYLES = ` } .superdoc-empty-sdt-placeholder::before { - content: ''; + visibility: hidden; } } `; diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts index 2ebad541b4..156224968a 100644 --- a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts +++ b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts @@ -224,6 +224,12 @@ export function applySdtContainerStyling( config = getSdtContainerConfig(containerSdt); } if (!config) return; + if ( + (isStructuredContentMetadata(sdt) && sdt.appearance === 'hidden') || + (isStructuredContentMetadata(containerSdt) && containerSdt.appearance === 'hidden') + ) { + return; + } const isStart = boundaryOptions?.isStart ?? config.isStart; const isEnd = boundaryOptions?.isEnd ?? config.isEnd; From 798741c40e73fa729864b464dd5e4080a7b7dbe0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:09:01 -0300 Subject: [PATCH 094/103] fix(pm-adapter): preserve vanished block sdt paragraphs --- .../src/sdt/structured-content-block.test.ts | 47 +++++++++++++++++++ .../src/sdt/structured-content-block.ts | 14 ++++++ 2 files changed, 61 insertions(+) diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index b55567e90c..53f25a1ee3 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -247,6 +247,53 @@ describe('structured-content-block', () => { expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); + it('should not emit a placeholder for a vanished empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + runProperties: { + vanish: true, + }, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(0); + expect(recordBlockKind).not.toHaveBeenCalled(); + }); + it('should process a single paragraph child', () => { const node: PMNode = { type: 'structuredContentBlock', diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index 931076ae82..cbf7a4c4b9 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -24,6 +24,16 @@ function isEmptyParagraphNode(node: PMNode): boolean { }); } +function isVanishedParagraphNode(node: PMNode): boolean { + const paragraphProperties = node.attrs?.paragraphProperties; + if (!paragraphProperties || typeof paragraphProperties !== 'object') return false; + + const runProperties = (paragraphProperties as { runProperties?: unknown }).runProperties; + if (!runProperties || typeof runProperties !== 'object') return false; + + return (runProperties as { vanish?: unknown }).vanish === true; +} + function asEmptyTextRun(run: unknown): TextRun | undefined { if (!run || typeof run !== 'object') return undefined; const candidate = run as TextRun; @@ -109,6 +119,10 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand } if (node.content.length === 1 && isEmptyParagraphNode(node.content[0])) { + if (isVanishedParagraphNode(node.content[0])) { + return; + } + const paragraphPos = positions.get(node.content[0]); const blockPos = positions.get(node); const contentPos = paragraphPos ? paragraphPos.start + 1 : blockPos ? blockPos.start + 1 : undefined; From 06cce7b2c8aae8600c4240809e2c5a82f4232b59 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:11:47 -0300 Subject: [PATCH 095/103] fix(pm-adapter): keep vanished sdt paragraph side effects --- .../src/sdt/structured-content-block.test.ts | 53 +++++++++++++++++++ .../src/sdt/structured-content-block.ts | 13 +++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index 53f25a1ee3..ebf61f5ebe 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -294,6 +294,59 @@ describe('structured-content-block', () => { expect(recordBlockKind).not.toHaveBeenCalled(); }); + it('should preserve non-paragraph converter output for a vanished empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + pageBreakBefore: true, + paragraphProperties: { + runProperties: { + vanish: true, + }, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const pageBreakBlock: FlowBlock = { + kind: 'pageBreak', + id: 'page-break-before-hidden-paragraph', + attrs: { source: 'pageBreakBefore' }, + }; + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([pageBreakBlock]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toEqual([pageBreakBlock]); + expect(recordBlockKind).toHaveBeenCalledWith('pageBreak'); + }); + it('should process a single paragraph child', () => { const node: PMNode = { type: 'structuredContentBlock', diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index cbf7a4c4b9..a4ce77f5de 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -119,10 +119,7 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand } if (node.content.length === 1 && isEmptyParagraphNode(node.content[0])) { - if (isVanishedParagraphNode(node.content[0])) { - return; - } - + const isVanishedParagraph = isVanishedParagraphNode(node.content[0]); const paragraphPos = positions.get(node.content[0]); const blockPos = positions.get(node); const contentPos = paragraphPos ? paragraphPos.start + 1 : blockPos ? blockPos.start + 1 : undefined; @@ -152,8 +149,16 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand }); return; } + if (isVanishedParagraph) { + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind?.(block.kind); + }); + return; + } } + if (isVanishedParagraph) return; emitPlaceholderBlock(contentPos); return; } From c7b2fa181e4b97e8827806258688d64572d553f0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:16:17 -0300 Subject: [PATCH 096/103] fix(pm-adapter): trust empty sdt paragraph conversion --- .../src/sdt/structured-content-block.test.ts | 45 +++++++++++++++++++ .../src/sdt/structured-content-block.ts | 12 +++-- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index ebf61f5ebe..fc26e66029 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -347,6 +347,51 @@ describe('structured-content-block', () => { expect(recordBlockKind).toHaveBeenCalledWith('pageBreak'); }); + it('should not synthesize a placeholder when tracked-change filtering removes an empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + runProperties: {}, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: { enabled: true, mode: 'final' }, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(0); + expect(recordBlockKind).not.toHaveBeenCalled(); + }); + it('should process a single paragraph child', () => { const node: PMNode = { type: 'structuredContentBlock', diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index a4ce77f5de..9b4835293f 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -149,13 +149,11 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand }); return; } - if (isVanishedParagraph) { - paragraphBlocks.forEach((block) => { - blocks.push(block); - recordBlockKind?.(block.kind); - }); - return; - } + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind?.(block.kind); + }); + return; } if (isVanishedParagraph) return; From bf6b4deb03d2e1b37057c4334d0d9b3409df0ac3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:22:52 -0300 Subject: [PATCH 097/103] fix(layout-engine): keep sdt placeholder pm range atomic --- packages/layout-engine/contracts/src/pm-range.ts | 10 ++++++++++ .../layout-bridge/test/remeasure.test.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/pm-range.ts b/packages/layout-engine/contracts/src/pm-range.ts index 35d77343d2..cbdb70af52 100644 --- a/packages/layout-engine/contracts/src/pm-range.ts +++ b/packages/layout-engine/contracts/src/pm-range.ts @@ -1,4 +1,5 @@ import type { FlowBlock, Line, ParagraphBlock, ParagraphMeasure } from './index.js'; +import { isEmptySdtPlaceholderRun } from './run-helpers.js'; /** * Represents a ProseMirror position range for a line or fragment. @@ -93,6 +94,15 @@ export function computeLinePmRange(block: FlowBlock, line: Line): LinePmRange { const runPmStart = coercePmStart(run); if (runPmStart == null) continue; + if (isEmptySdtPlaceholderRun(run)) { + const runPmEnd = coercePmEnd(run) ?? runPmStart; + if (pmStart == null) { + pmStart = runPmStart; + } + pmEnd = runPmEnd; + continue; + } + if (isAtomicRunKind((run as { kind?: unknown }).kind) || isImageLikeRun(run)) { const runPmEnd = coercePmEnd(run) ?? runPmStart + 1; if (pmStart == null) { diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index ec9f31a009..859fc773c1 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -11,7 +11,13 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; -import { EMPTY_SDT_PLACEHOLDER_TEXT, type ParagraphBlock, type Run, type TabStop } from '@superdoc/contracts'; +import { + EMPTY_SDT_PLACEHOLDER_TEXT, + computeLinePmRange, + type ParagraphBlock, + type Run, + type TabStop, +} from '@superdoc/contracts'; import { remeasureParagraph } from '../src/remeasure.ts'; /** @@ -222,12 +228,15 @@ describe('remeasureParagraph', () => { kind: 'text', visualPlaceholder: 'emptyBlockSdt', sdt: { type: 'structuredContent', scope: 'block', id: 'empty-block-sdt' }, + pmStart: 12, + pmEnd: 12, }), ]); const measure = remeasureParagraph(block, 500); expect(measure.lines).toHaveLength(1); expect(measure.lines[0].width).toBe(EMPTY_SDT_PLACEHOLDER_TEXT.length * CHAR_WIDTH); + expect(computeLinePmRange(block, measure.lines[0])).toEqual({ pmStart: 12, pmEnd: 12 }); }); it('keeps a visible empty SDT placeholder atomic when it is wider than the line', () => { From 826a5a153fe054760929d6b589db450654188332 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:23:34 -0300 Subject: [PATCH 098/103] fix(layout-engine): collapse hidden sdt placeholder text --- packages/layout-engine/painters/dom/src/styles.test.ts | 8 ++++---- packages/layout-engine/painters/dom/src/styles.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 1b3e1ebc04..4951508b73 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -164,8 +164,8 @@ describe('ensureSdtContainerStyles', () => { const viewingPlaceholderRule = cssText.match(/\.presentation-editor--viewing \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; - expect(viewingPlaceholderRule).toContain('visibility: hidden;'); - expect(viewingPlaceholderRule).not.toContain('content:'); + expect(viewingPlaceholderRule).toContain("content: '';"); + expect(viewingPlaceholderRule).not.toContain('visibility: hidden;'); }); it('suppresses empty SDT placeholder text in print mode', () => { @@ -176,8 +176,8 @@ describe('ensureSdtContainerStyles', () => { const printPlaceholderRule = cssText.match(/@media print\s*\{[\s\S]*?\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; - expect(printPlaceholderRule).toContain('visibility: hidden;'); - expect(printPlaceholderRule).not.toContain('content:'); + expect(printPlaceholderRule).toContain("content: '';"); + expect(printPlaceholderRule).not.toContain('visibility: hidden;'); }); it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 8a7f2d3f48..fd33e566c0 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -823,7 +823,7 @@ const SDT_CONTAINER_STYLES = ` } .presentation-editor--viewing .superdoc-empty-sdt-placeholder::before { - visibility: hidden; + content: ''; } .presentation-editor--viewing .superdoc-structured-content__label, @@ -852,7 +852,7 @@ const SDT_CONTAINER_STYLES = ` } .superdoc-empty-sdt-placeholder::before { - visibility: hidden; + content: ''; } } `; From b2f56c27c5cd719ff7616bed20d36da2c93dd42d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:48:37 -0300 Subject: [PATCH 099/103] fix(pm-adapter): preserve empty sdt bookmark placeholders --- .../src/sdt/structured-content-block.test.ts | 73 +++++++++++++++++++ .../src/sdt/structured-content-block.ts | 22 +++--- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index fc26e66029..2a3a8cffb8 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -247,6 +247,79 @@ describe('structured-content-block', () => { expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); + it('should emit a placeholder paragraph when the empty paragraph only has bookmark markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'bookmarkStart', attrs: { id: '1', name: 'EmptySdtBookmark' } }, + { type: 'bookmarkEnd', attrs: { id: '1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-bookmark-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + it('should not emit a placeholder for a vanished empty paragraph child', () => { const emptyParagraph: PMNode = { type: 'paragraph', diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index 9b4835293f..bed3943377 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -9,19 +9,23 @@ import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/c import type { PMNode, NodeHandlerContext } from '../types.js'; import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js'; +function isVisuallyEmptyInlineNode(node: PMNode): boolean { + if (node.type === 'text') { + return (node.text ?? '').length === 0; + } + + if (node.type === 'run' || node.type === 'bookmarkStart') { + return !Array.isArray(node.content) || node.content.every(isVisuallyEmptyInlineNode); + } + + return node.type === 'bookmarkEnd'; +} + function isEmptyParagraphNode(node: PMNode): boolean { if (node.type !== 'paragraph') return false; if (!Array.isArray(node.content) || node.content.length === 0) return true; - return node.content.every((child) => { - if (child.type === 'run') { - return !Array.isArray(child.content) || child.content.length === 0; - } - if (child.type === 'text') { - return (child.text ?? '').length === 0; - } - return false; - }); + return node.content.every(isVisuallyEmptyInlineNode); } function isVanishedParagraphNode(node: PMNode): boolean { From 27b548caa5768b331d2e6e78ee70af860c08f77a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:52:42 -0300 Subject: [PATCH 100/103] fix(pm-adapter): preserve comment-only sdt placeholders --- .../src/sdt/structured-content-block.test.ts | 73 +++++++++++++++++++ .../src/sdt/structured-content-block.ts | 4 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index 2a3a8cffb8..9ebd590006 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -320,6 +320,79 @@ describe('structured-content-block', () => { expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); + it('should emit a placeholder paragraph when the empty paragraph only has comment range markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'commentRangeStart', attrs: { 'w:id': 'comment-1' } }, + { type: 'commentRangeEnd', attrs: { 'w:id': 'comment-1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-comment-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + it('should not emit a placeholder for a vanished empty paragraph child', () => { const emptyParagraph: PMNode = { type: 'paragraph', diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index bed3943377..52f796b22d 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -9,6 +9,8 @@ import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/c import type { PMNode, NodeHandlerContext } from '../types.js'; import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js'; +const NON_RENDERED_STRUCTURAL_INLINE_TYPES = new Set(['bookmarkEnd', 'commentRangeStart', 'commentRangeEnd']); + function isVisuallyEmptyInlineNode(node: PMNode): boolean { if (node.type === 'text') { return (node.text ?? '').length === 0; @@ -18,7 +20,7 @@ function isVisuallyEmptyInlineNode(node: PMNode): boolean { return !Array.isArray(node.content) || node.content.every(isVisuallyEmptyInlineNode); } - return node.type === 'bookmarkEnd'; + return NON_RENDERED_STRUCTURAL_INLINE_TYPES.has(node.type); } function isEmptyParagraphNode(node: PMNode): boolean { From 78f37191b3c3e2f0c2706003c4e5151cc26c2cb1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 18:55:41 -0300 Subject: [PATCH 101/103] fix(pm-adapter): preserve permission-only sdt placeholders --- .../src/sdt/structured-content-block.test.ts | 73 +++++++++++++++++++ .../src/sdt/structured-content-block.ts | 8 +- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index 9ebd590006..d56965acc1 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -393,6 +393,79 @@ describe('structured-content-block', () => { expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); + it('should emit a placeholder paragraph when the empty paragraph only has permission range markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'permStart', attrs: { id: 'perm-1', edGrp: 'everyone' } }, + { type: 'permEnd', attrs: { id: 'perm-1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-permission-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + it('should not emit a placeholder for a vanished empty paragraph child', () => { const emptyParagraph: PMNode = { type: 'paragraph', diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index 52f796b22d..86f4efac22 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -9,7 +9,13 @@ import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/c import type { PMNode, NodeHandlerContext } from '../types.js'; import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js'; -const NON_RENDERED_STRUCTURAL_INLINE_TYPES = new Set(['bookmarkEnd', 'commentRangeStart', 'commentRangeEnd']); +const NON_RENDERED_STRUCTURAL_INLINE_TYPES = new Set([ + 'bookmarkEnd', + 'commentRangeStart', + 'commentRangeEnd', + 'permStart', + 'permEnd', +]); function isVisuallyEmptyInlineNode(node: PMNode): boolean { if (node.type === 'text') { From 50279e26e16432a435b8d37882305b66bf77d212 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 19:08:21 -0300 Subject: [PATCH 102/103] fix(super-editor): skip empty sdt scan on arrow right --- .../structured-content-select-plugin.js | 1 + .../structured-content-select-plugin.test.js | 25 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js index 9747b7ab95..288355d20d 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js @@ -25,6 +25,7 @@ export function createStructuredContentSelectPlugin(editor) { const isEditableSlotText = (text) => text.replace(/\u200B/g, '').length === 0; const resolveAdjacentEmptyInlineSdtEntry = () => { + if (event.key !== 'ArrowLeft') return null; if (!selection.empty) return null; let targetPos = null; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js index ec9864682c..f419cef6a8 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { SELECT_INLINE_SDT_BEFORE_RUN_START_META } from '@core/commands/selectInlineSdtBeforeRunStart.js'; +import { createStructuredContentSelectPlugin } from './structured-content-select-plugin.js'; function findNode(doc, nodeType) { let result = null; @@ -312,6 +313,28 @@ describe('StructuredContentSelectPlugin', () => { expect(editor.state.selection.to).toBe(insideSdt); }); + it('does not scan the document for empty inline SDT entry on ArrowRight', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('Lead '), inlineSdt, schema.text(' trail')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const afterSdt = sdt.pos + sdt.node.nodeSize; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, afterSdt))); + + const descendants = editor.state.doc.descendants.bind(editor.state.doc); + const descendantsSpy = vi.fn(descendants); + editor.state.doc.descendants = descendantsSpy; + + const plugin = createStructuredContentSelectPlugin(editor); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }); + + expect(plugin.props.handleKeyDown(editor.view, event)).toBe(false); + expect(descendantsSpy).not.toHaveBeenCalled(); + }); + it('does not intercept Shift+ArrowRight near inline SDT boundary', () => { const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]); From 6392bda5c793a2a886c31c8781107fe75ec35eac Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 27 May 2026 19:48:24 -0300 Subject: [PATCH 103/103] fix(layout-engine): show empty SDT placeholder text in viewing and print modes Remove CSS rules that blanked the ::before content for empty SDT placeholders in viewing and print modes so the placeholder prompt stays visible. Update tests to assert the rules are absent. --- packages/layout-engine/painters/dom/src/styles.test.ts | 8 ++++---- packages/layout-engine/painters/dom/src/styles.ts | 8 -------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 4951508b73..fec31ecc2b 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -156,7 +156,7 @@ describe('ensureSdtContainerStyles', () => { expect(hiddenPlaceholderBeforeRule).toContain("content: '';"); }); - it('suppresses empty SDT placeholder text in viewing mode', () => { + it('keeps empty SDT placeholder text visible in viewing mode', () => { ensureSdtContainerStyles(document); const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); @@ -164,11 +164,11 @@ describe('ensureSdtContainerStyles', () => { const viewingPlaceholderRule = cssText.match(/\.presentation-editor--viewing \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; - expect(viewingPlaceholderRule).toContain("content: '';"); + expect(viewingPlaceholderRule).toBe(''); expect(viewingPlaceholderRule).not.toContain('visibility: hidden;'); }); - it('suppresses empty SDT placeholder text in print mode', () => { + it('keeps empty SDT placeholder text visible in print mode', () => { ensureSdtContainerStyles(document); const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); @@ -176,7 +176,7 @@ describe('ensureSdtContainerStyles', () => { const printPlaceholderRule = cssText.match(/@media print\s*\{[\s\S]*?\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; - expect(printPlaceholderRule).toContain("content: '';"); + expect(printPlaceholderRule).toBe(''); expect(printPlaceholderRule).not.toContain('visibility: hidden;'); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index fd33e566c0..e8a8f61402 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -822,10 +822,6 @@ const SDT_CONTAINER_STYLES = ` border: none; } -.presentation-editor--viewing .superdoc-empty-sdt-placeholder::before { - content: ''; -} - .presentation-editor--viewing .superdoc-structured-content__label, .presentation-editor--viewing .superdoc-structured-content-inline__label { display: none !important; @@ -850,10 +846,6 @@ const SDT_CONTAINER_STYLES = ` .superdoc-structured-content-inline__label { display: none !important; } - - .superdoc-empty-sdt-placeholder::before { - content: ''; - } } `;