From dc33ec8f37538895731b66af641ee21ec3cc5e75 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:01:51 -0300 Subject: [PATCH 001/122] fix(super-editor): mark block SDT selected when contained image is selected --- .../presentation-editor/PresentationEditor.ts | 29 +++- .../tests/PresentationEditor.test.ts | 138 ++++++++++++++++++ 2 files changed, 161 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index d72de0811f..e667883919 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -4941,7 +4941,6 @@ export class PresentationEditor extends EventEmitter { */ #focusEditorAfterImageSelection(): void { this.#shouldScrollSelectionIntoView = true; - this.#scheduleSelectionUpdate(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } @@ -4950,6 +4949,7 @@ export class PresentationEditor extends EventEmitter { editorDom.focus(); this.#editor.view?.focus(); } + this.#scheduleSelectionUpdate({ immediate: true }); } #resolveFieldAnnotationSelectionFromElement( @@ -6641,14 +6641,23 @@ export class PresentationEditor extends EventEmitter { let node: ProseMirrorNode | null = null; let pos: number | null = null; let id: string | null = null; + let fallbackPos: number | null = null; if (selection instanceof NodeSelection) { - if (selection.node?.type?.name !== 'structuredContentBlock') { - this.#clearSelectedStructuredContentBlockClass(); - return; + if (selection.node?.type?.name === 'structuredContentBlock') { + node = selection.node; + pos = selection.from; + } else { + fallbackPos = selection.from; + const editorDoc = this.#editor?.view?.state?.doc; + const resolved = editorDoc ? findStructuredContentBlockAtPos(editorDoc, selection.from) : null; + if (!resolved) { + this.#clearSelectedStructuredContentBlockClass(); + return; + } + node = resolved.node; + pos = resolved.pos; } - node = selection.node; - pos = selection.from; } else { const editorDoc = this.#editor?.view?.state?.doc; if (!editorDoc) { @@ -6692,6 +6701,14 @@ export class PresentationEditor extends EventEmitter { } } + if (elements.length === 0 && fallbackPos != null && fallbackPos !== pos) { + const elementAtFallbackPos = this.getElementAtPos(fallbackPos, { fallbackToCoords: true }); + const container = elementAtFallbackPos?.closest?.(`.${DOM_CLASS_NAMES.BLOCK_SDT}`) as HTMLElement | null; + if (container) { + elements = [container]; + } + } + if (elements.length === 0) { this.#clearSelectedStructuredContentBlockClass(); return; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index d37adab113..37cdd4eabc 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -5,6 +5,7 @@ import type { Editor as EditorInstance } from '../../Editor.js'; import { Editor } from '../../Editor.js'; import { HeaderFooterEditorManager, HeaderFooterLayoutAdapter } from '../../header-footer/HeaderFooterRegistry.js'; import { buildMultiSectionIdentifier } from '@superdoc/layout-bridge'; +import { NodeSelection } from 'prosemirror-state'; type MockedEditor = Mock<(...args: unknown[]) => EditorInstance> & { mock: { @@ -396,6 +397,36 @@ describe('PresentationEditor', () => { let container: HTMLElement; let editor: PresentationEditor; + const makeNodeSelection = (from: number, to: number, node: Record) => { + const selection = Object.create(NodeSelection.prototype); + Object.defineProperty(selection, 'from', { value: from, configurable: true }); + Object.defineProperty(selection, 'to', { value: to, configurable: true }); + Object.defineProperty(selection, 'anchor', { value: from, configurable: true }); + Object.defineProperty(selection, 'head', { value: to, configurable: true }); + Object.defineProperty(selection, 'empty', { value: false, configurable: true }); + Object.defineProperty(selection, 'node', { value: node, configurable: true }); + return selection as NodeSelection; + }; + + const getLastEditorInstance = () => { + const results = (Editor as unknown as MockedEditor).mock.results; + return results[results.length - 1].value; + }; + + const getSelectionUpdateHandler = (editorInstance: EditorInstance) => { + const onMock = editorInstance.on as unknown as Mock; + const call = onMock.mock.calls.find(([event]) => event === 'selectionUpdate'); + expect(call).toBeTruthy(); + return call![1] as () => void; + }; + + const syncViewState = (editorInstance: EditorInstance) => { + ( + editorInstance.view as typeof editorInstance.view & { state?: EditorInstance['state']; hasFocus?: () => boolean } + ).state = editorInstance.state; + (editorInstance.view as typeof editorInstance.view & { hasFocus?: () => boolean }).hasFocus = vi.fn(() => true); + }; + beforeEach(() => { // Create a container element for the presentation editor container = document.createElement('div'); @@ -464,6 +495,113 @@ describe('PresentationEditor', () => { }); }); + describe('structured content selected chrome', () => { + it('marks a block SDT selected when an image NodeSelection is inside it', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'sdt-image-selection-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const editorInstance = getLastEditorInstance(); + syncViewState(editorInstance); + + const sdtNode = { + type: { name: 'structuredContentBlock' }, + attrs: { id: 'sdt-image-block' }, + nodeSize: 24, + }; + const imageNode = { + type: { name: 'image' }, + attrs: {}, + isBlock: false, + isInline: true, + isLeaf: true, + nodeSize: 1, + }; + const doc = { + ...editorInstance.state.doc, + nodeAt: vi.fn(() => imageNode), + resolve: vi.fn((pos: number) => ({ + pos, + depth: 2, + node: (depth: number) => { + if (depth === 1) return sdtNode; + if (depth === 2) return { type: { name: 'paragraph' } }; + return { type: { name: 'doc' } }; + }, + before: (depth: number) => (depth === 1 ? 90 : 93), + start: (depth: number) => (depth === 1 ? 91 : 94), + end: (depth: number) => (depth === 1 ? 113 : 95), + })), + }; + editorInstance.state.doc = doc as never; + (editorInstance.view as typeof editorInstance.view & { state: EditorInstance['state'] }).state = + editorInstance.state; + editorInstance.state.selection = makeNodeSelection(94, 95, imageNode); + + const sdtWrapper = document.createElement('div'); + sdtWrapper.className = 'superdoc-structured-content-block'; + sdtWrapper.dataset.sdtId = 'sdt-image-block'; + sdtWrapper.dataset.pmStart = '94'; + sdtWrapper.dataset.pmEnd = '95'; + container.querySelector('.presentation-editor__pages')?.appendChild(sdtWrapper); + + getSelectionUpdateHandler(editorInstance)(); + + expect(sdtWrapper.classList.contains('ProseMirror-selectednode')).toBe(true); + }); + + it('does not mark a block SDT selected when an image NodeSelection is outside it', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'outside-sdt-image-selection-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const editorInstance = getLastEditorInstance(); + syncViewState(editorInstance); + + const imageNode = { + type: { name: 'image' }, + attrs: {}, + isBlock: false, + isInline: true, + isLeaf: true, + nodeSize: 1, + }; + const doc = { + ...editorInstance.state.doc, + nodeAt: vi.fn(() => imageNode), + resolve: vi.fn((pos: number) => ({ + pos, + depth: 1, + node: (depth: number) => (depth === 1 ? { type: { name: 'paragraph' } } : { type: { name: 'doc' } }), + before: () => 10, + start: () => 11, + end: () => 20, + })), + }; + editorInstance.state.doc = doc as never; + (editorInstance.view as typeof editorInstance.view & { state: EditorInstance['state'] }).state = + editorInstance.state; + editorInstance.state.selection = makeNodeSelection(40, 41, imageNode); + + const sdtWrapper = document.createElement('div'); + sdtWrapper.className = 'superdoc-structured-content-block'; + sdtWrapper.dataset.sdtId = 'unrelated-sdt-block'; + sdtWrapper.dataset.pmStart = '90'; + sdtWrapper.dataset.pmEnd = '113'; + container.querySelector('.presentation-editor__pages')?.appendChild(sdtWrapper); + + getSelectionUpdateHandler(editorInstance)(); + + expect(sdtWrapper.classList.contains('ProseMirror-selectednode')).toBe(false); + }); + }); + describe('scrollToPosition', () => { let originalScrollIntoView: unknown; From 37d1ddc95c3559cef15647396dd91465e5918090 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:15:43 -0300 Subject: [PATCH 002/122] fix(layout-bridge): detect inline image run changes in paragraph diff --- .../layout-engine/layout-bridge/src/diff.ts | 32 +++++++++ .../layout-bridge/test/diff.test.ts | 67 ++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 82ef81fead..9e6b690eef 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -3,6 +3,7 @@ import type { ImageBlock, DrawingBlock, ImageDrawing, + ImageRun, BoxSpacing, ImageAnchor, ImageWrap, @@ -419,6 +420,12 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock for (let i = 0; i < a.runs.length; i += 1) { const runA = a.runs[i]; const runB = b.runs[i]; + if (runA.kind === 'image' || runB.kind === 'image') { + if (runA.kind !== 'image' || runB.kind !== 'image') return false; + if (!imageRunsEqual(runA, runB)) return false; + continue; + } + // MathRun: compare textContent (derived from OMML) to detect equation changes if (runA.kind === 'math' || runB.kind === 'math') { if (runA.kind !== runB.kind) return false; @@ -449,6 +456,31 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock return true; }; +const imageRunsEqual = (a: ImageRun, b: ImageRun): boolean => { + return ( + a.src === b.src && + a.width === b.width && + a.height === b.height && + a.alt === b.alt && + a.title === b.title && + a.clipPath === b.clipPath && + a.distTop === b.distTop && + a.distBottom === b.distBottom && + a.distLeft === b.distLeft && + a.distRight === b.distRight && + a.verticalAlign === b.verticalAlign && + a.rotation === b.rotation && + a.flipH === b.flipH && + a.flipV === b.flipV && + a.gain === b.gain && + a.blacklevel === b.blacklevel && + a.grayscale === b.grayscale && + jsonEqual(a.lum, b.lum) && + jsonEqual(a.hyperlink, b.hyperlink) && + shallowRecordEqual(a.dataAttrs, b.dataAttrs) + ); +}; + const imageBlocksEqual = (a: ImageBlock | ImageDrawing, b: ImageBlock | ImageDrawing): boolean => { return ( a.src === b.src && diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index 51c195f114..ef278ced5a 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import type { VectorShapeDrawing } from '@superdoc/contracts'; +import type { ImageRun, ParagraphBlock, VectorShapeDrawing } from '@superdoc/contracts'; import { computeDirtyRegions } from '../src/diff'; const block = (id: string, text: string) => ({ @@ -8,6 +8,19 @@ const block = (id: string, text: string) => ({ runs: [{ text, fontFamily: 'Arial', fontSize: 16 }], }); +const imageRun = (src: string, width: number, height: number): ImageRun => ({ + kind: 'image', + src, + width, + height, +}); + +const paragraphWithRuns = (id: string, runs: ParagraphBlock['runs']) => ({ + kind: 'paragraph' as const, + id, + runs, +}); + const drawing = (overrides?: Partial): VectorShapeDrawing => ({ kind: 'drawing', id: 'drawing-0', @@ -181,6 +194,58 @@ describe('computeDirtyRegions', () => { expect(result.firstDirtyIndex).toBe(0); }); + it('detects inline image height changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 60)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('detects inline image width changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 120, 50)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('treats identical inline image dimensions as stable', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(next.length); + expect(result.stableBlockIds.has('0-paragraph')).toBe(true); + }); + + it('detects inline image resize in mixed text and image paragraphs', () => { + const prev = [ + paragraphWithRuns('0-paragraph', [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16 }, + imageRun('img.png', 100, 50), + { text: ' after', fontFamily: 'Arial', fontSize: 16 }, + ]), + ]; + const next = [ + paragraphWithRuns('0-paragraph', [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16 }, + imageRun('img.png', 100, 60), + { text: ' after', fontFamily: 'Arial', fontSize: 16 }, + ]), + ]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('detects changes to later inline image runs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img1.png', 100, 50), imageRun('img2.png', 80, 40)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img1.png', 100, 50), imageRun('img2.png', 80, 60)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + it('treats unchanged drawing blocks as stable', () => { const prev = [drawing()]; const next = [drawing()]; From afe273aefcef76f0b01672eac33cca95909c17d9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:23:14 -0300 Subject: [PATCH 003/122] fix(super-editor): disable image resize inside content-locked SDTs --- .../v1/components/ImageResizeOverlay.test.js | 144 ++++++++++++++++++ .../v1/components/ImageResizeOverlay.vue | 33 +++- 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js index f2f2f14dda..2353a84ebe 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js @@ -21,6 +21,50 @@ function createMockEditor(overrides = {}) { }; } +function createResizableImageElement({ lockMode, ancestorLockMode } = {}) { + const imageEl = document.createElement('div'); + imageEl.setAttribute('data-pm-start', '0'); + imageEl.setAttribute('data-sd-block-id', 'image-block'); + imageEl.setAttribute( + 'data-image-metadata', + JSON.stringify({ + originalWidth: 100, + originalHeight: 50, + maxWidth: 500, + maxHeight: 500, + aspectRatio: 2, + minWidth: 20, + minHeight: 20, + }), + ); + if (lockMode) { + imageEl.setAttribute('data-lock-mode', lockMode); + } + imageEl.getBoundingClientRect = vi.fn(() => ({ + left: 10, + top: 20, + width: 100, + height: 50, + right: 110, + bottom: 70, + x: 10, + y: 20, + toJSON: () => {}, + })); + + if (!ancestorLockMode) { + document.body.appendChild(imageEl); + return { imageEl, remove: () => imageEl.remove() }; + } + + const sdtEl = document.createElement('div'); + sdtEl.className = 'superdoc-structured-content-block'; + sdtEl.setAttribute('data-lock-mode', ancestorLockMode); + sdtEl.appendChild(imageEl); + document.body.appendChild(sdtEl); + return { imageEl, remove: () => sdtEl.remove() }; +} + describe('ImageResizeOverlay', () => { describe('isResizeDisabled guard', () => { it('should report resize disabled when documentMode is viewing', () => { @@ -55,6 +99,106 @@ describe('ImageResizeOverlay', () => { expect(wrapper.vm.isResizeDisabled).toBe(false); }); + + it('should report resize disabled for images inside content-locked SDTs', () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: 'contentLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + props: { editor, visible: true, imageElement: imageEl }, + }); + + expect(wrapper.vm.isResizeDisabled).toBe(true); + + wrapper.unmount(); + remove(); + }); + }); + + it.each(['contentLocked', 'sdtContentLocked'])( + 'should not start image resize drag inside %s SDTs', + async (lockMode) => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: lockMode }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.dragState).toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(false); + expect(editor.view.dispatch).not.toHaveBeenCalled(); + expect(editor.view.state.tr.setNodeMarkup).not.toHaveBeenCalled(); + + wrapper.unmount(); + remove(); + }, + ); + + it('should not start image resize drag when the image element has contentLocked mode directly', async () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ lockMode: 'contentLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.dragState).toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(false); + expect(editor.view.dispatch).not.toHaveBeenCalled(); + + wrapper.unmount(); + remove(); + }); + + it('should still allow image resize inside sdtLocked SDTs', async () => { + const editor = createMockEditor(); + const imageNode = { + type: { name: 'image' }, + attrs: { size: { width: 100, height: 50 } }, + }; + editor.view.state.doc.nodeAt.mockReturnValue(imageNode); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: 'sdtLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + expect(wrapper.vm.dragState).not.toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(true); + + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + + expect(editor.view.state.tr.setNodeMarkup).toHaveBeenCalledWith( + 0, + null, + expect.objectContaining({ size: { width: 140, height: 70 } }), + ); + expect(editor.view.dispatch).toHaveBeenCalledWith(editor.view.state.tr); + + wrapper.unmount(); + remove(); }); it('should dispatch resize transactions through the presentation editor active editor', async () => { diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue index a4293db5d8..2d0abce3b2 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue @@ -31,6 +31,7 @@ const DIMENSION_CHANGE_THRESHOLD_PX = 1; const Z_INDEX_OVERLAY = 10; const Z_INDEX_HANDLE = 15; const Z_INDEX_GUIDELINE = 20; +const CONTENT_LOCKED_MODES = new Set(['contentLocked', 'sdtContentLocked']); /** * Validates that the editor prop conforms to expected ProseMirror structure @@ -78,8 +79,16 @@ const resizeEditor = computed(() => { return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor; }); +const isImageContentLocked = computed(() => { + const lockMode = resolveImageLockMode(props.imageElement); + return lockMode ? CONTENT_LOCKED_MODES.has(lockMode) : false; +}); + const isResizeDisabled = computed( - () => resizeEditor.value?.options?.documentMode === 'viewing' || !resizeEditor.value?.isEditable, + () => + resizeEditor.value?.options?.documentMode === 'viewing' || + !resizeEditor.value?.isEditable || + isImageContentLocked.value, ); /** @@ -107,6 +116,26 @@ const dragState = ref(null); */ const forcedCleanup = ref(false); +function readLockMode(element) { + return element?.dataset?.lockMode || element?.getAttribute?.('data-lock-mode') || null; +} + +function resolveImageLockMode(imageElement) { + if (!imageElement) return null; + + const directLockMode = readLockMode(imageElement); + if (directLockMode) return directLockMode; + + const innerLockedElement = imageElement.querySelector?.('[data-lock-mode]'); + const innerLockMode = readLockMode(innerLockedElement); + if (innerLockMode) return innerLockMode; + + const sdtElement = imageElement.closest?.( + '.superdoc-structured-content-block[data-lock-mode], .superdoc-structured-content-inline[data-lock-mode]', + ); + return readLockMode(sdtElement); +} + /** * Overlay position and size relative to image element. * The overlay is rendered inside .super-editor wrapper, so we need to @@ -536,7 +565,7 @@ function onDocumentMouseUp(event) { */ function dispatchResizeTransaction(blockId, newWidth, newHeight) { const editor = resizeEditor.value; - if (!isValidEditor(editor) || !props.imageElement) { + if (!isValidEditor(editor) || !props.imageElement || isResizeDisabled.value) { return; } From 2f54eace92a08a7abe9bfd150fc62b6f285d8c88 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:39:30 -0300 Subject: [PATCH 004/122] fix(layout-engine): keep block SDT chrome and inline images out of paragraph geometry Move block SDT border styling onto a ::after pseudo-element with pointer-events: none, and drop the 1px padding so the chrome no longer changes fragment geometry. Default inline image verticalAlign to 'top' so the image box stays within the measured line height. --- .../painters/dom/src/renderer.ts | 7 ++- .../painters/dom/src/styles.test.ts | 15 ++++++ .../layout-engine/painters/dom/src/styles.ts | 48 +++++++++++++++++-- .../src/converters/inline-converters/image.ts | 8 ++-- .../src/converters/paragraph.test.ts | 6 +-- 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 1488747529..525b429ecd 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5907,8 +5907,7 @@ export class DomPainter { // When we don't use a wrapper (no clipPath, or clipPath with width/height 0), apply them on the img so layout is correct. const useWrapper = hasClipPath && run.width > 0 && run.height > 0; if (!useWrapper) { - // Apply vertical alignment (bottom-aligned to text baseline) - img.style.verticalAlign = run.verticalAlign ?? 'bottom'; + img.style.verticalAlign = run.verticalAlign ?? 'top'; // Apply spacing as CSS margins if (run.distTop) { @@ -5985,7 +5984,7 @@ export class DomPainter { wrapper.style.height = `${run.height}px`; wrapper.style.boxSizing = 'border-box'; wrapper.style.overflow = 'hidden'; - wrapper.style.verticalAlign = run.verticalAlign ?? 'bottom'; + wrapper.style.verticalAlign = run.verticalAlign ?? 'top'; if (run.distTop) wrapper.style.marginTop = `${run.distTop}px`; if (run.distBottom) wrapper.style.marginBottom = `${run.distBottom}px`; if (run.distLeft) wrapper.style.marginLeft = `${run.distLeft}px`; @@ -6035,7 +6034,7 @@ export class DomPainter { wrapper.style.display = 'inline-block'; wrapper.style.width = `${run.width}px`; wrapper.style.height = `${run.height}px`; - wrapper.style.verticalAlign = run.verticalAlign ?? 'bottom'; + wrapper.style.verticalAlign = run.verticalAlign ?? 'top'; wrapper.style.position = 'relative'; wrapper.style.zIndex = '1'; if (run.distTop) wrapper.style.marginTop = `${run.distTop}px`; diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index f26a0f8ffd..fb44f49490 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -25,6 +25,21 @@ describe('ensureSdtContainerStyles', () => { expect(cssText).toContain('border-color: var(--sd-content-controls-inline-hover-border, transparent);'); }); + it('keeps block SDT chrome paint-only so it does not change fragment geometry', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const blockRule = cssText.match(/\.superdoc-structured-content-block\s*\{([^}]*)\}/)?.[1] ?? ''; + const chromeRule = cssText.match(/\.superdoc-structured-content-block::after\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(blockRule).not.toContain('padding:'); + expect(blockRule).not.toContain('border:'); + expect(chromeRule).toContain('position: absolute;'); + expect(chromeRule).toContain('border: 1px solid transparent;'); + expect(chromeRule).toContain('pointer-events: none;'); + }); + it('gives empty inline SDTs a default visible affordance', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 28a62683e6..d25c3f22bb 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -497,29 +497,46 @@ const SDT_CONTAINER_STYLES = ` /* Structured Content Block - Blue border container */ .superdoc-structured-content-block { - padding: 1px; box-sizing: border-box; border-radius: 4px; - border: 1px solid transparent; position: relative; } +.superdoc-structured-content-block::after { + content: ''; + position: absolute; + inset: 0; + border: 1px solid transparent; + border-radius: inherit; + box-sizing: border-box; + pointer-events: none; +} + .superdoc-structured-content-block:not(.ProseMirror-selectednode):hover { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); +} + +.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover::after { border-color: var(--sd-content-controls-block-hover-border, transparent); } /* Group hover (JavaScript-coordinated via PresentationEditor) */ .superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode) { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); +} + +.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode)::after { border-color: var(--sd-content-controls-block-hover-border, transparent); } .superdoc-structured-content-block.ProseMirror-selectednode { - border-color: var(--sd-content-controls-block-border, #629be7); outline: none; } +.superdoc-structured-content-block.ProseMirror-selectednode::after { + border-color: var(--sd-content-controls-block-border, #629be7); +} + /* Structured content drag handle/label - positioned above */ .superdoc-structured-content__label { font-size: 11px; @@ -566,29 +583,41 @@ const SDT_CONTAINER_STYLES = ` /* First fragment of a multi-fragment SDT: top corners, no bottom border */ .superdoc-structured-content-block[data-sdt-container-start="true"]:not([data-sdt-container-end="true"]) { border-radius: 4px 4px 0 0; +} + +.superdoc-structured-content-block[data-sdt-container-start="true"]:not([data-sdt-container-end="true"])::after { border-bottom: none; } /* Last fragment of a multi-fragment SDT: bottom corners, no top border */ .superdoc-structured-content-block[data-sdt-container-end="true"]:not([data-sdt-container-start="true"]) { border-radius: 0 0 4px 4px; +} + +.superdoc-structured-content-block[data-sdt-container-end="true"]:not([data-sdt-container-start="true"])::after { border-top: none; } /* Middle fragment (neither start nor end): no corners, no top/bottom borders */ .superdoc-structured-content-block:not([data-sdt-container-start="true"]):not([data-sdt-container-end="true"]) { border-radius: 0; +} + +.superdoc-structured-content-block:not([data-sdt-container-start="true"]):not([data-sdt-container-end="true"])::after { border-top: none; border-bottom: none; } /* Collapse double borders between adjacent SDT blocks */ .superdoc-structured-content-block + .superdoc-structured-content-block { - border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; } +.superdoc-structured-content-block + .superdoc-structured-content-block::after { + border-top: none; +} + /* Structured Content Inline - Inline wrapper with blue border */ .superdoc-structured-content-inline { padding: 1px; @@ -709,6 +738,13 @@ const SDT_CONTAINER_STYLES = ` border: none; } +.presentation-editor--viewing .superdoc-structured-content-block::after, +.presentation-editor--viewing .superdoc-structured-content-block:hover::after, +.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover::after, +.presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::after { + border: none; +} + .presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover, .presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover { background: none; @@ -740,6 +776,10 @@ const SDT_CONTAINER_STYLES = ` padding: 0; } + .superdoc-structured-content-block::after { + border: none; + } + .superdoc-document-section__tooltip, .superdoc-structured-content__label, .superdoc-structured-content-inline__label { diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index 2c9a4b5346..0c63581391 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -45,7 +45,7 @@ const DEFAULT_IMAGE_DIMENSION_PX = 100; * }, * positionMap * ) - * // Returns: { kind: 'image', src: 'data:...', width: 200, height: 150, alt: 'Company logo', distTop: 10, distBottom: 10, verticalAlign: 'bottom' } + * // Returns: { kind: 'image', src: 'data:...', width: 200, height: 150, alt: 'Company logo', distTop: 10, distBottom: 10, verticalAlign: 'top' } * * // Missing src - returns null * imageNodeToRun({ type: 'image', attrs: {} }, positionMap) @@ -56,7 +56,7 @@ const DEFAULT_IMAGE_DIMENSION_PX = 100; * { type: 'image', attrs: { src: 'image.png', size: { width: NaN, height: -10 } } }, * positionMap * ) - * // Returns: { kind: 'image', src: 'image.png', width: 100, height: 100, verticalAlign: 'bottom' } + * // Returns: { kind: 'image', src: 'image.png', width: 100, height: 100, verticalAlign: 'top' } * ``` */ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverterParams): ImageRun | null { @@ -116,8 +116,8 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter const distRight = pickNumber(wrapAttrs.distRight ?? wrapAttrs.distR); if (distRight != null) run.distRight = distRight; - // Default vertical alignment to bottom (text baseline alignment) - run.verticalAlign = 'bottom'; + // Keep the image box inside the measured line height. + run.verticalAlign = 'top'; // Position tracking const pos = positions.get(node); diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index b4ed57350e..a6bc766124 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -3962,7 +3962,7 @@ describe('paragraph converters', () => { distBottom: 20, distLeft: 5, distRight: 15, - verticalAlign: 'bottom', + verticalAlign: 'top', pmStart: 10, pmEnd: 11, }); @@ -4219,14 +4219,14 @@ describe('paragraph converters', () => { expect(result?.pmEnd).toBeUndefined(); }); - it('sets verticalAlign to bottom by default', () => { + it('sets verticalAlign to top by default', () => { const node: PMNode = { type: 'image', attrs: { src: 'image.png', inline: true }, }; const result = imageNodeToRun(buildImageParams(node, positions)); - expect(result?.verticalAlign).toBe('bottom'); + expect(result?.verticalAlign).toBe('top'); }); it('omits alt and title when not present', () => { From 8a1bc7ace84eda76306e87888e23cee64d0b4ec6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:50:49 -0300 Subject: [PATCH 005/122] fix(layout-engine): bottom-align text on lines with inline images When a paragraph line contains an inline image, set surrounding normal text runs to lineHeight: 'normal' and verticalAlign: 'bottom' so they sit beside the top-aligned image. Runs with explicit vertical positioning (vertAlign, baselineShift) are left untouched. --- .../painters/dom/src/index.test.ts | 91 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 14 +++ .../dom/src/text-style-rendering.test.ts | 1 + 3 files changed, 106 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 6d733a18f5..7d7b40cf70 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -7325,6 +7325,9 @@ describe('DomPainter', () => { }); describe('renderImageRun (inline image runs)', () => { + const inlineImageSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const renderInlineImageRun = ( run: Extract['runs'][number], lineWidth = 100, @@ -7377,6 +7380,94 @@ describe('DomPainter', () => { painter.paint(imageLayout, mount); }; + const renderInlineImageTextLine = (runs: Extract['runs']) => { + const imageBlock: FlowBlock = { + kind: 'paragraph', + id: 'img-text-block', + runs, + }; + + const imageMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: runs.length - 1, + toChar: 'text' in runs[runs.length - 1]! ? runs[runs.length - 1]!.text.length : 0, + width: 140, + ascent: 40, + descent: 0, + lineHeight: 40, + }, + ], + totalHeight: 40, + }; + + const imageLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'img-text-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 140, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [imageBlock], measures: [imageMeasure] }); + painter.paint(imageLayout, mount); + }; + + it('bottom-aligns normal text runs on lines containing inline images', () => { + renderInlineImageTextLine([ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 7 }, + { kind: 'image', src: inlineImageSrc, width: 40, height: 40, pmStart: 7, pmEnd: 8 }, + { text: ' after', fontFamily: 'Arial', fontSize: 16, pmStart: 8, pmEnd: 14 }, + ]); + + const textSpans = Array.from(mount.querySelectorAll('.superdoc-line > span')) as HTMLElement[]; + expect(textSpans.map((span) => span.textContent)).toEqual(['Before ', ' after']); + expect(textSpans[0]?.style.lineHeight).toBe('normal'); + expect(textSpans[0]?.style.verticalAlign).toBe('bottom'); + expect(textSpans[1]?.style.lineHeight).toBe('normal'); + expect(textSpans[1]?.style.verticalAlign).toBe('bottom'); + + const img = mount.querySelector('img') as HTMLImageElement | null; + expect(img?.style.verticalAlign).toBe('top'); + }); + + it('preserves explicit vertical positioning on text runs beside inline images', () => { + renderInlineImageTextLine([ + { text: 'Base ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 }, + { kind: 'image', src: inlineImageSrc, width: 40, height: 40, pmStart: 5, pmEnd: 6 }, + { + text: '2', + fontFamily: 'Arial', + fontSize: 10.4, + vertAlign: 'superscript', + pmStart: 6, + pmEnd: 7, + }, + ]); + + const textSpans = Array.from(mount.querySelectorAll('.superdoc-line > span')) as HTMLElement[]; + expect(textSpans.map((span) => span.textContent)).toEqual(['Base ', '2']); + expect(textSpans[0]?.style.lineHeight).toBe('normal'); + expect(textSpans[0]?.style.verticalAlign).toBe('bottom'); + expect(textSpans[1]?.style.lineHeight).toBe('1'); + expect(textSpans[1]?.style.verticalAlign).toBe('5.28px'); + }); + it('renders img element with valid data URL', () => { const imageBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 525b429ecd..fe332eb077 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5502,6 +5502,17 @@ export class DomPainter { return wrapper; } + private alignNormalTextBesideInlineImage(element: HTMLElement, run: Run, lineContainsInlineImage: boolean): void { + if (!lineContainsInlineImage) return; + if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) return; + + const textRun = run as TextRun; + if (normalizeBaselineShift(textRun.baselineShift) != null || textRun.vertAlign != null) return; + + element.style.lineHeight = 'normal'; + element.style.verticalAlign = 'bottom'; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; @@ -6608,6 +6619,7 @@ export class DomPainter { spaceCount, shouldJustify: justifyShouldApply, }); + const lineContainsInlineImage = runsForLine.some((run) => this.isImageRun(run)); const resolveLineIndentOffset = (): number => { if (indentOffsetOverride != null) { return indentOffsetOverride; @@ -6945,6 +6957,7 @@ export class DomPainter { if (styleId) { elem.setAttribute('styleid', styleId); } + this.alignNormalTextBesideInlineImage(elem, segmentRun, lineContainsInlineImage); // Determine X position for this segment // Layout positions are relative to content area start (0). // Add indentOffset to position content at the correct paragraph indent. @@ -7044,6 +7057,7 @@ export class DomPainter { if (styleId) { elem.setAttribute('styleid', styleId); } + this.alignNormalTextBesideInlineImage(elem, run, lineContainsInlineImage); // If this run has inline SDT, add to or create wrapper if (resolved && this.doc) { diff --git a/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts b/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts index a913bd1333..37ec02a69a 100644 --- a/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts +++ b/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts @@ -431,6 +431,7 @@ describe('DomPainter text style CSS rendering', () => { const span = container.querySelector('span'); expect(span).toBeTruthy(); + expect(span?.style.lineHeight).toBe(''); expect(span?.style.verticalAlign).toBe(''); }); From 8b624ed4118822b5a9420dc37c90eedf63fd698c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:03:42 -0300 Subject: [PATCH 006/122] fix(layout-engine): fit block SDT chrome to actual content width Compute content bounds from rendered lines (honoring paragraph alignment) and expose --sd-sdt-chrome-left/--sd-sdt-chrome-width on the fragment. Hover background moves to a ::before pseudo-element; both ::before and ::after use the chrome vars so the frame hugs the content instead of spanning the full fragment width. --- .../painters/dom/src/index.test.ts | 199 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 50 +++++ .../painters/dom/src/styles.test.ts | 10 + .../layout-engine/painters/dom/src/styles.ts | 35 ++- 4 files changed, 289 insertions(+), 5 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 7d7b40cf70..8057bc63c4 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13030,6 +13030,205 @@ describe('applyRunDataAttributes', () => { expect(fragment.dataset.sdtContainerEnd).toBe('true'); }); + it('limits block SDT chrome to paragraph content width', () => { + const textSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-text', + runs: [{ text: 'Short content', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 13 }], + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-text', + alias: 'Text Control', + }, + }, + }; + + const textSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 13, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const textSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-text', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 13, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [textSdtBlock], measures: [textSdtMeasure] }); + painter.paint(textSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + + it('limits block SDT chrome to inline image content width', () => { + const imageOnlySdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-image-only', + runs: [ + { + kind: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 210, + height: 118, + pmStart: 0, + pmEnd: 1, + }, + ], + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-image-only', + alias: 'Image Control', + }, + }, + }; + + const imageOnlySdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 210, + ascent: 118, + descent: 0, + lineHeight: 118, + }, + ], + totalHeight: 118, + }; + + const imageOnlySdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-image-only', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 1, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [imageOnlySdtBlock], measures: [imageOnlySdtMeasure] }); + painter.paint(imageOnlySdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('210px'); + }); + + it('positions block SDT chrome around centered paragraph content', () => { + const centeredSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-centered', + runs: [{ text: 'Centered', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + alignment: 'center', + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-centered', + alias: 'Centered Control', + }, + }, + }; + + const centeredSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const centeredSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-centered', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [centeredSdtBlock], measures: [centeredSdtMeasure] }); + painter.paint(centeredSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('110px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); + }); + it('updates block SDT boundaries when appending a new fragment during patch rendering', () => { const sdtMetadata = { type: 'structuredContent' as const, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index fe332eb077..98056ff4a5 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3231,6 +3231,7 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5513,6 +5514,55 @@ export class DomPainter { element.style.verticalAlign = 'bottom'; } + private applyBlockSdtChromeBounds( + element: HTMLElement, + block: ParagraphBlock, + lines: Line[], + fragmentWidth: number, + ): void { + const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; + if (sdt?.type !== 'structuredContent' || sdt.scope !== 'block') return; + + const expandedBlock = { ...block, runs: expandRunsForInlineNewlines(block.runs) }; + let contentLeft = Number.POSITIVE_INFINITY; + let contentRight = Number.NEGATIVE_INFINITY; + + for (const line of lines) { + const runsForLine = sliceRunsForLine(expandedBlock, line); + if (runsForLine.length === 0) continue; + + let hasVisibleContent = false; + for (const run of runsForLine) { + if (run.kind === 'lineBreak' || run.kind === 'break') continue; + if ((run.kind === 'text' || run.kind === undefined) && 'text' in run) { + if ((run.text ?? '').trim().length === 0) continue; + } + hasVisibleContent = true; + break; + } + + if (!hasVisibleContent) continue; + + const lineWidth = Math.max(0, line.naturalWidth ?? line.width ?? 0); + if (lineWidth <= 0) continue; + + const alignmentSlack = Math.max(0, fragmentWidth - lineWidth); + const alignment = block.attrs?.alignment; + const lineLeft = alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0; + contentLeft = Math.min(contentLeft, lineLeft); + contentRight = Math.max(contentRight, lineLeft + lineWidth); + } + + if (!Number.isFinite(contentLeft) || !Number.isFinite(contentRight)) return; + + const chromeLeft = Math.max(0, contentLeft); + const chromeWidth = Math.max(0, Math.min(fragmentWidth, contentRight) - chromeLeft); + if (chromeWidth <= 0 || chromeWidth >= fragmentWidth) return; + + element.style.setProperty('--sd-sdt-chrome-left', `${chromeLeft}px`); + element.style.setProperty('--sd-sdt-chrome-width', `${chromeWidth}px`); + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index fb44f49490..44878ede17 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -31,11 +31,21 @@ describe('ensureSdtContainerStyles', () => { const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); const cssText = styleEl?.textContent ?? ''; const blockRule = cssText.match(/\.superdoc-structured-content-block\s*\{([^}]*)\}/)?.[1] ?? ''; + const hoverRule = + cssText.match( + /\.superdoc-structured-content-block:not\(.ProseMirror-selectednode\):hover::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const backgroundRule = cssText.match(/\.superdoc-structured-content-block::before\s*\{([^}]*)\}/)?.[1] ?? ''; const chromeRule = cssText.match(/\.superdoc-structured-content-block::after\s*\{([^}]*)\}/)?.[1] ?? ''; expect(blockRule).not.toContain('padding:'); expect(blockRule).not.toContain('border:'); + expect(blockRule).toContain('--sd-sdt-chrome-left: 0px;'); + expect(blockRule).toContain('--sd-sdt-chrome-width: 100%;'); + expect(backgroundRule).toContain('width: var(--sd-sdt-chrome-width, 100%);'); + expect(hoverRule).toContain('background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2);'); expect(chromeRule).toContain('position: absolute;'); + expect(chromeRule).toContain('width: var(--sd-sdt-chrome-width, 100%);'); expect(chromeRule).toContain('border: 1px solid transparent;'); expect(chromeRule).toContain('pointer-events: none;'); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index d25c3f22bb..ace4755b5f 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -500,19 +500,36 @@ const SDT_CONTAINER_STYLES = ` box-sizing: border-box; border-radius: 4px; position: relative; + --sd-sdt-chrome-left: 0px; + --sd-sdt-chrome-width: 100%; +} + +.superdoc-structured-content-block::before { + content: ''; + position: absolute; + left: var(--sd-sdt-chrome-left, 0px); + top: 0; + bottom: 0; + width: var(--sd-sdt-chrome-width, 100%); + border-radius: inherit; + box-sizing: border-box; + pointer-events: none; } .superdoc-structured-content-block::after { content: ''; position: absolute; - inset: 0; + left: var(--sd-sdt-chrome-left, 0px); + top: 0; + bottom: 0; + width: var(--sd-sdt-chrome-width, 100%); border: 1px solid transparent; border-radius: inherit; box-sizing: border-box; pointer-events: none; } -.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover { +.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover::before { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); } @@ -521,7 +538,7 @@ const SDT_CONTAINER_STYLES = ` } /* Group hover (JavaScript-coordinated via PresentationEditor) */ -.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode) { +.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode)::before { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); } @@ -543,9 +560,9 @@ const SDT_CONTAINER_STYLES = ` align-items: center; justify-content: center; position: absolute; - left: 2px; + left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); top: -19px; - width: calc(100% - 4px); + width: calc(var(--sd-sdt-chrome-width, 100%) - 4px); max-width: 130px; min-width: 0; height: 18px; @@ -725,6 +742,14 @@ const SDT_CONTAINER_STYLES = ` z-index: 9999999; } +.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode) { + background-color: transparent; +} + +.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode)::before { + background-color: var(--sd-content-controls-lock-hover-bg, rgba(98, 155, 231, 0.08)); +} + /* Viewing mode: remove structured content affordances */ .presentation-editor--viewing .superdoc-structured-content-block, .presentation-editor--viewing .superdoc-structured-content-inline { From 34903ac142923e818132558d85a986caf042a8cb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:17:08 -0300 Subject: [PATCH 007/122] fix(layout-engine): allow top-aligned inline images --- packages/layout-engine/contracts/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 39d229d2b1..ef180b49a5 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -458,10 +458,10 @@ export type ImageRun = { /** * Vertical alignment of image relative to text baseline. - * Currently only 'bottom' is supported (image sits on baseline). - * Future: 'top', 'middle', 'baseline', 'text-top', 'text-bottom'. + * 'top' keeps the image box inside the measured line height; 'bottom' + * preserves legacy baseline alignment for existing callers. */ - verticalAlign?: 'bottom'; + verticalAlign?: 'top' | 'bottom'; /** Absolute ProseMirror position (inclusive) of this image run. */ pmStart?: number; From 5c93bf2e285fdcc34e0f8dedc3184ca2cf9477da Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:17:53 -0300 Subject: [PATCH 008/122] fix(dom): suppress SDT pseudo hover in viewing mode --- packages/layout-engine/painters/dom/src/styles.test.ts | 5 +++++ packages/layout-engine/painters/dom/src/styles.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 44878ede17..9a7b019a44 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -85,6 +85,10 @@ describe('ensureSdtContainerStyles', () => { const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); const cssText = styleEl?.textContent ?? ''; + const beforeRule = + cssText.match( + /\.presentation-editor--viewing \.superdoc-structured-content-block(?:\:hover|\.sdt-group-hover|\[data-lock-mode\]\.sdt-group-hover)::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; expect(cssText).toContain('.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover'); expect(cssText).toContain( @@ -94,6 +98,7 @@ describe('ensureSdtContainerStyles', () => { '.presentation-editor--viewing .superdoc-structured-content-inline[data-lock-mode]:hover', ); expect(cssText).toContain('background: none;'); + expect(beforeRule).toContain('background: none;'); }); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index ace4755b5f..85d9fcf512 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -770,6 +770,12 @@ const SDT_CONTAINER_STYLES = ` border: none; } +.presentation-editor--viewing .superdoc-structured-content-block:hover::before, +.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover::before, +.presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::before { + background: none; +} + .presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover, .presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover { background: none; From 55d5bfd8be3b9df6c64ed7ddc05c96da58438d08 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:19:34 -0300 Subject: [PATCH 009/122] fix(dom): offset block SDT chrome for indents --- .../painters/dom/src/index.test.ts | 64 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 54 ++++++++++++++-- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 8057bc63c4..2cdbce53af 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13093,6 +13093,70 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); }); + it('offsets block SDT chrome for indented paragraph content', () => { + const indentedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-indented', + runs: [{ text: 'Indented', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + indent: { left: 40 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-indented', + alias: 'Indented Control', + }, + }, + }; + + const indentedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const indentedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-indented', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [indentedSdtBlock], measures: [indentedSdtMeasure] }); + painter.paint(indentedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('40px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + it('limits block SDT chrome to inline image content width', () => { const imageOnlySdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 98056ff4a5..8dfd6ab13b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -54,6 +54,7 @@ import type { ResolvedImageItem, ResolvedDrawingItem, ResolvedListMarkerItem, + ResolvedParagraphContent, LayoutSourceIdentity, LayoutStoryLocator, } from '@superdoc/contracts'; @@ -3231,7 +3232,7 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); - this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width); + this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width, content); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5519,6 +5520,7 @@ export class DomPainter { block: ParagraphBlock, lines: Line[], fragmentWidth: number, + content?: ResolvedParagraphContent, ): void { const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; if (sdt?.type !== 'structuredContent' || sdt.scope !== 'block') return; @@ -5527,7 +5529,7 @@ export class DomPainter { let contentLeft = Number.POSITIVE_INFINITY; let contentRight = Number.NEGATIVE_INFINITY; - for (const line of lines) { + for (const [index, line] of lines.entries()) { const runsForLine = sliceRunsForLine(expandedBlock, line); if (runsForLine.length === 0) continue; @@ -5546,9 +5548,11 @@ export class DomPainter { const lineWidth = Math.max(0, line.naturalWidth ?? line.width ?? 0); if (lineWidth <= 0) continue; - const alignmentSlack = Math.max(0, fragmentWidth - lineWidth); + const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, content?.lines[index], index); + const alignmentSlack = Math.max(0, fragmentWidth - lineOffset - lineWidth); const alignment = block.attrs?.alignment; - const lineLeft = alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0; + const lineLeft = + lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); contentLeft = Math.min(contentLeft, lineLeft); contentRight = Math.max(contentRight, lineLeft + lineWidth); } @@ -5563,6 +5567,48 @@ export class DomPainter { element.style.setProperty('--sd-sdt-chrome-width', `${chromeWidth}px`); } + private resolveBlockSdtChromeLineOffset( + block: ParagraphBlock, + line: Line, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + lineIndex: number, + ): number { + if (resolvedLine) { + if (resolvedLine.isListFirstLine) { + return resolvedLine.resolvedListTextStartPx ?? resolvedLine.indentOffset; + } + if (resolvedLine.hasExplicitSegmentPositioning) { + return resolvedLine.indentOffset; + } + return Math.max(0, resolvedLine.paddingLeftPx + resolvedLine.textIndentPx); + } + + const paraIndent = block.attrs?.indent; + const indentLeft = paraIndent?.left ?? 0; + const firstLine = paraIndent?.firstLine ?? 0; + const hanging = paraIndent?.hanging ?? 0; + const suppressFirstLineIndent = (block.attrs as Record)?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : firstLine - hanging; + const isFirstLine = lineIndex === 0; + const hasExplicitSegmentPositioning = line.segments?.some((segment) => segment.x !== undefined) === true; + + if (hasExplicitSegmentPositioning) { + const effectiveLeftIndent = indentLeft < 0 ? 0 : indentLeft; + return Math.max(0, effectiveLeftIndent + (isFirstLine ? firstLineOffset : 0)); + } + + if (isFirstLine) { + return Math.max(0, indentLeft + firstLineOffset); + } + if (indentLeft > 0) { + return indentLeft; + } + if (hanging > 0 && indentLeft >= 0) { + return hanging; + } + return 0; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; From 04f6e77fe23b40878102209bb6cd19f0c446b3dd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:23:32 -0300 Subject: [PATCH 010/122] fix(dom): preserve SDT chrome continuation offsets --- .../painters/dom/src/index.test.ts | 104 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 7 +- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 2cdbce53af..3a8214e35f 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13157,6 +13157,110 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); }); + it('uses paragraph-global line index for block SDT chrome on continuation fragments', () => { + const continuedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-continued', + runs: [{ text: 'First line second line', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 22 }], + attrs: { + indent: { left: 40, firstLine: 30 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-continued', + alias: 'Continued Control', + }, + }, + }; + + const continuedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 10, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 11, + toRun: 0, + toChar: 22, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + + const fragment: Fragment = { + kind: 'para', + blockId: 'block-sdt-continued', + fromLine: 1, + toLine: 2, + x: 20, + y: 30, + width: 320, + pmStart: 11, + pmEnd: 22, + continuesFromPrev: true, + }; + + const continuedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [{ number: 1, fragments: [fragment] }], + }; + + const painter = createTestPainter({ blocks: [continuedSdtBlock], measures: [continuedSdtMeasure] }); + painter.setResolvedLayout({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + { + id: 'page-0', + index: 0, + number: 1, + width: 400, + height: 500, + items: [ + { + kind: 'fragment', + id: 'block-sdt-continued:1:2', + pageIndex: 0, + x: fragment.x, + y: fragment.y, + width: fragment.width, + height: 20, + fragmentKind: 'para', + fragment, + blockId: 'block-sdt-continued', + fragmentIndex: 0, + pmStart: fragment.pmStart, + pmEnd: fragment.pmEnd, + continuesFromPrev: true, + block: continuedSdtBlock, + measure: continuedSdtMeasure, + }, + ], + }, + ], + }); + painter.paint(continuedSdtLayout, mount); + + const paintedFragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(paintedFragment.style.width).toBe('320px'); + expect(paintedFragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('40px'); + expect(paintedFragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + it('limits block SDT chrome to inline image content width', () => { const imageOnlySdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 8dfd6ab13b..ee47f90379 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3232,7 +3232,7 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); - this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width, content); + this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width, fragment.fromLine, content); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5520,6 +5520,7 @@ export class DomPainter { block: ParagraphBlock, lines: Line[], fragmentWidth: number, + fragmentFromLine: number, content?: ResolvedParagraphContent, ): void { const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; @@ -5548,7 +5549,9 @@ export class DomPainter { const lineWidth = Math.max(0, line.naturalWidth ?? line.width ?? 0); if (lineWidth <= 0) continue; - const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, content?.lines[index], index); + const resolvedLine = content?.lines[index]; + const lineIndex = resolvedLine?.lineIndex ?? fragmentFromLine + index; + const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, resolvedLine, lineIndex); const alignmentSlack = Math.max(0, fragmentWidth - lineOffset - lineWidth); const alignment = block.attrs?.alignment; const lineLeft = From 3165abaf95ee4960dd81b1fff1f8154295be8e02 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:24:27 -0300 Subject: [PATCH 011/122] fix(dom): align SDT chrome within paragraph width --- .../painters/dom/src/index.test.ts | 68 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 28 +++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 3a8214e35f..b617ac9928 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13397,6 +13397,74 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); }); + it('positions centered block SDT chrome within paragraph indents', () => { + const centeredIndentedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-centered-indented', + runs: [{ text: 'Centered', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + alignment: 'center', + indent: { left: 40, right: 60 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-centered-indented', + alias: 'Centered Indented Control', + }, + }, + }; + + const centeredIndentedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const centeredIndentedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-centered-indented', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ + blocks: [centeredIndentedSdtBlock], + measures: [centeredIndentedSdtMeasure], + }); + painter.paint(centeredIndentedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('100px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); + }); + it('updates block SDT boundaries when appending a new fragment during patch rendering', () => { const sdtMetadata = { type: 'structuredContent' as const, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ee47f90379..2dbc3c85d9 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5552,7 +5552,14 @@ export class DomPainter { const resolvedLine = content?.lines[index]; const lineIndex = resolvedLine?.lineIndex ?? fragmentFromLine + index; const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, resolvedLine, lineIndex); - const alignmentSlack = Math.max(0, fragmentWidth - lineOffset - lineWidth); + const availableWidth = this.resolveBlockSdtChromeAvailableWidth( + block, + line, + fragmentWidth, + lineOffset, + resolvedLine, + ); + const alignmentSlack = Math.max(0, availableWidth - lineWidth); const alignment = block.attrs?.alignment; const lineLeft = lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); @@ -5612,6 +5619,25 @@ export class DomPainter { return 0; } + private resolveBlockSdtChromeAvailableWidth( + block: ParagraphBlock, + line: Line, + fragmentWidth: number, + lineOffset: number, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + ): number { + if (resolvedLine) { + return Math.max(0, resolvedLine.availableWidth); + } + + const rightIndent = Math.max(0, block.attrs?.indent?.right ?? 0); + const fallbackAvailableWidth = Math.max(0, fragmentWidth - lineOffset - rightIndent); + if (line.maxWidth != null) { + return Math.min(line.maxWidth, fallbackAvailableWidth); + } + return fallbackAvailableWidth; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; From 6e487957e1851aaa6b1d30fbdee1b75b17e271ce Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:25:14 -0300 Subject: [PATCH 012/122] fix(layout-bridge): dirty inline image SDT changes --- .../layout-engine/layout-bridge/src/diff.ts | 1 + .../layout-bridge/test/diff.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 9e6b690eef..6e61b0b80c 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -477,6 +477,7 @@ const imageRunsEqual = (a: ImageRun, b: ImageRun): boolean => { a.grayscale === b.grayscale && jsonEqual(a.lum, b.lum) && jsonEqual(a.hyperlink, b.hyperlink) && + jsonEqual(a.sdt, b.sdt) && shallowRecordEqual(a.dataAttrs, b.dataAttrs) ); }; diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index ef278ced5a..1cb1475c14 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -218,6 +218,28 @@ describe('computeDirtyRegions', () => { expect(result.stableBlockIds.has('0-paragraph')).toBe(true); }); + it('detects inline image SDT metadata changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [ + paragraphWithRuns('0-paragraph', [ + { + ...imageRun('img.png', 100, 50), + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'image-sdt', + lockMode: 'contentLocked', + }, + }, + ]), + ]; + + const result = computeDirtyRegions(prev, next); + + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + it('detects inline image resize in mixed text and image paragraphs', () => { const prev = [ paragraphWithRuns('0-paragraph', [ From 401851230424f4079e0079efc9bf4d3e33b16e13 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:30:21 -0300 Subject: [PATCH 013/122] fix(super-editor): honor ancestor image SDT locks --- .../v1/components/ImageResizeOverlay.test.js | 17 ++++++++++++++ .../v1/components/ImageResizeOverlay.vue | 22 ++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js index 2353a84ebe..fb6faf50a6 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js @@ -113,6 +113,23 @@ describe('ImageResizeOverlay', () => { wrapper.unmount(); remove(); }); + + it('should report resize disabled when an outer SDT is content-locked even if the image has an unlocked lock mode', () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ + lockMode: 'unlocked', + ancestorLockMode: 'contentLocked', + }); + + const wrapper = mount(ImageResizeOverlay, { + props: { editor, visible: true, imageElement: imageEl }, + }); + + expect(wrapper.vm.isResizeDisabled).toBe(true); + + wrapper.unmount(); + remove(); + }); }); it.each(['contentLocked', 'sdtContentLocked'])( diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue index 2d0abce3b2..b333be6f13 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue @@ -123,17 +123,27 @@ function readLockMode(element) { function resolveImageLockMode(imageElement) { if (!imageElement) return null; + const lockModes = []; const directLockMode = readLockMode(imageElement); - if (directLockMode) return directLockMode; + if (directLockMode) lockModes.push(directLockMode); - const innerLockedElement = imageElement.querySelector?.('[data-lock-mode]'); - const innerLockMode = readLockMode(innerLockedElement); - if (innerLockMode) return innerLockMode; + imageElement.querySelectorAll?.('[data-lock-mode]').forEach((element) => { + const lockMode = readLockMode(element); + if (lockMode) lockModes.push(lockMode); + }); - const sdtElement = imageElement.closest?.( + let sdtElement = imageElement.closest?.( '.superdoc-structured-content-block[data-lock-mode], .superdoc-structured-content-inline[data-lock-mode]', ); - return readLockMode(sdtElement); + while (sdtElement) { + const lockMode = readLockMode(sdtElement); + if (lockMode) lockModes.push(lockMode); + sdtElement = sdtElement.parentElement?.closest?.( + '.superdoc-structured-content-block[data-lock-mode], .superdoc-structured-content-inline[data-lock-mode]', + ); + } + + return lockModes.find((lockMode) => CONTENT_LOCKED_MODES.has(lockMode)) ?? lockModes[0] ?? null; } /** From 2f3dfc7a4f7ccb29ccee26308b8cf56ceeea7b01 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:31:59 -0300 Subject: [PATCH 014/122] fix(dom): size SDT chrome for justified lines --- .../painters/dom/src/index.test.ts | 75 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 57 +++++++++++++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b617ac9928..bce4f9e1d0 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13093,6 +13093,81 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); }); + it('expands block SDT chrome to justified line width', () => { + const justifiedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-justified', + runs: [{ text: 'Alpha beta gamma', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 16 }], + attrs: { + alignment: 'justify', + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-justified', + alias: 'Justified Control', + }, + }, + }; + + const justifiedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 10, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 11, + toRun: 0, + toChar: 16, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + + const justifiedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-justified', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 10, + continuesOnNext: true, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [justifiedSdtBlock], measures: [justifiedSdtMeasure] }); + painter.paint(justifiedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe(''); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe(''); + }); + it('offsets block SDT chrome for indented paragraph content', () => { const indentedSdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 2dbc3c85d9..a993936cc4 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3232,7 +3232,15 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); - this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width, fragment.fromLine, content); + this.applyBlockSdtChromeBounds( + fragmentEl, + block, + lines, + fragment.width, + fragment.fromLine, + paraContinuesOnNext, + content, + ); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5521,6 +5529,7 @@ export class DomPainter { lines: Line[], fragmentWidth: number, fragmentFromLine: number, + fragmentContinuesOnNext: boolean | undefined, content?: ResolvedParagraphContent, ): void { const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; @@ -5559,12 +5568,23 @@ export class DomPainter { lineOffset, resolvedLine, ); - const alignmentSlack = Math.max(0, availableWidth - lineWidth); + const paintedLineWidth = this.resolveBlockSdtChromePaintedLineWidth( + block, + line, + lineWidth, + availableWidth, + index, + lines.length, + fragmentContinuesOnNext, + resolvedLine, + content, + ); + const alignmentSlack = Math.max(0, availableWidth - paintedLineWidth); const alignment = block.attrs?.alignment; const lineLeft = lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); contentLeft = Math.min(contentLeft, lineLeft); - contentRight = Math.max(contentRight, lineLeft + lineWidth); + contentRight = Math.max(contentRight, lineLeft + paintedLineWidth); } if (!Number.isFinite(contentLeft) || !Number.isFinite(contentRight)) return; @@ -5638,6 +5658,37 @@ export class DomPainter { return fallbackAvailableWidth; } + private resolveBlockSdtChromePaintedLineWidth( + block: ParagraphBlock, + line: Line, + lineWidth: number, + availableWidth: number, + fragmentLineIndex: number, + fragmentLineCount: number, + fragmentContinuesOnNext: boolean | undefined, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + content: ResolvedParagraphContent | undefined, + ): number { + const explicitPositionedSegmentCount = line.segments?.filter((segment) => segment.x !== undefined).length ?? 0; + const hasMultipleExplicitPositionedSegments = explicitPositionedSegmentCount > 1; + const paragraphEndsWithLineBreak = + content?.paragraphEndsWithLineBreak === true || block.runs[block.runs.length - 1]?.kind === 'lineBreak'; + const isLastLineOfParagraph = + resolvedLine != null + ? resolvedLine.skipJustify + : fragmentLineIndex === fragmentLineCount - 1 && !fragmentContinuesOnNext; + const justifyShouldApply = shouldApplyJustify({ + alignment: block.attrs?.alignment, + hasExplicitPositioning: line.segments?.some((segment) => segment.x !== undefined) === true, + hasExplicitTabStops: line.hasExplicitTabStops === true, + isLastLineOfParagraph, + paragraphEndsWithLineBreak, + skipJustifyOverride: (resolvedLine?.skipJustify ?? false) || hasMultipleExplicitPositionedSegments, + }); + + return justifyShouldApply ? Math.max(lineWidth, availableWidth) : lineWidth; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; From de1668161e4d05669fbdf2b46ca21d4895982181 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:37:40 -0300 Subject: [PATCH 015/122] fix(dom): align RTL SDT chrome to text --- .../src/features/inline-direction/index.ts | 2 +- .../painters/dom/src/index.test.ts | 64 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 3 +- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts b/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts index 2492bf20f2..f08cbb3559 100644 --- a/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts +++ b/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts @@ -19,7 +19,7 @@ * @spec ECMA-376 §17.3.1.1 (bidi), §17.3.2.30 (rtl) */ -export { applyRtlStyles, shouldUseSegmentPositioning } from './rtl-styles.js'; +export { applyRtlStyles, resolveTextAlign, shouldUseSegmentPositioning } from './rtl-styles.js'; export { resolveRunDirectionAttribute, normalizeRtlDateTokenForWordParity, diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index bce4f9e1d0..8988662ac7 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13472,6 +13472,70 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); }); + it('positions block SDT chrome around default RTL paragraph content', () => { + const rtlSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-rtl', + runs: [{ text: 'مرحبا', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 }], + attrs: { + directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-rtl', + alias: 'RTL Control', + }, + }, + }; + + const rtlSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, + width: 80, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const rtlSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-rtl', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [rtlSdtBlock], measures: [rtlSdtMeasure] }); + painter.paint(rtlSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('240px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('80px'); + }); + it('positions centered block SDT chrome within paragraph indents', () => { const centeredIndentedSdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index a993936cc4..611941ba48 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -134,6 +134,7 @@ import { } from './features/paragraph-borders/index.js'; import { applyRtlStyles, + resolveTextAlign, shouldUseSegmentPositioning, resolveRunDirectionAttribute, normalizeRtlDateTokenForWordParity, @@ -5580,7 +5581,7 @@ export class DomPainter { content, ); const alignmentSlack = Math.max(0, availableWidth - paintedLineWidth); - const alignment = block.attrs?.alignment; + const alignment = resolveTextAlign(block.attrs?.alignment, getParagraphInlineDirection(block.attrs) === 'rtl'); const lineLeft = lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); contentLeft = Math.min(contentLeft, lineLeft); From a263c87b2d36cd6e4965a15c3521ecefe8a8db28 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:38:48 -0300 Subject: [PATCH 016/122] fix(layout-resolved): version inline image metadata --- .../src/versionSignature.test.ts | 59 ++++++++++++++++++- .../layout-resolved/src/versionSignature.ts | 11 ++++ .../painters/dom/src/renderer.ts | 31 ++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 4385b9453d..7a8eb51dd8 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ImageRun, SourceAnchor, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -66,3 +66,60 @@ describe('deriveBlockVersion - bidi', () => { expect(a).toBe(b); }); }); + +describe('deriveBlockVersion - inline image runs', () => { + const makeParagraphWithImage = (overrides: Partial = {}): FlowBlock => ({ + kind: 'paragraph', + id: 'p-image', + runs: [ + { + kind: 'image', + src: 'img.png', + width: 100, + height: 50, + ...overrides, + }, + ], + }); + + it('produces a different version when inline image SDT metadata changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const locked = deriveBlockVersion( + makeParagraphWithImage({ + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'image-sdt', + lockMode: 'contentLocked', + }, + }), + ); + + expect(locked).not.toBe(plain); + }); + + it('produces a different version when inline image data attributes change', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const withDataAttrs = deriveBlockVersion(makeParagraphWithImage({ dataAttrs: { 'data-example': '1' } })); + + expect(withDataAttrs).not.toBe(plain); + }); + + it('produces a different version when inline image paint metadata changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const withPaintMetadata = deriveBlockVersion( + makeParagraphWithImage({ + verticalAlign: 'top', + rotation: 90, + flipH: true, + gain: '50000', + blacklevel: '20000', + grayscale: true, + lum: { bright: 10000, contrast: -10000 }, + hyperlink: { url: 'https://example.com', tooltip: 'Example' }, + }), + ); + + expect(withPaintMetadata).not.toBe(plain); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 7d1f223147..724931317b 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -230,6 +230,17 @@ export const deriveBlockVersion = (block: FlowBlock): string => { imgRun.distLeft ?? '', imgRun.distRight ?? '', readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + imgRun.verticalAlign ?? '', + imgRun.rotation ?? '', + imgRun.flipH ? 1 : 0, + imgRun.flipV ? 1 : 0, + imgRun.gain ?? '', + imgRun.blacklevel ?? '', + imgRun.grayscale ? 1 : 0, + stableSerializeEvidenceValue(imgRun.lum), + stableSerializeEvidenceValue(imgRun.hyperlink), + stableSerializeEvidenceValue(imgRun.sdt), + stableSerializeEvidenceValue(imgRun.dataAttrs), ].join(','); } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 611941ba48..35463af5eb 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -7945,6 +7945,26 @@ const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); }; +const stableSerializeEvidenceValue = (value: unknown): string => { + if (value === undefined) return ''; + if (value === null) return 'null'; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerializeEvidenceValue(item)).join(',')}]`; + } + if (typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableSerializeEvidenceValue(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(String(value)); +}; + /** * Type guard to validate list marker attributes structure. * @@ -8047,6 +8067,17 @@ const deriveBlockVersion = (block: FlowBlock): string => { imgRun.distLeft ?? '', imgRun.distRight ?? '', readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + imgRun.verticalAlign ?? '', + imgRun.rotation ?? '', + imgRun.flipH ? 1 : 0, + imgRun.flipV ? 1 : 0, + imgRun.gain ?? '', + imgRun.blacklevel ?? '', + imgRun.grayscale ? 1 : 0, + stableSerializeEvidenceValue(imgRun.lum), + stableSerializeEvidenceValue(imgRun.hyperlink), + stableSerializeEvidenceValue(imgRun.sdt), + stableSerializeEvidenceValue(imgRun.dataAttrs), // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection ].join(','); } From 0b4e6b177a6930b004f0daa1baf61661598d7525 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:45:38 -0300 Subject: [PATCH 017/122] test(layout-bridge): cover inline image diff fields --- .../layout-bridge/test/diff.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index 1cb1475c14..b06b3dde46 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -218,6 +218,38 @@ describe('computeDirtyRegions', () => { expect(result.stableBlockIds.has('0-paragraph')).toBe(true); }); + it.each([ + ['src', { src: 'other.png' }], + ['alt', { alt: 'Diagram' }], + ['title', { title: 'Title' }], + ['clipPath', { clipPath: 'inset(1px)' }], + ['distTop', { distTop: 1 }], + ['distBottom', { distBottom: 2 }], + ['distLeft', { distLeft: 3 }], + ['distRight', { distRight: 4 }], + ['verticalAlign', { verticalAlign: 'top' as const }], + ['rotation', { rotation: 90 }], + ['flipH', { flipH: true }], + ['flipV', { flipV: true }], + ['gain', { gain: '50000' }], + ['blacklevel', { blacklevel: '20000' }], + ['grayscale', { grayscale: true }], + ['lum', { lum: { bright: 10000, contrast: -10000 } }], + ['hyperlink', { hyperlink: { url: 'https://example.com', tooltip: 'Example' } }], + ['dataAttrs', { dataAttrs: { 'data-example': '1' } }], + ] satisfies Array<[string, Partial]>)( + 'detects inline image %s changes inside paragraphs', + (_field, overrides) => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [{ ...imageRun('img.png', 100, 50), ...overrides }])]; + + const result = computeDirtyRegions(prev, next); + + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }, + ); + it('detects inline image SDT metadata changes inside paragraphs', () => { const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; const next = [ From 76d060fe641838144342a19f7f6583ce9f38edb9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:46:20 -0300 Subject: [PATCH 018/122] fix(super-editor): share SDT lock predicates --- .../src/editors/v1/components/ImageResizeOverlay.vue | 6 +++--- .../structured-content/StructuredContentViewBase.js | 7 +++---- .../editors/v1/extensions/structured-content/lockModes.js | 7 +++++++ 3 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/structured-content/lockModes.js diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue index b333be6f13..c77d97d44f 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue @@ -22,6 +22,7 @@