diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index ef180b49a5..f86b624070 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -290,6 +290,10 @@ export type FlowRunLink = { history?: boolean; }; +export const EMPTY_SDT_PLACEHOLDER_TEXT = 'Click or tap here to enter text'; + +export type SdtVisualPlaceholder = 'emptyInlineSdt' | 'emptyBlockSdt'; + /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. @@ -343,7 +347,7 @@ export type TextRun = RunMarks & { dataAttrs?: Record; sdt?: SdtMetadata; /** Layout-only placeholder for visual affordances that do not represent document text. */ - visualPlaceholder?: 'emptyInlineSdt'; + visualPlaceholder?: SdtVisualPlaceholder; link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ token?: 'pageNumber' | 'totalPageCount' | 'pageReference'; @@ -2199,6 +2203,11 @@ export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from // Pure transformations on inline-run shapes (used by pm-adapter, layout-bridge, // and painter-dom). Located in contracts to avoid reverse stage dependencies. -export { expandRunsForInlineNewlines, isEmptyInlineSdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js'; +export { + expandRunsForInlineNewlines, + isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, + sliceRunsForLine, +} from './run-helpers.js'; export * as Engines from './engines/index.js'; diff --git a/packages/layout-engine/contracts/src/pm-range.ts b/packages/layout-engine/contracts/src/pm-range.ts index 35d77343d2..cbdb70af52 100644 --- a/packages/layout-engine/contracts/src/pm-range.ts +++ b/packages/layout-engine/contracts/src/pm-range.ts @@ -1,4 +1,5 @@ import type { FlowBlock, Line, ParagraphBlock, ParagraphMeasure } from './index.js'; +import { isEmptySdtPlaceholderRun } from './run-helpers.js'; /** * Represents a ProseMirror position range for a line or fragment. @@ -93,6 +94,15 @@ export function computeLinePmRange(block: FlowBlock, line: Line): LinePmRange { const runPmStart = coercePmStart(run); if (runPmStart == null) continue; + if (isEmptySdtPlaceholderRun(run)) { + const runPmEnd = coercePmEnd(run) ?? runPmStart; + if (pmStart == null) { + pmStart = runPmStart; + } + pmEnd = runPmEnd; + continue; + } + if (isAtomicRunKind((run as { kind?: unknown }).kind) || isImageLikeRun(run)) { const runPmEnd = coercePmEnd(run) ?? runPmStart + 1; if (pmStart == null) { diff --git a/packages/layout-engine/contracts/src/run-helpers.test.ts b/packages/layout-engine/contracts/src/run-helpers.test.ts index afefce64c6..3922924bc0 100644 --- a/packages/layout-engine/contracts/src/run-helpers.test.ts +++ b/packages/layout-engine/contracts/src/run-helpers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { FlowBlock, Line, ParagraphBlock, Run, TabRun, TextRun, TrackedChangeMeta } from './index.js'; -import { expandRunsForInlineNewlines, sliceRunsForLine } from './run-helpers.js'; +import { expandRunsForInlineNewlines, isEmptySdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js'; describe('expandRunsForInlineNewlines', () => { const makeRun = (text: string, pmStart = 0): TextRun => ({ @@ -153,4 +153,17 @@ describe('sliceRunsForLine', () => { expect(sliceRunsForLine(block, line)).toEqual([run]); }); + + it('recognizes block SDT visual placeholders', () => { + const run: TextRun = { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 12, + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'sdt-block-1' }, + }; + + expect(isEmptySdtPlaceholderRun(run)).toBe(true); + }); }); diff --git a/packages/layout-engine/contracts/src/run-helpers.ts b/packages/layout-engine/contracts/src/run-helpers.ts index 8b04fa638a..516a53756f 100644 --- a/packages/layout-engine/contracts/src/run-helpers.ts +++ b/packages/layout-engine/contracts/src/run-helpers.ts @@ -9,14 +9,20 @@ import type { FlowBlock, Line, Run, TextRun } from './index.js'; -export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } { +export function isEmptySdtPlaceholderRun( + run: Run, +): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' | 'emptyBlockSdt' } { return ( (run.kind === 'text' || run.kind === undefined) && 'text' in run && - (run as TextRun).visualPlaceholder === 'emptyInlineSdt' + ((run as TextRun).visualPlaceholder === 'emptyInlineSdt' || (run as TextRun).visualPlaceholder === 'emptyBlockSdt') ); } +export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } { + return isEmptySdtPlaceholderRun(run) && run.visualPlaceholder === 'emptyInlineSdt'; +} + /** * Expands text runs that contain inline newlines into multiple runs. * @@ -90,7 +96,7 @@ export function sliceRunsForLine(block: FlowBlock, line: Line): Run[] { } const text = run.text ?? ''; - if (isEmptyInlineSdtPlaceholderRun(run)) { + if (isEmptySdtPlaceholderRun(run)) { result.push(run); continue; } diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index b58704acb6..22134eb907 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -10,7 +10,7 @@ import type { ParagraphIndent, LeaderDecoration, } from '@superdoc/contracts'; -import { Engines } from '@superdoc/contracts'; +import { EMPTY_SDT_PLACEHOLDER_TEXT, Engines, isEmptySdtPlaceholderRun } from '@superdoc/contracts'; import type { WordParagraphLayoutOutput } from '@superdoc/word-layout'; import { LIST_MARKER_GAP as _LIST_MARKER_GAP, @@ -126,6 +126,10 @@ function fontString(run: Run): string { * @returns Text content of the run, or empty string for non-text runs */ function runText(run: Run): string { + if (isEmptySdtPlaceholderRun(run)) { + return run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? '' : EMPTY_SDT_PLACEHOLDER_TEXT; + } + return 'src' in run || run.kind === 'lineBreak' || run.kind === 'break' || @@ -1380,6 +1384,17 @@ export function remeasureParagraph( if (text.length > 0 && isTextRun(run)) { lineMaxTextFontSize = Math.max(lineMaxTextFontSize, run.fontSize ?? 16); } + if (isEmptySdtPlaceholderRun(run)) { + const placeholderWidth = text.length > 0 ? measureRunSliceWidth(run, 0, text.length) : 0; + if (width > 0 && width + placeholderWidth > effectiveMaxWidth - WIDTH_FUDGE_PX) { + didBreakInThisLine = true; + break; + } + width += placeholderWidth; + endRun = r; + endChar = text.length > 0 ? text.length : start + 1; + continue; + } for (let c = start; c < text.length; c += 1) { const ch = text[c]; if (ch === '\t') { diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index 84ec9f746a..859fc773c1 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -11,7 +11,13 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; -import type { ParagraphBlock, Run, TabStop } from '@superdoc/contracts'; +import { + EMPTY_SDT_PLACEHOLDER_TEXT, + computeLinePmRange, + type ParagraphBlock, + type Run, + type TabStop, +} from '@superdoc/contracts'; import { remeasureParagraph } from '../src/remeasure.ts'; /** @@ -216,6 +222,53 @@ describe('remeasureParagraph', () => { expect(measure.totalHeight).toBe(0); }); + it('measures visible empty SDT placeholders using the placeholder prompt width', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'empty-block-sdt' }, + pmStart: 12, + pmEnd: 12, + }), + ]); + const measure = remeasureParagraph(block, 500); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(EMPTY_SDT_PLACEHOLDER_TEXT.length * CHAR_WIDTH); + expect(computeLinePmRange(block, measure.lines[0])).toEqual({ pmStart: 12, pmEnd: 12 }); + }); + + it('keeps a visible empty SDT placeholder atomic when it is wider than the line', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'narrow-empty-block-sdt' }, + }), + ]); + const measure = remeasureParagraph(block, 60); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].fromRun).toBe(0); + expect(measure.lines[0].toRun).toBe(0); + expect(measure.lines[0].width).toBe(EMPTY_SDT_PLACEHOLDER_TEXT.length * CHAR_WIDTH); + }); + + it('keeps hidden empty SDT placeholders zero-width during remeasurement', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'hidden-block-sdt', appearance: 'hidden' }, + }), + ]); + const measure = remeasureParagraph(block, 500); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(0); + }); + it('handles single character per line when maxWidth is very narrow', () => { // With maxWidth=11 (barely fits 1 char at 10px + fudge), each char should be on its own line const block = createBlock([textRun('ABC')]); diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index b8e993e1a4..a5a278a18a 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -9,6 +9,7 @@ import type { DrawingBlock, TableMeasure, } from '@superdoc/contracts'; +import { EMPTY_SDT_PLACEHOLDER_TEXT } from '@superdoc/contracts'; const expectParagraphMeasure = (measure: Measure): ParagraphMeasure => { expect(measure.kind).toBe('paragraph'); @@ -685,7 +686,7 @@ describe('measureBlock', () => { }); }); - it('measures empty inline SDT placeholders as a small inline box', async () => { + it('measures empty inline SDT placeholders using the visible placeholder text width', async () => { const block: FlowBlock = { kind: 'paragraph', id: 'empty-inline-sdt', @@ -707,14 +708,44 @@ describe('measureBlock', () => { const measure = expectParagraphMeasure(await measureBlock(block, 1000)); expect(measure.lines).toHaveLength(1); - expect(measure.lines[0]).toMatchObject({ - fromRun: 0, - fromChar: 0, - toRun: 0, - toChar: 0, - width: 8, - segments: [{ runIndex: 0, fromChar: 0, toChar: 0, width: 8 }], - }); + expect(measure.lines[0]).toMatchObject({ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0 }); + expect(measure.lines[0].width).toBeGreaterThan(8); + expect(measure.lines[0].segments).toHaveLength(1); + expect(measure.lines[0].segments[0]).toMatchObject({ runIndex: 0, fromChar: 0, toChar: 0 }); + expect(measure.lines[0].segments[0].width).toBe(measure.lines[0].width); + }); + + it('applies textTransform when measuring empty SDT placeholder text', async () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + expect(ctx).not.toBeNull(); + ctx!.font = '16px Arial'; + const transformedPlaceholderText = EMPTY_SDT_PLACEHOLDER_TEXT.toUpperCase(); + const transformedWidth = ctx!.measureText(transformedPlaceholderText).width; + const untransformedWidth = ctx!.measureText(EMPTY_SDT_PLACEHOLDER_TEXT).width; + expect(transformedWidth).not.toBeCloseTo(untransformedWidth, 2); + + const block: FlowBlock = { + kind: 'paragraph', + id: 'empty-inline-sdt-uppercase', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + textTransform: 'uppercase', + visualPlaceholder: 'emptyInlineSdt', + sdt: { type: 'structuredContent', scope: 'inline', id: 'sdt-empty-uppercase' }, + }, + ], + attrs: {}, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 1000)); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBeCloseTo(transformedWidth, 2); }); }); diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 1ae0f9395e..6e6aa87412 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -61,8 +61,9 @@ import { type CellSpacing, type TableBorders, type TableBorderValue, + EMPTY_SDT_PLACEHOLDER_TEXT, effectiveTableCellSpacing, - isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, LeaderDecoration, resolveBaseFontSizeForVerticalText, } from '@superdoc/contracts'; @@ -209,8 +210,6 @@ const FIELD_ANNOTATION_VERTICAL_PADDING = 6; // Vertical padding/border for pill const DEFAULT_FIELD_ANNOTATION_FONT_SIZE = 16; // Default font size for field annotations const DEFAULT_PARAGRAPH_FONT_SIZE = 12; const DEFAULT_PARAGRAPH_FONT_FAMILY = 'Arial'; -const EMPTY_INLINE_SDT_PLACEHOLDER_WIDTH = 8; - const isValidFontSize = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0; @@ -1036,7 +1035,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P const emptyParagraphRun = normalizedRuns.length === 1 && isEmptyTextRun(normalizedRuns[0] as Run) && - !isEmptyInlineSdtPlaceholderRun(normalizedRuns[0] as Run) + !isEmptySdtPlaceholderRun(normalizedRuns[0] as Run) ? (normalizedRuns[0] as TextRun) : null; if (emptyParagraphRun) { @@ -2018,11 +2017,22 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P continue; } - if (isEmptyInlineSdtPlaceholderRun(run)) { + if (isEmptySdtPlaceholderRun(run)) { + const placeholderFont = buildFontString(run).font; + const placeholderText = applyTextTransform(EMPTY_SDT_PLACEHOLDER_TEXT, run); + const measuredPlaceholderWidth = getMeasuredTextWidth( + placeholderText, + placeholderFont, + run.letterSpacing ?? 0, + ctx, + ); + const fallbackPlaceholderWidth = placeholderText.length * run.fontSize * 0.45; const placeholderWidth = run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? 0 - : EMPTY_INLINE_SDT_PLACEHOLDER_WIDTH; + : measuredPlaceholderWidth > 0 + ? measuredPlaceholderWidth + : fallbackPlaceholderWidth; if (!currentLine) { currentLine = { diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 8988662ac7..72e5ffd1c0 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2269,6 +2269,49 @@ describe('DomPainter', () => { expect(annotation?.style.fontSize).toBe('14pt'); }); + it('renders field annotation images with non-base64 SVG data URLs', () => { + const svg = ''; + const imageSrc = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + const block: FlowBlock = { + kind: 'paragraph', + id: 'fa-svg-image', + runs: [ + { + kind: 'fieldAnnotation', + variant: 'signature', + displayLabel: 'Signature', + fieldId: 'F1', + fieldType: 'signer', + fieldColor: '#980043', + imageSrc, + pmStart: 0, + pmEnd: 1, + }, + ], + }; + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + const testLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'fa-svg-image', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 }], + }, + ], + }; + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(testLayout, mount); + + const img = mount.querySelector('.annotation img') as HTMLImageElement | null; + expect(img).toBeTruthy(); + expect(img?.src).toBe(imageSrc); + expect(img?.alt).toBe('Signature'); + }); + it('sets explicit fontSize on math run wrapper', () => { const block: FlowBlock = { kind: 'paragraph', @@ -2837,8 +2880,151 @@ describe('DomPainter', () => { expect(wrapper?.dataset.empty).toBe('true'); expect(wrapper?.dataset.pmStart).toBe('8'); expect(wrapper?.dataset.pmEnd).toBe('8'); - expect(wrapper?.querySelector('.superdoc-empty-inline-sdt-placeholder')).toBeTruthy(); + const placeholder = wrapper?.querySelector('.superdoc-empty-inline-sdt-placeholder') as HTMLElement | null; + expect(placeholder).toBeTruthy(); + expect(placeholder?.classList.contains('superdoc-empty-sdt-placeholder')).toBe(true); + expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text'); expect(wrapper?.textContent).not.toContain('old content'); + expect(wrapper?.textContent).not.toContain('Click or tap here to enter text'); + }); + + it('renders placeholder chrome for an empty block SDT without adding document text', () => { + const sdt = { + type: 'structuredContent', + scope: 'block', + id: 'sc-block-empty-1', + alias: 'Empty block', + } as const; + const block: FlowBlock = { + kind: 'paragraph', + id: 'block-sc-empty', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 4, + visualPlaceholder: 'emptyBlockSdt', + sdt, + }, + ], + attrs: { sdt }, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 220, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sc-empty', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 3, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const fragment = mount.querySelector( + '.superdoc-structured-content-block[data-sdt-id="sc-block-empty-1"]', + ) as HTMLElement | null; + const placeholder = fragment?.querySelector('.superdoc-empty-block-sdt-placeholder') as HTMLElement | null; + + expect(fragment).toBeTruthy(); + expect(placeholder).toBeTruthy(); + expect(placeholder?.classList.contains('superdoc-empty-sdt-placeholder')).toBe(true); + expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text'); + expect(placeholder?.dataset.pmStart).toBe('4'); + expect(placeholder?.dataset.pmEnd).toBe('4'); + expect(placeholder?.style.fontFamily).toBe('Arial'); + expect(placeholder?.style.fontSize).toBe('16px'); + expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('220px'); + expect(fragment?.textContent).not.toContain('Click or tap here to enter text'); + }); + + it('marks hidden empty block SDT wrappers so placeholder chrome can be suppressed', () => { + const sdt = { + type: 'structuredContent', + scope: 'block', + id: 'sc-block-hidden-empty-1', + alias: 'Hidden empty block', + appearance: 'hidden', + } as const; + const block: FlowBlock = { + kind: 'paragraph', + id: 'block-sc-hidden-empty', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 4, + visualPlaceholder: 'emptyBlockSdt', + sdt, + }, + ], + attrs: { sdt }, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sc-hidden-empty', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 3, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const fragment = mount.querySelector('[data-sdt-id="sc-block-hidden-empty-1"]') as HTMLElement | null; + + expect(fragment).toBeTruthy(); + expect(fragment?.dataset.appearance).toBe('hidden'); + expect(fragment?.classList.contains('superdoc-structured-content-block')).toBe(false); + expect(fragment?.querySelector('.superdoc-structured-content__label')).toBeNull(); }); it('keeps inline SDT wrapper font-size in sync when run font-size changes', () => { @@ -7529,6 +7715,49 @@ describe('DomPainter', () => { expect(img?.height).toBe(100); }); + it('renders img element with non-base64 SVG data URL', () => { + const svg = + 'Signature'; + const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + + renderInlineImageRun({ + kind: 'image', + src: svgDataUrl, + width: 100, + height: 50, + }); + + const img = mount.querySelector('img'); + expect(img).toBeTruthy(); + expect(img?.src).toBe(svgDataUrl); + expect(img?.width).toBe(100); + expect(img?.height).toBe(50); + }); + + it('rejects non-base64 raster data URLs', () => { + renderInlineImageRun({ + kind: 'image', + src: 'data:image/png,not-base64', + width: 100, + height: 100, + }); + + const img = mount.querySelector('img'); + expect(img).toBeNull(); + }); + + it('rejects non-image data URLs without requiring base64', () => { + renderInlineImageRun({ + kind: 'image', + src: 'data:text/html;charset=utf-8,%3Cscript%3Ealert(1)%3C%2Fscript%3E', + width: 100, + height: 100, + }); + + const img = mount.querySelector('img'); + expect(img).toBeNull(); + }); + it('renders DrawingML luminance using percentage units', () => { const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 35463af5eb..385ccbec99 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -60,6 +60,7 @@ import type { } from '@superdoc/contracts'; import { LAYOUT_BOUNDARY_SCHEMA, + EMPTY_SDT_PLACEHOLDER_TEXT, adjustAvailableWidthForTextIndent, buildLayoutSourceIdentityForFragment, calculateJustifySpacing, @@ -68,6 +69,7 @@ import { getCellSpacingPx, getParagraphInlineDirection, isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, normalizeColumnLayout, normalizeBaselineShift, resolveBaseFontSizeForVerticalText, @@ -78,7 +80,7 @@ import { import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; import { toCssFontFamily } from '@superdoc/font-utils'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; +import { encodeTooltip, isValidImageDataUrl, sanitizeHref } from '@superdoc/url-validation'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; import { @@ -924,18 +926,6 @@ const MAX_HREF_LENGTH = 2048; const SAFE_ANCHOR_PATTERN = /^[A-Za-z0-9._-]+$/; -/** - * Maximum allowed length for data URLs (10MB). - * Prevents denial of service attacks from extremely large embedded images. - */ -const MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; // 10MB - -/** - * Regular expression to validate data URL format for images. - * Only allows common, safe image MIME types with base64 encoding. - * Prevents XSS and malformed data URL attacks. - */ -const VALID_IMAGE_DATA_URL = /^data:image\/(png|jpeg|jpg|gif|svg\+xml|webp|bmp|ico|tiff?);base64,/i; const SVG_NS = 'http://www.w3.org/2000/svg'; const WORDART_LINE_FILL_RATIO = 0.9; @@ -5547,6 +5537,10 @@ export class DomPainter { let hasVisibleContent = false; for (const run of runsForLine) { if (run.kind === 'lineBreak' || run.kind === 'break') continue; + if (isEmptySdtPlaceholderRun(run)) { + hasVisibleContent = true; + break; + } if ((run.kind === 'text' || run.kind === undefined) && 'text' in run) { if ((run.text ?? '').trim().length === 0) continue; } @@ -5717,15 +5711,22 @@ export class DomPainter { } } - private renderEmptyInlineSdtPlaceholderRun(run: TextRun): HTMLElement | null { + private renderEmptySdtPlaceholderRun(run: TextRun): HTMLElement | null { if (!this.doc) return null; const elem = this.doc.createElement('span'); - elem.classList.add('superdoc-empty-inline-sdt-placeholder'); + elem.classList.add('superdoc-empty-sdt-placeholder'); + if (run.visualPlaceholder === 'emptyInlineSdt') { + elem.classList.add('superdoc-empty-inline-sdt-placeholder'); + } else if (run.visualPlaceholder === 'emptyBlockSdt') { + elem.classList.add('superdoc-empty-block-sdt-placeholder'); + } elem.setAttribute('aria-hidden', 'true'); + elem.dataset.placeholderText = EMPTY_SDT_PLACEHOLDER_TEXT; elem.dataset.layoutEpoch = String(this.layoutEpoch); if (run.pmStart != null) elem.dataset.pmStart = String(run.pmStart); if (run.pmEnd != null) elem.dataset.pmEnd = String(run.pmEnd); this.applySdtDataset(elem, run.sdt); + applyRunStyles(elem, run); return elem; } @@ -5866,8 +5867,8 @@ export class DomPainter { return null; } - if (isEmptyInlineSdtPlaceholderRun(run)) { - return this.renderEmptyInlineSdtPlaceholderRun(run); + if (isEmptySdtPlaceholderRun(run)) { + return this.renderEmptySdtPlaceholderRun(run); } // Handle TextRun @@ -5973,9 +5974,9 @@ export class DomPainter { * Renders an ImageRun as an inline element. * * SECURITY NOTES: - * - Data URLs are validated against VALID_IMAGE_DATA_URL regex to ensure proper format - * - Size limit (MAX_DATA_URL_LENGTH) prevents DoS attacks from extremely large images - * - Only allows safe image MIME types (png, jpeg, gif, etc.) with base64 encoding + * - Data URLs are validated against an allowlist of image MIME types + * - Size limit prevents DoS attacks from extremely large images + * - Only allows safe image MIME types; non-base64 data URLs are limited to SVG * - Non-data URLs are sanitized through sanitizeUrl to prevent XSS * * METADATA ATTRIBUTE: @@ -6023,13 +6024,8 @@ export class DomPainter { // but are safe for elements when properly validated const isDataUrl = typeof run.src === 'string' && run.src.startsWith('data:'); if (isDataUrl) { - // SECURITY: Validate data URL format and size - if (run.src.length > MAX_DATA_URL_LENGTH) { - // Reject data URLs that are too large (DoS prevention) - return null; - } - if (!VALID_IMAGE_DATA_URL.test(run.src)) { - // Reject data URLs with invalid MIME types or encoding + // SECURITY: Validate data URL MIME type, encoding, and size. + if (!isValidImageDataUrl(run.src)) { return null; } img.src = run.src; @@ -6386,7 +6382,7 @@ export class DomPainter { // SECURITY: Validate data URLs const isDataUrl = run.imageSrc.startsWith('data:'); if (isDataUrl) { - if (run.imageSrc.length <= MAX_DATA_URL_LENGTH && VALID_IMAGE_DATA_URL.test(run.imageSrc)) { + if (isValidImageDataUrl(run.imageSrc)) { img.src = run.imageSrc; } else { // Invalid data URL - fall back to displayLabel @@ -7626,6 +7622,7 @@ export class DomPainter { 'sdtScope', 'sdtTag', 'sdtAlias', + 'appearance', 'lockMode', 'sdtSectionTitle', 'sdtSectionType', @@ -7756,6 +7753,7 @@ export class DomPainter { this.setDatasetString(el, 'sdtScope', metadata.scope); this.setDatasetString(el, 'sdtTag', metadata.tag); this.setDatasetString(el, 'sdtAlias', metadata.alias); + this.setDatasetString(el, 'appearance', metadata.appearance); // Always set lockMode (defaulting to 'unlocked') so CSS can target all SDTs uniformly. this.setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); } else if (metadata.type === 'documentSection') { diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 9a7b019a44..fec31ecc2b 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -65,19 +65,119 @@ describe('ensureSdtContainerStyles', () => { expect(emptyRule).not.toContain('vertical-align'); }); - it('reserves empty inline SDT width without adding line-box height', () => { + it('uses the same label box model for block and inline SDTs', () => { ensureSdtContainerStyles(document); const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); const cssText = styleEl?.textContent ?? ''; - const placeholderRule = cssText.match(/\.superdoc-empty-inline-sdt-placeholder\s*\{([^}]*)\}/)?.[1] ?? ''; + const sharedLabelRule = + cssText.match( + /\.superdoc-structured-content__label,\s*\.superdoc-structured-content-inline__label\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const inlineSelectedRule = + cssText.match( + /\.superdoc-structured-content-inline\.ProseMirror-selectednode \.superdoc-structured-content-inline__label\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const sharedLabelDragHandleRule = + cssText.match( + /\.superdoc-structured-content__label::before,\s*\.superdoc-structured-content-inline__label::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const inlineLabelRule = + [...cssText.matchAll(/\.superdoc-structured-content-inline__label\s*\{([^}]*)\}/g)] + .map((match) => match[1] ?? '') + .find((rule) => rule.includes('bottom: calc(100% + 1px);')) ?? ''; + const blockLabelRule = cssText.match(/\.superdoc-structured-content__label\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(sharedLabelRule).toContain('height: 18px;'); + expect(sharedLabelRule).toContain('padding: 0 4px;'); + expect(sharedLabelRule).toContain('border: 1px solid var(--sd-content-controls-label-border, #629be7);'); + expect(sharedLabelRule).toContain('box-sizing: border-box;'); + expect(sharedLabelRule).toContain('align-items: center;'); + expect(sharedLabelRule).toContain('justify-content: center;'); + expect(sharedLabelDragHandleRule).toContain("content: '';"); + expect(sharedLabelDragHandleRule).toContain('height: 8px;'); + expect(sharedLabelDragHandleRule).toContain( + 'radial-gradient(circle, currentColor 1px, transparent 1px) center 0 / 2px 2px no-repeat,', + ); + expect(sharedLabelDragHandleRule).toContain('center 3px / 2px 2px no-repeat,'); + expect(sharedLabelDragHandleRule).toContain('center 6px / 2px 2px no-repeat;'); + expect(inlineSelectedRule).toContain('display: inline-flex;'); + expect(inlineLabelRule).toContain('border-radius: 4px 4px 0 0;'); + expect(blockLabelRule).toContain('white-space: nowrap;'); + expect(blockLabelRule).toContain('top: -18px;'); + expect(blockLabelRule).toContain('width: max-content;'); + expect(blockLabelRule).toContain('max-width: 130px;'); + expect(blockLabelRule).toContain('min-width: 0;'); + expect(blockLabelRule).not.toContain('width: calc(var(--sd-sdt-chrome-width, 100%) - 4px);'); + expect(cssText).toContain('.superdoc-structured-content__label span'); + expect(cssText).toContain('flex: 1 1 auto;'); + expect(cssText).toContain('bottom: calc(100% + 1px);'); + }); + + it('renders empty SDT placeholder text and active selection styling', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const placeholderRule = cssText.match(/\.superdoc-empty-sdt-placeholder\s*\{([^}]*)\}/)?.[1] ?? ''; + const placeholderBeforeRule = cssText.match(/\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + const selectedRule = + cssText.match( + /\.superdoc-structured-content-inline\.ProseMirror-selectednode \.superdoc-empty-sdt-placeholder::before,\s*\.superdoc-structured-content-block\.ProseMirror-selectednode \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; expect(placeholderRule).toContain('display: inline-block;'); - expect(placeholderRule).toContain('width: 8px;'); - expect(placeholderRule).toContain('height: 0;'); - expect(placeholderRule).toContain('line-height: 0;'); + expect(placeholderRule).toContain('line-height: normal;'); expect(placeholderRule).toContain('vertical-align: baseline;'); - expect(placeholderRule).not.toContain('height: 1em;'); + expect(placeholderRule).toContain('white-space: nowrap;'); + expect(placeholderBeforeRule).toContain('content: attr(data-placeholder-text);'); + expect(placeholderBeforeRule).toContain('color: var(--sd-content-controls-placeholder-text, #a6a6a6);'); + expect(selectedRule).toContain('background-color: var(--sd-content-controls-placeholder-selected-bg, Highlight);'); + expect(selectedRule).not.toMatch(/(^|\n)\s*color\s*:/); + }); + + it('suppresses empty block SDT placeholder text when the SDT appearance is hidden', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const hiddenPlaceholderRule = + cssText.match( + /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder,\s*\.superdoc-empty-sdt-placeholder\[data-appearance='hidden'\]\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const hiddenPlaceholderBeforeRule = + cssText.match( + /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder::before,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder::before,\s*\.superdoc-empty-sdt-placeholder\[data-appearance='hidden'\]::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + + expect(hiddenPlaceholderRule).toContain('width: 0;'); + expect(hiddenPlaceholderRule).toContain('min-width: 0;'); + expect(hiddenPlaceholderRule).toContain('overflow: hidden;'); + expect(hiddenPlaceholderBeforeRule).toContain("content: '';"); + }); + + it('keeps empty SDT placeholder text visible in viewing mode', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const viewingPlaceholderRule = + cssText.match(/\.presentation-editor--viewing \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(viewingPlaceholderRule).toBe(''); + expect(viewingPlaceholderRule).not.toContain('visibility: hidden;'); + }); + + it('keeps empty SDT placeholder text visible in print mode', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const printPlaceholderRule = + cssText.match(/@media print\s*\{[\s\S]*?\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(printPlaceholderRule).toBe(''); + expect(printPlaceholderRule).not.toContain('visibility: hidden;'); }); it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 85d9fcf512..e8a8f61402 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -554,33 +554,55 @@ const SDT_CONTAINER_STYLES = ` border-color: var(--sd-content-controls-block-border, #629be7); } -/* Structured content drag handle/label - positioned above */ -.superdoc-structured-content__label { +/* Structured content labels - shared box model; positioning differs by scope. */ +.superdoc-structured-content__label, +.superdoc-structured-content-inline__label { font-size: 11px; align-items: center; justify-content: center; - position: absolute; - left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); - top: -19px; - width: calc(var(--sd-sdt-chrome-width, 100%) - 4px); - max-width: 130px; - min-width: 0; height: 18px; padding: 0 4px; border: 1px solid var(--sd-content-controls-label-border, #629be7); - border-bottom: none; - border-radius: 6px 6px 0 0; background-color: var(--sd-content-controls-label-bg, #629be7ee); color: var(--sd-content-controls-label-text, #ffffff); box-sizing: border-box; - z-index: 10; display: none; pointer-events: auto; cursor: pointer; user-select: none; } +.superdoc-structured-content__label::before, +.superdoc-structured-content-inline__label::before { + content: ''; + width: 2px; + height: 8px; + margin-right: 4px; + background: + radial-gradient(circle, currentColor 1px, transparent 1px) center 0 / 2px 2px no-repeat, + radial-gradient(circle, currentColor 1px, transparent 1px) center 3px / 2px 2px no-repeat, + radial-gradient(circle, currentColor 1px, transparent 1px) center 6px / 2px 2px no-repeat; + flex: 0 0 auto; +} + +/* Structured content drag handle/label - positioned above */ +.superdoc-structured-content__label { + position: absolute; + left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); + top: -18px; + width: max-content; + max-width: 130px; + min-width: 0; + border-bottom: none; + border-radius: 6px 6px 0 0; + white-space: nowrap; + z-index: 10; +} + .superdoc-structured-content__label span { + display: block; + flex: 1 1 auto; + min-width: 0; max-width: 100%; overflow: hidden; white-space: nowrap; @@ -664,42 +686,50 @@ const SDT_CONTAINER_STYLES = ` border-color: var(--sd-content-controls-inline-border, #629be7); } -.superdoc-empty-inline-sdt-placeholder { +.superdoc-empty-sdt-placeholder { display: inline-block; - width: 8px; - height: 0; - line-height: 0; + line-height: normal; vertical-align: baseline; - overflow: hidden; + white-space: nowrap; +} + +.superdoc-empty-sdt-placeholder::before { + content: attr(data-placeholder-text); + color: var(--sd-content-controls-placeholder-text, #a6a6a6); } -.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder { +.superdoc-structured-content-inline.ProseMirror-selectednode .superdoc-empty-sdt-placeholder::before, +.superdoc-structured-content-block.ProseMirror-selectednode .superdoc-empty-sdt-placeholder::before { + background-color: var(--sd-content-controls-placeholder-selected-bg, Highlight); +} + +.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder, +.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder, +.superdoc-empty-sdt-placeholder[data-appearance='hidden'] { width: 0; min-width: 0; + overflow: hidden; +} + +.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder::before, +.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder::before, +.superdoc-empty-sdt-placeholder[data-appearance='hidden']::before { + content: ''; } /* Inline structured content label - shown when active */ .superdoc-structured-content-inline__label { position: absolute; - bottom: calc(100% + 2px); + bottom: calc(100% + 1px); left: 50%; transform: translateX(-50%); - font-size: 11px; - padding: 0 4px; - border: 1px solid var(--sd-content-controls-label-border, #629be7); - background-color: var(--sd-content-controls-label-bg, #629be7ee); - color: var(--sd-content-controls-label-text, #ffffff); - border-radius: 4px; + border-radius: 4px 4px 0 0; white-space: nowrap; z-index: 100; - display: none; - pointer-events: auto; - cursor: pointer; - user-select: none; } .superdoc-structured-content-inline.ProseMirror-selectednode .superdoc-structured-content-inline__label { - display: block; + display: inline-flex; } .superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover .superdoc-structured-content-inline__label { diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts index 2ebad541b4..156224968a 100644 --- a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts +++ b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts @@ -224,6 +224,12 @@ export function applySdtContainerStyling( config = getSdtContainerConfig(containerSdt); } if (!config) return; + if ( + (isStructuredContentMetadata(sdt) && sdt.appearance === 'hidden') || + (isStructuredContentMetadata(containerSdt) && containerSdt.appearance === 'hidden') + ) { + return; + } const isStart = boundaryOptions?.isStart ?? config.isStart; const isEnd = boundaryOptions?.isEnd ?? config.isEnd; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts index 9f0e1ddf68..c5195c9655 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts @@ -1,4 +1,4 @@ -import type { FieldAnnotationRun, FieldAnnotationMetadata } from '@superdoc/contracts'; +import type { FieldAnnotationRun } from '@superdoc/contracts'; import type { PMNode } from '../../types.js'; import { type InlineConverterParams } from './common'; import { resolveNodeSdtMetadata } from '../../sdt/index.js'; @@ -16,7 +16,7 @@ import { resolveNodeSdtMetadata } from '../../sdt/index.js'; * @returns FieldAnnotationRun object with all extracted properties */ export function fieldAnnotationNodeToRun({ node, positions }: InlineConverterParams): FieldAnnotationRun { - const fieldMetadata = resolveNodeSdtMetadata(node, 'fieldAnnotation') as FieldAnnotationMetadata | null; + const fieldMetadata = resolveNodeSdtMetadata(node, 'fieldAnnotation'); // If there's inner content, extract text to use as displayLabel override let contentText: string | undefined; diff --git a/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts b/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts index 5205f1c172..8d5514a1d8 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts @@ -2,7 +2,7 @@ * Tests for SDT Metadata Module */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, expectTypeOf } from 'vitest'; import { hasInstruction, getNodeInstruction, @@ -14,7 +14,16 @@ import { applySdtMetadataToListBlock, } from './metadata.js'; import type { PMNode } from '../types.js'; -import type { ParagraphBlock, TableBlock, ListBlock, SdtMetadata } from '@superdoc/contracts'; +import type { + ParagraphBlock, + TableBlock, + ListBlock, + SdtMetadata, + FieldAnnotationMetadata, + StructuredContentMetadata, + DocumentSectionMetadata, + DocPartMetadata, +} from '@superdoc/contracts'; describe('metadata', () => { describe('hasInstruction', () => { @@ -176,6 +185,25 @@ describe('metadata', () => { // Both calls should return the same cached object expect(result1).toBe(result2); }); + + it('narrows the return type when a literal override is provided', () => { + const node = { type: 'fieldAnnotation', attrs: { fieldId: 'field-1' } } as PMNode; + + expectTypeOf(resolveNodeSdtMetadata(node)).toEqualTypeOf(); + expectTypeOf(resolveNodeSdtMetadata(node, 'fieldAnnotation')).toEqualTypeOf< + FieldAnnotationMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'structuredContent')).toEqualTypeOf< + StructuredContentMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'structuredContentBlock')).toEqualTypeOf< + StructuredContentMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'documentSection')).toEqualTypeOf< + DocumentSectionMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'docPartObject')).toEqualTypeOf(); + }); }); describe('applySdtMetadataToParagraphBlocks', () => { diff --git a/packages/layout-engine/pm-adapter/src/sdt/metadata.ts b/packages/layout-engine/pm-adapter/src/sdt/metadata.ts index 3953190518..322bad72e5 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/metadata.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/metadata.ts @@ -6,10 +6,29 @@ * document sections, TOC entries, structured content blocks, etc. */ -import type { FlowBlock, TableBlock, ListBlock, SdtMetadata } from '@superdoc/contracts'; +import type { + FlowBlock, + TableBlock, + ListBlock, + SdtMetadata, + FieldAnnotationMetadata, + StructuredContentMetadata, + DocumentSectionMetadata, + DocPartMetadata, +} from '@superdoc/contracts'; import type { PMNode } from '../types.js'; import { resolveSdtMetadata } from '@superdoc/style-engine'; +type SdtMetadataForOverride = TOverride extends 'fieldAnnotation' + ? FieldAnnotationMetadata + : TOverride extends 'structuredContent' | 'structuredContentBlock' + ? StructuredContentMetadata + : TOverride extends 'documentSection' + ? DocumentSectionMetadata + : TOverride extends 'docPartObject' + ? DocPartMetadata + : SdtMetadata; + /** * Type guard to check if a node has instruction attribute. */ @@ -57,7 +76,10 @@ export function getDocPartObjectId(node: PMNode): string | undefined { * @param overrideType - Optional type override (e.g., 'documentSection', 'docPartObject') * @returns Resolved SDT metadata, or undefined if none */ -export function resolveNodeSdtMetadata(node: PMNode, overrideType?: string): SdtMetadata | undefined { +export function resolveNodeSdtMetadata( + node: PMNode, + overrideType?: TOverride, +): SdtMetadataForOverride | undefined { const attrs = node.attrs; if (!attrs) return undefined; const nodeType = overrideType ?? node.type; @@ -74,7 +96,7 @@ export function resolveNodeSdtMetadata(node: PMNode, overrideType?: string): Sdt nodeType, attrs, cacheKey, - }); + }) as SdtMetadataForOverride | undefined; } /** diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index f9587ff8e1..d56965acc1 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -31,17 +31,23 @@ describe('structured-content-block', () => { const mockConverterContext = { docx: {} } as never; const scbMetadata: SdtMetadata = { - type: 'structuredContentBlock', + type: 'structuredContent', + scope: 'block', id: 'scb-1', }; + const nonEmptyParagraph = (text = 'Text'): PMNode => ({ + type: 'paragraph', + content: [{ type: 'text', text }], + }); beforeEach(() => { vi.clearAllMocks(); + mockPositionMap.clear(); }); // ==================== Basic Functionality Tests ==================== describe('Basic functionality', () => { - it('should return early if node.content is not an array', () => { + it('should emit a placeholder paragraph if node.content is not an array', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -50,6 +56,8 @@ describe('structured-content-block', () => { const blocks: FlowBlock[] = []; const recordBlockKind = vi.fn(); + mockPositionMap.set(node, { start: 10, end: 12 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); const context: NodeHandlerContext = { blocks, @@ -70,15 +78,29 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); - expect(blocks).toHaveLength(0); - expect(recordBlockKind).not.toHaveBeenCalled(); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + id: 'paragraph-test-id', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 11, + pmEnd: 11, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); it('should throw if paragraphToFlowBlocks is not provided', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -102,7 +124,7 @@ describe('structured-content-block', () => { expect(() => handleStructuredContentBlockNode(node, context)).toThrow(); }); - it('should handle empty children array', () => { + it('should emit a placeholder paragraph for empty children array', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -133,6 +155,458 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should emit a placeholder paragraph for a single empty paragraph child', () => { + const emptyParagraph: PMNode = { type: 'paragraph', content: [] }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 14 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 13 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-empty-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + color: '#123456', + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + fontFamily: 'Aptos', + fontSize: 14, + color: '#123456', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(paragraphToFlowBlocks).toHaveBeenCalledWith( + expect.objectContaining({ + para: emptyParagraph, + positions: mockPositionMap, + }), + ); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should emit a placeholder paragraph when the empty paragraph only has bookmark markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'bookmarkStart', attrs: { id: '1', name: 'EmptySdtBookmark' } }, + { type: 'bookmarkEnd', attrs: { id: '1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-bookmark-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should emit a placeholder paragraph when the empty paragraph only has comment range markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'commentRangeStart', attrs: { 'w:id': 'comment-1' } }, + { type: 'commentRangeEnd', attrs: { 'w:id': 'comment-1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-comment-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should emit a placeholder paragraph when the empty paragraph only has permission range markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'permStart', attrs: { id: 'perm-1', edGrp: 'everyone' } }, + { type: 'permEnd', attrs: { id: 'perm-1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-permission-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should not emit a placeholder for a vanished empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + runProperties: { + vanish: true, + }, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(0); + expect(recordBlockKind).not.toHaveBeenCalled(); + }); + + it('should preserve non-paragraph converter output for a vanished empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + pageBreakBefore: true, + paragraphProperties: { + runProperties: { + vanish: true, + }, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const pageBreakBlock: FlowBlock = { + kind: 'pageBreak', + id: 'page-break-before-hidden-paragraph', + attrs: { source: 'pageBreakBefore' }, + }; + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([pageBreakBlock]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toEqual([pageBreakBlock]); + expect(recordBlockKind).toHaveBeenCalledWith('pageBreak'); + }); + + it('should not synthesize a placeholder when tracked-change filtering removes an empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + runProperties: {}, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: { enabled: true, mode: 'final' }, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + expect(blocks).toHaveLength(0); expect(recordBlockKind).not.toHaveBeenCalled(); }); @@ -234,7 +708,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -273,7 +747,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -322,7 +796,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -363,7 +837,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -402,7 +876,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -452,7 +926,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -488,7 +962,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: {}, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -694,7 +1168,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -735,7 +1209,7 @@ describe('structured-content-block', () => { // ==================== Edge Cases ==================== describe('Edge cases', () => { - it('should handle node with null content', () => { + it('should emit a placeholder paragraph for null content', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -744,6 +1218,7 @@ describe('structured-content-block', () => { const blocks: FlowBlock[] = []; const recordBlockKind = vi.fn(); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); const context: NodeHandlerContext = { blocks, @@ -764,14 +1239,19 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); - expect(blocks).toHaveLength(0); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [{ visualPlaceholder: 'emptyBlockSdt', sdt: scbMetadata }], + }); }); it('should handle converter returning empty array', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index 9302e06b60..86f4efac22 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -5,10 +5,78 @@ * paragraphs and tables while preserving their content structure. */ -import type { ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/contracts'; import type { PMNode, NodeHandlerContext } from '../types.js'; import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js'; +const NON_RENDERED_STRUCTURAL_INLINE_TYPES = new Set([ + 'bookmarkEnd', + 'commentRangeStart', + 'commentRangeEnd', + 'permStart', + 'permEnd', +]); + +function isVisuallyEmptyInlineNode(node: PMNode): boolean { + if (node.type === 'text') { + return (node.text ?? '').length === 0; + } + + if (node.type === 'run' || node.type === 'bookmarkStart') { + return !Array.isArray(node.content) || node.content.every(isVisuallyEmptyInlineNode); + } + + return NON_RENDERED_STRUCTURAL_INLINE_TYPES.has(node.type); +} + +function isEmptyParagraphNode(node: PMNode): boolean { + if (node.type !== 'paragraph') return false; + if (!Array.isArray(node.content) || node.content.length === 0) return true; + + return node.content.every(isVisuallyEmptyInlineNode); +} + +function isVanishedParagraphNode(node: PMNode): boolean { + const paragraphProperties = node.attrs?.paragraphProperties; + if (!paragraphProperties || typeof paragraphProperties !== 'object') return false; + + const runProperties = (paragraphProperties as { runProperties?: unknown }).runProperties; + if (!runProperties || typeof runProperties !== 'object') return false; + + return (runProperties as { vanish?: unknown }).vanish === true; +} + +function asEmptyTextRun(run: unknown): TextRun | undefined { + if (!run || typeof run !== 'object') return undefined; + const candidate = run as TextRun; + if (!('text' in candidate) || candidate.text !== '') return undefined; + const kind = (candidate as { kind?: unknown }).kind; + return kind == null || kind === 'text' ? candidate : undefined; +} + +function applyPlaceholderToEmptyParagraphBlocks( + paragraphBlocks: FlowBlock[], + metadata: TextRun['sdt'], + contentPos?: number, +): boolean { + let applied = false; + paragraphBlocks.forEach((block) => { + if (block.kind !== 'paragraph') return; + const run = block.runs.map(asEmptyTextRun).find(Boolean); + if (!run) return; + run.kind = 'text'; + run.text = ''; + run.sdt = metadata; + run.visualPlaceholder = 'emptyBlockSdt'; + if (contentPos != null) { + run.pmStart = contentPos; + run.pmEnd = contentPos; + } + applied = true; + }); + return applied; +} + /** * Handle structured content block nodes. * Processes child paragraphs and tables, applying SDT metadata. @@ -17,13 +85,13 @@ import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMeta * @param context - Shared handler context */ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHandlerContext): void { - if (!Array.isArray(node.content)) return; - const { blocks, recordBlockKind, nextBlockId, positions, + defaultFont, + defaultSize, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -33,7 +101,77 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand themeColors, } = context; const structuredContentMetadata = resolveNodeSdtMetadata(node, 'structuredContentBlock'); - const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; + const paragraphToFlowBlocks = converters?.paragraphToFlowBlocks; + + const emitPlaceholderBlock = (contentPos?: number): void => { + if (!structuredContentMetadata) return; + const placeholderRun: TextRun = { + kind: 'text', + text: '', + fontFamily: defaultFont, + fontSize: defaultSize, + sdt: structuredContentMetadata, + visualPlaceholder: 'emptyBlockSdt', + ...(contentPos != null ? { pmStart: contentPos, pmEnd: contentPos } : {}), + }; + const placeholderBlock: ParagraphBlock = { + kind: 'paragraph', + id: nextBlockId('paragraph'), + runs: [placeholderRun], + attrs: { sdt: structuredContentMetadata }, + }; + blocks.push(placeholderBlock); + recordBlockKind?.(placeholderBlock.kind); + }; + + if (!Array.isArray(node.content) || node.content.length === 0) { + const pos = positions.get(node); + emitPlaceholderBlock(pos ? pos.start + 1 : undefined); + return; + } + + if (node.content.length === 1 && isEmptyParagraphNode(node.content[0])) { + const isVanishedParagraph = isVanishedParagraphNode(node.content[0]); + const paragraphPos = positions.get(node.content[0]); + const blockPos = positions.get(node); + const contentPos = paragraphPos ? paragraphPos.start + 1 : blockPos ? blockPos.start + 1 : undefined; + + if (paragraphToFlowBlocks) { + const convertedBlocks = paragraphToFlowBlocks({ + para: node.content[0], + nextBlockId, + positions, + trackedChangesConfig, + bookmarks, + hyperlinkConfig, + themeColors, + enableComments, + converters, + converterContext, + }); + const paragraphBlocks = Array.isArray(convertedBlocks) ? convertedBlocks : []; + applySdtMetadataToParagraphBlocks( + paragraphBlocks.filter((b) => b.kind === 'paragraph') as ParagraphBlock[], + structuredContentMetadata, + ); + if (applyPlaceholderToEmptyParagraphBlocks(paragraphBlocks, structuredContentMetadata, contentPos)) { + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind?.(block.kind); + }); + return; + } + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind?.(block.kind); + }); + return; + } + + if (isVanishedParagraph) return; + emitPlaceholderBlock(contentPos); + return; + } // SD-1333: a documentPartObject is a transparent SDT wrapper. When it sits // as a direct child of a structuredContentBlock (e.g. a Signature SDT @@ -42,6 +180,9 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand // outer SDT metadata to them. const visitChild = (child: PMNode): void => { if (child.type === 'paragraph') { + if (!paragraphToFlowBlocks) { + throw new Error('paragraphToFlowBlocks converter is required for structuredContentBlock paragraphs'); + } const paragraphBlocks = paragraphToFlowBlocks({ para: child, nextBlockId, diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts index bfd3b47778..1a2e13e361 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts @@ -65,6 +65,8 @@ type CoreCommandNames = | 'selectInlineSdtBeforeRunStart' | 'selectInlineSdtAfterRunEnd' | 'deleteBlockSdtAtTextBlockStart' + | 'moveIntoBlockSdtBeforeTextBlockStart' + | 'moveIntoBlockSdtAfterTextBlockEnd' | 'deleteSkipEmptyRun' | 'deleteNextToRun' | 'deleteAtomAfter' diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts index ef8c702ac0..29aeb75e6a 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts @@ -11,5 +11,7 @@ describe('core command map types', () => { expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'"); expect(declaration).toContain("| 'selectInlineSdtAfterRunEnd'"); + expect(declaration).toContain("| 'moveIntoBlockSdtBeforeTextBlockStart'"); + expect(declaration).toContain("| 'moveIntoBlockSdtAfterTextBlockEnd'"); }); }); diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js new file mode 100644 index 0000000000..e5b4df09f2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/textPositions.js @@ -0,0 +1,110 @@ +const ZERO_WIDTH_MARKER_NODE_NAMES = new Set([ + 'bookmarkStart', + 'bookmarkEnd', + 'commentRangeStart', + 'commentRangeEnd', + 'commentReference', + 'permStart', + 'permEnd', + 'permStartBlock', + 'permEndBlock', + 'tableOfContentsEntry', + 'indexEntry', + 'authorityEntry', + 'passthroughInline', + 'passthroughBlock', +]); + +export function isZeroWidthMarker(node) { + if (node.type.name === 'fieldAnnotation' && node.attrs?.hidden === true) return true; + return ZERO_WIDTH_MARKER_NODE_NAMES.has(node.type.name); +} + +/** + * Finds the first text cursor position inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +export function findFirstTextPosInNode(node, nodePos) { + if (node.isText) return nodePos; + + for (let index = 0, offset = 0; index < node.childCount; index += 1) { + const child = node.child(index); + const childPos = nodePos + 1 + offset; + const found = findFirstTextPosInNode(child, childPos); + if (found != null) return found; + offset += child.nodeSize; + } + + return null; +} + +/** + * Finds the first cursor position for visible content inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +export function findFirstContentCursorPosInNode(node, nodePos) { + if (isZeroWidthMarker(node)) return null; + if (node.isText || node.isAtom) return nodePos; + if (node.isTextblock && node.childCount === 0) return nodePos + 1; + + for (let index = 0, offset = 0; index < node.childCount; index += 1) { + const child = node.child(index); + const childPos = nodePos + 1 + offset; + const found = findFirstContentCursorPosInNode(child, childPos); + if (found != null) return found; + offset += child.nodeSize; + } + + if (node.isTextblock) return nodePos + 1; + + return null; +} + +/** + * Finds the last text cursor position inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +export function findLastTextPosInNode(node, nodePos) { + if (node.isText) return nodePos + (node.text?.length ?? 0); + + for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { + const child = node.child(index); + offset -= child.nodeSize; + const childPos = nodePos + 1 + offset; + const found = findLastTextPosInNode(child, childPos); + if (found != null) return found; + } + + return null; +} + +/** + * Finds the last cursor position for visible content inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +export function findLastContentCursorPosInNode(node, nodePos) { + if (isZeroWidthMarker(node)) return null; + if (node.isText) return nodePos + (node.text?.length ?? 0); + if (node.isAtom) return node.isInline ? nodePos + node.nodeSize : nodePos; + if (node.isTextblock && node.childCount === 0) return nodePos + 1; + + for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { + const child = node.child(index); + offset -= child.nodeSize; + const childPos = nodePos + 1 + offset; + const found = findLastContentCursorPosInNode(child, childPos); + if (found != null) return found; + } + + if (node.isTextblock) return nodePos + node.nodeSize - 1; + + return null; +} diff --git a/packages/super-editor/src/editors/v1/core/commands/index.js b/packages/super-editor/src/editors/v1/core/commands/index.js index 541c2c15af..82ee55614d 100644 --- a/packages/super-editor/src/editors/v1/core/commands/index.js +++ b/packages/super-editor/src/editors/v1/core/commands/index.js @@ -54,6 +54,8 @@ export * from './backspaceAcrossRuns.js'; export * from './backspaceAtomBefore.js'; export * from './selectInlineSdtBeforeRunStart.js'; export * from './deleteBlockSdtAtTextBlockStart.js'; +export * from './moveIntoBlockSdtBeforeTextBlockStart.js'; +export * from './moveIntoBlockSdtAfterTextBlockEnd.js'; export * from './deleteSkipEmptyRun.js'; export * from './deleteNextToRun.js'; export * from './deleteAtomAfter.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js new file mode 100644 index 0000000000..d2bc26a07a --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.js @@ -0,0 +1 @@ +export { moveIntoBlockSdtAfterTextBlockEnd } from './moveIntoBlockSdtAtTextBlockBoundary.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js new file mode 100644 index 0000000000..0f80f2b1a7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAfterTextBlockEnd.test.js @@ -0,0 +1,631 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; +import { moveIntoBlockSdtAfterTextBlockEnd } from './moveIntoBlockSdtAfterTextBlockEnd.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + structuredContentBlock: { + group: 'block', + content: 'block*', + isolating: true, + attrs: { + lockMode: { default: 'unlocked' }, + }, + }, + permStartBlock: { group: 'block', atom: true }, + table: { group: 'block', content: 'tableRow+' }, + tableRow: { content: 'tableCell+' }, + tableCell: { content: 'block+' }, + noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '-' }, + bookmarkEnd: { inline: true, group: 'inline', atom: true }, + tableOfContentsEntry: { inline: true, group: 'inline', atom: true }, + passthroughBlock: { group: 'block', atom: true }, + mathBlock: { group: 'block', atom: true }, + fieldAnnotation: { + inline: true, + group: 'inline', + atom: true, + attrs: { hidden: { default: false } }, + }, + image: { inline: true, group: 'inline', atom: true }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); +const atomRun = (schema, nodeName) => schema.nodes.run.create(null, schema.nodes[nodeName].create()); +const marker = (schema, nodeName) => schema.nodes[nodeName].create({ id: 'marker-id' }); +const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); +const emptyParagraph = (schema) => schema.nodes.paragraph.create(); + +const makeDoc = (schema) => + schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.table.create(null, [ + schema.nodes.tableRow.create(null, [ + schema.nodes.tableCell.create(null, paragraph(schema, 'A1')), + schema.nodes.tableCell.create(null, paragraph(schema, 'B1')), + ]), + schema.nodes.tableRow.create(null, [ + schema.nodes.tableCell.create(null, paragraph(schema, 'A2')), + schema.nodes.tableCell.create(null, paragraph(schema, 'B2')), + ]), + ]), + ]), + paragraph(schema, 'After'), + ]); + +const findTextPos = (doc, text, offset = 0) => { + let found = null; + doc.descendants((node, pos) => { + if (!node.isText || found != null) return found == null; + const index = node.text.indexOf(text); + if (index === -1) return true; + found = pos + index + offset; + return false; + }); + expect(found).not.toBeNull(); + return found; +}; + +const findEmptyParagraphTextPos = (doc) => { + let found = null; + doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.childCount === 0 && found == null) { + found = pos + 1; + return false; + } + return true; + }); + expect(found).not.toBeNull(); + return found; +}; + +const findNodePos = (doc, nodeName) => { + let found = null; + doc.descendants((node, pos) => { + if (node.type.name === nodeName && found == null) { + found = pos; + return false; + } + return true; + }); + expect(found).not.toBeNull(); + return found; +}; + +describe('moveIntoBlockSdtAfterTextBlockEnd', () => { + it('moves from the end of the preceding paragraph to the first text position inside a following block SDT', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const beforeEnd = findTextPos(doc, 'Before', 6); + const a1Start = findTextPos(doc, 'A1'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.doc.textContent).toBe('BeforeA1B1A2B2After'); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(a1Start); + expect(dispatched.selection.to).toBe(a1Start); + }); + + it('moves from an empty preceding paragraph to the first text position inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + emptyParagraph(schema), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findEmptyParagraphTextPos(doc); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(innerStart); + expect(dispatched.selection.to).toBe(innerStart); + }); + + it('moves from a nested preceding paragraph to a sibling block SDT inside the same parent SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.structuredContentBlock.create(null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + ]), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(innerStart); + expect(dispatched.selection.to).toBe(innerStart); + }); + + it('moves into a following block SDT that only contains an empty paragraph', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [emptyParagraph(schema)]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const targetPos = findEmptyParagraphTextPos(doc); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(targetPos); + expect(dispatched.selection.to).toBe(targetPos); + }); + + it('returns false for a following block SDT with no cursor target', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('moves into the leading empty paragraph of a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [emptyParagraph(schema), paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const targetPos = findEmptyParagraphTextPos(doc); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(targetPos); + expect(dispatched.selection.to).toBe(targetPos); + }); + + it('ignores trailing inline markers when checking the preceding paragraph end', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Before'), marker(schema, 'bookmarkEnd')]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('ignores leading inline markers when targeting a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [marker(schema, 'bookmarkEnd'), run(schema, 'Inner')]), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('targets a marker-only leading paragraph inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [marker(schema, 'bookmarkEnd')]), + paragraph(schema, 'Inner'), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const targetPos = findNodePos(doc, 'bookmarkEnd'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(targetPos); + }); + + it('skips leading hidden block markers when targeting a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.permStartBlock.create(), + paragraph(schema, 'Inner'), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('skips hidden block markers between the preceding paragraph and following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.permStartBlock.create(), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('ignores trailing hidden metadata atoms when checking the preceding paragraph end', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Before'), schema.nodes.tableOfContentsEntry.create()]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('skips leading hidden metadata atoms when targeting a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.passthroughBlock.create(), + paragraph(schema, 'Inner'), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('selects a visible leading block atom inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [schema.nodes.mathBlock.create(), paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const mathBlockStart = findNodePos(doc, 'mathBlock'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(mathBlockStart); + }); + + it('ignores trailing hidden field annotations when checking the preceding paragraph end', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [ + run(schema, 'Before'), + schema.nodes.fieldAnnotation.create({ hidden: true }), + ]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('targets text after a hidden leading field annotation inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [ + schema.nodes.fieldAnnotation.create({ hidden: true }), + run(schema, 'Inner'), + ]), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const innerStart = findTextPos(doc, 'Inner'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerStart); + }); + + it('returns false when visible field annotation content appears after the last text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [ + run(schema, 'Before'), + schema.nodes.fieldAnnotation.create({ hidden: false }), + ]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 6)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when visible inline atom content appears after the last text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Before'), schema.nodes.image.create()]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 6)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('targets a visible leading inline atom inside a following block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [schema.nodes.image.create(), run(schema, 'Inner')]), + ]), + paragraph(schema, 'After'), + ]); + const beforeEnd = findTextPos(doc, 'Before', 6); + const imageStart = findNodePos(doc, 'image'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(imageStart); + }); + + it('returns false when inline atom content appears after the last text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Before'), atomRun(schema, 'noBreakHyphen')]), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 6)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the caret is not at the visible textblock end', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 5)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the next sibling is not a block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [paragraph(schema, 'Before'), paragraph(schema, 'After')]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'Before', 6)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAtTextBlockBoundary.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAtTextBlockBoundary.js new file mode 100644 index 0000000000..e145481720 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtAtTextBlockBoundary.js @@ -0,0 +1,88 @@ +import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, + isZeroWidthMarker, +} from './helpers/textPositions.js'; + +function findAncestorDepth($pos, predicate) { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (predicate($pos.node(depth))) return depth; + } + return null; +} + +function findSiblingAcrossHiddenMarkers(doc, pos, direction) { + let currentPos = pos; + let node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter; + + while (node && isZeroWidthMarker(node)) { + currentPos += direction === 'before' ? -node.nodeSize : node.nodeSize; + node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter; + } + + return { + node, + nodePos: direction === 'before' && node ? currentPos - node.nodeSize : currentPos, + }; +} + +function createSelectionAtContentPos(doc, pos, bias) { + const $pos = doc.resolve(pos); + if ($pos.parent.inlineContent) return TextSelection.create(doc, pos); + if ($pos.nodeAfter && NodeSelection.isSelectable($pos.nodeAfter)) return NodeSelection.create(doc, pos); + return Selection.near($pos, bias); +} + +function moveIntoAdjacentBlockSdt(direction) { + return ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const { $from } = selection; + const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); + if (textblockDepth == null) return false; + + const textblock = $from.node(textblockDepth); + const textblockPos = $from.before(textblockDepth); + + const atTextblockBoundary = + direction === 'before' + ? (findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth)) + : (findLastContentCursorPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth)); + if (atTextblockBoundary !== $from.pos) return false; + + const siblingBoundaryPos = direction === 'before' ? textblockPos : $from.after(textblockDepth); + const { node, nodePos } = findSiblingAcrossHiddenMarkers(state.doc, siblingBoundaryPos, direction); + if (node?.type.name !== 'structuredContentBlock') return false; + + const targetPos = + direction === 'before' + ? findLastContentCursorPosInNode(node, nodePos) + : findFirstContentCursorPosInNode(node, nodePos); + if (targetPos == null) return false; + + if (dispatch) { + const targetSelection = createSelectionAtContentPos(state.doc, targetPos, direction === 'before' ? -1 : 1); + dispatch(state.tr.setSelection(targetSelection).scrollIntoView()); + } + + return true; + }; +} + +/** + * Moves the caret into the previous block SDT when Backspace is pressed at the + * start of the following textblock. + * + * @returns {import('@core/commands/types').Command} + */ +export const moveIntoBlockSdtBeforeTextBlockStart = () => moveIntoAdjacentBlockSdt('before'); + +/** + * Moves the caret into the next block SDT when Delete is pressed at the end of + * the preceding textblock. + * + * @returns {import('@core/commands/types').Command} + */ +export const moveIntoBlockSdtAfterTextBlockEnd = () => moveIntoAdjacentBlockSdt('after'); diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js new file mode 100644 index 0000000000..c4a54d55dd --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.js @@ -0,0 +1 @@ +export { moveIntoBlockSdtBeforeTextBlockStart } from './moveIntoBlockSdtAtTextBlockBoundary.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js new file mode 100644 index 0000000000..12aa6855d1 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/moveIntoBlockSdtBeforeTextBlockStart.test.js @@ -0,0 +1,615 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; +import { moveIntoBlockSdtBeforeTextBlockStart } from './moveIntoBlockSdtBeforeTextBlockStart.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + structuredContentBlock: { + group: 'block', + content: 'block*', + isolating: true, + attrs: { + lockMode: { default: 'unlocked' }, + }, + }, + permEndBlock: { group: 'block', atom: true }, + table: { group: 'block', content: 'tableRow+' }, + tableRow: { content: 'tableCell+' }, + tableCell: { content: 'block+' }, + noBreakHyphen: { inline: true, group: 'inline', atom: true, leafText: () => '-' }, + bookmarkEnd: { inline: true, group: 'inline', atom: true }, + tableOfContentsEntry: { inline: true, group: 'inline', atom: true }, + passthroughBlock: { group: 'block', atom: true }, + mathBlock: { group: 'block', atom: true }, + fieldAnnotation: { + inline: true, + group: 'inline', + atom: true, + attrs: { hidden: { default: false } }, + }, + image: { inline: true, group: 'inline', atom: true }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); +const atomRun = (schema, nodeName) => schema.nodes.run.create(null, schema.nodes[nodeName].create()); +const marker = (schema, nodeName) => schema.nodes[nodeName].create({ id: 'marker-id' }); +const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); +const emptyParagraph = (schema) => schema.nodes.paragraph.create(); + +const makeDoc = (schema) => + schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.table.create(null, [ + schema.nodes.tableRow.create(null, [ + schema.nodes.tableCell.create(null, paragraph(schema, 'A1')), + schema.nodes.tableCell.create(null, paragraph(schema, 'B1')), + ]), + schema.nodes.tableRow.create(null, [ + schema.nodes.tableCell.create(null, paragraph(schema, 'A2')), + schema.nodes.tableCell.create(null, paragraph(schema, 'B2')), + ]), + ]), + ]), + paragraph(schema, 'After'), + ]); + +const findTextPos = (doc, text, offset = 0) => { + let found = null; + doc.descendants((node, pos) => { + if (!node.isText || found != null) return found == null; + const index = node.text.indexOf(text); + if (index === -1) return true; + found = pos + index + offset; + return false; + }); + expect(found).not.toBeNull(); + return found; +}; + +const findEmptyParagraphTextPos = (doc) => { + let found = null; + doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.childCount === 0 && found == null) { + found = pos + 1; + return false; + } + return true; + }); + expect(found).not.toBeNull(); + return found; +}; + +const findNodePos = (doc, nodeName) => { + let found = null; + doc.descendants((node, pos) => { + if (node.type.name === nodeName && found == null) { + found = pos; + return false; + } + return true; + }); + expect(found).not.toBeNull(); + return found; +}; + +describe('moveIntoBlockSdtBeforeTextBlockStart', () => { + it('moves from the start of the following paragraph to the last text position inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const afterStart = findTextPos(doc, 'After'); + const b2End = findTextPos(doc, 'B2', 2); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.doc.textContent).toBe('BeforeA1B1A2B2After'); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(b2End); + expect(dispatched.selection.to).toBe(b2End); + }); + + it('moves from an empty following paragraph to the last text position inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + emptyParagraph(schema), + ]); + const afterStart = findEmptyParagraphTextPos(doc); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(innerEnd); + expect(dispatched.selection.to).toBe(innerEnd); + }); + + it('moves from a nested following paragraph to a sibling block SDT inside the same parent SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + paragraph(schema, 'After'), + ]), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(innerEnd); + expect(dispatched.selection.to).toBe(innerEnd); + }); + + it('moves into a previous block SDT that only contains an empty paragraph', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [emptyParagraph(schema)]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const targetPos = findEmptyParagraphTextPos(doc); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(targetPos); + expect(dispatched.selection.to).toBe(targetPos); + }); + + it('returns false for a previous block SDT with no cursor target', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('moves into the trailing empty paragraph of a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner'), emptyParagraph(schema)]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const targetPos = findEmptyParagraphTextPos(doc); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.steps).toHaveLength(0); + expect(dispatched.selection.from).toBe(targetPos); + expect(dispatched.selection.to).toBe(targetPos); + }); + + it('ignores leading inline markers when checking the following paragraph start', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [marker(schema, 'bookmarkEnd'), run(schema, 'After')]), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('ignores trailing inline markers when targeting a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Inner'), marker(schema, 'bookmarkEnd')]), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('targets the end of a marker-only trailing paragraph inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + paragraph(schema, 'Inner'), + schema.nodes.paragraph.create(null, [marker(schema, 'bookmarkEnd')]), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const targetPos = findNodePos(doc, 'bookmarkEnd') + schema.nodes.bookmarkEnd.create().nodeSize; + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(targetPos); + }); + + it('skips trailing hidden block markers when targeting a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + paragraph(schema, 'Inner'), + schema.nodes.permEndBlock.create(), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('skips hidden block markers between the previous block SDT and following paragraph', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.permEndBlock.create(), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('ignores leading hidden metadata atoms when checking the following paragraph start', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [schema.nodes.tableOfContentsEntry.create(), run(schema, 'After')]), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('skips trailing hidden metadata atoms when targeting a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + paragraph(schema, 'Inner'), + schema.nodes.passthroughBlock.create(), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('selects a visible trailing block atom inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner'), schema.nodes.mathBlock.create()]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const mathBlockStart = findNodePos(doc, 'mathBlock'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(mathBlockStart); + }); + + it('ignores leading hidden field annotations when checking the following paragraph start', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [ + schema.nodes.fieldAnnotation.create({ hidden: true }), + run(schema, 'After'), + ]), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('targets text before a hidden trailing field annotation inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [ + run(schema, 'Inner'), + schema.nodes.fieldAnnotation.create({ hidden: true }), + ]), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const innerEnd = findTextPos(doc, 'Inner', 5); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(innerEnd); + }); + + it('returns false when visible field annotation content appears before the first text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [ + schema.nodes.fieldAnnotation.create({ hidden: false }), + run(schema, 'After'), + ]), + ]); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, findTextPos(doc, 'After')) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when visible inline atom content appears before the first text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [schema.nodes.image.create(), run(schema, 'After')]), + ]); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, findTextPos(doc, 'After')) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('targets a visible trailing inline atom inside a previous block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [ + schema.nodes.paragraph.create(null, [run(schema, 'Inner'), schema.nodes.image.create()]), + ]), + paragraph(schema, 'After'), + ]); + const afterStart = findTextPos(doc, 'After'); + const imageEnd = findNodePos(doc, 'image') + schema.nodes.image.create().nodeSize; + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection.from).toBe(imageEnd); + }); + + it('returns false when inline atom content appears before the first text position', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + paragraph(schema, 'Before'), + schema.nodes.structuredContentBlock.create(null, [paragraph(schema, 'Inner')]), + schema.nodes.paragraph.create(null, [atomRun(schema, 'noBreakHyphen'), run(schema, 'After')]), + ]); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, findTextPos(doc, 'After')) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the caret is not at the visible textblock start', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'After', 1)), + }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the previous sibling is not a block SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [paragraph(schema, 'Before'), paragraph(schema, 'After')]); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, findTextPos(doc, 'After')) }); + const dispatch = vi.fn(); + + const ok = moveIntoBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js index f653d6e360..98538477e7 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js @@ -38,6 +38,7 @@ describe('handleBackspace chain ordering', () => { undoInputRule: make('undoInputRule'), deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'), + moveIntoBlockSdtBeforeTextBlockStart: make('moveIntoBlockSdtBeforeTextBlockStart'), backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'), backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'), backspaceAtomBefore: make('backspaceAtomBefore'), @@ -75,6 +76,7 @@ describe('handleBackspace chain ordering', () => { // step 2 sets inputType meta and returns false (no command call) 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtBeforeRunStart', + 'moveIntoBlockSdtBeforeTextBlockStart', 'backspaceEmptyRunParagraph', 'backspaceSkipEmptyRun', 'backspaceAtomBefore', @@ -105,6 +107,7 @@ describe('handleBackspace chain ordering', () => { expect(callLog[0]).toBe('undoInputRule'); expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart'); expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart'); + expect(callLog[3]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); }); it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => { @@ -176,6 +179,7 @@ describe('handleDelete chain ordering', () => { const commands = { deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'), + moveIntoBlockSdtAfterTextBlockEnd: make('moveIntoBlockSdtAfterTextBlockEnd'), deleteSkipEmptyRun: make('deleteSkipEmptyRun'), deleteAtomAfter: make('deleteAtomAfter'), deleteNextToRun: make('deleteNextToRun'), @@ -208,6 +212,7 @@ describe('handleDelete chain ordering', () => { expect(callLog).toEqual([ 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtAfterRunEnd', + 'moveIntoBlockSdtAfterTextBlockEnd', 'deleteSkipEmptyRun', 'deleteAtomAfter', 'deleteNextToRun', diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap.js b/packages/super-editor/src/editors/v1/core/extensions/keymap.js index c1873ff34f..20422b5383 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -39,6 +39,7 @@ export const handleBackspace = (editor) => { }, () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtBeforeRunStart(), + () => commands.moveIntoBlockSdtBeforeTextBlockStart(), () => commands.backspaceEmptyRunParagraph(), () => commands.backspaceSkipEmptyRun(), () => commands.backspaceAtomBefore(), @@ -60,6 +61,7 @@ export const handleDelete = (editor) => { return editor.commands.first(({ commands }) => [ () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtAfterRunEnd(), + () => commands.moveIntoBlockSdtAfterTextBlockEnd(), () => commands.deleteSkipEmptyRun(), () => commands.deleteAtomAfter(), () => commands.deleteNextToRun(), diff --git a/packages/super-editor/src/editors/v1/core/helpers/getViewModeSelectionWithoutStructuredContent.js b/packages/super-editor/src/editors/v1/core/helpers/getViewModeSelectionWithoutStructuredContent.js index 9e159eb082..189dd311b2 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/getViewModeSelectionWithoutStructuredContent.js +++ b/packages/super-editor/src/editors/v1/core/helpers/getViewModeSelectionWithoutStructuredContent.js @@ -1,11 +1,10 @@ import { NodeSelection, Selection } from 'prosemirror-state'; - -const STRUCTURED_CONTENT_NODE_TYPES = new Set(['structuredContent', 'structuredContentBlock']); +import { isStructuredContentNodeType } from '../../extensions/structured-content/nodeTypes.js'; function findEnclosingStructuredContentPosition($pos) { for (let depth = $pos.depth; depth > 0; depth--) { const node = $pos.node(depth); - if (STRUCTURED_CONTENT_NODE_TYPES.has(node.type.name)) { + if (isStructuredContentNodeType(node.type.name)) { return $pos.before(depth); } } @@ -16,7 +15,7 @@ function findEnclosingStructuredContentPosition($pos) { export function getViewModeSelectionWithoutStructuredContent(state) { const { selection, doc } = state; - if (selection instanceof NodeSelection && STRUCTURED_CONTENT_NODE_TYPES.has(selection.node.type.name)) { + if (selection instanceof NodeSelection && isStructuredContentNodeType(selection.node.type.name)) { const candidate = Selection.near(doc.resolve(selection.from), -1); const candidatePos = findEnclosingStructuredContentPosition(candidate.$from); if (candidatePos !== null) return null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts index fb5c61a54b..5c77da3e7e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.test.ts @@ -156,4 +156,65 @@ describe('createInternalNodeMoveTransaction', () => { expect(canInsertAt).toHaveBeenCalledWith(tr.doc, 70, sourceNode); expect(tr.insert).toHaveBeenCalledWith(70, sourceNode); }); + + it('moves a block node to the after boundary when dropped near the end of text while dragging upward', () => { + const { doc, tr, sourceNode } = createMoveState({ mappedTarget: 88 }); + doc.nodeAt.mockImplementation((pos: number) => (pos === 120 ? sourceNode : null)); + tr.doc = { + content: { size: 200 }, + resolve: vi.fn(() => ({ + depth: 1, + before: vi.fn(() => 70), + after: vi.fn(() => 90), + })), + } as never; + const canInsertAt = vi.fn((_doc, pos: number) => pos === 70 || pos === 90); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 120, + sourceEnd: 130, + targetPos: 88, + canInsertAt, + }, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.mappedTarget).toBe(90); + } + expect(tr.insert).toHaveBeenCalledWith(90, sourceNode); + }); + + it('avoids restoring a moved block to its original boundary when another fallback boundary is valid', () => { + const { doc, tr, sourceNode } = createMoveState({ sourceNodeSize: 40 }); + doc.nodeAt.mockImplementation((pos: number) => (pos === 19 ? sourceNode : null)); + tr.doc = { + content: { size: 59 }, + resolve: vi.fn(() => ({ + depth: 1, + before: vi.fn(() => 10), + after: vi.fn(() => 19), + })), + } as never; + tr.mapping.map = vi.fn((pos: number) => pos); + const canInsertAt = vi.fn((_doc, pos: number) => pos === 10 || pos === 19); + + const result = createInternalNodeMoveTransaction( + { doc: doc as never, tr: tr as never }, + { + sourceStart: 19, + sourceEnd: 59, + targetPos: 17, + canInsertAt, + }, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.mappedTarget).toBe(10); + } + expect(tr.insert).toHaveBeenCalledWith(10, sourceNode); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts index 5da4ea8646..385430510a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/internal-node-move.ts @@ -19,6 +19,10 @@ type InternalMoveState = { }; type TargetBias = 'before' | 'after'; +type BoundaryCandidate = { + pos: number; + side: TargetBias; +}; export function canInsertNodeAtPosition(doc: ProseMirrorNode, pos: number, node: ProseMirrorNode): boolean { try { @@ -42,31 +46,44 @@ function resolveInsertionBoundary( node: ProseMirrorNode, canInsertAt: InternalMoveRequest['canInsertAt'], bias: TargetBias, + avoidPos?: number, ): number | null { try { const resolvedPos = doc.resolve(pos); - const candidates: number[] = []; + const candidates: BoundaryCandidate[] = []; for (let depth = resolvedPos.depth; depth > 0; depth--) { - const before = resolvedPos.before(depth); - const after = resolvedPos.after(depth); - if (bias === 'before') { - candidates.push(before, after); - } else { - candidates.push(after, before); - } + candidates.push( + { pos: resolvedPos.before(depth), side: 'before' }, + { pos: resolvedPos.after(depth), side: 'after' }, + ); } + candidates.sort((a, b) => { + const distanceDelta = Math.abs(a.pos - pos) - Math.abs(b.pos - pos); + if (distanceDelta !== 0) return distanceDelta; + if (a.side === bias && b.side !== bias) return -1; + if (b.side === bias && a.side !== bias) return 1; + return 0; + }); + + let avoidedCandidate: number | null = null; for (const candidate of candidates) { - if (candidate < 0 || candidate > doc.content.size) continue; - if (candidate === pos) continue; - if (canInsertAt(doc, candidate, node)) return candidate; + const candidatePos = candidate.pos; + if (candidatePos < 0 || candidatePos > doc.content.size) continue; + if (candidatePos === pos) continue; + if (!canInsertAt(doc, candidatePos, node)) continue; + if (avoidPos != null && candidatePos === avoidPos) { + avoidedCandidate = candidatePos; + continue; + } + return candidatePos; } + + return avoidedCandidate; } catch { return null; } - - return null; } export function createInternalNodeMoveTransaction( @@ -92,6 +109,7 @@ export function createInternalNodeMoveTransaction( tr.delete(sourceStart, sourceEnd); const mappedTarget = tr.mapping.map(targetPos); + const mappedSourceStart = tr.mapping.map(sourceStart); if (mappedTarget < 0 || mappedTarget > tr.doc.content.size) { return { ok: false, reason: 'invalid-target' }; } @@ -104,6 +122,7 @@ export function createInternalNodeMoveTransaction( sourceNode, canInsertAt, targetPos <= sourceStart ? 'before' : 'after', + mappedSourceStart, ); if (boundaryTarget == null) { return { ok: false, reason: 'invalid-target' }; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 29dad7095c..1639d5c71d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -72,6 +72,7 @@ const COMMENT_THREAD_HIT_TOLERANCE_PX = 3; const INLINE_SDT_LABEL_SELECTOR = `.${DOM_CLASS_NAMES.INLINE_SDT_LABEL}`; const BLOCK_SDT_LABEL_SELECTOR = `.${DOM_CLASS_NAMES.BLOCK_SDT_LABEL}`; const SDT_LABEL_SELECTOR = `${INLINE_SDT_LABEL_SELECTOR}, ${BLOCK_SDT_LABEL_SELECTOR}`; +const EMPTY_SDT_PLACEHOLDER_SELECTOR = '.superdoc-empty-sdt-placeholder'; const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray = [ [0, 0], [-COMMENT_THREAD_HIT_TOLERANCE_PX, 0], @@ -1719,13 +1720,14 @@ export class EditorInputManager { let nextSelection: Selection; let inlineSdtBoundaryPos: number | null = null; let inlineSdtBoundaryDirection: 'before' | 'after' | null = null; + const clickedEmptySdtPlaceholder = target?.closest?.(EMPTY_SDT_PLACEHOLDER_SELECTOR) != null; const inlineSdt = clickDepth === 1 ? findStructuredContentInlineAtPos(doc, hit.pos) : null; - if (inlineSdt && hit.pos >= inlineSdt.end) { + if (!clickedEmptySdtPlaceholder && inlineSdt && hit.pos >= inlineSdt.end) { const afterInlineSdt = inlineSdt.pos + inlineSdt.node.nodeSize; inlineSdtBoundaryPos = afterInlineSdt; inlineSdtBoundaryDirection = 'after'; nextSelection = TextSelection.create(doc, afterInlineSdt); - } else if (inlineSdt && hit.pos <= inlineSdt.start) { + } else if (!clickedEmptySdtPlaceholder && inlineSdt && hit.pos <= inlineSdt.start) { inlineSdtBoundaryPos = inlineSdt.pos; inlineSdtBoundaryDirection = 'before'; nextSelection = TextSelection.create(doc, inlineSdt.pos); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts index b22985dc67..17072dba52 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts @@ -1651,6 +1651,72 @@ describe('computeDomCaretPageLocal', () => { y: 20, }); }); + + it('positions caret at the left edge of an empty block SDT placeholder', () => { + painterHost.innerHTML = ` +
+
+ + + +
+
+ `; + + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const lineEl = painterHost.querySelector('.superdoc-line') as HTMLElement; + const placeholderEl = painterHost.querySelector('.superdoc-empty-block-sdt-placeholder') as HTMLElement; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + lineEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 100, 16)); + placeholderEl.getBoundingClientRect = vi.fn(() => createRect(10, 34, 205, 16)); + + const options = createCaretOptions(); + const caret = computeDomCaretPageLocal(options, 5); + + expect(caret).not.toBe(null); + expect(caret).toMatchObject({ + pageIndex: 0, + x: 10, + y: 20, + }); + }); + + it('positions caret at the right edge when it is after an empty inline SDT placeholder', () => { + painterHost.innerHTML = ` +
+
+ Lead + + + + trail. +
+
+ `; + + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const lineEl = painterHost.querySelector('.superdoc-line') as HTMLElement; + const placeholderEl = painterHost.querySelector('.superdoc-empty-inline-sdt-placeholder') as HTMLElement; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + lineEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 250, 16)); + placeholderEl.getBoundingClientRect = vi.fn(() => createRect(60, 20, 205, 16)); + + const options = createCaretOptions(); + const caret = computeDomCaretPageLocal(options, 10); + + expect(caret).not.toBe(null); + expect(caret).toMatchObject({ + pageIndex: 0, + x: 265, + y: 20, + }); + }); }); describe('index rebuild for disconnected elements', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts index fca9af1dba..3f3bac5361 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts @@ -58,7 +58,9 @@ function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent { ); } -function createMockDoc(mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary' | 'nestedInlineInBlock') { +function createMockDoc( + mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary' | 'emptyInlineSdt' | 'nestedInlineInBlock', +) { return { content: { size: 200 }, nodeAt: vi.fn(() => ({ nodeSize: 20 })), @@ -89,6 +91,19 @@ function createMockDoc(mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary end: (depth: number) => (depth === 2 ? 12 : 199), }; } + if (mode === 'emptyInlineSdt') { + return { + depth: 2, + node: (depth: number) => { + if (depth === 2) return { type: { name: 'structuredContent' }, nodeSize: 2 }; + if (depth === 1) return { type: { name: 'paragraph' } }; + return { type: { name: 'doc' } }; + }, + before: (depth: number) => (depth === 2 ? 8 : 0), + start: (depth: number) => (depth === 2 ? 9 : 1), + end: (depth: number) => (depth === 2 ? 9 : 199), + }; + } if (mode === 'nestedInlineInBlock') { return { depth: 3, @@ -175,7 +190,9 @@ describe('EditorInputManager structured content clicks', () => { let getEditor: Mock; let mockHitTestTable: Mock; - function mountWithDoc(mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary' | 'nestedInlineInBlock') { + function mountWithDoc( + mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary' | 'emptyInlineSdt' | 'nestedInlineInBlock', + ) { mockEditor.state.doc = createMockDoc(mode); } @@ -240,7 +257,7 @@ describe('EditorInputManager structured content clicks', () => { getEditor, getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })), getEpochMapper: vi.fn(() => ({ - mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 12, toEpoch: 1 })), + mapPosFromLayoutToCurrentDetailed: vi.fn((pos: number) => ({ ok: true, pos, toEpoch: 1 })), })), getViewportHost: vi.fn(() => viewportHost), getVisibleHost: vi.fn(() => visibleHost), @@ -371,6 +388,38 @@ describe('EditorInputManager structured content clicks', () => { expect(mockNodeSelectionCreate).not.toHaveBeenCalled(); }); + it('keeps placeholder clicks inside an empty inline structured content node', () => { + mountWithDoc('emptyInlineSdt'); + (resolvePointerPositionHit as unknown as Mock).mockReturnValueOnce({ + pos: 9, + layoutEpoch: 1, + pageIndex: 0, + blockId: 'body-1', + column: 0, + lineIndex: 0, + }); + const target = document.createElement('span'); + target.className = 'superdoc-empty-sdt-placeholder superdoc-empty-inline-sdt-placeholder'; + viewportHost.appendChild(target); + + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 28, + clientY: 28, + } as PointerEventInit), + ); + + expect(resolvePointerPositionHit as unknown as Mock).toHaveBeenCalled(); + expect(mockTextSelectionCreate).toHaveBeenCalledWith(mockEditor.state.doc, 9); + expect(mockApplyEditableSlotAtInlineBoundary).not.toHaveBeenCalled(); + expect(mockNodeSelectionCreate).not.toHaveBeenCalled(); + }); + it('selects the whole inline structured content when its label is clicked', () => { mountWithDoc('inlineSdtAfterBoundary'); const wrapper = document.createElement('span'); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js index 059ccf8e53..a18381b3d8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.js @@ -1,5 +1,6 @@ import { parseSizeUnit } from '../utilities/index.js'; import { xml2js } from 'xml-js'; +import { getDataUriMetadata, tryDecodeDataUriText } from './helpers/mediaHelpers.js'; // --- Browser-compatible CRC32 (replaces buffer-crc32 to avoid Node.js Buffer dependency) --- const CRC32_TABLE = new Uint32Array(256); @@ -33,9 +34,16 @@ function base64ToUint8Array(base64) { return bytes; } +function stringToUtf8ArrayBuffer(value) { + return new globalThis.TextEncoder().encode(value).buffer; +} + /** - * Convert a base64 string or data URI to an ArrayBuffer. - * Accepts ArrayBuffer, TypedArray, data URI, or raw base64 string. + * Convert media data to an ArrayBuffer for DOCX packaging. + * + * Accepts ArrayBuffer, TypedArray, raw base64 strings, base64 data URIs, and + * percent-encoded non-base64 SVG data URIs. Other non-base64 data URI MIME + * types are rejected, and malformed percent-encoded SVG payloads throw. * * @param {string|ArrayBuffer|Uint8Array} data * @returns {ArrayBuffer} @@ -50,11 +58,25 @@ function dataUriToArrayBuffer(data) { let base64 = data; if (data.startsWith('data:')) { - const commaIndex = data.indexOf(','); - if (commaIndex === -1) { - throw new Error('Invalid data URI: missing base64 content'); + const metadata = getDataUriMetadata(data); + if (!metadata?.hasPayloadSeparator) { + throw new Error('Invalid data URI: missing content'); } - base64 = data.substring(commaIndex + 1); + + if (!metadata.isBase64) { + if (metadata.mimeType !== 'image/svg+xml') { + throw new Error(`Unsupported non-base64 data URI media type: ${metadata.mimeType || 'unknown'}`); + } + + const decodedPayload = tryDecodeDataUriText(metadata.payload); + if (decodedPayload == null) { + throw new Error('Invalid non-base64 data URI payload'); + } + + return stringToUtf8ArrayBuffer(decodedPayload); + } + + base64 = metadata.payload; } return base64ToUint8Array(base64).buffer; @@ -351,10 +373,11 @@ const getArrayBufferFromUrl = async (input) => { return await response.arrayBuffer(); } - // If this is a data URI we need only the payload portion - const base64Payload = isDataUri ? trimmed.split(',', 2)[1] : trimmed.replace(/\s/g, ''); + if (isDataUri) { + return dataUriToArrayBuffer(trimmed); + } - return base64ToUint8Array(base64Payload).buffer; + return base64ToUint8Array(trimmed.replace(/\s/g, '')).buffer; }; const getContentTypesFromXml = (contentTypesXml) => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js index 60ac1a898c..e86cdab2e5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers.test.js @@ -11,6 +11,7 @@ import { detectImageType, eighthPointsToPixels, } from './helpers.js'; +import { getFallbackImageNameFromDataUri } from './helpers/mediaHelpers.js'; describe('polygonToObj', () => { it('should return null for null input', () => { @@ -335,6 +336,15 @@ describe('getArrayBufferFromUrl', () => { expect(Array.from(new Uint8Array(result))).toEqual(Array.from(bytes)); }); + it('decodes non-base64 data URIs into an ArrayBuffer', async () => { + const svg = ''; + const dataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + + const result = await getArrayBufferFromUrl(dataUri); + + expect(new TextDecoder().decode(result)).toBe(svg); + }); + it('decodes bare base64 strings into an ArrayBuffer', async () => { const bytes = new Uint8Array([55, 66, 77]); const base64 = Buffer.from(bytes).toString('base64'); @@ -407,6 +417,22 @@ describe('dataUriToArrayBuffer', () => { expect(Array.from(new Uint8Array(result))).toEqual([11, 22, 33]); }); + it('decodes a non-base64 data URI string', () => { + const svg = ''; + const result = dataUriToArrayBuffer(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`); + expect(new TextDecoder().decode(result)).toBe(svg); + }); + + it('rejects malformed non-base64 SVG payloads', () => { + expect(() => dataUriToArrayBuffer('data:image/svg+xml,%')).toThrow('Invalid non-base64 data URI payload'); + }); + + it('rejects non-base64 raster data URI strings', () => { + expect(() => dataUriToArrayBuffer('data:image/png,not-base64')).toThrow( + 'Unsupported non-base64 data URI media type', + ); + }); + it('decodes a raw base64 string', () => { const bytes = new Uint8Array([55, 66, 77]); const base64 = Buffer.from(bytes).toString('base64'); @@ -424,6 +450,21 @@ describe('dataUriToArrayBuffer', () => { }); }); +describe('getFallbackImageNameFromDataUri', () => { + it('normalizes SVG extension when the data URI has no parameters', () => { + const svg = ''; + const dataUri = `data:image/svg+xml,${encodeURIComponent(svg)}`; + + expect(getFallbackImageNameFromDataUri(dataUri)).toBe('image.svg'); + }); + + it('normalizes MIME aliases to Word-compatible image extensions', () => { + expect(getFallbackImageNameFromDataUri('data:image/jpeg;base64,abc')).toBe('image.jpg'); + expect(getFallbackImageNameFromDataUri('data:image/tiff;base64,abc')).toBe('image.tif'); + expect(getFallbackImageNameFromDataUri('data:image/x-icon;base64,abc')).toBe('image.ico'); + }); +}); + describe('detectImageType', () => { it('detects PNG from magic bytes', () => { // PNG signature: 89 50 4E 47 0D 0A 1A 0A diff --git a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js index e01fb502c0..c48596675a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/helpers/mediaHelpers.js @@ -1,3 +1,8 @@ +import { + getDataUriMetadata as getSharedDataUriMetadata, + tryDecodeDataUriText as tryDecodeSharedDataUriText, +} from '@superdoc/url-validation'; + export const sanitizeDocxMediaName = (value, fallback = 'image') => { if (!value) return fallback; @@ -5,12 +10,41 @@ export const sanitizeDocxMediaName = (value, fallback = 'image') => { return sanitized || fallback; }; -export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => { - if (!src || typeof src !== 'string') return fallback; +const MIME_TYPE_TO_EXTENSION = { + 'image/svg+xml': 'svg', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/tiff': 'tif', + 'image/tif': 'tif', + 'image/x-icon': 'ico', + 'image/vnd.microsoft.icon': 'ico', + 'image/ico': 'ico', +}; + +export const getImageExtensionFromMimeType = (mimeType) => { + const normalizedMimeType = String(mimeType || '').toLowerCase(); + if (MIME_TYPE_TO_EXTENSION[normalizedMimeType]) return MIME_TYPE_TO_EXTENSION[normalizedMimeType]; + + const [type, subtype] = normalizedMimeType.split('/'); + if (type !== 'image' || !subtype) return null; + + return subtype; +}; + +export const getDataUriMetadata = (src = '') => { + const metadata = getSharedDataUriMetadata(src); + if (!metadata) return null; + + return { + ...metadata, + extension: getImageExtensionFromMimeType(metadata.mimeType), + }; +}; + +export const tryDecodeDataUriText = (payload) => tryDecodeSharedDataUriText(payload); - const [prefix] = src.split(';'); - const [, maybeType] = prefix.split('/'); - const extension = maybeType?.toLowerCase(); +export const getFallbackImageNameFromDataUri = (src = '', fallback = 'image') => { + const extension = getDataUriMetadata(src)?.extension; return extension ? `${fallback}.${extension}` : fallback; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js index 0e1a4f5b01..5b735f3637 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.js @@ -1,4 +1,5 @@ import { base64ToUint8Array } from './helpers.js'; +import { getDataUriMetadata, tryDecodeDataUriText } from './helpers/mediaHelpers.js'; /** * Read intrinsic image dimensions from raw binary headers. @@ -144,6 +145,24 @@ function readWebpDimensions(bytes) { return null; } +function readSvgDimensions(svgText) { + if (typeof svgText !== 'string') return null; + + const svgMatch = svgText.match(/]*>/i); + if (!svgMatch) return null; + + const widthMatch = svgMatch[0].match(/\bwidth=(["']?)([0-9.]+)(?:px)?\1/i); + const heightMatch = svgMatch[0].match(/\bheight=(["']?)([0-9.]+)(?:px)?\1/i); + const width = widthMatch ? Number.parseFloat(widthMatch[2]) : NaN; + const height = heightMatch ? Number.parseFloat(heightMatch[2]) : NaN; + + if (Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0) { + return { width, height }; + } + + return null; +} + /** * Extract dimensions from a data URI's base64 payload. * @@ -153,14 +172,23 @@ function readWebpDimensions(bytes) { export function readImageDimensionsFromDataUri(dataUri) { if (typeof dataUri !== 'string' || !dataUri.startsWith('data:')) return null; - const commaIndex = dataUri.indexOf(','); - if (commaIndex === -1) return null; + const metadata = getDataUriMetadata(dataUri); + if (!metadata?.hasPayloadSeparator || !metadata.payload) return null; + + if (metadata.mimeType === 'image/svg+xml') { + try { + const svgText = metadata.isBase64 ? atob(metadata.payload) : tryDecodeDataUriText(metadata.payload); + if (svgText == null) return null; + return readSvgDimensions(svgText); + } catch { + return null; + } + } - const base64Payload = dataUri.slice(commaIndex + 1); - if (!base64Payload) return null; + if (!metadata.isBase64) return null; try { - const bytes = base64ToUint8Array(base64Payload); + const bytes = base64ToUint8Array(metadata.payload); return readImageDimensions(bytes); } catch { return null; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js index 2afbb1a044..828c090c90 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/image-dimensions.test.js @@ -1,6 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { readImageDimensions, readImageDimensionsFromDataUri } from './image-dimensions.js'; +afterEach(() => { + vi.unstubAllGlobals(); +}); + // --------------------------------------------------------------------------- // Helpers to build minimal valid headers // --------------------------------------------------------------------------- @@ -216,6 +220,20 @@ describe('readImageDimensionsFromDataUri', () => { expect(readImageDimensionsFromDataUri(uri)).toEqual({ width: 320, height: 240 }); }); + it('reads dimensions from non-base64 SVG data URI', () => { + const svg = ''; + const uri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + expect(readImageDimensionsFromDataUri(uri)).toEqual({ width: 200, height: 50 }); + }); + + it('rejects non-base64 raster data URIs without encoding payload text as bytes', () => { + const textEncoderConstructor = vi.fn(() => ({ encode: vi.fn(() => new Uint8Array()) })); + vi.stubGlobal('TextEncoder', textEncoderConstructor); + + expect(readImageDimensionsFromDataUri('data:image/png,not-base64')).toBeNull(); + expect(textEncoderConstructor).not.toHaveBeenCalled(); + }); + it('returns null for non-data-URI string', () => { expect(readImageDimensionsFromDataUri('https://example.com/image.png')).toBeNull(); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js index 35827ba02d..6057f1f5b3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { defaultNodeListHandler } from './docxImporter'; +import { stableHexHash } from '@core/utilities/hash.js'; /** * Parse comments.xml into SuperDoc-ready comments @@ -601,21 +602,6 @@ const applyParentRelationships = (comments, parentMap, trackedChangeParentMap = }); }; -/** - * Lightweight, non-cryptographic FNV-1a 32-bit hash for stable identifiers. - * - * @param {string} input - * @returns {string} 8-char hex string - */ -const simpleHash = (input) => { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); - } - return (hash >>> 0).toString(16).padStart(8, '0'); -}; - /** * Resolve a stable comment ID for imported comments. * - Prefer the explicit internal ID when present. @@ -625,6 +611,6 @@ const simpleHash = (input) => { const getCommentId = (internalId, importedId, createdTime) => { if (internalId != null) return internalId; if (importedId == null || !Number.isFinite(createdTime)) return uuidv4(); - const hash = simpleHash(`${importedId}-${createdTime}`); + const hash = stableHexHash(`${importedId}-${createdTime}`); return `imported-${hash}`; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index f6de2ece9e..6be57550c8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -1,15 +1,70 @@ import { emuToPixels, pixelsToEmu, degreesToRot } from '@converter/helpers.js'; -import { getFallbackImageNameFromDataUri, sanitizeDocxMediaName } from '@converter/helpers/mediaHelpers.js'; +import { + getDataUriMetadata, + getFallbackImageNameFromDataUri, + sanitizeDocxMediaName, +} from '@converter/helpers/mediaHelpers.js'; import { prepareTextAnnotation } from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js'; import { wrapTextInRun } from '@converter/exporter.js'; import { generateDocxRandomId } from '@core/helpers/index.js'; import { readImageDimensionsFromDataUri } from '@converter/image-dimensions.js'; +import { simpleStringHash } from '@core/utilities/hash.js'; +import { isValidImageDataUrl } from '@superdoc/url-validation'; const DECORATIVE_EXT_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}'; const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/decorative'; const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'; +function createMediaTargetForDataUri(params, src) { + if (!isValidImageDataUrl(src)) return null; + + const metadata = getDataUriMetadata(src); + + const extension = metadata.extension; + if (!extension) return null; + + if (!params.media) params.media = {}; + if (!params.dataUriMediaTargets) params.dataUriMediaTargets = new Map(); + const cachedPackagePath = params.dataUriMediaTargets.get(src); + if (cachedPackagePath && params.media[cachedPackagePath] === src) { + return cachedPackagePath.slice('word/'.length); + } + + const fileBaseName = sanitizeDocxMediaName(`image-${simpleStringHash(src)}`, 'image'); + let fileName = `${fileBaseName}.${extension}`; + let packagePath = `word/media/${fileName}`; + if (params.media[packagePath] === src) { + params.dataUriMediaTargets.set(src, packagePath); + return `media/${fileName}`; + } + + if (params.media[packagePath] && params.media[packagePath] !== src) { + fileName = `${fileBaseName}_${generateDocxRandomId(8)}.${extension}`; + packagePath = `word/media/${fileName}`; + } + const relationshipTarget = `media/${fileName}`; + + params.media[packagePath] = src; + params.dataUriMediaTargets.set(src, packagePath); + + return relationshipTarget; +} + +function getMediaTargetForImageSrc(params, src) { + return src?.startsWith('data:') ? createMediaTargetForDataUri(params, src) : src?.split('word/')[1]; +} + +function fallbackForMissingMediaTarget(params) { + if (params.node.type === 'fieldAnnotation') return prepareTextAnnotation(params); + + console.warn('Skipping image export because media target could not be resolved.', { + nodeType: params.node.type, + src: params.node.attrs?.src, + }); + return null; +} + /** * Resolve the hyperlink relationship rId for an image, if applicable. * Called once so that both wp:docPr and pic:cNvPr share the same rId. @@ -179,7 +234,7 @@ export const translateImageNode = (params) => { // For fieldAnnotations without a recognizable MIME type, fall back to text // annotation before attempting size resolution (they have no image data). if (params.node.type === 'fieldAnnotation' && !imageId) { - const type = src?.split(';')[0].split('/')[1]; + const type = getDataUriMetadata(src)?.extension; if (!type) { return prepareTextAnnotation(params); } @@ -217,25 +272,21 @@ export const translateImageNode = (params) => { if (w && h) size = { w, h }; } - if (imageId) { - const path = src?.split('word/')[1]; - const relationships = params.isHeaderFooter ? params.existingRelationships : getDocumentRelationships(params); - const existingRelation = findImageRelationship(relationships, { + if (imageId || params.node.type === 'image') { + const path = getMediaTargetForImageSrc(params, src); + if (!path) return fallbackForMissingMediaTarget(params); + + imageId = resolveImageRelationshipId(params, { id: imageId, - target: path, + path, }); - - if (existingRelation) { - imageId = existingRelation.attributes.Id; - } else { - addImageRelationshipForId(params, imageId, path); - } - } else if (params.node.type === 'image' && !imageId) { - const path = src?.split('word/')[1]; - imageId = addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { // We already handled the no-type case above; here the type IS valid. - const type = src?.split(';')[0].split('/')[1]; + if (!isValidImageDataUrl(src)) return prepareTextAnnotation(params); + + const metadata = getDataUriMetadata(src); + + const type = metadata.extension; const sanitizedHash = sanitizeDocxMediaName(attrs.hash, generateDocxRandomId(4)); const fileName = `${imageName}_${sanitizedHash}.${type}`; @@ -510,6 +561,7 @@ function addImageRelationshipForId(params, id, imagePath) { }, }; params.relationships.push(newRel); + return id; } function getDocumentRelationships(params) { @@ -518,6 +570,24 @@ function getDocumentRelationships(params) { return rels?.elements?.find((el) => el.name === 'Relationships')?.elements ?? []; } +function getImageRelationshipLookup(params) { + return [ + ...(params.relationships || []), + ...(params.isHeaderFooter ? params.existingRelationships || [] : getDocumentRelationships(params)), + ]; +} + +function resolveImageRelationshipId(params, { id, path }) { + const existingRelation = findImageRelationship(getImageRelationshipLookup(params), { + ...(id ? { id } : {}), + target: path, + }); + + if (existingRelation) return existingRelation.attributes.Id; + if (id) return addImageRelationshipForId(params, id, path); + return addNewImageRelationship(params, path); +} + function findImageRelationship(relationships = [], { id, target }) { return relationships.find((rel) => { if (rel?.attributes?.Type !== IMAGE_REL_TYPE) return false; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 88c6a6f7e3..4034cfac60 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -4,6 +4,7 @@ import { } from '@converter/v3/handlers/wp/helpers/decode-image-node-helpers.js'; import * as helpers from '@converter/helpers.js'; import * as annotationHelpers from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js'; +import * as coreHelpers from '@core/helpers/index.js'; vi.mock('@converter/helpers.js', async (importOriginal) => { const actual = await importOriginal(); @@ -38,6 +39,10 @@ vi.mock(import('@core/helpers/index.js'), async (importOriginal) => { }; }); +vi.mock('@core/utilities/hash.js', () => ({ + simpleStringHash: vi.fn(() => '123'), +})); + describe('translateImageNode', () => { let baseParams; @@ -112,6 +117,277 @@ describe('translateImageNode', () => { expect(result.elements).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'a:graphic' })])); }); + it('should register data URI image media when rId is missing', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node.attrs = { + src, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }; + + const result = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + const target = baseParams.relationships[0].attributes.Target; + expect(target).toMatch(/^media\/image-\d+\.svg$/); + expect(baseParams.media[`word/${target}`]).toBe(src); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); + }); + + it('should reuse data URI image media and relationship for duplicate payloads', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node.attrs = { + src, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }; + + const firstResult = translateImageNode(baseParams); + const secondResult = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(Object.keys(baseParams.media)).toEqual([`word/${baseParams.relationships[0].attributes.Target}`]); + + const firstBlip = firstResult.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + const secondBlip = secondResult.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(secondBlip.attributes['r:embed']).toBe(firstBlip.attributes['r:embed']); + }); + + it('should reuse the same collision media target for repeated data URI payloads', () => { + const firstSrc = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + const collidingSrc = 'data:image/svg+xml;base64,PHN2ZyBpZD0iMiI+PC9zdmc+'; + baseParams.node.attrs = { + src: firstSrc, + alt: 'First Image', + size: { width: 20, height: 10 }, + }; + translateImageNode(baseParams); + + vi.mocked(coreHelpers.generateDocxRandomId).mockClear(); + baseParams.node.attrs = { + src: collidingSrc, + alt: 'Colliding Image', + size: { width: 20, height: 10 }, + }; + + translateImageNode(baseParams); + translateImageNode(baseParams); + + expect(Object.keys(baseParams.media).sort()).toEqual(['word/media/image-123.svg', 'word/media/image-123_123.svg']); + expect(baseParams.relationships.map((rel) => rel.attributes.Target)).toEqual([ + 'media/image-123.svg', + 'media/image-123_123.svg', + ]); + expect(vi.mocked(coreHelpers.generateDocxRandomId).mock.calls.filter(([length]) => length === 8)).toHaveLength(1); + }); + + it('should create a media target when a data URI image already has an rId', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node.attrs = { + src, + rId: 'rIdExisting', + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }; + + translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(baseParams.relationships[0].attributes).toMatchObject({ + Id: 'rIdExisting', + Target: expect.stringMatching(/^media\/.+\.svg$/), + }); + expect(baseParams.relationships[0].attributes.Target).not.toBeUndefined(); + expect(baseParams.media[`word/${baseParams.relationships[0].attributes.Target}`]).toBe(src); + }); + + it('should not add duplicate relationships for repeated data URI image rIds', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node.attrs = { + src, + rId: 'rIdExisting', + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }; + + translateImageNode(baseParams); + translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(baseParams.relationships[0].attributes).toMatchObject({ + Id: 'rIdExisting', + Target: expect.stringMatching(/^media\/.+\.svg$/), + }); + }); + + it('should register raster data URI image media when rId is missing', () => { + const src = 'data:image/png;base64,iVBORw0KGgo='; + baseParams.node.attrs = { + src, + alt: 'Raster Example', + size: { width: 20, height: 10 }, + }; + + const result = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + const target = baseParams.relationships[0].attributes.Target; + expect(target).toMatch(/^media\/image-\d+\.png$/); + expect(baseParams.media[`word/${target}`]).toBe(src); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); + }); + + it('should reuse document relationship by target when image rId is missing', () => { + baseParams.node.attrs = { + src: 'word/media/test.png', + size: { width: 100, height: 50 }, + }; + baseParams.converter.convertedXml['word/_rels/document.xml.rels'].elements[0].elements.push({ + type: 'element', + name: 'Relationship', + attributes: { + Id: 'rIdDocumentImage', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: 'media/test.png', + }, + }); + + const result = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(0); + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(blip.attributes['r:embed']).toBe('rIdDocumentImage'); + }); + + it('should not export non-base64 raster data URI media', () => { + baseParams.node.attrs = { + src: 'data:image/png,not-base64', + alt: 'Raster Example', + size: { width: 20, height: 10 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + + it('should not export malformed base64 image data URI media', () => { + baseParams.node.attrs = { + src: 'data:image/png;base64,%%%', + alt: 'Malformed Image', + size: { width: 20, height: 10 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + + it('should not export non-image data URI media', () => { + baseParams.node.attrs = { + src: 'data:text/html,%3Cscript%3Ealert(1)%3C%2Fscript%3E', + alt: 'HTML Example', + size: { width: 20, height: 10 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + + it('should not create a corrupt relationship when image src is null', () => { + baseParams.node.attrs = { + src: null, + rId: 'rIdMissingSrc', + size: { width: 200, height: 50 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + + it('should skip data URI image export when no media target can be created', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + baseParams.node.attrs = { + src: 'data:,payload', + size: { width: 200, height: 50 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + expect(warn).toHaveBeenCalledWith( + 'Skipping image export because media target could not be resolved.', + expect.objectContaining({ nodeType: 'image', src: 'data:,payload' }), + ); + warn.mockRestore(); + }); + + it('should not add an existing image rId relationship when data URI media target is invalid', () => { + baseParams.node.attrs = { + src: 'data:,payload', + rId: 'rIdInvalidData', + size: { width: 200, height: 50 }, + }; + + const result = translateImageNode(baseParams); + + expect(result).toBeNull(); + expect(baseParams.relationships).toHaveLength(0); + expect(baseParams.media).toEqual({}); + }); + + it('should fall back to text for fieldAnnotation with rId and invalid data URI media target', () => { + const params = { + ...baseParams, + node: { + type: 'fieldAnnotation', + attrs: { + src: 'data:,payload', + rId: 'rIdInvalidData', + size: { width: 200, height: 50 }, + }, + }, + }; + + const result = translateImageNode(params); + + expect(annotationHelpers.prepareTextAnnotation).toHaveBeenCalledWith(params); + expect(result).toEqual({ type: 'text', text: 'annotation' }); + expect(params.relationships).toHaveLength(0); + expect(params.media).toEqual({}); + }); + it('should use clamped fallback size (1 EMU) when attrs.size is empty', () => { baseParams.node.attrs.size = {}; @@ -206,6 +482,75 @@ describe('translateImageNode', () => { expect(result).toEqual({ type: 'text', text: 'annotation' }); }); + it('should export fieldAnnotation SVG data URI media with svg extension', () => { + const src = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='; + baseParams.node = { + type: 'fieldAnnotation', + attrs: { + fieldId: 'signatureField', + hash: 'signatureHash', + src, + size: { width: 200, height: 50 }, + }, + }; + + const result = translateImageNode(baseParams); + + expect(baseParams.relationships).toHaveLength(1); + expect(baseParams.relationships[0].attributes.Target).toBe('media/signatureField_signatureHash.svg'); + expect(baseParams.media['word/media/signatureField_signatureHash.svg']).toBe(src); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + expect(blip.attributes['r:embed']).toBe(baseParams.relationships[0].attributes.Id); + }); + + it('should fall back to text for fieldAnnotation with non-base64 raster data URI', () => { + const params = { + ...baseParams, + node: { + type: 'fieldAnnotation', + attrs: { + fieldId: 'signatureField', + hash: 'signatureHash', + src: 'data:image/png,not-base64', + size: { width: 200, height: 50 }, + }, + }, + }; + + const result = translateImageNode(params); + + expect(annotationHelpers.prepareTextAnnotation).toHaveBeenCalledWith(params); + expect(result).toEqual({ type: 'text', text: 'annotation' }); + expect(params.relationships).toHaveLength(0); + expect(params.media).toEqual({}); + }); + + it('should fall back to text for fieldAnnotation with malformed non-base64 SVG data URI', () => { + const params = { + ...baseParams, + node: { + type: 'fieldAnnotation', + attrs: { + fieldId: 'signatureField', + hash: 'signatureHash', + src: 'data:image/svg+xml,%', + size: { width: 200, height: 50 }, + }, + }, + }; + + const result = translateImageNode(params); + + expect(annotationHelpers.prepareTextAnnotation).toHaveBeenCalledWith(params); + expect(result).toEqual({ type: 'text', text: 'annotation' }); + expect(params.relationships).toHaveLength(0); + expect(params.media).toEqual({}); + }); + it('should resize images inside tableCell to maxWidth', () => { baseParams.node.attrs.size = { width: 500, height: 500 }; baseParams.tableCell = { diff --git a/packages/super-editor/src/editors/v1/core/utilities/hash.js b/packages/super-editor/src/editors/v1/core/utilities/hash.js new file mode 100644 index 0000000000..0cc26e74e1 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/utilities/hash.js @@ -0,0 +1,18 @@ +export const simpleStringHash = (str) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash).toString(); +}; + +export const stableHexHash = (input) => { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +}; diff --git a/packages/super-editor/src/editors/v1/core/utilities/index.js b/packages/super-editor/src/editors/v1/core/utilities/index.js index ae4f05fcbe..a2cd3c1ed8 100644 --- a/packages/super-editor/src/editors/v1/core/utilities/index.js +++ b/packages/super-editor/src/editors/v1/core/utilities/index.js @@ -9,3 +9,4 @@ export * from './parseSizeUnit.js'; export * from './minMax.js'; export * from './clipboardUtils.js'; export * from './cssColorToHex.js'; +export * from './hash.js'; diff --git a/packages/super-editor/src/editors/v1/core/utilities/tests/utilities.test.js b/packages/super-editor/src/editors/v1/core/utilities/tests/utilities.test.js index 8cad9bdc07..ff4a19f544 100644 --- a/packages/super-editor/src/editors/v1/core/utilities/tests/utilities.test.js +++ b/packages/super-editor/src/editors/v1/core/utilities/tests/utilities.test.js @@ -5,6 +5,7 @@ import { carbonCopy } from '../carbonCopy.js'; import { createStyleTag } from '../createStyleTag.js'; import { deleteProps } from '../deleteProps.js'; import { getMediaObjectUrls } from '../imageBlobs.js'; +import { simpleStringHash, stableHexHash } from '../hash.js'; import { isEmptyObject } from '../isEmptyObject.js'; import { isIOS } from '../isIOS.js'; import { isMacOS } from '../isMacOS.js'; @@ -106,6 +107,17 @@ describe('core utilities', () => { }); }); + describe('hash utilities', () => { + it('preserves the simple string hash used by image filenames', () => { + expect(simpleStringHash('fake-image-payload')).toBe('1287114076'); + expect(simpleStringHash('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=')).toBe('1253334850'); + }); + + it('preserves the stable hex hash used by imported comment IDs', () => { + expect(stableHexHash('1-1707568200000')).toBe('58b122b1'); + }); + }); + describe('createStyleTag', () => { it('creates a new style tag when absent', () => { const style = createStyleTag('.foo { color: red; }'); diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts index 3739307bd7..3029ce1b24 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts @@ -282,6 +282,40 @@ describe('DomPointerMapping', () => { } } }); + + it('maps clicks on empty SDT placeholder chrome to the SDT content position', () => { + container.innerHTML = ` +
+
+
+ +
+
+
+ `; + + const page = container.querySelector('.superdoc-page') as HTMLElement; + const fragment = container.querySelector('.superdoc-fragment') as HTMLElement; + const line = container.querySelector('.superdoc-line') as HTMLElement; + const placeholder = container.querySelector('.superdoc-empty-sdt-placeholder') as HTMLElement; + + mockRect(page, { left: 100, top: 10, width: 300, height: 30 }); + mockRect(fragment, { left: 100, top: 10, width: 300, height: 30 }); + mockRect(line, { left: 110, top: 10, width: 250, height: 20 }); + mockRect(placeholder, { left: 110, top: 10, width: 220, height: 20 }); + + withMockedElementsFromPoint( + [placeholder, line, fragment, page, container, document.body, document.documentElement], + () => { + expect(clickToPositionDom(container, 310, 18)).toBe(11); + }, + ); + }); }); // ----------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts index 483f8d7f63..226180eed8 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts @@ -38,6 +38,7 @@ const CLASS = { line: DOM_CLASS_NAMES.LINE, tableFragment: DOM_CLASS_NAMES.TABLE_FRAGMENT, inlineSdtWrapper: DOM_CLASS_NAMES.INLINE_SDT_WRAPPER, + emptySdtPlaceholder: 'superdoc-empty-sdt-placeholder', } as const; /** Augmented Document type for the `elementsFromPoint` API. */ @@ -488,6 +489,11 @@ function resolvePositionInLine( const { start: spanStart, end: spanEnd } = readPmRange(targetEl); if (!Number.isFinite(spanStart) || !Number.isFinite(spanEnd)) return null; + + if (targetEl.classList.contains(CLASS.emptySdtPlaceholder)) { + return spanStart; + } + const rightCaretBoundary = resolveRightCaretBoundary(spanEls, targetIndex, spanStart, spanEnd); // Non-text or empty element → snap to nearest edge diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.test.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.test.ts new file mode 100644 index 0000000000..2c81f474b9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; + +import { DomPositionIndex } from './DomPositionIndex.ts'; + +describe('DomPositionIndex', () => { + it('excludes structured-content chrome labels from caret position lookup', () => { + const container = document.createElement('div'); + + const fragment = document.createElement('div'); + fragment.className = `${DOM_CLASS_NAMES.FRAGMENT} ${DOM_CLASS_NAMES.BLOCK_SDT} ${DOM_CLASS_NAMES.TABLE_FRAGMENT}`; + fragment.dataset.pmStart = '16'; + fragment.dataset.pmEnd = '44'; + + const label = document.createElement('div'); + label.className = DOM_CLASS_NAMES.BLOCK_SDT_LABEL; + label.dataset.pmStart = '16'; + label.dataset.pmEnd = '44'; + label.textContent = 'Block With Table'; + + const line = document.createElement('div'); + line.className = DOM_CLASS_NAMES.LINE; + line.dataset.pmStart = '16'; + line.dataset.pmEnd = '18'; + + const span = document.createElement('span'); + span.dataset.pmStart = '16'; + span.dataset.pmEnd = '18'; + span.textContent = 'A1'; + + line.appendChild(span); + fragment.append(label, line); + container.appendChild(fragment); + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.findEntryAtPosition(16)?.el).toBe(span); + }); +}); diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts index 386340a327..06351e19ee 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts @@ -1,4 +1,4 @@ -import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { DOM_CLASS_NAMES, STRUCTURED_CONTENT_CHROME_LABEL_CLASS_NAMES } from '@superdoc/dom-contract'; import { sortedIndexBy } from 'lodash'; import { debugLog, getSelectionDebugConfig } from '../core/presentation-editor/selection/SelectionDebug.js'; @@ -30,6 +30,10 @@ export type DomPositionIndexEntry = { }; function isExcludedFromBodyDomIndex(node: HTMLElement): boolean { + if (STRUCTURED_CONTENT_CHROME_LABEL_CLASS_NAMES.some((className) => node.classList.contains(className))) { + return true; + } + if (node.closest('.superdoc-page-header, .superdoc-page-footer')) { return true; } diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts index d05c732615..3df97b5cb4 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts @@ -578,9 +578,12 @@ export function computeDomCaretPageLocal( const elRect = targetEl.getBoundingClientRect(); // For non-text elements (images, math), position caret at the right edge // when pos matches pmEnd (cursor after the element) - const isEmptyInlineSdtPlaceholder = targetEl.classList.contains('superdoc-empty-inline-sdt-placeholder'); - const atEnd = !isEmptyInlineSdtPlaceholder && pos >= entry.pmEnd; - const lineEl = isEmptyInlineSdtPlaceholder ? (targetEl.closest('.superdoc-line') as HTMLElement | null) : null; + const isEmptySdtPlaceholder = + targetEl.classList.contains('superdoc-empty-sdt-placeholder') || + targetEl.classList.contains('superdoc-empty-inline-sdt-placeholder') || + targetEl.classList.contains('superdoc-empty-block-sdt-placeholder'); + const atEnd = isEmptySdtPlaceholder ? pos > entry.pmEnd : pos >= entry.pmEnd; + const lineEl = isEmptySdtPlaceholder ? (targetEl.closest('.superdoc-line') as HTMLElement | null) : null; const yRect = lineEl?.getBoundingClientRect() ?? elRect; return { pageIndex: Number(page.dataset.pageIndex ?? '0'), diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js index 022001ab9f..2363216805 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.js @@ -1,20 +1,8 @@ // @ts-check -const DEFAULT_MIME_TYPE = 'application/octet-stream'; +import { getDataUriMetadata, tryDecodeDataUriText } from '@converter/helpers/mediaHelpers.js'; +import { simpleStringHash } from '@core/utilities/hash.js'; -/** - * Generates a simple hash from a string. - * @param {string} str - The input string. - * @returns {string} The generated hash. - */ -const simpleHash = (str) => { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32-bit integer - } - return Math.abs(hash).toString(); -}; +const DEFAULT_MIME_TYPE = 'application/octet-stream'; /** * Decodes a base64-encoded string into a binary string. @@ -35,38 +23,53 @@ const decodeBase64ToBinaryString = (data) => { throw new Error('Unable to decode base64 payload in the current environment.'); }; +const binaryStringToBytes = (binaryString) => { + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; + /** - * Extract metadata from a base64-encoded string. - * @param {string} base64String - The base64-encoded string. + * Extract metadata from a data URI string. + * @param {string} dataUri - The data URI string. * @returns {Object} An object containing mimeType, binaryString, and filename. */ -const extractBase64Meta = (base64String) => { - const [meta = '', payload = ''] = base64String.split(','); - const mimeMatch = meta.match(/:(.*?);/); - const rawMimeType = mimeMatch ? mimeMatch[1] : ''; +const extractBase64Meta = (dataUri) => { + const metadata = getDataUriMetadata(dataUri); + if (!metadata?.hasPayloadSeparator) return null; + + const rawMimeType = metadata?.rawMimeType || ''; const mimeType = rawMimeType || DEFAULT_MIME_TYPE; - const binaryString = decodeBase64ToBinaryString(payload); - const hash = simpleHash(binaryString); - const extension = mimeType.split('/')[1] || 'bin'; + const isBase64 = Boolean(metadata?.isBase64); + const payload = metadata?.payload || ''; + const binaryString = isBase64 ? decodeBase64ToBinaryString(payload) : tryDecodeDataUriText(payload); + if (binaryString == null) return null; + + const hash = simpleStringHash(binaryString); + const extension = metadata?.extension || 'bin'; const filename = `image-${hash}.${extension}`; - return { mimeType, binaryString, filename }; + return { mimeType, binaryString, filename, isBase64 }; }; -export const getBase64FileMeta = (base64String) => { - const { mimeType, filename } = extractBase64Meta(base64String); +export const getBase64FileMeta = (dataUri) => { + const meta = extractBase64Meta(dataUri); + if (!meta) return { mimeType: DEFAULT_MIME_TYPE, filename: 'image-0.bin' }; + + const { mimeType, filename } = meta; return { mimeType, filename }; }; -export const base64ToFile = (base64String) => { - const { mimeType, binaryString, filename } = extractBase64Meta(base64String); - const fileType = mimeType || DEFAULT_MIME_TYPE; +export const base64ToFile = (dataUri) => { + const meta = extractBase64Meta(dataUri); + if (!meta) return null; - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } + const { mimeType, binaryString, filename, isBase64 } = meta; + const fileType = mimeType || DEFAULT_MIME_TYPE; - const blob = new Blob([bytes], { type: fileType }); + const data = isBase64 ? binaryStringToBytes(binaryString) : binaryString; + const blob = new Blob([data], { type: fileType }); return new File([blob], filename, { type: fileType }); }; diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index ab6cbf43df..f25e20bfe9 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -33,7 +33,7 @@ describe('handleBase64', () => { const file = base64ToFile(base64); expect(file.type).toBe('image/jpeg'); - expect(file.name).toMatch(/^image-\d+\.jpeg$/); + expect(file.name).toMatch(/^image-\d+\.jpg$/); expect(file.size).toBe(Buffer.byteLength(payload)); }); @@ -59,6 +59,64 @@ describe('handleBase64', () => { expect(filename).toBe(file.name); }); + it('normalizes svg+xml data URI filenames to .svg', () => { + vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); + + const payload = ''; + const base64 = base64ForPayload(payload, 'image/svg+xml'); + + const { filename, mimeType } = getBase64FileMeta(base64); + const file = base64ToFile(base64); + + expect(mimeType).toBe('image/svg+xml'); + expect(filename).toBe(file.name); + expect(file.name).toMatch(/^image-\d+\.svg$/); + expect(file.type).toBe('image/svg+xml'); + }); + + it('normalizes svg+xml MIME casing for filenames', () => { + vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); + + const payload = ''; + const base64 = base64ForPayload(payload, 'Image/SVG+XML'); + + const { filename, mimeType } = getBase64FileMeta(base64); + const file = base64ToFile(base64); + + expect(mimeType).toBe('Image/SVG+XML'); + expect(filename).toBe(file.name); + expect(file.name).toMatch(/^image-\d+\.svg$/); + expect(file.type).toBe('image/svg+xml'); + }); + + it('handles non-base64 svg data URI filenames', async () => { + const payload = ''; + const dataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(payload)}`; + + const { filename, mimeType } = getBase64FileMeta(dataUri); + const file = base64ToFile(dataUri); + + expect(mimeType).toBe('image/svg+xml'); + expect(filename).toBe(file.name); + expect(file.name).toMatch(/^image-\d+\.svg$/); + expect(file.type).toBe('image/svg+xml'); + await expect(file.text()).resolves.toBe(payload); + }); + + it('returns null when non-base64 payload percent decoding fails', () => { + const dataUri = 'data:image/svg+xml,%'; + + const file = base64ToFile(dataUri); + + expect(file).toBeNull(); + }); + + it('returns null for data URIs without a payload separator', () => { + const file = base64ToFile('data:image/svg+xml'); + + expect(file).toBeNull(); + }); + it('defaults metadata when mime data is missing', () => { vi.stubGlobal('atob', (encoded) => Buffer.from(encoded, 'base64').toString('binary')); diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js index b5ff363ee4..31d68ef95e 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.browser.test.js @@ -39,10 +39,14 @@ vi.mock('prosemirror-transform', () => ({ })); // ── Image helper mocks ─────────────────────────────────────────────── -vi.mock('./handleBase64', () => ({ - base64ToFile: vi.fn(() => null), - getBase64FileMeta: vi.fn(() => ({ filename: 'image.png' })), -})); +vi.mock('./handleBase64', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + base64ToFile: vi.fn(() => null), + getBase64FileMeta: vi.fn(actual.getBase64FileMeta), + }; +}); vi.mock('./handleUrl', () => ({ urlToFile: vi.fn(() => Promise.resolve(null)), @@ -50,6 +54,7 @@ vi.mock('./handleUrl', () => ({ })); vi.mock('./startImageUpload', () => ({ + MAX_IMAGE_FILE_BYTES: 5 * 1024 * 1024, checkAndProcessImage: vi.fn(), uploadAndInsertImage: vi.fn(), addImageRelationship: vi.fn(() => 'rId99'), @@ -63,8 +68,9 @@ vi.mock('./fileNameUtils.js', () => ({ // ── Imports (after mocks) ───────────────────────────────────────────── import { Decoration } from 'prosemirror-view'; import { handleBrowserPath } from './imageRegistrationPlugin.js'; +import { base64ToFile, getBase64FileMeta } from './handleBase64'; import { urlToFile, validateUrlAccessibility } from './handleUrl'; -import { addImageRelationship } from './startImageUpload'; +import { addImageRelationship, checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; // ── Helpers ─────────────────────────────────────────────────────────── const createImageNode = (attrs) => ({ @@ -151,6 +157,54 @@ describe('handleBrowserPath', () => { expect(tr.delete).toHaveBeenCalledTimes(2); }); + it('registers sized raster data URI images in place without placeholder deletion', () => { + const pngDataUri = 'data:image/png;base64,iVBORw0KGgo='; + const imageNode = createImageNode({ + src: pngDataUri, + size: { width: 20, height: 10 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + expect(Decoration.widget).not.toHaveBeenCalled(); + expect(tr.delete).not.toHaveBeenCalled(); + expect(checkAndProcessImage).not.toHaveBeenCalled(); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + expect(addImageRelationship).toHaveBeenCalledWith({ + editor, + path: expect.stringMatching(/^media\/image-\d+\.png$/), + }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith(20, undefined, { + ...imageNode.attrs, + src: expect.stringMatching(/^word\/media\/image-\d+\.png$/), + rId: 'rId99', + }); + }); + + it('runs oversized sized raster data URI images through image validation', async () => { + const oversizedRasterDataUri = `data:image/png;base64,${'A'.repeat(7 * 1024 * 1024)}`; + const oversizedRasterFile = new File(['x'.repeat(5 * 1024 * 1024 + 1)], 'too-large.png', { + type: 'image/png', + }); + const imageNode = createImageNode({ + src: oversizedRasterDataUri, + size: { width: 20, height: 10 }, + }); + base64ToFile.mockReturnValueOnce(oversizedRasterFile); + checkAndProcessImage.mockResolvedValueOnce({ file: null, size: { width: 0, height: 0 } }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + await flushPromises(); + + expect(Decoration.widget).toHaveBeenCalled(); + expect(tr.delete).toHaveBeenCalledWith(20, 21); + expect(checkAndProcessImage).toHaveBeenCalledWith({ + getMaxContentSize: expect.any(Function), + file: oversizedRasterFile, + }); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + }); + it('deletes non-relative image nodes in descending position order', () => { const foundImages = [ { node: createImageNode({ src: 'https://a.com/1.png' }), pos: 5, id: {} }, @@ -164,6 +218,121 @@ describe('handleBrowserPath', () => { const [secondPos] = tr.delete.mock.calls[1]; expect(firstPos).toBeGreaterThan(secondPos); }); + + it('registers sized SVG data URI images in place without canvas processing', () => { + const svgDataUri = 'data:image/svg+xml;base64,PHN2Zy8+'; + const id = {}; + const imageNode = createImageNode({ + src: svgDataUri, + size: { width: 200, height: 50 }, + }); + getBase64FileMeta.mockReturnValueOnce({ filename: 'image-123.svg', mimeType: 'image/svg+xml' }); + + handleBrowserPath([{ node: imageNode, pos: 20, id }], editor, view, state); + + expect(checkAndProcessImage).not.toHaveBeenCalled(); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + expect(addImageRelationship).toHaveBeenCalledWith({ + editor, + path: expect.stringMatching(/^media\/image-\d+\.svg$/), + }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith(20, undefined, { + ...imageNode.attrs, + src: expect.stringMatching(/^word\/media\/image-\d+\.svg$/), + rId: 'rId99', + }); + }); + + it('registers sized non-base64 SVG data URI images in place', () => { + const svgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent('')}`; + const imageNode = createImageNode({ + src: svgDataUri, + size: { width: 200, height: 50 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + expect(checkAndProcessImage).not.toHaveBeenCalled(); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + expect(addImageRelationship).toHaveBeenCalledWith({ + editor, + path: expect.stringMatching(/^media\/image-\d+\.svg$/), + }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith(20, undefined, { + ...imageNode.attrs, + src: expect.stringMatching(/^word\/media\/image-\d+\.svg$/), + rId: 'rId99', + }); + }); + + it('does not register malformed sized SVG data URI images in place', () => { + const imageNode = createImageNode({ + src: 'data:image/svg+xml', + size: { width: 200, height: 50 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + expect(Object.keys(editor.storage.image.media)).toHaveLength(0); + expect(addImageRelationship).not.toHaveBeenCalled(); + expect(tr.setNodeMarkup).not.toHaveBeenCalled(); + expect(tr.delete).toHaveBeenCalledWith(20, 21); + }); + + it('does not register percent-malformed sized SVG data URI images in place', () => { + const imageNode = createImageNode({ + src: 'data:image/svg+xml,%', + size: { width: 200, height: 50 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + expect(Object.keys(editor.storage.image.media)).toHaveLength(0); + expect(addImageRelationship).not.toHaveBeenCalled(); + expect(tr.setNodeMarkup).not.toHaveBeenCalled(); + expect(tr.delete).toHaveBeenCalledWith(20, 21); + }); + + it('runs SVG files over the upload byte budget through image validation before upload', async () => { + const oversizedSvgDataUri = `data:image/svg+xml;base64,${'A'.repeat(7 * 1024 * 1024)}`; + const oversizedSvgFile = new File(['x'.repeat(5 * 1024 * 1024 + 1)], 'too-large.svg', { + type: 'image/svg+xml', + }); + const imageNode = createImageNode({ + src: oversizedSvgDataUri, + size: { width: 200, height: 50 }, + }); + base64ToFile.mockReturnValueOnce(oversizedSvgFile); + checkAndProcessImage.mockResolvedValueOnce({ file: null, size: { width: 0, height: 0 } }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + await flushPromises(); + + expect(checkAndProcessImage).toHaveBeenCalledWith({ + getMaxContentSize: expect.any(Function), + file: oversizedSvgFile, + }); + expect(uploadAndInsertImage).not.toHaveBeenCalled(); + expect(view.dispatch).toHaveBeenCalled(); + }); + + it('mirrors in-place SVG media to the parent editor media store', () => { + const svgDataUri = 'data:image/svg+xml;base64,PHN2Zy8+'; + const parentEditor = { storage: { image: { media: {} } } }; + editor.options.parentEditor = parentEditor; + editor.options.isHeaderOrFooter = true; + const imageNode = createImageNode({ + src: svgDataUri, + size: { width: 200, height: 50 }, + }); + + handleBrowserPath([{ node: imageNode, pos: 20, id: {} }], editor, view, state); + + const mediaPath = Object.keys(editor.storage.image.media)[0]; + expect(mediaPath).toMatch(/^word\/media\/image-\d+\.svg$/); + expect(editor.storage.image.media[mediaPath]).toBe(svgDataUri); + expect(parentEditor.storage.image.media[mediaPath]).toBe(svgDataUri); + }); }); describe('registerRelativeImages (via handleBrowserPath)', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js index 5db1c2fdca..1e85489736 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -3,10 +3,10 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; import { ReplaceStep, ReplaceAroundStep } from 'prosemirror-transform'; import { base64ToFile, getBase64FileMeta } from './handleBase64'; import { urlToFile, validateUrlAccessibility } from './handleUrl'; -import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; +import { checkAndProcessImage, MAX_IMAGE_FILE_BYTES, uploadAndInsertImage } from './startImageUpload'; import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js'; import { addImageRelationship } from '@extensions/image/imageHelpers/startImageUpload.js'; -import { isRelativeUrl } from '@superdoc/url-validation'; +import { getDataUriMetadata, isRelativeUrl, isValidImageDataUrl, tryDecodeDataUriText } from '@superdoc/url-validation'; const key = new PluginKey('ImageRegistration'); /** @@ -193,15 +193,48 @@ const parseSizeFromImageUrl = (src) => { const hasFinitePositiveSize = (size) => Number.isFinite(size?.width) && size.width > 0 && Number.isFinite(size?.height) && size.height > 0; +const isSvgFile = (file) => file?.type === 'image/svg+xml'; + +const getBase64PayloadByteLength = (payload = '') => { + const normalized = payload.replace(/\s/g, ''); + if (!normalized) return 0; + const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0; + return Math.floor((normalized.length * 3) / 4) - padding; +}; + +const getDataUriDecodedByteLength = (src) => { + const metadata = getDataUriMetadata(src); + if (!metadata?.hasPayloadSeparator) return null; + + if (metadata.isBase64) return getBase64PayloadByteLength(metadata.payload); + + const decoded = tryDecodeDataUriText(metadata.payload); + if (decoded == null) return null; + return new globalThis.TextEncoder().encode(decoded).byteLength; +}; + +const shouldRegisterInPlace = (node) => + isValidImageDataUrl(node.attrs?.src) && + hasFinitePositiveSize(node.attrs?.size) && + getDataUriDecodedByteLength(node.attrs.src) <= MAX_IMAGE_FILE_BYTES; + const getOrInitMediaStore = (editor) => { if (!editor?.storage?.image?.media) { editor.storage.image.media = {}; } const mediaStore = editor.storage.image.media; - const existingFileNames = new Set(Object.keys(mediaStore).map((k) => k.split('/').pop())); + const parentMediaStore = editor?.options?.parentEditor?.storage?.image?.media; + const mediaStores = [mediaStore]; + if (parentMediaStore && parentMediaStore !== mediaStore) { + mediaStores.push(parentMediaStore); + } + const existingFileNames = new Set(); + mediaStores.forEach((store) => { + Object.keys(store).forEach((k) => existingFileNames.add(k.split('/').pop())); + }); - return { mediaStore, existingFileNames }; + return { mediaStore, mediaStores, existingFileNames }; }; /** @@ -214,7 +247,12 @@ const getOrInitMediaStore = (editor) => { */ export const handleNodePath = (foundImages, editor, state) => { const { tr } = state; - const { mediaStore, existingFileNames } = getOrInitMediaStore(editor); + registerImagesInTransaction(foundImages, editor, tr); + return tr; +}; + +const registerImagesInTransaction = (foundImages, editor, tr) => { + const { mediaStores, existingFileNames } = getOrInitMediaStore(editor); foundImages.forEach(({ node, pos }) => { const { src } = node.attrs; @@ -223,7 +261,9 @@ export const handleNodePath = (foundImages, editor, state) => { existingFileNames.add(uniqueFileName); const mediaPath = buildMediaPath(uniqueFileName); - mediaStore[mediaPath] = src; + mediaStores.forEach((store) => { + store[mediaPath] = src; + }); // Sync image data to Y.Doc media map so other collab clients can access it. // We write directly to the Y.Doc map instead of using editor.commands because @@ -244,8 +284,6 @@ export const handleNodePath = (foundImages, editor, state) => { rId, }); }); - - return tr; }; /** @@ -255,7 +293,7 @@ export const handleNodePath = (foundImages, editor, state) => { * @param {Object} editor - The editor instance. * @param {import('prosemirror-view').EditorView} view - The editor view instance. * @param {import('prosemirror-state').EditorState} state - The current editor state. - * @returns {import('prosemirror-state').Transaction} - The updated transaction with image nodes replaced by placeholders and registration process initiated. + * @returns {import('prosemirror-state').Transaction} - The updated transaction with in-place registrations and placeholders for images that require async processing. * @internal Exported for testing only. */ export const handleBrowserPath = (foundImages, editor, view, state) => { @@ -264,19 +302,28 @@ export const handleBrowserPath = (foundImages, editor, view, state) => { // Relative paths are resolved by the browser natively for display. // Register them in the background for export without removing from the document. const relativeImages = foundImages.filter(({ node }) => isRelativeUrl(node.attrs?.src)); - const imagesToProcess = foundImages.filter(({ node }) => !isRelativeUrl(node.attrs?.src)); + const inPlaceImages = foundImages.filter( + ({ node }) => !isRelativeUrl(node.attrs?.src) && shouldRegisterInPlace(node), + ); + const imagesToProcess = foundImages.filter( + ({ node }) => !isRelativeUrl(node.attrs?.src) && !shouldRegisterInPlace(node), + ); if (relativeImages.length > 0) { registerRelativeImages(relativeImages, editor, view); } - if (imagesToProcess.length === 0) return null; + const tr = state.tr; + if (inPlaceImages.length > 0) { + registerImagesInTransaction(inPlaceImages, editor, tr); + } + + if (imagesToProcess.length === 0) return tr.docChanged ? tr : null; // Register the images. (async process). registerImages(imagesToProcess, editor, view); - // Remove all the images that were found. These will eventually be replaced by the updated images. - const tr = state.tr; + // Remove only images that require async processing. These will eventually be replaced by updated images. // We need to delete the image nodes and replace them with decorations. This will change their positions. @@ -487,20 +534,24 @@ const registerImages = async (foundImages, editor, view) => { } try { - const process = await checkAndProcessImage({ - getMaxContentSize: () => editor.getMaxContentSize(), - file, - }); + if (isSvgFile(file) && hasFinitePositiveSize(image.node.attrs?.size) && file.size <= MAX_IMAGE_FILE_BYTES) { + await uploadAndInsertImage({ editor, view, file, size: image.node.attrs.size, id }); + } else { + const process = await checkAndProcessImage({ + getMaxContentSize: () => editor.getMaxContentSize(), + file, + }); + + if (!process.file) { + // Processing failed, remove placeholder + const tr = view.state.tr; + removeImagePlaceholder(view.state, tr, id); + view.dispatch(tr); + return; + } - if (!process.file) { - // Processing failed, remove placeholder - const tr = view.state.tr; - removeImagePlaceholder(view.state, tr, id); - view.dispatch(tr); - return; + await uploadAndInsertImage({ editor, view, file: process.file, size: process.size, id }); } - - await uploadAndInsertImage({ editor, view, file: process.file, size: process.size, id }); } catch (error) { console.error(`Error processing image from ${src}:`, error); // Ensure placeholder is removed even on error diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js index ba5b33c992..5d7fa062fb 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js @@ -6,10 +6,10 @@ import { generateDocxRandomId } from '@core/helpers/index.js'; import { findOrCreateRelationship } from '@core/parts/adapters/relationships-mutation.js'; import { resolveHeaderFooterRelsPartIdFromRefId } from '@core/parts/adapters/header-footer-sync.js'; -const fileTooLarge = (file) => { - let fileSizeMb = Number((file.size / (1024 * 1024)).toFixed(4)); +export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; - if (fileSizeMb > 5) { +const fileTooLarge = (file) => { + if (file.size > MAX_IMAGE_FILE_BYTES) { window.alert('Image size must be less than 5MB'); return true; } diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.js b/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.js new file mode 100644 index 0000000000..a10e146904 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.js @@ -0,0 +1,5 @@ +export const STRUCTURED_CONTENT_NODE_TYPES = new Set(['structuredContent', 'structuredContentBlock']); + +export function isStructuredContentNodeType(nodeTypeName) { + return STRUCTURED_CONTENT_NODE_TYPES.has(nodeTypeName); +} diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.test.js new file mode 100644 index 0000000000..39ad0c74f5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/nodeTypes.test.js @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { STRUCTURED_CONTENT_NODE_TYPES, isStructuredContentNodeType } from './nodeTypes.js'; + +describe('structured content node types', () => { + it('recognizes inline and block structured content node types', () => { + expect(STRUCTURED_CONTENT_NODE_TYPES).toEqual(new Set(['structuredContent', 'structuredContentBlock'])); + expect(isStructuredContentNodeType('structuredContent')).toBe(true); + expect(isStructuredContentNodeType('structuredContentBlock')).toBe(true); + expect(isStructuredContentNodeType('paragraph')).toBe(false); + expect(isStructuredContentNodeType(null)).toBe(false); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.test.js index a582ab4feb..ea0caf5275 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.test.js @@ -40,6 +40,18 @@ function findFirstTextNode(node) { return found; } +function findFirstNodeByType(node, typeName) { + let found = null; + node.descendants((child) => { + if (child.type.name === typeName) { + found = child; + return false; + } + return true; + }); + return found; +} + describe('StructuredContentTableCommands', () => { let editor; let schema; @@ -890,6 +902,52 @@ describe('StructuredContent ID Validation', () => { }); describe('insertStructuredContentBlock', () => { + it('preserves preset image content when inserting an sdtLocked block', () => { + const signatureSrc = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; + + const didInsert = editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215856', + tag: '{"fieldType":"signer"}', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: signatureSrc, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }); + + expect(didInsert).toBe(true); + + const insertedBlock = findFirstNodeByType(editor.state.doc, 'structuredContentBlock'); + expect(insertedBlock).not.toBeNull(); + expect(insertedBlock.attrs).toMatchObject({ + id: '1299215856', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }); + + const insertedImage = findFirstNodeByType(insertedBlock, 'image'); + expect(insertedImage).not.toBeNull(); + expect(insertedImage.attrs).toMatchObject({ + src: expect.stringMatching(/^word\/media\/image-\d+\.svg$/), + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }); + expect(editor.storage.image.media[insertedImage.attrs.src]).toBe(signatureSrc); + }); + it('accepts valid integer string IDs', () => { expect(() => { editor.commands.insertStructuredContentBlock({ diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js index 1e70ddbb25..65c2951b3d 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js @@ -1,4 +1,4 @@ -import { NodeSelection, Plugin, PluginKey } from 'prosemirror-state'; +import { NodeSelection, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { ySyncPluginKey } from 'y-prosemirror'; import { BLOCK_NODE_METADATA_UPDATE_META } from '../block-node/block-node.js'; @@ -122,6 +122,13 @@ export function createStructuredContentLockPlugin() { exactContentSDT.lockMode === 'contentLocked' || exactContentSDT.lockMode === 'sdtContentLocked'; const isWrapperDeletable = exactContentSDT.lockMode !== 'sdtLocked' && exactContentSDT.lockMode !== 'sdtContentLocked'; + const isFullyLocked = exactContentSDT.lockMode === 'sdtContentLocked'; + if (isFullyLocked && exactContentSDT.type === 'structuredContent' && (isBackspace || isDelete)) { + const collapsePos = isBackspace ? exactContentSDT.pos : exactContentSDT.end; + view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, collapsePos))); + event.preventDefault(); + return true; + } if (isContentLocked && isWrapperDeletable) { if (isCut) { const tr = state.tr.setSelection(NodeSelection.create(state.doc, exactContentSDT.pos)); @@ -159,6 +166,26 @@ export function createStructuredContentLockPlugin() { return true; } + const inlineSdtAncestor = sdtNodes.find( + (s) => s.type === 'structuredContent' && from > s.pos && from < s.end, + ); + const inlineSdtContentEditable = + inlineSdtAncestor && + inlineSdtAncestor.lockMode !== 'contentLocked' && + inlineSdtAncestor.lockMode !== 'sdtContentLocked'; + if ((isBackspace || isDelete) && inlineSdtContentEditable && selection.$from.parent.type.name === 'run') { + const deleteFrom = isBackspace ? from - 1 : from; + const deleteTo = isBackspace ? from : from + 1; + const staysInsideInlineSdt = deleteFrom > inlineSdtAncestor.pos && deleteTo < inlineSdtAncestor.end; + const staysInsideRun = isBackspace ? from > selection.$from.start() : from < selection.$from.end(); + + if (staysInsideInlineSdt && staysInsideRun) { + view.dispatch(state.tr.delete(deleteFrom, deleteTo).scrollIntoView()); + event.preventDefault(); + return true; + } + } + if (isBackspace && from > 0) { affectedFrom = from - 1; // Path 2 — caret is exactly at the trailing wrapper boundary of an @@ -229,6 +256,11 @@ export function createStructuredContentLockPlugin() { return true; } + const inputType = tr.getMeta?.('inputType'); + if (inputType === 'historyUndo' || inputType === 'historyRedo') { + return true; + } + if (tr.getMeta?.(BLOCK_NODE_METADATA_UPDATE_META)) { return true; } diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js index 8b7aa3f431..432de5e231 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js @@ -621,6 +621,70 @@ describe('StructuredContentLockPlugin', () => { expect(result.prevented).toBe(true); expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(!shouldDeleteWrapper); }); + + it('sdtLocked + Delete before typed inline SDT text deletes the text and preserves the wrapper', () => { + const beforeText = schema.text('Before '); + const sdtRun = schema.nodes.run.create(null, schema.text('a')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'sdtLocked' }, sdtRun); + const afterText = schema.text(' After'); + const paragraph = schema.nodes.paragraph.create(null, [beforeText, sdt, afterText]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + let runPos = null; + state.doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent === 'a') { + runPos = pos; + return false; + } + return true; + }); + expect(sdtInfo).not.toBeNull(); + expect(runPos).not.toBeNull(); + + placeCaretAt(state, runPos + 1); + + const result = invokeLockHandleKeyDown('Delete'); + + expect(result.handled).toBe(true); + expect(result.prevented).toBe(true); + const nextSdtInfo = findSDTNode(editor.state.doc, 'structuredContent'); + expect(nextSdtInfo).not.toBeNull(); + expect(nextSdtInfo.node.textContent).toBe(''); + expect(editor.state.doc.textContent).toBe('Before After'); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(nextSdtInfo.pos + 1); + }); + + it('sdtLocked + collapsed Cmd+X inside typed inline SDT text does not delete content', () => { + const beforeText = schema.text('Before '); + const sdtRun = schema.nodes.run.create(null, schema.text('abc')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'sdtLocked' }, sdtRun); + const afterText = schema.text(' After'); + const paragraph = schema.nodes.paragraph.create(null, [beforeText, sdt, afterText]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const originalText = state.doc.textContent; + + let runPos = null; + state.doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent === 'abc') { + runPos = pos; + return false; + } + return true; + }); + expect(runPos).not.toBeNull(); + + placeCaretAt(state, runPos + 2); + + const result = invokeLockHandleKeyDown('x', { metaKey: true }); + + expect(result.handled).toBe(false); + expect(result.prevented).toBe(false); + expect(editor.state.doc.textContent).toBe(originalText); + }); }); describe('Path 1 — selection covers SDT content (label selection / triple-click)', () => { @@ -650,6 +714,11 @@ describe('StructuredContentLockPlugin', () => { if (shouldDeleteWrapper) { expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); + } else if (lockMode === 'sdtContentLocked') { + const sel = editor.state.selection; + expect(sel).toBeInstanceOf(TextSelection); + expect(sel.empty).toBe(true); + expect(sel.from).toBe(sdtInfo.pos); } else { // No wrapper deletion: selection unchanged. const sel = editor.state.selection; @@ -696,6 +765,36 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); }); + it('sdtContentLocked: exact content selection + Backspace collapses before inline SDT, then deletes preceding text', () => { + const leadingRun = schema.nodes.run.create(null, schema.text('Lead ')); + const sdtRun = schema.nodes.run.create(null, schema.text('inline value')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'sdtContentLocked' }, sdtRun); + const trailingRun = schema.nodes.run.create(null, schema.text('ail.')); + const paragraph = schema.nodes.paragraph.create(null, [leadingRun, sdt, trailingRun]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + setSelection(state, TextSelection.create(state.doc, sdtInfo.pos + 1, sdtInfo.end - 1)); + + const result = invokeLockHandleKeyDown('Backspace'); + + expect(result.handled).toBe(true); + expect(result.prevented).toBe(true); + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(sdtInfo.pos); + expect(findSDTNode(editor.state.doc, 'structuredContent').node.textContent).toBe('inline value'); + + handleBackspace(editor); + + const sdtAfter = findSDTNode(editor.state.doc, 'structuredContent'); + expect(sdtAfter).not.toBeNull(); + expect(sdtAfter.node.attrs.lockMode).toBe('sdtContentLocked'); + expect(sdtAfter.node.textContent).toBe('inline value'); + expect(editor.state.doc.textContent).toBe('Leadinline valueail.'); + }); + it.each([ ['unlocked', false, true], ['sdtLocked', false, true], @@ -719,6 +818,11 @@ describe('StructuredContentLockPlugin', () => { } else { expect(sdtAfter).not.toBeNull(); expect(sdtAfter.node.textContent === '').toBe(deletesContent); + if (lockMode === 'sdtContentLocked') { + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(sdtAfter.end); + } } }, ); @@ -790,6 +894,33 @@ describe('StructuredContentLockPlugin', () => { expect(finalState.doc.textContent).not.toBe(originalContent); expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(true); }); + + it('sdtLocked: undo restores inline SDT content deleted by Backspace', () => { + const leadingRun = schema.nodes.run.create(null, schema.text('Lead ')); + const sdtRun = schema.nodes.run.create(null, schema.text('inline value')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'sdtLocked' }, sdtRun); + const trailingRun = schema.nodes.run.create(null, schema.text('ail.')); + const paragraph = schema.nodes.paragraph.create(null, [leadingRun, sdt, trailingRun]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + placeCaretAt(state, sdtInfo.end); + handleBackspace(editor); + handleBackspace(editor); + + let sdtAfterDelete = findSDTNode(editor.state.doc, 'structuredContent'); + expect(sdtAfterDelete).not.toBeNull(); + expect(sdtAfterDelete.node.textContent).toBe(''); + + expect(editor.commands.undo()).toBe(true); + + sdtAfterDelete = findSDTNode(editor.state.doc, 'structuredContent'); + expect(sdtAfterDelete).not.toBeNull(); + expect(sdtAfterDelete.node.attrs.lockMode).toBe('sdtLocked'); + expect(sdtAfterDelete.node.textContent).toBe('inline value'); + expect(editor.state.doc.textContent).toBe('Lead inline valueail.'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js index 9ff9997977..288355d20d 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js @@ -1,4 +1,4 @@ -import { Plugin } from 'prosemirror-state'; +import { Plugin, TextSelection } from 'prosemirror-state'; import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js'; import { SELECT_INLINE_SDT_BEFORE_RUN_START_META } from '@core/commands/selectInlineSdtBeforeRunStart.js'; @@ -24,6 +24,26 @@ export function createStructuredContentSelectPlugin(editor) { const { selection } = state; const isEditableSlotText = (text) => text.replace(/\u200B/g, '').length === 0; + const resolveAdjacentEmptyInlineSdtEntry = () => { + if (event.key !== 'ArrowLeft') return null; + if (!selection.empty) return null; + + let targetPos = null; + state.doc.descendants((node, pos) => { + if (node.type.name !== 'structuredContent') return true; + if (node.content.size !== 0) return true; + + if (event.key === 'ArrowLeft' && selection.from === pos + node.nodeSize) { + targetPos = pos + 1; + return false; + } + + return true; + }); + + return targetPos; + }; + const resolveBoundaryExit = ($pos) => { for (let depth = $pos.depth; depth > 0; depth -= 1) { const node = $pos.node(depth); @@ -62,6 +82,17 @@ export function createStructuredContentSelectPlugin(editor) { return null; }; + const adjacentEmptySdtEntry = resolveAdjacentEmptyInlineSdtEntry(); + if (adjacentEmptySdtEntry != null) { + try { + view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, adjacentEmptySdtEntry))); + event.preventDefault(); + return true; + } catch { + return false; + } + } + const nextPos = resolveBoundaryExit(selection.$from); if (nextPos == null) return false; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js index d1586c2c8e..f419cef6a8 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { SELECT_INLINE_SDT_BEFORE_RUN_START_META } from '@core/commands/selectInlineSdtBeforeRunStart.js'; +import { createStructuredContentSelectPlugin } from './structured-content-select-plugin.js'; function findNode(doc, nodeType) { let result = null; @@ -292,6 +293,48 @@ describe('StructuredContentSelectPlugin', () => { expect(editor.state.selection.from).toBeGreaterThanOrEqual(sdt.pos + 1); }); + it('moves back inside an empty inline SDT with ArrowLeft from its trailing boundary', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('Lead '), inlineSdt, schema.text(' trail')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const insideSdt = sdt.pos + 1; + const afterSdt = sdt.pos + sdt.node.nodeSize; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, afterSdt))); + + const handled = pressArrow('ArrowLeft'); + + expect(handled).toBe(true); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(insideSdt); + expect(editor.state.selection.to).toBe(insideSdt); + }); + + it('does not scan the document for empty inline SDT entry on ArrowRight', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('Lead '), inlineSdt, schema.text(' trail')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const afterSdt = sdt.pos + sdt.node.nodeSize; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, afterSdt))); + + const descendants = editor.state.doc.descendants.bind(editor.state.doc); + const descendantsSpy = vi.fn(descendants); + editor.state.doc.descendants = descendantsSpy; + + const plugin = createStructuredContentSelectPlugin(editor); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }); + + expect(plugin.props.handleKeyDown(editor.view, event)).toBe(false); + expect(descendantsSpy).not.toHaveBeenCalled(); + }); + it('does not intercept Shift+ArrowRight near inline SDT boundary', () => { const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]); diff --git a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/tableBoundaryNavigation.js b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/tableBoundaryNavigation.js index 9afc16779a..1f7056f668 100644 --- a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/tableBoundaryNavigation.js +++ b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/tableBoundaryNavigation.js @@ -2,6 +2,7 @@ import { Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state'; import { CellSelection, TableMap } from 'prosemirror-tables'; import { getTableVisualDirection } from '@superdoc/contracts'; +import { findFirstTextPosInNode, findLastTextPosInNode } from '@core/commands/helpers/textPositions.js'; const TABLE_CELL_ROLES = new Set(['cell', 'header_cell']); @@ -214,42 +215,6 @@ function isFirstCellInTable(context) { return rect.left === 0 && rect.top === 0; } -/** - * Finds the first text position inside a node. - * @param {import('prosemirror-model').Node} node - * @param {number} nodePos - * @returns {number | null} - */ -function findFirstTextPosInNode(node, nodePos) { - if (node.isText) return nodePos; - for (let index = 0, offset = 0; index < node.childCount; index += 1) { - const child = node.child(index); - const childPos = nodePos + 1 + offset; - const found = findFirstTextPosInNode(child, childPos); - if (found != null) return found; - offset += child.nodeSize; - } - return null; -} - -/** - * Finds the last text position inside a node. - * @param {import('prosemirror-model').Node} node - * @param {number} nodePos - * @returns {number | null} - */ -function findLastTextPosInNode(node, nodePos) { - if (node.isText) return nodePos + (node.text?.length ?? 0); - for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { - const child = node.child(index); - offset -= child.nodeSize; - const childPos = nodePos + 1 + offset; - const found = findLastTextPosInNode(child, childPos); - if (found != null) return found; - } - return null; -} - /** * Finds the first text position after a boundary, or null if no text node exists. * @param {import('prosemirror-state').EditorState} state diff --git a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js index 680c77569c..34bddf0125 100644 --- a/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js +++ b/packages/super-editor/src/editors/v1/tests/helpers/mediaHelpers.test.js @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + getDataUriMetadata, getFallbackImageNameFromDataUri, sanitizeDocxMediaName, + tryDecodeDataUriText, } from '../../core/super-converter/helpers/mediaHelpers.js'; describe('sanitizeDocxMediaName', () => { @@ -25,6 +27,35 @@ describe('sanitizeDocxMediaName', () => { }); }); +describe('getDataUriMetadata', () => { + it('extracts MIME type, base64 flag, payload, and normalized extension', () => { + const result = getDataUriMetadata('data:image/svg+xml;charset=utf-8;base64,PHN2Zy8+'); + + expect(result).toEqual({ + hasPayloadSeparator: true, + rawMimeType: 'image/svg+xml', + mimeType: 'image/svg+xml', + isBase64: true, + payload: 'PHN2Zy8+', + extension: 'svg', + }); + }); + + it('handles no-parameter SVG data URIs without including the payload in the extension', () => { + const result = getDataUriMetadata('data:image/svg+xml,%3Csvg%2F%3E'); + + expect(result).toMatchObject({ + mimeType: 'image/svg+xml', + payload: '%3Csvg%2F%3E', + extension: 'svg', + }); + }); + + it('returns null for non-data URI input', () => { + expect(getDataUriMetadata('word/media/image.png')).toBeNull(); + }); +}); + describe('getFallbackImageNameFromDataUri', () => { it('returns a filename with extension extracted from data URI', () => { const dataUri = 'data:image/png;base64,AAAA'; @@ -33,11 +64,22 @@ describe('getFallbackImageNameFromDataUri', () => { it('normalises the extension casing', () => { const dataUri = 'data:image/JPEG;base64,AAAA'; - expect(getFallbackImageNameFromDataUri(dataUri)).toBe('image.jpeg'); + expect(getFallbackImageNameFromDataUri(dataUri)).toBe('image.jpg'); }); it('returns fallback when type cannot be derived', () => { expect(getFallbackImageNameFromDataUri('data:,')).toBe('image'); + expect(getFallbackImageNameFromDataUri('data:text/html,%3Cp%3Ebad%3C%2Fp%3E')).toBe('image'); expect(getFallbackImageNameFromDataUri('', 'custom')).toBe('custom'); }); }); + +describe('tryDecodeDataUriText', () => { + it('decodes percent-encoded data URI text payloads', () => { + expect(tryDecodeDataUriText('%3Csvg%2F%3E')).toBe(''); + }); + + it('returns null for malformed percent escapes', () => { + expect(tryDecodeDataUriText('%')).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js new file mode 100644 index 0000000000..9ebe678741 --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js @@ -0,0 +1,453 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { createDomPainter } from '@superdoc/painter-dom'; +import { resolveLayout } from '@superdoc/layout-resolved'; +import { Editor } from '@core/Editor.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const SIGNATURE_SRC = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; +const ENCODED_SIGNATURE_SVG = ''; +const ENCODED_SIGNATURE_SRC = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(ENCODED_SIGNATURE_SVG)}`; +const PNG_SRC = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/Ur/9wAAAABJRU5ErkJggg=='; + +const findFirstNodeByType = (node, typeName) => { + let found = null; + node.descendants((child) => { + if (child.type.name === typeName) { + found = child; + return false; + } + return true; + }); + return found; +}; + +const findNodeByTypeAndId = (node, typeName, id) => { + let found = null; + node.descendants((child) => { + if (child.type.name === typeName && child.attrs?.id === id) { + found = child; + return false; + } + return true; + }); + return found; +}; + +const collectNodesByType = (node, typeName) => { + const found = []; + node.descendants((child) => { + if (child.type.name === typeName) found.push(child); + return true; + }); + return found; +}; + +const collectElementsByName = (node, name, result = []) => { + if (!node || typeof node !== 'object') return result; + if (node.name === name) result.push(node); + (node.elements || []).forEach((child) => collectElementsByName(child, name, result)); + return result; +}; + +const getChildElement = (node, name) => node?.elements?.find((child) => child.name === name); + +const hasDescendantNamed = (node, name) => collectElementsByName(node, name).length > 0; + +const DEFAULT_CONVERTER_CONTEXT = { + docx: {}, + translatedLinkedStyles: { + docDefaults: {}, + latentStyles: {}, + styles: {}, + }, + translatedNumbering: { + abstracts: {}, + definitions: {}, + }, +}; + +const TEST_PAGE = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, +}; + +const paintSavedModel = (pmDoc, mediaFiles) => { + const { blocks } = toFlowBlocks(pmDoc, { + converterContext: DEFAULT_CONVERTER_CONTEXT, + mediaFiles, + }); + const contentWidth = TEST_PAGE.pageSize.w - TEST_PAGE.margins.left - TEST_PAGE.margins.right; + const measures = blocks.map((block) => { + const imageRun = block.runs?.find((run) => run.kind === 'image'); + const lineHeight = imageRun?.height ?? 20; + + return { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: Math.max((block.runs?.length ?? 1) - 1, 0), + toChar: 0, + width: imageRun?.width ?? contentWidth, + ascent: lineHeight, + descent: 0, + lineHeight, + }, + ], + totalHeight: lineHeight, + }; + }); + + let y = TEST_PAGE.margins.top; + const fragments = blocks.flatMap((block, index) => { + const measure = measures[index]; + if (block.kind !== 'paragraph') return []; + + const fragment = { + kind: 'para', + blockId: block.id, + fromLine: 0, + toLine: measure.lines?.length ?? 1, + x: TEST_PAGE.margins.left, + y, + width: contentWidth, + }; + y += measure.totalHeight ?? 20; + return [fragment]; + }); + + const layout = { + pageSize: TEST_PAGE.pageSize, + pages: [{ number: 1, fragments }], + }; + const mount = document.createElement('div'); + document.body.appendChild(mount); + + const painter = createDomPainter({}); + const resolvedLayout = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + painter.paint({ resolvedLayout }, mount); + + return { mount, blocks }; +}; + +describe('SD-3116 structured content image round-trip', () => { + let editor; + let reopened; + let paintMount; + + afterEach(() => { + editor?.destroy(); + reopened?.destroy(); + paintMount?.remove(); + editor = null; + reopened = null; + paintMount = null; + }); + + it('exports and reopens a block SDT containing preset image content', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const didInsert = editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215856', + tag: '{"fieldType":"signer"}', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: SIGNATURE_SRC, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }); + + expect(didInsert).toBe(true); + + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true, isFinalDoc: false }); + const documentXml = parseXmlToJson(updatedDocs['word/document.xml']); + const sdt = collectElementsByName(documentXml, 'w:sdt').find((candidate) => { + const sdtPr = getChildElement(candidate, 'w:sdtPr'); + return sdtPr?.elements?.some((el) => el.name === 'w:id' && el.attributes?.['w:val'] === '1299215856'); + }); + + expect(sdt).toBeDefined(); + const sdtContent = getChildElement(sdt, 'w:sdtContent'); + expect(sdtContent).toBeDefined(); + expect(hasDescendantNamed(sdtContent, 'a:blip')).toBe(true); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const [roundTripDocx, roundTripMedia, roundTripMediaFiles, roundTripFonts] = await Editor.loadXmlData( + exported, + true, + ); + ({ editor: reopened } = initTestEditor({ + content: roundTripDocx, + media: roundTripMedia, + mediaFiles: roundTripMediaFiles, + fonts: roundTripFonts, + isNewFile: false, + })); + + const reopenedBlock = findFirstNodeByType(reopened.state.doc, 'structuredContentBlock'); + expect(reopenedBlock?.attrs).toMatchObject({ + id: '1299215856', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }); + + const reopenedImage = findFirstNodeByType(reopenedBlock, 'image'); + expect(reopenedImage?.attrs).toMatchObject({ + alt: 'Signature Example', + size: { width: 200, height: 50 }, + }); + expect(reopenedImage?.attrs.src).toMatch(/^word\/media\/.+\.svg$/); + }); + + it('repaints preset image content from a saved document model without export and re-import', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const didInsert = editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215860', + tag: '{"fieldType":"signer"}', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: SIGNATURE_SRC, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }); + + expect(didInsert).toBe(true); + + const savedModel = editor.getJSON(); + const savedMedia = { ...editor.storage.image.media }; + const savedImage = findFirstNodeByType(editor.state.doc, 'image'); + expect(savedImage?.attrs.src).toMatch(/^word\/media\/image-\d+\.svg$/); + expect(savedMedia[savedImage.attrs.src]).toBe(SIGNATURE_SRC); + + const painted = paintSavedModel(savedModel, savedMedia); + paintMount = painted.mount; + + expect(painted.blocks).toHaveLength(1); + expect(painted.blocks[0].attrs?.sdt).toMatchObject({ + type: 'structuredContent', + scope: 'block', + id: '1299215860', + }); + + const img = paintMount.querySelector('img'); + expect(img?.getAttribute('src')).toBe(SIGNATURE_SRC); + expect(img?.getAttribute('alt')).toBe('Signature Example'); + }); + + it('round-trips inline text SDTs and block plain-text SDTs', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + expect( + editor.commands.insertStructuredContentInline({ + attrs: { + id: '1299215861', + tag: 'inline_text_sdt', + alias: 'Inline text TEST', + lockMode: 'sdtLocked', + }, + text: 'Inline plain text', + }), + ).toBe(true); + + expect( + editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215862', + tag: 'block_text_sdt', + alias: 'Block text TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [{ type: 'text', text: 'Block plain text' }], + }, + }), + ).toBe(true); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const [roundTripDocx, roundTripMedia, roundTripMediaFiles, roundTripFonts] = await Editor.loadXmlData( + exported, + true, + ); + ({ editor: reopened } = initTestEditor({ + content: roundTripDocx, + media: roundTripMedia, + mediaFiles: roundTripMediaFiles, + fonts: roundTripFonts, + isNewFile: false, + })); + + const inlineSdt = findNodeByTypeAndId(reopened.state.doc, 'structuredContent', '1299215861'); + expect(inlineSdt?.attrs).toMatchObject({ + alias: 'Inline text TEST', + lockMode: 'sdtLocked', + }); + expect(inlineSdt?.textContent).toBe('Inline plain text'); + + const blockSdt = findNodeByTypeAndId(reopened.state.doc, 'structuredContentBlock', '1299215862'); + expect(blockSdt?.attrs).toMatchObject({ + alias: 'Block text TEST', + lockMode: 'sdtLocked', + }); + expect(blockSdt?.textContent).toBe('Block plain text'); + }); + + it('exports non-base64 SVG preset image content as decoded media bytes', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const didInsert = editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215857', + tag: '{"fieldType":"signer"}', + alias: 'Signature TEST', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: ENCODED_SIGNATURE_SRC, + alt: 'Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }); + + expect(didInsert).toBe(true); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const [, , exportedMediaFiles] = await Editor.loadXmlData(exported, true); + const svgMediaEntry = Object.entries(exportedMediaFiles).find(([path]) => path.endsWith('.svg')); + + expect(svgMediaEntry).toBeDefined(); + expect(Buffer.from(svgMediaEntry[1], 'base64').toString('utf8')).toBe(ENCODED_SIGNATURE_SVG); + }); + + it('round-trips two block SDTs with different preset image types in one document', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + expect( + editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215863', + tag: 'svg_signature_sdt', + alias: 'SVG Signature', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: SIGNATURE_SRC, + alt: 'SVG Signature Example', + size: { width: 200, height: 50 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }), + ).toBe(true); + + expect( + editor.commands.insertStructuredContentBlock({ + attrs: { + id: '1299215864', + tag: 'png_signature_sdt', + alias: 'PNG Signature', + lockMode: 'sdtLocked', + }, + json: { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + src: PNG_SRC, + alt: 'PNG Signature Example', + size: { width: 20, height: 10 }, + wrap: { type: 'Inline' }, + }, + }, + ], + }, + }), + ).toBe(true); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const [roundTripDocx, roundTripMedia, roundTripMediaFiles, roundTripFonts] = await Editor.loadXmlData( + exported, + true, + ); + ({ editor: reopened } = initTestEditor({ + content: roundTripDocx, + media: roundTripMedia, + mediaFiles: roundTripMediaFiles, + fonts: roundTripFonts, + isNewFile: false, + })); + + const reopenedSvgBlock = findNodeByTypeAndId(reopened.state.doc, 'structuredContentBlock', '1299215863'); + const reopenedPngBlock = findNodeByTypeAndId(reopened.state.doc, 'structuredContentBlock', '1299215864'); + const reopenedSvgImage = findFirstNodeByType(reopenedSvgBlock, 'image'); + const reopenedPngImage = findFirstNodeByType(reopenedPngBlock, 'image'); + + expect(reopenedSvgImage?.attrs).toMatchObject({ + alt: 'SVG Signature Example', + size: { width: 200, height: 50 }, + }); + expect(reopenedPngImage?.attrs).toMatchObject({ + alt: 'PNG Signature Example', + size: { width: 20, height: 10 }, + }); + expect(reopenedSvgImage?.attrs.src).toMatch(/^word\/media\/.+\.svg$/); + expect(reopenedPngImage?.attrs.src).toMatch(/^word\/media\/.+\.png$/); + expect( + new Set(collectNodesByType(reopened.state.doc, 'image').map((node) => node.attrs.src)).size, + ).toBeGreaterThanOrEqual(2); + expect(Object.keys(roundTripMediaFiles).filter((path) => /\.(svg|png)$/.test(path))).toHaveLength(2); + }); +}); diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts index ef94b5ebb5..987333845c 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { historyKey } from 'prosemirror-history'; -import { NodeSelection } from 'prosemirror-state'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; const getActiveFormattingMock = vi.hoisted(() => vi.fn(() => [])); @@ -87,6 +88,81 @@ describe('createHeadlessToolbar', () => { controller.destroy(); }); + it('does not execute commands that are currently reported disabled', () => { + const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + parseDOM: [{ tag: 'p' }], + }, + text: { group: 'inline' }, + structuredContent: { + group: 'inline', + inline: true, + content: 'inline*', + attrs: { + lockMode: { default: 'unlocked' }, + }, + toDOM: () => ['span', 0], + parseDOM: [{ tag: 'span' }], + }, + }, + }); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [ + schema.text('A '), + schema.node('structuredContent', { lockMode: 'contentLocked' }, [schema.text('Locked')]), + ]), + ]); + const baseState = EditorState.create({ schema, doc }); + const state = baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, 5))); + const toggleBold = vi.fn(() => true); + const insertTable = vi.fn(() => true); + const superdoc = createActiveEditorHost({ + commands: { toggleBold, insertTable }, + state, + }); + + const controller = createHeadlessToolbar({ + superdoc, + commands: ['bold'], + }); + + expect(controller.getSnapshot().commands.bold?.disabled).toBe(true); + expect(controller.execute?.('bold')).toBe(false); + expect(toggleBold).not.toHaveBeenCalled(); + expect(controller.execute?.('table-insert', { rows: 1, cols: 1 })).toBe(false); + expect(insertTable).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('does not execute disabled mutation commands in viewing mode', () => { + const toggleBold = vi.fn(() => true); + const superdoc = createActiveEditorHost({ + commands: { toggleBold }, + extra: { + options: { + documentMode: 'viewing', + }, + }, + }); + + const controller = createHeadlessToolbar({ + superdoc, + commands: ['bold'], + }); + + expect(controller.getSnapshot().commands.bold?.disabled).toBe(true); + expect(controller.execute?.('bold')).toBe(false); + expect(toggleBold).not.toHaveBeenCalled(); + + controller.destroy(); + }); + it('executes track-changes accept-selection through the registry direct command path', () => { const acceptTrackedChangeFromToolbar = vi.fn(() => true); const superdoc = createActiveEditorHost({ @@ -95,7 +171,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['track-changes-accept-selection'], }); expect(controller.execute?.('track-changes-accept-selection')).toBe(true); @@ -112,7 +187,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['track-changes-reject-selection'], }); expect(controller.execute?.('track-changes-reject-selection')).toBe(true); @@ -693,7 +767,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['undo'], }); expect(controller.execute?.('undo')).toBe(true); @@ -720,7 +793,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['redo'], }); expect(controller.execute?.('redo')).toBe(true); @@ -938,7 +1010,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: ['linked-style'], }); expect(controller.execute?.('linked-style', { id: 'Heading1' })).toBe(true); @@ -997,7 +1068,6 @@ describe('createHeadlessToolbar', () => { const controller = createHeadlessToolbar({ superdoc, - commands: [id], }); expect(controller.execute?.(id)).toBe(true); diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts index 5abca58d52..60ce20b564 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.ts @@ -6,6 +6,7 @@ import type { ToolbarSubscriptionEvent, } from './types.js'; import { createToolbarSnapshot } from './create-toolbar-snapshot.js'; +import { hasContentLockedStructuredContentSelection } from './helpers/context.js'; import { subscribeToolbarEvents } from './subscribe-toolbar-events.js'; import { createToolbarRegistry } from './toolbar-registry.js'; import type { BuiltInToolbarRegistryEntry } from './internal-types.js'; @@ -45,6 +46,38 @@ const executeRegistryCommand = ( return executeDirectCommand(id, snapshot, toolbarRegistry, payload); }; +const CONTENT_LOCK_EXECUTION_EXEMPT_IDS = new Set([ + 'undo', + 'redo', + 'ruler', + 'formatting-marks', + 'zoom', + 'document-mode', +]); + +const isToolbarCommandExecutionDisabled = ( + id: PublicToolbarItemId, + superdoc: CreateHeadlessToolbarOptions['superdoc'], + snapshot: ToolbarSnapshot, + toolbarRegistry: Partial>, +): boolean => { + const snapshotState = snapshot.commands[id]; + if (snapshotState) return snapshotState.disabled; + + if (CONTENT_LOCK_EXECUTION_EXEMPT_IDS.has(id) || !hasContentLockedStructuredContentSelection(snapshot.context)) { + return false; + } + + const entry = toolbarRegistry[id]; + if (!entry) return false; + + try { + return entry.state({ context: snapshot.context, superdoc }).disabled; + } catch { + return true; + } +}; + export const createHeadlessToolbar = (options: CreateHeadlessToolbarOptions): HeadlessToolbarController => { const listeners = new Set<(event: ToolbarSubscriptionEvent) => void>(); const toolbarRegistry = createToolbarRegistry(); @@ -107,6 +140,10 @@ export const createHeadlessToolbar = (options: CreateHeadlessToolbarOptions): He }, execute(id: PublicToolbarItemId, payload?: unknown) { + if (isToolbarCommandExecutionDisabled(id, options.superdoc, snapshot, toolbarRegistry)) { + return false; + } + const result = executeRegistryCommand(id, options.superdoc, snapshot, toolbarRegistry, payload); if (result && !destroyed) { refreshControllerState(); diff --git a/packages/super-editor/src/headless-toolbar/helpers/context.ts b/packages/super-editor/src/headless-toolbar/helpers/context.ts index a27ab19c2c..7f62746139 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/context.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/context.ts @@ -1,5 +1,7 @@ import { findParentNode } from '../../editors/v1/core/helpers/findParentNode.js'; import { calculateResolvedParagraphProperties } from '../../editors/v1/extensions/paragraph/resolvedPropertiesCache.js'; +import { isContentLockedMode } from '../../editors/v1/extensions/structured-content/lockModes.js'; +import { isStructuredContentNodeType } from '../../editors/v1/extensions/structured-content/nodeTypes.js'; import { NodeSelection } from 'prosemirror-state'; import type { ToolbarContext } from '../types.js'; @@ -32,3 +34,50 @@ export const isFieldAnnotationSelection = (context: ToolbarContext | null) => { const selection = resolveStateEditor(context)?.state?.selection; return selection instanceof NodeSelection && selection?.node?.type?.name === 'fieldAnnotation'; }; + +const isContentLockedStructuredContentNode = (node: any) => { + return isStructuredContentNodeType(node?.type?.name) && isContentLockedMode(node?.attrs?.lockMode); +}; + +const resolvedPositionHasContentLockedStructuredContent = ($pos: any) => { + if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') return false; + + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (isContentLockedStructuredContentNode($pos.node(depth))) return true; + } + + return false; +}; + +export const hasContentLockedStructuredContentSelection = (context: ToolbarContext | null) => { + const state = resolveStateEditor(context)?.state; + const selection = state?.selection; + const doc = state?.doc; + if (!selection || !doc) return false; + + if (selection instanceof NodeSelection && isContentLockedStructuredContentNode(selection.node)) { + return true; + } + + if ( + resolvedPositionHasContentLockedStructuredContent(selection.$from) || + resolvedPositionHasContentLockedStructuredContent(selection.$to) + ) { + return true; + } + + if (typeof doc.nodesBetween !== 'function' || selection.from == null || selection.to == null) { + return false; + } + + let hasLockedNode = false; + doc.nodesBetween(selection.from, selection.to, (node: any) => { + if (isContentLockedStructuredContentNode(node)) { + hasLockedNode = true; + return false; + } + return !hasLockedNode; + }); + + return hasLockedNode; +}; diff --git a/packages/super-editor/src/headless-toolbar/helpers/document.ts b/packages/super-editor/src/headless-toolbar/helpers/document.ts index bf093fcb0f..958f90e669 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/document.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/document.ts @@ -1,6 +1,6 @@ import { undoDepth, redoDepth } from 'prosemirror-history'; import { yUndoPluginKey } from 'y-prosemirror'; -import { isCommandDisabled } from './general.js'; +import { isCommandDisabled, isMutationCommandDisabled } from './general.js'; import { resolveStateEditor } from './context.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; @@ -67,7 +67,7 @@ export const getCurrentRedoDepth = (context: ToolbarContext | null) => { export const createDocumentOperationCapabilityStateDeriver = (operationId: string) => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - if (isCommandDisabled(context)) { + if (isMutationCommandDisabled(context)) { return { active: false, disabled: true, diff --git a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts index 87d5347e76..d4104951b1 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts @@ -4,7 +4,7 @@ import { getActiveFormatting } from '../../editors/v1/core/helpers/getActiveForm import { getFileOpener, processAndInsertImageFile } from '../../editors/v1/extensions/image/imageHelpers/index.js'; import { TextSelection, Selection } from 'prosemirror-state'; import { getCurrentResolvedParagraphProperties, isFieldAnnotationSelection, resolveStateEditor } from './context.js'; -import { createDirectCommandExecute, isCommandDisabled } from './general.js'; +import { createDirectCommandExecute, isMutationCommandDisabled } from './general.js'; import type { ToolbarContext } from '../types.js'; /** @@ -104,7 +104,7 @@ export const createBoldStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -129,7 +129,7 @@ export const createItalicStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -151,7 +151,7 @@ export const createUnderlineStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -173,7 +173,7 @@ export const createStrikethroughStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -195,7 +195,7 @@ export const createCopyFormatStateDeriver = ({ context }: { context: ToolbarContext | null }) => { return { active: hasStoredCopyFormat(context), - disabled: isCommandDisabled(context), + disabled: isMutationCommandDisabled(context), }; }; @@ -204,7 +204,7 @@ export const createFontSizeStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -242,7 +242,7 @@ export const createFontFamilyStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -284,7 +284,7 @@ export const createTextColorStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -313,7 +313,7 @@ export const createHighlightColorStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -342,7 +342,7 @@ export const createLinkStateDeriver = ({ context }: { context: ToolbarContext | null }) => { const stateEditor = resolveStateEditor(context); const formatting = stateEditor ? getActiveFormatting(stateEditor) : []; - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { diff --git a/packages/super-editor/src/headless-toolbar/helpers/general.ts b/packages/super-editor/src/headless-toolbar/helpers/general.ts index a0d925f462..ed814d288e 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/general.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/general.ts @@ -1,4 +1,4 @@ -import { resolveStateEditor } from './context.js'; +import { hasContentLockedStructuredContentSelection, resolveStateEditor } from './context.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; export const isCommandDisabled = (context: ToolbarContext | null) => { @@ -8,10 +8,14 @@ export const isCommandDisabled = (context: ToolbarContext | null) => { return documentMode === 'viewing'; }; +export const isMutationCommandDisabled = (context: ToolbarContext | null) => { + return isCommandDisabled(context) || hasContentLockedStructuredContentSelection(context); +}; + export const createDisabledStateDeriver = (options?: { withValue?: boolean }) => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const disabled = isCommandDisabled(context); + const disabled = isMutationCommandDisabled(context); if (options?.withValue) { return { diff --git a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts index 3d15a6849d..061f9c4949 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts @@ -5,7 +5,7 @@ import { twipsToLines } from '../../editors/v1/core/super-converter/helpers.js'; import { getQuickFormatList } from '../../editors/v1/extensions/linked-styles/index.js'; import { mapStoredJustificationToDisplayAlignment } from '../../editors/v1/core/helpers/paragraph-alignment.js'; import { getCurrentParagraphParent, getCurrentResolvedParagraphProperties, resolveStateEditor } from './context.js'; -import { createDirectCommandExecute, isCommandDisabled } from './general.js'; +import { createDirectCommandExecute, isCommandDisabled, isMutationCommandDisabled } from './general.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; const getCurrentParagraphJustification = (context: ToolbarContext | null) => { @@ -18,7 +18,7 @@ const getCurrentParagraphJustification = (context: ToolbarContext | null) => { export const createParagraphDirectionStateDeriver = (direction: 'ltr' | 'rtl') => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) return { active: false, disabled: true, value: null }; const rightToLeft = getCurrentResolvedParagraphProperties(context)?.rightToLeft; @@ -69,7 +69,7 @@ export const createTextAlignStateDeriver = export const createLineHeightStateDeriver = () => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { @@ -93,7 +93,7 @@ export const createLineHeightStateDeriver = export const createLinkedStyleStateDeriver = () => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); const stateEditor = resolveStateEditor(context); if (isDisabled || !stateEditor) { @@ -126,7 +126,7 @@ export const createLinkedStyleStateDeriver = export const createListStateDeriver = (numberingType: 'bullet' | 'ordered') => ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { - const isDisabled = isCommandDisabled(context); + const isDisabled = isMutationCommandDisabled(context); if (isDisabled) { return { diff --git a/packages/super-editor/src/headless-toolbar/helpers/table.ts b/packages/super-editor/src/headless-toolbar/helpers/table.ts index 3b9f324102..df6dd80cfe 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/table.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/table.ts @@ -1,6 +1,6 @@ import { isInTable } from '../../editors/v1/core/helpers/isInTable.js'; import { resolveStateEditor } from './context.js'; -import { isCommandDisabled } from './general.js'; +import { isMutationCommandDisabled } from './general.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; export const createTableActionsStateDeriver = @@ -8,7 +8,7 @@ export const createTableActionsStateDeriver = ({ context }: { context: ToolbarContext | null }): ToolbarCommandState => { const editor = resolveStateEditor(context); const inTable = editor?.state?.selection?.$head ? isInTable(editor.state) : false; - const disabled = isCommandDisabled(context) || !inTable; + const disabled = isMutationCommandDisabled(context) || !inTable; return { active: false, diff --git a/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts b/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts index 3a1899c720..74f31c6499 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts @@ -3,7 +3,7 @@ import { isTrackedChangeActionAllowed, } from '../../editors/v1/extensions/track-changes/permission-helpers.js'; import { resolveStateEditor } from './context.js'; -import { isCommandDisabled } from './general.js'; +import { isMutationCommandDisabled } from './general.js'; import type { ToolbarContext } from '../types.js'; // SD-3213f: prefer the narrow `superdoc.getComment(id)` method when @@ -43,7 +43,7 @@ const enrichTrackedChanges = (trackedChanges: Array> = [], s export const createTrackChangesSelectionActionStateDeriver = (action: 'accept' | 'reject') => ({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }) => { - if (isCommandDisabled(context)) { + if (isMutationCommandDisabled(context)) { return { active: false, disabled: true, diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 281a91074b..541e886596 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { historyKey } from 'prosemirror-history'; -import { PluginKey } from 'prosemirror-state'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; const getActiveFormattingMock = vi.hoisted(() => vi.fn(() => [])); const getYUndoPluginStateMock = vi.hoisted(() => vi.fn(() => undefined)); @@ -47,6 +48,116 @@ const createContext = (): ToolbarContext => ({ editor: {} as any, }); +const sdtSchema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + parseDOM: [{ tag: 'p' }], + }, + text: { group: 'inline' }, + structuredContent: { + group: 'inline', + inline: true, + content: 'inline*', + attrs: { + id: { default: null }, + lockMode: { default: 'unlocked' }, + }, + toDOM: () => ['span', 0], + parseDOM: [{ tag: 'span' }], + }, + structuredContentBlock: { + group: 'block', + content: 'block+', + attrs: { + id: { default: null }, + lockMode: { default: 'unlocked' }, + }, + toDOM: () => ['div', 0], + parseDOM: [{ tag: 'div' }], + }, + }, +}); + +const makeToolbarContextWithSelection = (state: EditorState): ToolbarContext => ({ + ...createContext(), + editor: { + state, + options: { + documentMode: 'editing', + }, + } as any, +}); + +const findNodeById = (doc: any, id: string) => { + let result: { node: any; pos: number } | null = null; + doc.descendants((node: any, pos: number) => { + if (result) return false; + if (String(node.attrs?.id) === id) { + result = { node, pos }; + return false; + } + return true; + }); + if (!result) throw new Error(`Missing test node "${id}"`); + return result; +}; + +const findTextPos = (doc: any, text: string) => { + let result: number | null = null; + doc.descendants((node: any, pos: number) => { + if (result != null) return false; + if (node.isText && node.text?.includes(text)) { + result = pos + node.text.indexOf(text); + return false; + } + return true; + }); + if (result == null) throw new Error(`Missing test text "${text}"`); + return result; +}; + +const makeInlineSdtState = (lockMode: string, selectionKind: 'inside' | 'node' | 'span' = 'inside') => { + const doc = sdtSchema.node('doc', null, [ + sdtSchema.node('paragraph', null, [ + sdtSchema.text('A '), + sdtSchema.node('structuredContent', { id: 'inline-sdt', lockMode }, [sdtSchema.text('Field')]), + sdtSchema.text(' Z'), + ]), + ]); + + const baseState = EditorState.create({ schema: sdtSchema, doc }); + const inlineSdt = findNodeById(doc, 'inline-sdt'); + + if (selectionKind === 'node') { + return baseState.apply(baseState.tr.setSelection(NodeSelection.create(doc, inlineSdt.pos))); + } + + if (selectionKind === 'span') { + return baseState.apply( + baseState.tr.setSelection(TextSelection.create(doc, findTextPos(doc, 'A'), findTextPos(doc, 'Z') + 1)), + ); + } + + return baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, findTextPos(doc, 'Field') + 1))); +}; + +const makeBlockSdtState = (lockMode: string) => { + const doc = sdtSchema.node('doc', null, [ + sdtSchema.node('paragraph', null, [sdtSchema.text('Before')]), + sdtSchema.node('structuredContentBlock', { id: 'block-sdt', lockMode }, [ + sdtSchema.node('paragraph', null, [sdtSchema.text('Block field')]), + ]), + sdtSchema.node('paragraph', null, [sdtSchema.text('After')]), + ]); + + const baseState = EditorState.create({ schema: sdtSchema, doc }); + return baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, findTextPos(doc, 'Block field') + 1))); +}; + describe('createToolbarRegistry', () => { afterEach(() => { vi.clearAllMocks(); @@ -1226,6 +1337,91 @@ describe('createToolbarRegistry', () => { }); }); + it.each(['contentLocked', 'sdtContentLocked'])( + 'disables representative mutation commands inside a %s inline SDT', + (lockMode) => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeInlineSdtState(lockMode)); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry.italic?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry.underline?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry.link?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry.image?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['table-insert']?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['clear-formatting']?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['copy-format']?.state({ context, superdoc: {} })?.disabled).toBe(true); + }, + ); + + it.each(['unlocked', 'sdtLocked'])('does not disable mutation commands from %s SDTs alone', (lockMode) => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeInlineSdtState(lockMode)); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry.link?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry['table-insert']?.state({ context, superdoc: {} })?.disabled).toBe(false); + }); + + it('leaves document controls governed by their existing rules inside content-locked SDTs', () => { + getYUndoPluginStateMock.mockReturnValue({ + undoManager: { + undoStack: [1], + redoStack: [1], + }, + }); + + const registry = createToolbarRegistry(); + const baseContext = makeToolbarContextWithSelection(makeInlineSdtState('contentLocked')); + const context = { + ...baseContext, + editor: { + ...baseContext.editor, + options: { + ydoc: {}, + documentMode: 'editing', + }, + } as any, + }; + + expect(registry.undo?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry.redo?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry.ruler?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry.zoom?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect(registry['document-mode']?.state({ context, superdoc: {} })?.disabled).toBe(false); + expect( + registry['formatting-marks']?.state({ + context, + superdoc: { + toggleFormattingMarks: vi.fn(), + }, + })?.disabled, + ).toBe(false); + }); + + it('keeps text-align available when mutation commands are disabled inside a locked block SDT paragraph', () => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeBlockSdtState('contentLocked')); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['text-align']?.state({ context, superdoc: {} })?.disabled).toBe(false); + }); + + it('disables mutation commands for a NodeSelection on a locked SDT', () => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeInlineSdtState('sdtContentLocked', 'node')); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); + }); + + it('disables mutation commands for a range spanning locked SDT content', () => { + const registry = createToolbarRegistry(); + const context = makeToolbarContextWithSelection(makeInlineSdtState('contentLocked', 'span')); + + expect(registry.bold?.state({ context, superdoc: {} })?.disabled).toBe(true); + expect(registry['bullet-list']?.state({ context, superdoc: {} })?.disabled).toBe(true); + }); + // ------------------------------------------------------------------------- // PR-2873 (SD-2527) — full coverage of bullet + ordered style derivation // diff --git a/shared/url-validation/index.d.ts b/shared/url-validation/index.d.ts index 39a49ad26c..ab21b0152d 100644 --- a/shared/url-validation/index.d.ts +++ b/shared/url-validation/index.d.ts @@ -87,6 +87,24 @@ export function encodeTooltip(raw: string | null | undefined, maxLength?: number export const DEFAULT_TOOLTIP_MAX_LENGTH: number; +export const MAX_IMAGE_DATA_URL_LENGTH: number; + +export const IMAGE_DATA_URL_MIME_TYPES: readonly string[]; + +export type DataUriMetadata = { + hasPayloadSeparator: boolean; + payload: string; + rawMimeType: string; + mimeType: string; + isBase64: boolean; +}; + +export function getDataUriMetadata(src?: string): DataUriMetadata | null; + +export function tryDecodeDataUriText(payload?: string): string | null; + +export function isValidImageDataUrl(src: unknown): boolean; + export const UrlValidationConstants: { DEFAULT_ALLOWED_PROTOCOLS: string[]; OPTIONAL_PROTOCOLS: string[]; diff --git a/shared/url-validation/index.js b/shared/url-validation/index.js index 1f88860d17..98af73cfa9 100644 --- a/shared/url-validation/index.js +++ b/shared/url-validation/index.js @@ -64,6 +64,112 @@ const BLOCKED_PROTOCOLS = ['javascript', 'data', 'vbscript', 'file', 'ssh', 'ws' */ const DEFAULT_MAX_LENGTH = 2048; +/** + * Maximum allowed length for image data URLs. + * Prevents resource exhaustion from extremely large embedded images. + */ +export const MAX_IMAGE_DATA_URL_LENGTH = 10 * 1024 * 1024; + +/** + * Canonical set of image data URL MIME types supported by rendering and export. + */ +export const IMAGE_DATA_URL_MIME_TYPES = Object.freeze([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/bmp', + 'image/ico', + 'image/tif', + 'image/tiff', +]); + +/** + * Parse a data URI into the MIME type, payload, and base64 flag used by image + * validation and DOCX export. Returns null for non-data URI strings. + * + * The payload separator is tracked separately so callers can reject malformed + * values like `data:image/svg+xml` instead of treating them as empty files. + * + * @param {string} src + * @returns {{hasPayloadSeparator: boolean, payload: string, rawMimeType: string, mimeType: string, isBase64: boolean}|null} + */ +export const getDataUriMetadata = (src = '') => { + if (typeof src !== 'string' || !src.startsWith('data:')) return null; + + const commaIndex = src.indexOf(','); + const hasPayloadSeparator = commaIndex !== -1; + const metadata = src.slice(5, hasPayloadSeparator ? commaIndex : undefined); + const payload = hasPayloadSeparator ? src.slice(commaIndex + 1) : ''; + const [rawMimeType = '', ...parameters] = metadata.split(';'); + const mimeType = rawMimeType.toLowerCase(); + + return { + hasPayloadSeparator, + payload, + rawMimeType, + mimeType, + isBase64: parameters.some((part) => part.toLowerCase() === 'base64'), + }; +}; + +/** + * Percent-decode a non-base64 data URI payload. + * + * @param {string} payload + * @returns {string|null} Decoded text, or null when the payload has invalid percent escapes. + */ +export const tryDecodeDataUriText = (payload = '') => { + try { + return decodeURIComponent(payload); + } catch { + return null; + } +}; + +const BASE64_PAYLOAD_PATTERN = /^[A-Za-z0-9+/]+$/; + +const isValidBase64Payload = (payload = '') => { + const normalizedPayload = payload.replace(/\s/g, ''); + if (!normalizedPayload || normalizedPayload.length % 4 === 1) return false; + + const padding = normalizedPayload.match(/=+$/)?.[0] || ''; + if (padding.length > 2) return false; + + const body = padding ? normalizedPayload.slice(0, -padding.length) : normalizedPayload; + if (!body || !BASE64_PAYLOAD_PATTERN.test(body) || body.includes('=')) return false; + if (!padding) return true; + if (normalizedPayload.length % 4 !== 0) return false; + + return body.length % 4 === (padding.length === 1 ? 3 : 2); +}; + +/** + * Validate an image data URL for rendering and export. + * + * The URL must use a supported image MIME type, include a comma payload + * separator, and stay within MAX_IMAGE_DATA_URL_LENGTH. Base64 payloads are + * accepted for all supported image MIME types. Non-base64 payloads are accepted + * only for SVG, where the percent-encoded text must decode successfully. + * + * @param {unknown} src + * @returns {boolean} + */ +export const isValidImageDataUrl = (src) => { + if (typeof src !== 'string' || !src.startsWith('data:') || src.length > MAX_IMAGE_DATA_URL_LENGTH) { + return false; + } + + const metadata = getDataUriMetadata(src); + if (!metadata?.hasPayloadSeparator || !IMAGE_DATA_URL_MIME_TYPES.includes(metadata.mimeType)) return false; + if (metadata.isBase64) return isValidBase64Payload(metadata.payload); + if (metadata.mimeType !== 'image/svg+xml') return false; + + return tryDecodeDataUriText(metadata.payload) != null; +}; + /** * Default maximum tooltip length in characters. * diff --git a/shared/url-validation/index.test.js b/shared/url-validation/index.test.js index 247b5d97c2..bbbed9ad28 100644 --- a/shared/url-validation/index.test.js +++ b/shared/url-validation/index.test.js @@ -1,7 +1,61 @@ import { describe, expect, it, beforeEach, afterEach, spyOn } from 'bun:test'; -import { sanitizeHref, encodeTooltip, UrlValidationConstants, buildAllowedProtocols, isRelativeUrl } from './index.js'; +import { + sanitizeHref, + encodeTooltip, + UrlValidationConstants, + buildAllowedProtocols, + isRelativeUrl, + IMAGE_DATA_URL_MIME_TYPES, + MAX_IMAGE_DATA_URL_LENGTH, + getDataUriMetadata, + isValidImageDataUrl, +} from './index.js'; describe('url-validation', () => { + describe('image data URL policy', () => { + it('exports the shared MIME allowlist and size cap', () => { + expect(IMAGE_DATA_URL_MIME_TYPES).toContain('image/svg+xml'); + expect(IMAGE_DATA_URL_MIME_TYPES).toContain('image/png'); + expect(MAX_IMAGE_DATA_URL_LENGTH).toBe(10 * 1024 * 1024); + }); + + it('parses data URI metadata once for shared consumers', () => { + expect(getDataUriMetadata('data:image/svg+xml;charset=utf-8;base64,PHN2Zy8+')).toEqual({ + hasPayloadSeparator: true, + payload: 'PHN2Zy8+', + rawMimeType: 'image/svg+xml', + mimeType: 'image/svg+xml', + isBase64: true, + }); + }); + + it('validates image data URLs with the shared renderer/export policy', () => { + expect(isValidImageDataUrl('data:image/png;base64,abc')).toBe(true); + expect(isValidImageDataUrl('data:image/svg+xml,%3Csvg%2F%3E')).toBe(true); + expect(isValidImageDataUrl('data:image/png,not-base64')).toBe(false); + expect(isValidImageDataUrl('data:text/html,%3Cp%3Ebad%3C%2Fp%3E')).toBe(false); + expect(isValidImageDataUrl('data:image/svg+xml,%')).toBe(false); + }); + + it('rejects malformed base64 image data URL payloads before export', () => { + expect(isValidImageDataUrl('data:image/png;base64,%%%')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,a')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,ab=c')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,==')).toBe(false); + expect(isValidImageDataUrl('data:image/png;base64,abc=')).toBe(true); + expect(isValidImageDataUrl('data:image/png;base64,YWJjZA==')).toBe(true); + }); + + it('accepts image data URLs at the maximum length and rejects one byte over', () => { + const prefix = 'data:image/svg+xml,'; + const payload = 'a'.repeat(MAX_IMAGE_DATA_URL_LENGTH - prefix.length); + + expect(isValidImageDataUrl(`${prefix}${payload}`)).toBe(true); + expect(isValidImageDataUrl(`${prefix}${payload}a`)).toBe(false); + }); + }); + describe('sanitizeHref', () => { it('allows fully-qualified https URLs', () => { const result = sanitizeHref('https://example.com'); diff --git a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts index d9084282dd..f2224500df 100644 --- a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts +++ b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts @@ -265,6 +265,63 @@ async function isLabelVisible(page: Page, blockSelector: string): Promise { + return page.evaluate(() => { + const editor = (window as any).editor; + const { schema } = editor; + const paragraph = (text: string) => + schema.nodes.paragraph.create(null, schema.nodes.run.create(null, schema.text(text))); + const cell = (text: string) => schema.nodes.tableCell.create(null, paragraph(text)); + + const blockSdt = schema.nodes.structuredContentBlock.create( + { + id: 'sd3237-block-table', + alias: 'Block With Table', + tag: 'block-table', + lockMode: 'unlocked', + controlType: 'richText', + }, + [ + schema.nodes.table.create( + { + tableLayout: 'fixed', + tableProperties: { tableLayout: 'fixed', tableWidth: { value: 0, type: 'auto' } }, + grid: [{ col: 4680 }, { col: 4680 }], + }, + [ + schema.nodes.tableRow.create(null, [cell('A1'), cell('B1')]), + schema.nodes.tableRow.create(null, [cell('A2'), cell('B2')]), + ], + ), + ], + ); + + const doc = schema.nodes.doc.create(null, [paragraph('Before'), blockSdt, paragraph('After')]); + editor.view.dispatch(editor.state.tr.replaceWith(0, editor.state.doc.content.size, doc.content)); + + let afterStart: number | null = null; + let beforeEnd: number | null = null; + let a1Start: number | null = null; + let b2End: number | null = null; + editor.state.doc.descendants((node: any, pos: number) => { + if (!node.isText || !node.text) return true; + if (node.text === 'Before') beforeEnd = pos + node.text.length; + if (node.text === 'After') afterStart = pos; + if (node.text === 'A1') a1Start = pos; + if (node.text === 'B2') b2End = pos + node.text.length; + return true; + }); + + if (beforeEnd == null || afterStart == null || a1Start == null || b2End == null) { + throw new Error('Failed to build block SDT table fixture'); + } + + return { beforeEnd, afterStart, a1Start, b2End }; + }); +} + test.describe('SD-3237 structured content interactions', () => { test.beforeEach(async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); @@ -455,4 +512,76 @@ test.describe('SD-3237 structured content interactions', () => { to: inlineRange.nodeEnd, }); }); + + test('Backspace at paragraph after block SDT table moves into SDT without deleting following text', async ({ + superdoc, + }) => { + const { afterStart, b2End } = await loadBlockSdtTableBackspaceFixture(superdoc.page); + await superdoc.waitForStable(); + + await superdoc.setTextSelection(afterStart); + await superdoc.page.evaluate(() => (window as any).editor.view.focus()); + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => { + const { state } = (window as any).editor; + const { selection } = state; + const parentTypes: string[] = []; + for (let depth = selection.$from.depth; depth > 0; depth -= 1) { + parentTypes.push(selection.$from.node(depth).type.name); + } + return { + text: state.doc.textContent, + from: selection.from, + to: selection.to, + empty: selection.empty, + parentTypes, + }; + }); + + expect(result).toMatchObject({ + text: 'BeforeA1B1A2B2After', + from: b2End, + to: b2End, + empty: true, + }); + expect(result.parentTypes).toContain('structuredContentBlock'); + }); + + test('Delete at paragraph before block SDT table moves into SDT without deleting preceding text', async ({ + superdoc, + }) => { + const { beforeEnd, a1Start } = await loadBlockSdtTableBackspaceFixture(superdoc.page); + await superdoc.waitForStable(); + + await superdoc.setTextSelection(beforeEnd); + await superdoc.page.evaluate(() => (window as any).editor.view.focus()); + await superdoc.press('Delete'); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => { + const { state } = (window as any).editor; + const { selection } = state; + const parentTypes: string[] = []; + for (let depth = selection.$from.depth; depth > 0; depth -= 1) { + parentTypes.push(selection.$from.node(depth).type.name); + } + return { + text: state.doc.textContent, + from: selection.from, + to: selection.to, + empty: selection.empty, + parentTypes, + }; + }); + + expect(result).toMatchObject({ + text: 'BeforeA1B1A2B2After', + from: a1Start, + to: a1Start, + empty: true, + }); + expect(result.parentTypes).toContain('structuredContentBlock'); + }); }); diff --git a/tests/behavior/tests/sdt/sdt-content-lock-toolbar.spec.ts b/tests/behavior/tests/sdt/sdt-content-lock-toolbar.spec.ts new file mode 100644 index 0000000000..6bb89528d0 --- /dev/null +++ b/tests/behavior/tests/sdt/sdt-content-lock-toolbar.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +test('default toolbar disables mutation controls inside content-locked SDT content', async ({ superdoc }) => { + await superdoc.type('Before '); + await superdoc.waitForStable(); + + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { id: '6201', alias: 'Toolbar Lock', lockMode: 'contentLocked' }, + text: 'Locked value', + }); + }); + await superdoc.waitForStable(); + + const lockedTextPos = await superdoc.findTextPos('Locked value'); + await superdoc.setTextSelection(lockedTextPos + 1); + await superdoc.waitForStable(); + + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/disabled/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/disabled/); + await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/disabled/); + await expect(superdoc.page.locator('[data-item="btn-link"]')).toHaveClass(/disabled/); +});