diff --git a/packages/super-editor/src/core/Editor.d.ts b/packages/super-editor/src/core/Editor.d.ts index d79c37f48b..1ab290d901 100644 --- a/packages/super-editor/src/core/Editor.d.ts +++ b/packages/super-editor/src/core/Editor.d.ts @@ -58,5 +58,13 @@ export declare class Editor { */ can(): CanObject; + /** + * Get the maximum content size based on page dimensions and margins. + * When the cursor is inside a table cell, the max width is constrained to that + * cell's width so that newly inserted images are never wider than their containing cell. + * Returns empty object in web layout mode or when no page size is available. + */ + getMaxContentSize(): { width?: number; height?: number }; + [key: string]: any; } diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 7ab46d37db..e1b5e1cace 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -94,6 +94,19 @@ const PIXELS_PER_INCH = 96; const MAX_HEIGHT_BUFFER_PX = 50; const MAX_WIDTH_BUFFER_PX = 20; +/** + * Given a table cell node, returns the total cell content width in pixels. + * Sums all colwidth values and subtracts left/right cell margins (padding). + */ +function getCellContentWidthPx(cellNode: PmNode): number { + const colwidth: number[] = cellNode.attrs?.colwidth ?? []; + const totalWidth = colwidth.reduce((sum: number, w: number) => sum + (w || 0), 0); + const margins = cellNode.attrs?.cellMargins; + const leftMargin = margins?.left ?? 0; + const rightMargin = margins?.right ?? 0; + return Math.max(totalWidth - leftMargin - rightMargin, 0); +} + /** * Image storage structure used by the image extension */ @@ -2246,8 +2259,13 @@ export class Editor extends EventEmitter { } /** - * Get the maximum content size based on page dimensions and margins - * @returns Size object with width and height in pixels, or empty object if no page size + * Get the maximum content size based on page dimensions and margins. + * + * When the cursor is inside a table cell, the max width is constrained to that + * cell's width (derived from `colwidth` minus cell margins) so that newly inserted + * images are never wider than their containing cell. + * + * @returns Size object with width and height in pixels, or empty object if no page size. * @note In web layout mode, returns empty object to skip content constraints. * CSS max-width: 100% handles responsive display while preserving full resolution. */ @@ -2278,6 +2296,21 @@ export class Editor extends EventEmitter { // All sizes are in inches so we multiply by PIXELS_PER_INCH to get pixels const maxHeight = height * PIXELS_PER_INCH - topPx - bottomPx - MAX_HEIGHT_BUFFER_PX; const maxWidth = width * PIXELS_PER_INCH - leftPx - rightPx - MAX_WIDTH_BUFFER_PX; + + // When the cursor is inside a table cell, constrain width to the cell's content + // width so images inserted into a cell are never wider than that cell. + const { $head } = this.state.selection; + for (let d = $head.depth; d > 0; d--) { + const node = $head.node(d); + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + const cellWidth = getCellContentWidthPx(node); + if (cellWidth > 0) { + return { width: cellWidth, height: maxHeight }; + } + break; + } + } + return { width: maxWidth, height: maxHeight, diff --git a/packages/super-editor/src/core/Editor.webLayout.test.ts b/packages/super-editor/src/core/Editor.webLayout.test.ts index e9456d1175..8072c1b044 100644 --- a/packages/super-editor/src/core/Editor.webLayout.test.ts +++ b/packages/super-editor/src/core/Editor.webLayout.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; import { Editor } from './Editor.js'; import { loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { getStarterExtensions } from '@extensions/index.js'; @@ -177,4 +177,151 @@ describe('Editor Web Layout Mode', () => { }); }); }); + describe('table cell context', () => { + /** + * Builds a minimal fake editor whose state.selection.$head walks up through + * ancestor nodes at the given depths. Each entry in `ancestors` becomes the + * node returned by $head.node(d) for d = ancestors.length down to 1. + * + * pageSize is in inches (matching the real converter shape). + */ + function makeEditor({ + ancestors, + pageSize = { width: 8.5, height: 11 }, + pageMargins = { top: 1, bottom: 1, left: 1, right: 1 }, + }: { + ancestors: Array<{ type: { name: string }; attrs: Record }>; + pageSize?: { width: number; height: number }; + pageMargins?: { top: number; bottom: number; left: number; right: number }; + }) { + const $head = { + depth: ancestors.length, + node: (d: number) => ancestors[d - 1], + }; + + return { + converter: { pageStyles: { pageSize, pageMargins } }, + options: { viewOptions: { layout: 'print' } }, + state: { selection: { $head } }, + isWebLayout() { + return (this as any).options.viewOptions?.layout === 'web'; + }, + }; + } + + it('constrains width to cell colwidth when cursor is inside a tableCell', () => { + const editor = makeEditor({ + ancestors: [ + { type: { name: 'tableRow' }, attrs: {} }, + { type: { name: 'tableCell' }, attrs: { colwidth: [200], cellMargins: null } }, + { type: { name: 'paragraph' }, attrs: {} }, + ], + }); + + const size = Editor.prototype.getMaxContentSize.call(editor); + + expect(size.width).toBe(200); + // Height is still derived from the page dimensions + expect(size.height).toBeGreaterThan(0); + }); + + it('subtracts left and right cellMargins from the cell width', () => { + const editor = makeEditor({ + ancestors: [ + { type: { name: 'tableRow' }, attrs: {} }, + { + type: { name: 'tableCell' }, + attrs: { colwidth: [300], cellMargins: { left: 20, right: 15 } }, + }, + { type: { name: 'paragraph' }, attrs: {} }, + ], + }); + + const size = Editor.prototype.getMaxContentSize.call(editor); + + expect(size.width).toBe(265); // 300 - 20 - 15 + }); + + it('sums multiple colwidth values for spanned cells', () => { + const editor = makeEditor({ + ancestors: [ + { type: { name: 'tableRow' }, attrs: {} }, + { + type: { name: 'tableCell' }, + attrs: { colwidth: [150, 150], cellMargins: null }, + }, + { type: { name: 'paragraph' }, attrs: {} }, + ], + }); + + const size = Editor.prototype.getMaxContentSize.call(editor); + + expect(size.width).toBe(300); + }); + + it('constrains width when cursor is inside a tableHeader', () => { + const editor = makeEditor({ + ancestors: [ + { type: { name: 'tableRow' }, attrs: {} }, + { type: { name: 'tableHeader' }, attrs: { colwidth: [180], cellMargins: null } }, + { type: { name: 'paragraph' }, attrs: {} }, + ], + }); + + const size = Editor.prototype.getMaxContentSize.call(editor); + + expect(size.width).toBe(180); + }); + + it('falls back to page content width when not inside a table cell', () => { + // Standard Letter page (8.5 × 11 in) with 1 in margins on each side + const PIXELS_PER_INCH = 96; + const MAX_WIDTH_BUFFER_PX = 20; + const expectedWidth = (8.5 - 1 - 1) * PIXELS_PER_INCH - MAX_WIDTH_BUFFER_PX; // 6.5 in content + + const editor = makeEditor({ + ancestors: [{ type: { name: 'paragraph' }, attrs: {} }], + }); + + const size = Editor.prototype.getMaxContentSize.call(editor); + + expect(size.width).toBe(expectedWidth); + }); + + it('falls back to page content width when colwidth is empty', () => { + const PIXELS_PER_INCH = 96; + const MAX_WIDTH_BUFFER_PX = 20; + const expectedWidth = (8.5 - 1 - 1) * PIXELS_PER_INCH - MAX_WIDTH_BUFFER_PX; + + const editor = makeEditor({ + ancestors: [ + { type: { name: 'tableRow' }, attrs: {} }, + { type: { name: 'tableCell' }, attrs: { colwidth: [], cellMargins: null } }, + { type: { name: 'paragraph' }, attrs: {} }, + ], + }); + + const size = Editor.prototype.getMaxContentSize.call(editor); + + expect(size.width).toBe(expectedWidth); + }); + + it('falls back to page content width when colwidth is missing', () => { + const PIXELS_PER_INCH = 96; + const MAX_WIDTH_BUFFER_PX = 20; + const expectedWidth = (8.5 - 1 - 1) * PIXELS_PER_INCH - MAX_WIDTH_BUFFER_PX; + + const editor = makeEditor({ + ancestors: [ + { type: { name: 'tableRow' }, attrs: {} }, + { type: { name: 'tableCell' }, attrs: { colwidth: null, cellMargins: null } }, + { type: { name: 'paragraph' }, attrs: {} }, + ], + }); + + const size = Editor.prototype.getMaxContentSize.call(editor); + + expect(size.width).toBe(expectedWidth); + }); + }); }); diff --git a/tests/behavior/tests/tables/image-resize-in-cell.spec.ts b/tests/behavior/tests/tables/image-resize-in-cell.spec.ts new file mode 100644 index 0000000000..99810561d1 --- /dev/null +++ b/tests/behavior/tests/tables/image-resize-in-cell.spec.ts @@ -0,0 +1,214 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +/** + * Behavior test: images inserted into a table cell must be + * constrained to the cell's content width. + * + * Flow: + * 1. Insert a 1-column table with an explicit, narrow column width (200 px). + * 2. Click into the only cell so the cursor lands inside it. + * 3. Simulate a drag-drop of an image that is much wider than the cell. + * 4. Assert the image node's stored width is ≤ the cell's colwidth. + * + * The constraint is applied by Editor.getMaxContentSize(), which, when the + * selection is inside a tableCell / tableHeader, returns the cell's colwidth + * minus cell margins instead of the full page content width. + */ + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +// ─── helpers ──────────────────────────────────────────────────────────────── + +type PlacementSnapshot = { + imageCount: number; + imageWidth: number | null; +}; + +async function getImageSnapshot(superdoc: SuperDocFixture): Promise { + return superdoc.page.evaluate(() => { + const doc = (window as any).editor?.state?.doc; + if (!doc) throw new Error('Editor document is unavailable.'); + + let imageCount = 0; + let imageWidth: number | null = null; + + doc.descendants((node: any) => { + if (node.type?.name === 'image') { + imageCount += 1; + if (imageWidth === null) { + imageWidth = node.attrs?.size?.width ?? null; + } + } + }); + + return { imageCount, imageWidth }; + }); +} + +/** + * Move the editor cursor into the first table cell by using the ProseMirror + * command API, so the selection is deterministic regardless of layout. + */ +async function placeCursorInFirstTableCell(superdoc: SuperDocFixture): Promise { + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + const doc = editor?.state?.doc; + if (!doc) throw new Error('Editor document is unavailable.'); + + let cellPos: number | null = null; + + doc.descendants((node: any, pos: number) => { + if (cellPos !== null) return false; + if (node.type?.name === 'tableCell' || node.type?.name === 'tableHeader') { + // pos points at the cell node; pos+1 is the start of its content + cellPos = pos + 1; + return false; + } + }); + + if (cellPos === null) throw new Error('No table cell found in document.'); + + editor.commands.setTextSelection({ from: cellPos, to: cellPos }); + }); + await superdoc.waitForStable(); +} + +/** + * Dispatch a synthetic drag-drop carrying a canvas-generated PNG of the given + * pixel dimensions at the viewport's centre. Returns the number of files the + * drop handler saw, mirroring the pattern in drag-drop-image-insertion.spec.ts. + */ +async function dropOversizedImageAtViewportCentre( + superdoc: SuperDocFixture, + imageWidthPx: number, + imageHeightPx: number, +): Promise<{ droppedFileCount: number }> { + return superdoc.page.evaluate( + async ({ w, h }) => { + // Find drop target — prefer the presentation viewport, fall back to #editor. + const host = document.querySelector('.presentation-editor__viewport') ?? document.querySelector('#editor'); + if (!host) throw new Error('Could not locate drop target element.'); + + // Build a File from a canvas so the image plugin can read real pixel data. + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not get 2D canvas context.'); + ctx.fillStyle = '#0055ff'; + ctx.fillRect(0, 0, w, h); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) throw new Error('Failed to generate PNG blob.'); + + const file = new File([blob], 'wide-test-image.png', { type: 'image/png' }); + + const dt = new DataTransfer(); + dt.items.add(file); + dt.effectAllowed = 'copy'; + + // Drop at the viewport centre. + const rect = host.getBoundingClientRect(); + const dropX = Math.round(rect.left + rect.width / 2); + const dropY = Math.round(rect.top + rect.height / 2); + + let droppedFileCount = 0; + + host.addEventListener( + 'drop', + (ev: Event) => { + droppedFileCount = (ev as DragEvent).dataTransfer?.files?.length ?? 0; + }, + { once: true }, + ); + + host.dispatchEvent( + new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer: dt, + clientX: dropX, + clientY: dropY, + }), + ); + + host.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: dt, + clientX: dropX, + clientY: dropY, + }), + ); + + return { droppedFileCount }; + }, + { w: imageWidthPx, h: imageHeightPx }, + ); +} + +// ─── test ──────────────────────────────────────────────────────────────────── + +test('image dropped into a narrow table cell is constrained to the cell width', async ({ superdoc, browserName }) => { + // Synthetic DataTransfer file drops are only fully supported in Chromium. + test.skip(browserName !== 'chromium', 'Synthetic file DataTransfer drag/drop is deterministic in Chromium only.'); + + // ── 1. Insert a single-column table with a well-known, narrow column width. + // 200 px is much narrower than a typical page content width (~580 px), + // so any image wider than 200 px exercises the constraint path. + const CELL_WIDTH_PX = 200; + + await superdoc.executeCommand('insertTable', { + rows: 1, + cols: 1, + withHeaderRow: false, + columnWidths: [CELL_WIDTH_PX], + }); + await superdoc.waitForStable(); + + await superdoc.assertTableExists(1, 1); + + // ── 2. Place the cursor inside the cell so getMaxContentSize() picks up the + // cell context. + await placeCursorInFirstTableCell(superdoc); + + // ── 3. Drop an image that is far wider than the cell. + // We use a 1 200 × 900 px image — well over 200 px — so any pass-through + // would leave the image clearly wider than the cell. + const IMAGE_WIDTH_PX = 1200; + const IMAGE_HEIGHT_PX = 900; + + const { droppedFileCount } = await dropOversizedImageAtViewportCentre(superdoc, IMAGE_WIDTH_PX, IMAGE_HEIGHT_PX); + + // If the drop handler did not receive any files the test environment does not + // support synthetic drops; skip rather than fail. + if (droppedFileCount === 0) { + test.skip(true, 'Synthetic drop did not deliver files in this environment.'); + return; + } + + // ── 4. Wait for the image node to appear in the document (the upload pipeline + // is async — it processes the file, resizes it, then commits the node). + await expect + .poll(async () => (await getImageSnapshot(superdoc)).imageCount, { timeout: 20_000 }) + .toBeGreaterThanOrEqual(1); + + await superdoc.waitForStable(); + + const { imageCount, imageWidth } = await getImageSnapshot(superdoc); + + // Skip gracefully when the environment drops the file but the plugin does not + // complete the insert (e.g. missing canvas support in headless mode). + if (imageCount === 0) { + test.skip(true, 'Image drop was received but no image node was inserted; skipping.'); + return; + } + + expect(imageCount).toBe(1); + + // The stored width must be present and must not exceed the cell's colwidth. + expect(imageWidth).not.toBeNull(); + expect(imageWidth as number).toBeGreaterThan(0); + expect(imageWidth as number).toBeLessThanOrEqual(CELL_WIDTH_PX); +});