diff --git a/apps/cli/package.json b/apps/cli/package.json index 457a11f73f..044f759676 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "bin": { "superdoc": "./dist/index.js" diff --git a/apps/cli/platforms/cli-darwin-arm64/package.json b/apps/cli/platforms/cli-darwin-arm64/package.json index 08729c5d21..14942d06d1 100644 --- a/apps/cli/platforms/cli-darwin-arm64/package.json +++ b/apps/cli/platforms/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-darwin-arm64", - "version": "0.6.0", + "version": "0.7.0", "os": [ "darwin" ], diff --git a/apps/cli/platforms/cli-darwin-x64/package.json b/apps/cli/platforms/cli-darwin-x64/package.json index b0e0c51232..33204890b9 100644 --- a/apps/cli/platforms/cli-darwin-x64/package.json +++ b/apps/cli/platforms/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-darwin-x64", - "version": "0.6.0", + "version": "0.7.0", "os": [ "darwin" ], diff --git a/apps/cli/platforms/cli-linux-arm64/package.json b/apps/cli/platforms/cli-linux-arm64/package.json index 4cfa5272f3..b5dbce1ade 100644 --- a/apps/cli/platforms/cli-linux-arm64/package.json +++ b/apps/cli/platforms/cli-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-linux-arm64", - "version": "0.6.0", + "version": "0.7.0", "os": [ "linux" ], diff --git a/apps/cli/platforms/cli-linux-x64/package.json b/apps/cli/platforms/cli-linux-x64/package.json index 02be8cbfd0..429377de3f 100644 --- a/apps/cli/platforms/cli-linux-x64/package.json +++ b/apps/cli/platforms/cli-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-linux-x64", - "version": "0.6.0", + "version": "0.7.0", "os": [ "linux" ], diff --git a/apps/cli/platforms/cli-windows-x64/package.json b/apps/cli/platforms/cli-windows-x64/package.json index 5ff4791782..08010d90a0 100644 --- a/apps/cli/platforms/cli-windows-x64/package.json +++ b/apps/cli/platforms/cli-windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-windows-x64", - "version": "0.6.0", + "version": "0.7.0", "os": [ "win32" ], diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 6e4d3204ef..e15141f92b 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -241,6 +241,34 @@ describe('layoutDocument', () => { expect(layout.columns).toMatchObject({ count: 2, gap: 20, withSeparator: true }); }); +<<<<<<< HEAD +======= + it('preserves explicit column widths on page-level column metadata', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, widths: [100, 400], equalWidth: false, withSeparator: true }, + }; + const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columns).toEqual({ + count: 2, + gap: 20, + widths: [100, 400], + equalWidth: false, + withSeparator: true, + }); + expect(layout.columns).toEqual({ + count: 2, + gap: 20, + widths: [100, 400], + equalWidth: false, + withSeparator: true, + }); + }); + +>>>>>>> origin/stable it('does not set "page.columns" on single column layout', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 413ce32247..68e65affa7 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1099,7 +1099,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } if (activeColumns.count > 1) { +<<<<<<< HEAD page.columns = { count: activeColumns.count, gap: activeColumns.gap, withSeparator: activeColumns.withSeparator }; +======= + page.columns = cloneColumnLayout(activeColumns); +>>>>>>> origin/stable } // Set vertical alignment from active section state @@ -2604,10 +2608,14 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // after processing sections. Page/region-specific column changes are encoded // implicitly via fragment positions. Consumers should not assume this is // a static document-wide value. +<<<<<<< HEAD columns: activeColumns.count > 1 ? { count: activeColumns.count, gap: activeColumns.gap, withSeparator: activeColumns.withSeparator } : undefined, +======= + columns: activeColumns.count > 1 ? cloneColumnLayout(activeColumns) : undefined, +>>>>>>> origin/stable }; } diff --git a/packages/layout-engine/layout-engine/src/section-props.test.ts b/packages/layout-engine/layout-engine/src/section-props.test.ts index 0d9601c369..20ebd3b912 100644 --- a/packages/layout-engine/layout-engine/src/section-props.test.ts +++ b/packages/layout-engine/layout-engine/src/section-props.test.ts @@ -93,4 +93,24 @@ describe('computeNextSectionPropsAtBreak', () => { expect(map.get(0)?.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); expect(map.get(2)?.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); }); +<<<<<<< HEAD +======= + + it('preserves explicit column widths and equalWidth in snapshots', () => { + const sourceColumns = { count: 2, gap: 48, widths: [120, 360], equalWidth: false, withSeparator: true }; + const blocks: FlowBlock[] = [sectionBreak({ id: 'sb-0', columns: sourceColumns })]; + const map = computeNextSectionPropsAtBreak(blocks); + const snapshot = map.get(0); + + expect(snapshot?.columns).toEqual({ + count: 2, + gap: 48, + widths: [120, 360], + equalWidth: false, + withSeparator: true, + }); + expect(snapshot?.columns).not.toBe(sourceColumns); + expect(snapshot?.columns?.widths).not.toBe(sourceColumns.widths); + }); +>>>>>>> origin/stable }); diff --git a/packages/layout-engine/layout-engine/src/section-props.ts b/packages/layout-engine/layout-engine/src/section-props.ts index 82bab70f88..86be73f1e7 100644 --- a/packages/layout-engine/layout-engine/src/section-props.ts +++ b/packages/layout-engine/layout-engine/src/section-props.ts @@ -1,4 +1,8 @@ import type { ColumnLayout, FlowBlock, SectionVerticalAlign } from '@superdoc/contracts'; +<<<<<<< HEAD +======= +import { cloneColumnLayout } from './column-utils.js'; +>>>>>>> origin/stable /** * Section-level formatting properties that control page layout. @@ -21,6 +25,11 @@ export type SectionProps = { vAlign?: SectionVerticalAlign; }; +const snapshotColumns = (columns?: ColumnLayout): ColumnLayout | undefined => { + if (!columns) return undefined; + return cloneColumnLayout(columns); +}; + /** * Extracts section properties from a section break block if any are present. * Returns null if the block has no section-related properties. @@ -59,7 +68,11 @@ const _snapshotSectionProps = (block: FlowBlock): SectionProps | null => { } if (block.columns) { hasProps = true; +<<<<<<< HEAD props.columns = { count: block.columns.count, gap: block.columns.gap, withSeparator: block.columns.withSeparator }; +======= + props.columns = snapshotColumns(block.columns); +>>>>>>> origin/stable } if (block.orientation) { hasProps = true; @@ -135,11 +148,15 @@ export function computeNextSectionPropsAtBreak(blocks: FlowBlock[]): Map>>>>>> origin/stable } if (source.orientation) { props.orientation = source.orientation; diff --git a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts index afa608933b..fd47dae5fc 100644 --- a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts @@ -74,6 +74,22 @@ describe('DomPainter renderColumnSeparators', () => { expect(seps.map((s) => s.style.left)).toEqual(['296px', '520px']); }); +<<<<<<< HEAD +======= + it('uses explicit column widths when drawing separators for page.columns', () => { + const page = buildPage({ + columns: { count: 2, gap: 48, widths: [200, 952], equalWidth: false, withSeparator: true }, + }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + // contentWidth=624, availableWidth=576. Explicit widths [200, 952] are + // normalized to [100, 476], so the separator belongs at 96 + 100 + 24 = 220. + expect(seps[0].style.left).toBe('220px'); + }); + +>>>>>>> origin/stable it('renders nothing when withSeparator is false', () => { const page = buildPage({ columns: { count: 2, gap: 48, withSeparator: false } }); paintOnce(buildLayout(page), mount); @@ -211,5 +227,27 @@ describe('DomPainter renderColumnSeparators', () => { expect(seps[0].style.top).toBe('96px'); expect(seps[0].style.height).toBe('864px'); }); +<<<<<<< HEAD +======= + + it('uses explicit column widths when drawing separators for columnRegions', () => { + const page = buildPage({ + columnRegions: [ + { + yStart: 96, + yEnd: 500, + columns: { count: 2, gap: 48, widths: [200, 952], equalWidth: false, withSeparator: true }, + }, + ], + }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + expect(seps[0].style.top).toBe('96px'); + expect(seps[0].style.height).toBe('404px'); + expect(seps[0].style.left).toBe('220px'); + }); +>>>>>>> origin/stable }); }); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 350110f057..3b39f61e26 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1,5 +1,6 @@ import type { ChartDrawing, + ColumnLayout, CustomGeometryData, DrawingBlock, DrawingFragment, @@ -61,6 +62,7 @@ import { calculateJustifySpacing, computeLinePmRange, getCellSpacingPx, + normalizeColumnLayout, normalizeBaselineShift, resolveBaseFontSizeForVerticalText, shouldApplyJustify, @@ -2346,6 +2348,7 @@ export class DomPainter { if (!columns.withSeparator) continue; if (columns.count <= 1) continue; +<<<<<<< HEAD const columnWidth = (contentWidth - columns.gap * (columns.count - 1)) / columns.count; // Given the separator will have 1px width, ensure column has a larger width. if (columnWidth <= 1) continue; @@ -2355,6 +2358,15 @@ export class DomPainter { for (let i = 0; i < columns.count - 1; i++) { const separatorX = leftMargin + (i + 1) * columnWidth + i * columns.gap + columns.gap / 2; +======= + const regionHeight = yEnd - yStart; + if (regionHeight <= 0) continue; + + const separatorPositions = this.getColumnSeparatorPositions(columns, leftMargin, contentWidth); + if (separatorPositions.length === 0) continue; + + for (const separatorX of separatorPositions) { +>>>>>>> origin/stable const separatorEl = this.doc.createElement('div'); separatorEl.style.position = 'absolute'; @@ -2369,6 +2381,43 @@ export class DomPainter { } } +<<<<<<< HEAD +======= + private getColumnSeparatorPositions(columns: ColumnLayout, leftMargin: number, contentWidth: number): number[] { + const hasExplicitWidths = Array.isArray(columns.widths) && columns.widths.length > 0; + + if (!hasExplicitWidths) { + const equalWidth = (contentWidth - columns.gap * (columns.count - 1)) / columns.count; + if (equalWidth <= 1) return []; + + const separatorPositions: number[] = []; + for (let index = 0; index < columns.count - 1; index += 1) { + separatorPositions.push(leftMargin + (index + 1) * equalWidth + index * columns.gap + columns.gap / 2); + } + return separatorPositions; + } + + const normalizedColumns = normalizeColumnLayout(columns, contentWidth); + if (normalizedColumns.count <= 1) return []; + + const columnWidths = + normalizedColumns.widths ?? Array.from({ length: normalizedColumns.count }, () => normalizedColumns.width); + // A 1px separator only makes sense when every participating column is wider than the separator itself. + if (columnWidths.some((columnWidth) => columnWidth <= 1)) return []; + + const separatorPositions: number[] = []; + let cursorX = leftMargin; + + for (let index = 0; index < normalizedColumns.count - 1; index += 1) { + const currentColumnWidth = columnWidths[index] ?? normalizedColumns.width; + separatorPositions.push(cursorX + currentColumnWidth + normalizedColumns.gap / 2); + cursorX += currentColumnWidth + normalizedColumns.gap; + } + + return separatorPositions; + } + +>>>>>>> origin/stable private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void { if (this.isSemanticFlow) return; this.renderDecorationSection(pageEl, page, pageIndex, 'header'); diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index c93b3af722..b944661d85 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -117,6 +117,46 @@ describe('SuperDocEditor', () => { { timeout: 5000 }, ); }); + + it('should route onTransaction through the latest callback after rerender', async () => { + const ref = createRef(); + const onReady = vi.fn(); + const firstOnTransaction = vi.fn(); + const secondOnTransaction = vi.fn(); + + const { rerender } = render(); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + + const instance = ref.current?.getInstance(); + expect(instance).toBeTruthy(); + + const transactionEvent = { + editor: {}, + sourceEditor: {}, + transaction: { docChanged: true }, + surface: 'body', + }; + + const firstCallCountBeforeManualDispatch = firstOnTransaction.mock.calls.length; + (instance as any).config.onTransaction(transactionEvent); + + expect(firstOnTransaction).toHaveBeenLastCalledWith(transactionEvent); + expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeManualDispatch + 1); + expect(secondOnTransaction).not.toHaveBeenCalled(); + + rerender(); + + expect(ref.current?.getInstance()).toBe(instance); + + const firstCallCountBeforeRerenderDispatch = firstOnTransaction.mock.calls.length; + const secondCallCountBeforeManualDispatch = secondOnTransaction.mock.calls.length; + (instance as any).config.onTransaction(transactionEvent); + + expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeRerenderDispatch); + expect(secondOnTransaction).toHaveBeenLastCalledWith(transactionEvent); + expect(secondOnTransaction).toHaveBeenCalledTimes(secondCallCountBeforeManualDispatch + 1); + }); }); describe('onEditorDestroy', () => { diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx index b215bdacc0..b022552456 100644 --- a/packages/react/src/SuperDocEditor.tsx +++ b/packages/react/src/SuperDocEditor.tsx @@ -17,6 +17,7 @@ import type { SuperDocReadyEvent, SuperDocEditorCreateEvent, SuperDocEditorUpdateEvent, + SuperDocTransactionEvent, SuperDocContentErrorEvent, SuperDocExceptionEvent, } from './types'; @@ -46,6 +47,7 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null); @@ -192,6 +196,11 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef { + if (!destroyed) { + callbacksRef.current.onTransaction?.(event); + } + }, onContentError: (event: SuperDocContentErrorEvent) => { if (!destroyed) { callbacksRef.current.onContentError?.(event); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index dfb96a27ce..8f237542c8 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -66,6 +66,27 @@ export interface SuperDocEditorUpdateEvent { /** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */ sectionType?: string | null; } +<<<<<<< HEAD +======= + +/** Event passed to onTransaction callback. Mirrors superdoc's EditorTransactionEvent. */ +export interface SuperDocTransactionEvent { + /** The primary editor associated with the transaction. For header/footer edits, this is the main body editor. */ + editor: Editor; + /** The editor instance that emitted the transaction. For body edits, this matches `editor`. */ + sourceEditor: Editor; + /** The ProseMirror transaction or transaction-like payload emitted by the source editor. */ + transaction: any; + /** Time spent applying the transaction, in milliseconds. */ + duration?: number; + /** The surface where the transaction originated. */ + surface: EditorSurface; + /** Relationship ID for header/footer edits. */ + headerId?: string | null; + /** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */ + sectionType?: string | null; +} +>>>>>>> origin/stable /** Event passed to onContentError callback */ export interface SuperDocContentErrorEvent { @@ -107,6 +128,7 @@ type ExplicitCallbackProps = | 'onEditorCreate' | 'onEditorDestroy' | 'onEditorUpdate' + | 'onTransaction' | 'onContentError' | 'onException'; @@ -127,6 +149,9 @@ export interface CallbackProps { /** Callback when document content is updated */ onEditorUpdate?: (event: SuperDocEditorUpdateEvent) => void; + /** Callback when a transaction is emitted */ + onTransaction?: (event: SuperDocTransactionEvent) => void; + /** Callback when there is a content parsing error */ onContentError?: (event: SuperDocContentErrorEvent) => void; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index f6cb80864b..67eaf224e8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -1241,11 +1241,25 @@ function getNumberingDefinitions(docx, converter) { * @param {Object} docx The parsed docx object * @returns {Boolean} True if the document has alternating headers and footers, false otherwise */ +<<<<<<< HEAD +======= +const ST_ON_OFF_TRUE_VALUES = new Set(['1', 'true', 'on']); + +const isStOnOffEnabled = (element) => { + if (!element) return false; + + const rawValue = element.attributes?.['w:val']; + if (rawValue == null) return true; + + return ST_ON_OFF_TRUE_VALUES.has(String(rawValue).trim().toLowerCase()); +}; + +>>>>>>> origin/stable export const isAlternatingHeadersOddEven = (docx) => { const settings = docx['word/settings.xml']; if (!settings || !settings.elements?.length) return false; const { elements = [] } = settings.elements[0]; const evenOdd = elements.find((el) => el.name === 'w:evenAndOddHeaders'); - return !!evenOdd; + return isStOnOffEnabled(evenOdd); }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js index b64c9c7997..4af0d0c201 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js @@ -505,6 +505,19 @@ describe('isAlternatingHeadersOddEven', () => { expect(isAlternatingHeadersOddEven(docx)).toBe(true); }); +<<<<<<< HEAD +======= + it.each([['1'], ['true'], ['on']])('returns true when uses truthy w:val=%s', (value) => { + const docx = makeSettingsDocx([{ type: 'element', name: 'w:evenAndOddHeaders', attributes: { 'w:val': value } }]); + expect(isAlternatingHeadersOddEven(docx)).toBe(true); + }); + + it.each([['0'], ['false'], ['off']])('returns false when uses falsy w:val=%s', (value) => { + const docx = makeSettingsDocx([{ type: 'element', name: 'w:evenAndOddHeaders', attributes: { 'w:val': value } }]); + expect(isAlternatingHeadersOddEven(docx)).toBe(false); + }); + +>>>>>>> origin/stable it('returns false when is absent', () => { const docx = makeSettingsDocx([ { type: 'element', name: 'w:zoom', attributes: { 'w:percent': '100' } }, diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 6f1a8c26b2..9bd209d55b 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -1,7 +1,7 @@ { "name": "superdoc", "type": "module", - "version": "1.26.0", + "version": "1.27.0", "license": "AGPL-3.0", "repository": { "type": "git", diff --git a/packages/template-builder/package.json b/packages/template-builder/package.json index 2c40b88301..5898334e61 100644 --- a/packages/template-builder/package.json +++ b/packages/template-builder/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/template-builder", - "version": "1.4.0", + "version": "1.5.0", "description": "React template builder component for SuperDoc", "type": "module", "main": "./dist/index.js",