diff --git a/packages/super-editor/src/core/commands/insertContent.test.js b/packages/super-editor/src/core/commands/insertContent.test.js index 22c57b7242..38ca141e97 100644 --- a/packages/super-editor/src/core/commands/insertContent.test.js +++ b/packages/super-editor/src/core/commands/insertContent.test.js @@ -327,3 +327,71 @@ describe('insertContent (integration) list export', () => { expect(Number(topBorder?.attributes?.['w:sz'])).toBeGreaterThan(0); }); }); + +// --------------------------------------------------------------------------- +// CI-only: horizontal rule insertion (requires real Editor which depends on +// @superdoc/document-api — unresolvable in local workspace, works in CI). +// --------------------------------------------------------------------------- +describe.skipIf(!process.env.CI)('insertContent (integration) horizontal rule', () => { + let helpers = null; + let cachedDocxData = null; + + const setupEditor = async () => { + vi.resetModules(); + vi.doUnmock('../helpers/contentProcessor.js'); + + if (!helpers) { + helpers = await import('../../tests/helpers/helpers.js'); + } + if (!cachedDocxData) { + cachedDocxData = await helpers.loadTestDataForEditorTests('blank-doc.docx'); + } + + const { docx, media, mediaFiles, fonts } = cachedDocxData; + const { editor } = helpers.initTestEditor({ content: docx, media, mediaFiles, fonts, mode: 'docx' }); + return editor; + }; + + const countHorizontalRules = (editor) => { + let count = 0; + const content = editor.getJSON().content || []; + for (const block of content) { + if (block.type === 'contentBlock' && block.attrs?.horizontalRule) count++; + // contentBlock is inline — check inside paragraph > run or paragraph directly + for (const inline of block.content || []) { + if (inline.type === 'contentBlock' && inline.attrs?.horizontalRule) count++; + for (const child of inline.content || []) { + if (child.type === 'contentBlock' && child.attrs?.horizontalRule) count++; + } + } + } + return count; + }; + + it('insertContent with contentType html creates a horizontal rule', async () => { + const editor = await setupEditor(); + expect(countHorizontalRules(editor)).toBe(0); + + editor.commands.insertContent('
', { contentType: 'html' }); + + expect(countHorizontalRules(editor)).toBe(1); + }); + + it('insertContent with contentType markdown creates a horizontal rule', async () => { + const editor = await setupEditor(); + expect(countHorizontalRules(editor)).toBe(0); + + editor.commands.insertContent('---', { contentType: 'markdown' }); + + expect(countHorizontalRules(editor)).toBe(1); + }); + + it('insertContent with bare
(no contentType) creates a horizontal rule', async () => { + const editor = await setupEditor(); + expect(countHorizontalRules(editor)).toBe(0); + + editor.commands.insertContent('
'); + + expect(countHorizontalRules(editor)).toBe(1); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js index fb329cefa5..9d0e1896d5 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js @@ -19,18 +19,106 @@ export function translateContentBlock(params) { return wrapTextInRun(alternateContent); } +// Nominal full-width value for VML style. Word ignores this when o:hr="t" +// is present and renders the rect at full page width instead. +const FULL_WIDTH_PT = '468pt'; +const FULL_WIDTH_PT_VALUE = 468; + +// Conversion factor matching the importer (1pt ~= 1.33px). +const PX_PER_PT = 1.33; + +/** + * Convert a pixel value to a VML point string (e.g. 2 -> "1.5pt"). + * Rounds to one decimal place to match typical OOXML precision. + * @param {number} px + * @returns {string} + */ +function pxToPt(px) { + const pt = Math.round((px / PX_PER_PT) * 10) / 10; + return `${pt}pt`; +} + +/** + * Convert supported size values to VML point strings. + * Supports: + * - numbers (treated as px) + * - pixel strings (e.g. "200px") + * - numeric strings (e.g. "200") + * - percentages for width (e.g. "50%", "100%") + * @param {unknown} value + * @param {{ allowPercent?: boolean }} [options] + * @returns {string|null} + */ +function sizeToPt(value, options = {}) { + const { allowPercent = false } = options; + + if (typeof value === 'number') { + return Number.isFinite(value) ? pxToPt(value) : null; + } + + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) return null; + + if (allowPercent && trimmed.endsWith('%')) { + const percent = Number.parseFloat(trimmed.slice(0, -1)); + if (!Number.isFinite(percent) || percent <= 0) return null; + + if (percent >= 100) return FULL_WIDTH_PT; + + const pt = Math.round(((FULL_WIDTH_PT_VALUE * percent) / 100) * 10) / 10; + return `${pt}pt`; + } + + const normalized = trimmed.endsWith('px') ? trimmed.slice(0, -2) : trimmed; + const px = Number.parseFloat(normalized); + if (!Number.isFinite(px)) return null; + + return pxToPt(px); +} + +/** + * Build a VML style string from the node's `size` attribute. + * Used as a fallback when no raw `style` was preserved from import. + * @param {{ width?: unknown, height?: unknown }} size + * @returns {string} + */ +function synthesizeVmlStyle(size) { + const parts = []; + + if (size.width != null) { + const widthPt = sizeToPt(size.width, { allowPercent: true }); + if (widthPt) { + parts.push(`width:${widthPt}`); + } + } + + if (size.height != null) { + const heightPt = sizeToPt(size.height); + if (heightPt) { + parts.push(`height:${heightPt}`); + } + } + + return parts.join(';'); +} + /** * @param {Object} params - The parameters for translation. * @returns {Object} The XML representation. */ export function translateVRectContentBlock(params) { const { node } = params; - const { vmlAttributes, background, attributes, style } = node.attrs; + const { horizontalRule, vmlAttributes, background, attributes, style, size } = node.attrs; const rectAttrs = { id: attributes?.id || `_x0000_i${Math.floor(Math.random() * 10000)}`, }; + // --- Style (VML CSS dimensions) --- if (style) { rectAttrs.style = style; } @@ -39,6 +127,7 @@ export function translateVRectContentBlock(params) { rectAttrs.fillcolor = background; } + // --- VML HR flags --- if (vmlAttributes) { if (vmlAttributes.hralign) rectAttrs['o:hralign'] = vmlAttributes.hralign; if (vmlAttributes.hrstd) rectAttrs['o:hrstd'] = vmlAttributes.hrstd; @@ -54,6 +143,22 @@ export function translateVRectContentBlock(params) { }); } + // Synthesize style only when not already provided by style/attributes. + if (!rectAttrs.style && horizontalRule && size) { + const synthesized = synthesizeVmlStyle(size); + if (synthesized) { + rectAttrs.style = synthesized; + } + } + + // Ensure horizontal-rule VML flags are complete even if metadata is partial. + if (horizontalRule) { + if (rectAttrs['o:hr'] == null) rectAttrs['o:hr'] = 't'; + if (rectAttrs['o:hrstd'] == null) rectAttrs['o:hrstd'] = 't'; + if (rectAttrs['o:hralign'] == null) rectAttrs['o:hralign'] = 'center'; + if (rectAttrs.stroked == null) rectAttrs.stroked = 'f'; + } + // Create the v:rect element const rect = { name: 'v:rect', diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js index 7df8eb264b..1af103dbd6 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js @@ -185,3 +185,128 @@ describe('translateVRectContentBlock', () => { expect(generateRandomSigned32BitIntStrId).toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// VML fallback synthesis: horizontalRule nodes without legacy VML metadata +// (created via insertHorizontalRule or parsed from
tags). +// --------------------------------------------------------------------------- +describe('translateVRectContentBlock - VML synthesis for new HRs', () => { + /** Helper: build params matching createDefaultHorizontalRuleAttrs() output. */ + const buildNewHRParams = (overrides = {}) => ({ + node: { + attrs: { + horizontalRule: true, + size: { width: '100%', height: 2 }, + background: '#e5e7eb', + ...overrides, + }, + }, + }); + + /** Helper: extract the v:rect attributes from the translated result. */ + const getRectAttrs = (result) => result.elements[0].elements[0].attributes; + + beforeEach(() => { + vi.clearAllMocks(); + generateRandomSigned32BitIntStrId.mockReturnValue('12345678'); + wrapTextInRun.mockImplementation((content) => ({ name: 'w:r', elements: [content] })); + }); + + it('should synthesize VML HR flags when vmlAttributes is absent', () => { + const rectAttrs = getRectAttrs(translateVRectContentBlock(buildNewHRParams())); + + expect(rectAttrs['o:hr']).toBe('t'); + expect(rectAttrs['o:hrstd']).toBe('t'); + expect(rectAttrs['o:hralign']).toBe('center'); + expect(rectAttrs.stroked).toBe('f'); + }); + + it('should synthesize VML style from size for full-width HR', () => { + const rectAttrs = getRectAttrs(translateVRectContentBlock(buildNewHRParams())); + + // width: 100% -> nominal 468pt, height: 2px -> 1.5pt + expect(rectAttrs.style).toBe('width:468pt;height:1.5pt'); + }); + + it('should synthesize VML style from size for fixed-width HR', () => { + const params = buildNewHRParams({ size: { width: 200, height: 3 } }); + const rectAttrs = getRectAttrs(translateVRectContentBlock(params)); + + // 200px / 1.33 ~= 150.4pt, 3px / 1.33 ~= 2.3pt + expect(rectAttrs.style).toBe('width:150.4pt;height:2.3pt'); + }); + + it('should synthesize VML style from percentage width without NaN values', () => { + const params = buildNewHRParams({ size: { width: '50%', height: 2 } }); + const rectAttrs = getRectAttrs(translateVRectContentBlock(params)); + + expect(rectAttrs.style).toBe('width:234pt;height:1.5pt'); + expect(rectAttrs.style).not.toContain('NaN'); + }); + + it('should synthesize VML style from px strings without NaN values', () => { + const params = buildNewHRParams({ size: { width: '200px', height: '3px' } }); + const rectAttrs = getRectAttrs(translateVRectContentBlock(params)); + + expect(rectAttrs.style).toBe('width:150.4pt;height:2.3pt'); + expect(rectAttrs.style).not.toContain('NaN'); + }); + + it('should omit invalid style dimensions instead of emitting NaNpt', () => { + const params = buildNewHRParams({ size: { width: 'auto', height: 2 } }); + const rectAttrs = getRectAttrs(translateVRectContentBlock(params)); + + expect(rectAttrs.style).toBe('height:1.5pt'); + expect(rectAttrs.style).not.toContain('NaN'); + }); + + it('should set fillcolor from background', () => { + const rectAttrs = getRectAttrs(translateVRectContentBlock(buildNewHRParams())); + + expect(rectAttrs.fillcolor).toBe('#e5e7eb'); + }); + + it('should synthesize missing HR flags when vmlAttributes is partial', () => { + const params = buildNewHRParams({ + vmlAttributes: { hralign: 'left' }, + }); + const rectAttrs = getRectAttrs(translateVRectContentBlock(params)); + + expect(rectAttrs['o:hralign']).toBe('left'); + expect(rectAttrs['o:hr']).toBe('t'); + expect(rectAttrs['o:hrstd']).toBe('t'); + expect(rectAttrs.stroked).toBe('f'); + }); + + it('should preserve explicit vmlAttributes over synthesis', () => { + const params = buildNewHRParams({ + vmlAttributes: { hr: 't', hrstd: 't', hralign: 'left', stroked: 'f' }, + }); + const rectAttrs = getRectAttrs(translateVRectContentBlock(params)); + + // Uses the explicit value, not the synthesized default + expect(rectAttrs['o:hralign']).toBe('left'); + }); + + it('should preserve explicit style over synthesis', () => { + const params = buildNewHRParams({ style: 'width:300pt;height:2pt' }); + const rectAttrs = getRectAttrs(translateVRectContentBlock(params)); + + expect(rectAttrs.style).toBe('width:300pt;height:2pt'); + }); + + it('should produce a complete exportable v:rect for a default HR', () => { + const rectAttrs = getRectAttrs(translateVRectContentBlock(buildNewHRParams())); + + // Every attribute Word needs to render an HR should be present + expect(rectAttrs).toMatchObject({ + style: 'width:468pt;height:1.5pt', + fillcolor: '#e5e7eb', + 'o:hr': 't', + 'o:hrstd': 't', + 'o:hralign': 'center', + stroked: 'f', + }); + expect(rectAttrs.id).toBeDefined(); + }); +}); diff --git a/packages/super-editor/src/extensions/content-block/content-block.js b/packages/super-editor/src/extensions/content-block/content-block.js index 03020ea48f..7e94fbed14 100644 --- a/packages/super-editor/src/extensions/content-block/content-block.js +++ b/packages/super-editor/src/extensions/content-block/content-block.js @@ -43,6 +43,20 @@ import { Node, Attribute } from '@core/index.js'; * }) */ +/** + * Default attributes for a horizontal rule content block. + * Single source of truth shared by both `parseDOM` (for `
` tags) + * and the `insertHorizontalRule` command. + * @returns {ContentBlockAttributes} + */ +export function createDefaultHorizontalRuleAttrs() { + return { + horizontalRule: true, + size: { width: '100%', height: 2 }, + background: '#e5e7eb', + }; +} + /** * @module ContentBlock * @sidebarTitle Content Block @@ -147,6 +161,14 @@ export const ContentBlock = Node.create({ return [ { tag: `div[data-type="${this.name}"]`, + // Paragraph registers a broad `tag: 'div'` rule at default priority 50. + // Without explicit priority, PM's insertion-order tie-breaking lets + // paragraph consume our div first. Priority 60 ensures contentBlock wins. + priority: 60, + }, + { + tag: 'hr', + getAttrs: () => createDefaultHorizontalRuleAttrs(), }, ]; }, @@ -170,11 +192,7 @@ export const ContentBlock = Node.create({ ({ commands }) => { return commands.insertContent({ type: this.name, - attrs: { - horizontalRule: true, - size: { width: '100%', height: 2 }, - background: '#e5e7eb', - }, + attrs: createDefaultHorizontalRuleAttrs(), }); }, diff --git a/packages/super-editor/src/extensions/content-block/content-block.test.js b/packages/super-editor/src/extensions/content-block/content-block.test.js new file mode 100644 index 0000000000..2751e040ff --- /dev/null +++ b/packages/super-editor/src/extensions/content-block/content-block.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; +import { Schema, DOMParser } from 'prosemirror-model'; +import { createDefaultHorizontalRuleAttrs } from './content-block.js'; +import { createDocFromHTML } from '../../core/helpers/importHtml.js'; +import { createDocFromMarkdown } from '../../core/helpers/importMarkdown.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a minimal ProseMirror schema that mirrors the real extension setup: + * - paragraph with a broad `div` rule at default priority (50) + * - contentBlock with `div[data-type]` at priority 60 and `hr` rule + * + * This reproduces the real parser priority interaction between paragraph + * and contentBlock without pulling in the full extension framework. + */ +function buildTestSchema() { + return new Schema({ + nodes: { + doc: { content: 'block+' }, + + paragraph: { + group: 'block', + content: 'inline*', + parseDOM: [{ tag: 'p' }, { tag: 'div' }], + }, + + contentBlock: { + group: 'inline', + content: '', + atom: true, + inline: true, + isolating: true, + attrs: { + horizontalRule: { default: false }, + size: { default: null }, + background: { default: null }, + }, + parseDOM: [ + { tag: 'div[data-type="contentBlock"]', priority: 60 }, + { tag: 'hr', getAttrs: () => createDefaultHorizontalRuleAttrs() }, + ], + toDOM(node) { + return ['div', { 'data-type': 'contentBlock' }]; + }, + }, + + text: { group: 'inline' }, + }, + }); +} + +/** + * Parse an HTML string using the test schema's DOMParser. + */ +function parseHTML(html) { + const schema = buildTestSchema(); + const container = document.createElement('div'); + container.innerHTML = html; + return DOMParser.fromSchema(schema).parse(container); +} + +/** + * Find the first node of the given type in a PM document. + * Returns null if not found. + */ +function findNode(doc, typeName) { + let found = null; + doc.descendants((node) => { + if (!found && node.type.name === typeName) { + found = node; + return false; + } + }); + return found; +} + +/** + * Build a mock editor suitable for createDocFromHTML / createDocFromMarkdown. + * happy-dom provides the global `document` so the import pipeline has DOM access. + */ +function buildMockEditor() { + return { schema: buildTestSchema(), options: {} }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('contentBlock
parsing', () => { + describe('raw DOMParser', () => { + it('parses
into a contentBlock with horizontalRule attrs', () => { + const doc = parseHTML('
'); + + const cb = findNode(doc, 'contentBlock'); + expect(cb).not.toBeNull(); + expect(cb.attrs.horizontalRule).toBe(true); + expect(cb.attrs.size).toEqual({ width: '100%', height: 2 }); + expect(cb.attrs.background).toBe('#e5e7eb'); + }); + + it('auto-wraps the inline contentBlock in a paragraph', () => { + const doc = parseHTML('
'); + + // doc > paragraph > contentBlock + const paragraph = doc.firstChild; + expect(paragraph.type.name).toBe('paragraph'); + + const cb = findNode(paragraph, 'contentBlock'); + expect(cb).not.toBeNull(); + }); + + it('parses
alongside other content', () => { + const doc = parseHTML('

before


after

'); + + const blocks = []; + doc.forEach((child) => blocks.push(child)); + + expect(blocks).toHaveLength(3); + expect(blocks[0].type.name).toBe('paragraph'); + expect(blocks[0].textContent).toBe('before'); + expect(blocks[1].type.name).toBe('paragraph'); + expect(findNode(blocks[1], 'contentBlock')).not.toBeNull(); + expect(blocks[2].type.name).toBe('paragraph'); + expect(blocks[2].textContent).toBe('after'); + }); + }); + + describe('full HTML import pipeline', () => { + it('parses
through createDocFromHTML', () => { + const editor = buildMockEditor(); + const doc = createDocFromHTML('
', editor); + + const cb = findNode(doc, 'contentBlock'); + expect(cb).not.toBeNull(); + expect(cb.attrs.horizontalRule).toBe(true); + expect(cb.attrs.size).toEqual({ width: '100%', height: 2 }); + expect(cb.attrs.background).toBe('#e5e7eb'); + }); + }); + + describe('full markdown import pipeline', () => { + it('parses --- through createDocFromMarkdown', () => { + const editor = buildMockEditor(); + const doc = createDocFromMarkdown('---', editor); + + const cb = findNode(doc, 'contentBlock'); + expect(cb).not.toBeNull(); + expect(cb.attrs.horizontalRule).toBe(true); + expect(cb.attrs.size).toEqual({ width: '100%', height: 2 }); + expect(cb.attrs.background).toBe('#e5e7eb'); + }); + }); +}); + +describe('contentBlock shared defaults', () => { + it('insertHorizontalRule and
parsing use the same default attrs', () => { + // Parse an
and extract the attrs set by getAttrs + const doc = parseHTML('
'); + const parsedAttrs = findNode(doc, 'contentBlock').attrs; + + // Get the attrs the insertHorizontalRule command would use + const commandAttrs = createDefaultHorizontalRuleAttrs(); + + expect(parsedAttrs.horizontalRule).toBe(commandAttrs.horizontalRule); + expect(parsedAttrs.size).toEqual(commandAttrs.size); + expect(parsedAttrs.background).toBe(commandAttrs.background); + }); +}); + +describe('contentBlock div[data-type] parsing', () => { + it('still parses div[data-type="contentBlock"] correctly', () => { + const doc = parseHTML('
'); + + const cb = findNode(doc, 'contentBlock'); + expect(cb).not.toBeNull(); + expect(cb.attrs.horizontalRule).toBe(false); + }); + + it('priority prevents paragraph from consuming div[data-type="contentBlock"]', () => { + // If priority were missing, paragraph's broad `div` rule (priority 50) + // would match first because paragraph registers before contentBlock. + // With priority 60, contentBlock's rule wins. + const doc = parseHTML('
'); + + const cb = findNode(doc, 'contentBlock'); + expect(cb).not.toBeNull(); + + // contentBlock is inline, so PM wraps it in a paragraph. + // Verify the paragraph contains a contentBlock child (not that the + // div was consumed as a paragraph itself with no contentBlock inside). + const topChild = doc.firstChild; + expect(topChild.type.name).toBe('paragraph'); + expect(findNode(topChild, 'contentBlock')).not.toBeNull(); + }); +});