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
2 changes: 2 additions & 0 deletions packages/super-editor/src/core/Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,7 @@ export class Editor extends EventEmitter {
exportXmlOnly = false,
comments = [],
getUpdatedDocs = false,
fieldsHighlightColor = null,
} = {}) {
// Pre-process the document state to prepare for export
const json = this.#prepareDocumentForExport(comments);
Expand All @@ -1567,6 +1568,7 @@ export class Editor extends EventEmitter {
comments,
this,
exportJsonOnly,
fieldsHighlightColor,
);

if (exportXmlOnly || exportJsonOnly) return documentXml;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ class SuperConverter {
comments = [],
editor,
exportJsonOnly = false,
fieldsHighlightColor,
) {
const commentsWithParaIds = comments.map((c) => prepareCommentParaIds(c));
const commentDefinitions = commentsWithParaIds.map((c, index) =>
Expand All @@ -423,6 +424,7 @@ class SuperConverter {
commentsExportType,
isFinalDoc,
editor,
fieldsHighlightColor,
});

if (exportJsonOnly) return result;
Expand Down Expand Up @@ -478,6 +480,7 @@ class SuperConverter {
isFinalDoc = false,
editor,
isHeaderFooter = false,
fieldsHighlightColor = null,
}) {
const bodyNode = this.savedTagsToRestore.find((el) => el.name === 'w:body');

Expand All @@ -496,6 +499,7 @@ class SuperConverter {
exportedCommentDefs: commentDefinitions,
editor,
isHeaderFooter,
fieldsHighlightColor,
});

return { result, params };
Expand Down
38 changes: 34 additions & 4 deletions packages/super-editor/src/core/super-converter/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -2009,7 +2009,7 @@ const translateFieldAttrsToMarks = (attrs = {}) => {
* @returns {XmlReadyNode} The translated field annotation node
*/
function translateFieldAnnotation(params) {
const { node, isFinalDoc } = params;
const { node, isFinalDoc, fieldsHighlightColor } = params;
const { attrs = {} } = node;
const annotationHandler = getTranslationByAnnotationType(attrs.type);
if (!annotationHandler) return {};
Expand All @@ -2031,7 +2031,14 @@ function translateFieldAnnotation(params) {
sdtContentElements = [...processedNode.elements];
}
}
sdtContentElements = [getFieldHighlightJson(), ...sdtContentElements];

sdtContentElements = [...sdtContentElements];

// Set field background color only if param is provided, default to transparent
const fieldBackgroundTag = getFieldHighlightJson(fieldsHighlightColor);
if (fieldBackgroundTag) {
sdtContentElements.unshift(fieldBackgroundTag);
}

// Contains only the main attributes.
const annotationAttrs = {
Expand Down Expand Up @@ -2478,14 +2485,37 @@ const getAutoPageJson = (type, outputMarks = []) => {
];
};

const getFieldHighlightJson = () => {
/**
* Get the JSON representation of the field highlight
* @param {string} fieldsHighlightColor - The highlight color for the field. Must be valid HEX.
* @returns {Object} The JSON representation of the field highlight
*/
export const getFieldHighlightJson = (fieldsHighlightColor) => {
if (!fieldsHighlightColor) return null;

// Normalize input
let parsedColor = fieldsHighlightColor.trim();

// Regex: optional '#' + 3/4/6/8 hex digits
const hexRegex = /^#?([A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/;

if (!hexRegex.test(parsedColor)) {
Copy link

Copilot AI Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation occurs after trimming but before checking if the trimmed string is empty. An input of only whitespace will pass the trim but fail the regex, which could lead to unexpected behavior. Consider checking for empty string after trim.

Copilot uses AI. Check for mistakes.
console.warn(`Invalid HEX color provided to fieldsHighlightColor export param: ${fieldsHighlightColor}`);
return null;
}

// Remove '#' if present
if (parsedColor.startsWith('#')) {
parsedColor = parsedColor.slice(1);
}

return {
name: 'w:rPr',
elements: [
{
name: 'w:shd',
attributes: {
'w:fill': '7AA6FF',
'w:fill': `#${parsedColor}`,
Copy link

Copilot AI Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic removes the '#' character but then adds it back on line 2518. This is redundant and potentially confusing. Consider either keeping the '#' throughout or removing it consistently.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harbournick, Do we really need to add # here?

'w:color': 'auto',
'w:val': 'clear',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getFieldHighlightJson } from './exporter.js';

const extractFill = (result) => result?.elements?.[0]?.attributes?.['w:fill'];

describe('getFieldHighlightJson (non-throwing)', () => {
let warnSpy;

beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warnSpy.mockRestore();
});

it('returns null for falsy inputs (undefined, null, empty string)', () => {
expect(getFieldHighlightJson()).toBeNull();
expect(getFieldHighlightJson(undefined)).toBeNull();
expect(getFieldHighlightJson(null)).toBeNull();
expect(getFieldHighlightJson('')).toBeNull();
expect(warnSpy).not.toHaveBeenCalled();
});

it('accepts 3/4/6/8-digit HEX with or without # (case preserved)', () => {
const cases = [
['#FFF', '#FFF'],
['FFF', '#FFF'],
['#ffff', '#ffff'],
['FFFF', '#FFFF'],
['#A1B2C3', '#A1B2C3'],
['a1b2c3', '#a1b2c3'],
['#A1B2C3D4', '#A1B2C3D4'],
['a1b2c3d4', '#a1b2c3d4'],
];

for (const [input, expectedFill] of cases) {
const out = getFieldHighlightJson(input);
expect(out).toBeTruthy();
expect(out.name).toBe('w:rPr');
expect(extractFill(out)).toBe(expectedFill);
expect(out.elements[0].attributes['w:color']).toBe('auto');
expect(out.elements[0].attributes['w:val']).toBe('clear');
}
expect(warnSpy).not.toHaveBeenCalled();
});

it('trims surrounding whitespace and still validates', () => {
const out1 = getFieldHighlightJson(' #ABCDEF ');
expect(extractFill(out1)).toBe('#ABCDEF');

const out2 = getFieldHighlightJson(' abc ');
expect(extractFill(out2)).toBe('#abc');
expect(warnSpy).not.toHaveBeenCalled();
});

it('treats pure whitespace as invalid (returns null and warns)', () => {
const out = getFieldHighlightJson(' ');
expect(out).toBeNull();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(/Invalid HEX color/i);
});

it('returns null and warns for invalid HEX formats', () => {
const invalid = [
'#GGG',
'GGGZ',
'red',
'12345',
'#12345',
'#1234567',
'1234567',
'#',
'##123',
'xyz',
'#ffffgfff',
'12',
'#12',
];

for (const input of invalid) {
const out = getFieldHighlightJson(input);
expect(out).toBeNull();
}
expect(warnSpy).toHaveBeenCalledTimes(invalid.length);
for (let i = 0; i < invalid.length; i++) {
expect(warnSpy.mock.calls[i][0]).toMatch(/Invalid HEX color/i);
}
});

it('adds a leading # when missing', () => {
const out = getFieldHighlightJson('ABCDEF');
expect(extractFill(out)).toBe('#ABCDEF');
expect(warnSpy).not.toHaveBeenCalled();
});
});
7 changes: 4 additions & 3 deletions packages/superdoc/src/core/SuperDoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@
/**
* Initialize telemetry service.
*/
#initTelemetry() {

Check warning on line 405 in packages/superdoc/src/core/SuperDoc.js

View workflow job for this annotation

GitHub Actions / Lint & Format Check

'#initTelemetry' is defined but never used
this.telemetry = new Telemetry({
enabled: this.config.telemetry?.enabled ?? true,
licenseKey: this.config.telemetry?.licenseKey,
Expand Down Expand Up @@ -742,10 +742,11 @@
additionalFileNames = [],
isFinalDoc = false,
triggerDownload = true,
fieldsHighlightColor = null,
} = {}) {
// Get the docx files first
const baseFileName = exportedName ? cleanName(exportedName) : cleanName(this.config.title);
const docxFiles = await this.exportEditorsToDOCX({ commentsType, isFinalDoc });
const docxFiles = await this.exportEditorsToDOCX({ commentsType, isFinalDoc, fieldsHighlightColor });
const blobsToZip = [...additionalFiles];
const filenames = [...additionalFileNames];

Expand Down Expand Up @@ -780,7 +781,7 @@
* @param {{ commentsType?: string, isFinalDoc?: boolean }} [options]
* @returns {Promise<Array<Blob>>}
*/
async exportEditorsToDOCX({ commentsType, isFinalDoc } = {}) {
async exportEditorsToDOCX({ commentsType, isFinalDoc, fieldsHighlightColor } = {}) {
const comments = [];
if (commentsType !== 'clean') {
if (this.commentsStore && typeof this.commentsStore.translateCommentsForExport === 'function') {
Expand All @@ -792,7 +793,7 @@
this.superdocStore.documents.forEach((doc) => {
const editor = doc.getEditor();
if (editor) {
docxPromises.push(editor.exportDocx({ isFinalDoc, comments, commentsType }));
docxPromises.push(editor.exportDocx({ isFinalDoc, comments, commentsType, fieldsHighlightColor }));
}
});
return await Promise.all(docxPromises);
Expand Down
Loading