From a8c6f8dfbddbf06f47b50482d2f4c9b2f8551d55 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 13 Aug 2025 09:57:53 -0400 Subject: [PATCH] fix: default field export background transparent, allow config on export --- packages/super-editor/src/core/Editor.js | 2 + .../core/super-converter/SuperConverter.js | 4 + .../src/core/super-converter/exporter.js | 38 +++++++- .../getFieldHighlightJson.test.js | 96 +++++++++++++++++++ packages/superdoc/src/core/SuperDoc.js | 7 +- 5 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/getFieldHighlightJson.test.js diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index 95aa1991b8..ef465e2e00 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -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); @@ -1567,6 +1568,7 @@ export class Editor extends EventEmitter { comments, this, exportJsonOnly, + fieldsHighlightColor, ); if (exportXmlOnly || exportJsonOnly) return documentXml; diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 105e51c08e..09742b7fbe 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -409,6 +409,7 @@ class SuperConverter { comments = [], editor, exportJsonOnly = false, + fieldsHighlightColor, ) { const commentsWithParaIds = comments.map((c) => prepareCommentParaIds(c)); const commentDefinitions = commentsWithParaIds.map((c, index) => @@ -423,6 +424,7 @@ class SuperConverter { commentsExportType, isFinalDoc, editor, + fieldsHighlightColor, }); if (exportJsonOnly) return result; @@ -478,6 +480,7 @@ class SuperConverter { isFinalDoc = false, editor, isHeaderFooter = false, + fieldsHighlightColor = null, }) { const bodyNode = this.savedTagsToRestore.find((el) => el.name === 'w:body'); @@ -496,6 +499,7 @@ class SuperConverter { exportedCommentDefs: commentDefinitions, editor, isHeaderFooter, + fieldsHighlightColor, }); return { result, params }; diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 9baa24c2dc..cf79153ab2 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -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 {}; @@ -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 = { @@ -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)) { + 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}`, 'w:color': 'auto', 'w:val': 'clear', }, diff --git a/packages/super-editor/src/core/super-converter/getFieldHighlightJson.test.js b/packages/super-editor/src/core/super-converter/getFieldHighlightJson.test.js new file mode 100644 index 0000000000..9fe4ccad9a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/getFieldHighlightJson.test.js @@ -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(); + }); +}); diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 66c873f449..cb75d40285 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -742,10 +742,11 @@ export class SuperDoc extends EventEmitter { 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]; @@ -780,7 +781,7 @@ export class SuperDoc extends EventEmitter { * @param {{ commentsType?: string, isFinalDoc?: boolean }} [options] * @returns {Promise>} */ - async exportEditorsToDOCX({ commentsType, isFinalDoc } = {}) { + async exportEditorsToDOCX({ commentsType, isFinalDoc, fieldsHighlightColor } = {}) { const comments = []; if (commentsType !== 'clean') { if (this.commentsStore && typeof this.commentsStore.translateCommentsForExport === 'function') { @@ -792,7 +793,7 @@ export class SuperDoc extends EventEmitter { 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);