Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/layout-engine/style-engine/src/ooxml/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ describe('ooxml - resolveRunProperties', () => {
expect(result.italic).toBe(true);
expect(result.color).toEqual({ val: 'CCCCCC' });
});
it('does not treat paragraph mark run properties as inherited text styling', () => {
const params = buildParams({ translatedLinkedStyles: null });
const result = resolveRunProperties(params, { italic: true }, { runProperties: { bold: true } });
expect(result).toEqual({ italic: true });
});
});

describe('ooxml - resolveParagraphProperties', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3270,15 +3270,14 @@ export class Editor extends EventEmitter<EditorEventMap> {
const provider = this.options.collaborationProvider;

const doReplaceFileSync = () => {
const mediaFiles = this.options.mediaFiles ?? {};

// 1. Insert new PM doc into Y fragment (must happen first)
this.#insertNewFileData();

// 2. Seed parts from new converter snapshot (prunes stale parts)
seedPartsFromEditor(this, ydoc, { replaceExisting: true });

// 3. Replace media map (prune stale + upsert new)
const mediaFiles = this.options.mediaFiles ?? {};
const mediaMap = ydoc.getMap('media');
for (const key of mediaMap.keys()) {
if (!(key in mediaFiles)) mediaMap.delete(key);
Expand Down Expand Up @@ -3319,6 +3318,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
}, SYNC_TIMEOUT_MS);
}
});
doReplaceFileSync();
Comment thread
harbournick marked this conversation as resolved.
} else {
this.#insertNewFileData();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,85 @@
import { processOutputMarks } from '@converter/exporter.js';
import { TrackFormatMarkName } from '@extensions/track-changes/constants.js';

const getMarkType = (mark) => mark?.type?.name ?? mark?.type ?? null;

const toRunPropertyElements = (marks = []) =>
processOutputMarks(marks).filter((element) => element && typeof element === 'object' && element.name);

/**
* Creates export element for trackFormat mark
* @param {Array} marks SD node marks.
* @returns {Object|undefined} Properties element for trackFormat change or undefined.
* Return the first trackFormat mark from a mark list.
*
* @param {Array} marks
* @returns {Object|null}
*/
export const createTrackStyleMark = (marks) => {
const trackStyleMark = marks.find((mark) => mark.type === 'trackFormat');
if (trackStyleMark) {
return {
type: 'element',
name: 'w:rPrChange',
attributes: {
'w:id': trackStyleMark.attrs.id,
'w:author': trackStyleMark.attrs.author,
'w:authorEmail': trackStyleMark.attrs.authorEmail,
'w:date': trackStyleMark.attrs.date,
},
elements: trackStyleMark.attrs.before.map((mark) => processOutputMarks([mark])).filter((r) => r !== undefined),
};
export const findTrackFormatMark = (marks = []) =>
marks.find((mark) => getMarkType(mark) === TrackFormatMarkName) ?? null;

/**
* Build a valid OOXML <w:rPrChange> node from a trackFormat mark.
*
* OOXML stores the "before" state under a nested <w:rPr> inside <w:rPrChange>.
* The visible "after" state is already represented by the owning run's current
* run properties, so only the "before" marks need to be serialized here.
*
* @param {Object|null|undefined} trackFormatMark
* @returns {Object|undefined}
*/
export const createRunPropertiesChangeElement = (trackFormatMark) => {
if (!trackFormatMark) return undefined;

const beforeMarks = Array.isArray(trackFormatMark.attrs?.before) ? trackFormatMark.attrs.before : [];
const previousRunProperties = {
type: 'element',
name: 'w:rPr',
elements: toRunPropertyElements(beforeMarks),
};

return {
type: 'element',
name: 'w:rPrChange',
attributes: {
'w:id': trackFormatMark.attrs?.id,
'w:author': trackFormatMark.attrs?.author,
'w:authorEmail': trackFormatMark.attrs?.authorEmail,
'w:date': trackFormatMark.attrs?.date,
},
elements: [previousRunProperties],
};
};

/**
* Append a track-format change node to an OOXML <w:rPr> element if one is not already present.
*
* @param {Object|null|undefined} runPropertiesNode
* @param {Array} marks
* @returns {Object|null|undefined}
*/
export const appendTrackFormatChangeToRunProperties = (runPropertiesNode, marks = []) => {
if (!runPropertiesNode) return runPropertiesNode;

const trackFormatMark = findTrackFormatMark(marks);
if (!trackFormatMark) return runPropertiesNode;

if (!Array.isArray(runPropertiesNode.elements)) {
runPropertiesNode.elements = [];
}

const hasExistingChange = runPropertiesNode.elements.some((element) => element?.name === 'w:rPrChange');
if (hasExistingChange) return runPropertiesNode;

const changeElement = createRunPropertiesChangeElement(trackFormatMark);
if (changeElement) {
runPropertiesNode.elements.push(changeElement);
}
return undefined;

return runPropertiesNode;
};

/**
* Backward-compatible alias kept while older tests and callers migrate.
*
* @param {Array} marks
* @returns {Object|undefined}
*/
export const createTrackStyleMark = (marks) => createRunPropertiesChangeElement(findTrackFormatMark(marks));
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { NodeTranslator } from '@translator';
import { createAttributeHandler } from '@converter/v3/handlers/utils.js';
import { exportSchemaToJson } from '@converter/exporter.js';
import { createTrackStyleMark } from '@converter/v3/handlers/helpers.js';

/** @type {import('@translator').XmlNodeName} */
const XML_NODE_NAME = 'w:del';
Expand Down Expand Up @@ -70,15 +69,15 @@ function decode(params) {
return null;
}

const trackingMarks = ['trackInsert', 'trackFormat', 'trackDelete'];
const marks = node.marks;
const trackingMarks = ['trackInsert', 'trackDelete'];
const marks = Array.isArray(node.marks) ? node.marks : [];
const trackedMark = marks.find((m) => m.type === 'trackDelete');
const trackStyleMark = createTrackStyleMark(marks);
node.marks = marks.filter((m) => !trackingMarks.includes(m.type));
if (trackStyleMark) {
node.marks.push(trackStyleMark);
if (!trackedMark) {
return null;
}

node.marks = marks.filter((m) => !trackingMarks.includes(m.type));

const translatedTextNode = exportSchemaToJson({ ...params, node });
const textNode = translatedTextNode.elements.find((n) => n.name === 'w:t');
textNode.name = 'w:delText';
Expand Down Expand Up @@ -106,7 +105,7 @@ export const config = {
};

/**
* The NodeTranslator instance for the w:b element.
* The NodeTranslator instance for the w:del element.
* @type {import('@translator').NodeTranslator}
*/
export const translator = NodeTranslator.from(config);
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@ import { describe, expect, it, vi } from 'vitest';
import { config, translator } from './del-translator.js';
import { NodeTranslator } from '@translator';
import { exportSchemaToJson } from '@converter/exporter.js';
import { createTrackStyleMark } from '@converter/v3/handlers/helpers.js';

// Mock external modules
vi.mock('@converter/exporter.js', () => ({
exportSchemaToJson: vi.fn(),
}));

vi.mock('@converter/v3/handlers/helpers.js', () => ({
createTrackStyleMark: vi.fn(),
}));

describe('w:del translator', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -98,7 +93,6 @@ describe('w:del translator', () => {
const mockTranslatedNode = { elements: [mockTextNode] };

exportSchemaToJson.mockReturnValue(mockTranslatedNode);
createTrackStyleMark.mockReturnValue(null);

const node = {
type: 'text',
Expand All @@ -125,21 +119,43 @@ describe('w:del translator', () => {
expect(config.decode({ node: {} })).toBeNull();
});

it('preserves trackStyleMark if created', () => {
it('returns null when the node is missing a trackDelete mark', () => {
const node = {
type: 'text',
marks: [{ type: 'trackDelete', attrs: {} }],
marks: [{ type: 'italic', attrs: { value: true } }],
};

expect(config.decode({ node })).toBeNull();
expect(exportSchemaToJson).not.toHaveBeenCalled();
});

it('keeps trackFormat marks for downstream text export', () => {
const trackFormatMark = {
type: 'trackFormat',
attrs: {
id: 'format-1',
author: 'Missy Fox',
date: '2026-01-07T20:24:39Z',
before: [],
after: [{ type: 'italic', attrs: { value: true } }],
},
};
const node = {
type: 'text',
marks: [{ type: 'trackDelete', attrs: {} }, { type: 'italic', attrs: { value: true } }, trackFormatMark],
};

const mockTrackStyleMark = { type: 'trackStyle', attrs: {} };
createTrackStyleMark.mockReturnValue(mockTrackStyleMark);
exportSchemaToJson.mockReturnValue({ elements: [{ name: 'w:t' }] });

const result = config.decode({ node });
config.decode({ node });

expect(createTrackStyleMark).toHaveBeenCalled();
expect(result).toBeTruthy();
expect(result.elements[0].elements[0].name).toBe('w:delText');
expect(exportSchemaToJson).toHaveBeenCalledWith(
expect.objectContaining({
node: expect.objectContaining({
marks: [{ type: 'italic', attrs: { value: true } }, trackFormatMark],
}),
}),
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { NodeTranslator } from '@translator';
import { createAttributeHandler } from '@converter/v3/handlers/utils.js';
import { exportSchemaToJson } from '@converter/exporter.js';
import { createTrackStyleMark } from '@converter/v3/handlers/helpers.js';

/** @type {import('@translator').XmlNodeName} */
const XML_NODE_NAME = 'w:ins';
Expand Down Expand Up @@ -69,15 +68,15 @@ function decode(params) {
return null;
}

const trackingMarks = ['trackInsert', 'trackFormat', 'trackDelete'];
const marks = node.marks;
const trackingMarks = ['trackInsert', 'trackDelete'];
const marks = Array.isArray(node.marks) ? node.marks : [];
const trackedMark = marks.find((m) => m.type === 'trackInsert');
const trackStyleMark = createTrackStyleMark(marks);
node.marks = marks.filter((m) => !trackingMarks.includes(m.type));
if (trackStyleMark) {
node.marks.push(trackStyleMark);
if (!trackedMark) {
return null;
}

node.marks = marks.filter((m) => !trackingMarks.includes(m.type));

const translatedTextNode = exportSchemaToJson({ ...params, node });

return {
Expand All @@ -103,7 +102,7 @@ export const config = {
};

/**
* The NodeTranslator instance for the w:b element.
* The NodeTranslator instance for the w:ins element.
* @type {import('@translator').NodeTranslator}
*/
export const translator = NodeTranslator.from(config);
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,13 @@ import { describe, expect, it, vi } from 'vitest';
import { config, translator } from './ins-translator.js';
import { NodeTranslator } from '@translator';
import { exportSchemaToJson } from '@converter/exporter.js';
import { createTrackStyleMark } from '@converter/v3/handlers/helpers.js';

// Mock external modules
vi.mock('@converter/exporter.js', () => ({
exportSchemaToJson: vi.fn(),
}));

vi.mock('@converter/v3/handlers/helpers.js', () => ({
createTrackStyleMark: vi.fn(),
}));

describe('w:del translator', () => {
describe('w:ins translator', () => {
beforeEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -98,7 +93,6 @@ describe('w:del translator', () => {
const mockTranslatedNode = { elements: [mockTextNode] };

exportSchemaToJson.mockReturnValue(mockTranslatedNode);
createTrackStyleMark.mockReturnValue(null);

const node = {
type: 'text',
Expand All @@ -124,21 +118,43 @@ describe('w:del translator', () => {
expect(config.decode({ node: {} })).toBeNull();
});

it('preserves trackStyleMark if created', () => {
it('returns null when the node is missing a trackInsert mark', () => {
const node = {
type: 'text',
marks: [{ type: 'bold', attrs: { value: true } }],
};

expect(config.decode({ node })).toBeNull();
expect(exportSchemaToJson).not.toHaveBeenCalled();
});

it('keeps trackFormat marks for downstream text export', () => {
const trackFormatMark = {
type: 'trackFormat',
attrs: {
id: 'format-1',
author: 'Missy Fox',
date: '2026-01-07T20:24:39Z',
before: [],
after: [{ type: 'bold', attrs: { value: true } }],
},
};
const node = {
type: 'text',
marks: [{ type: 'trackInsert', attrs: {} }],
marks: [{ type: 'trackInsert', attrs: {} }, { type: 'bold', attrs: { value: true } }, trackFormatMark],
};

const mockTrackStyleMark = { type: 'trackStyle', attrs: {} };
createTrackStyleMark.mockReturnValue(mockTrackStyleMark);
exportSchemaToJson.mockReturnValue({ elements: [{ name: 'w:t' }] });

const result = config.decode({ node });
config.decode({ node });

expect(createTrackStyleMark).toHaveBeenCalled();
expect(result).toBeTruthy();
expect(result.elements[0].elements[0].name).toBe('w:t');
expect(exportSchemaToJson).toHaveBeenCalledWith(
expect.objectContaining({
node: expect.objectContaining({
marks: [{ type: 'bold', attrs: { value: true } }, trackFormatMark],
}),
}),
);
});
});
});
Loading
Loading