Skip to content
Closed
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
27 changes: 27 additions & 0 deletions packages/super-editor/src/core/Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ export class Editor extends EventEmitter {
*/
isFocused = false;

/**
* Track the number of transactions that actually changed the document
* @type {number}
*/
#transactionCount = 0;

options = {
element: null,
selector: null,
Expand Down Expand Up @@ -1344,12 +1350,22 @@ export class Editor extends EventEmitter {
return;
}

// Increment transaction count when document actually changes
this.#transactionCount++;

this.emit('update', {
editor: this,
transaction,
});
}

/**
* Reset the change tracking when loading a new document
*/
#resetChangeTracking() {
this.#transactionCount = 0;
}

/**
* Get attrs of the currently selected node or mark.
* @param {String} nameOrType
Expand Down Expand Up @@ -1514,6 +1530,7 @@ export class Editor extends EventEmitter {
* @param {string} [options.commentsType] - The type of comments to include
* @param {Array} [options.comments=[]] - Array of comments to include in the document
* @param {boolean} [options.getUpdatedDocs=false] - When set to true return only updated docx files
* @param {boolean} [options.preserveOriginalIfUnchanged=true] - Whether to return original file if no changes detected
* @returns {Promise<Blob|ArrayBuffer|Object>} The exported DOCX file or updated docx files
*/
async exportDocx({
Expand All @@ -1524,7 +1541,14 @@ export class Editor extends EventEmitter {
comments = [],
getUpdatedDocs = false,
fieldsHighlightColor = null,
preserveOriginalIfUnchanged = true,
} = {}) {
// Check if document has been changed and we have the original file
if (preserveOriginalIfUnchanged && this.#transactionCount === 0 && this.options.fileSource && !getUpdatedDocs) {
// Return the original file if no changes detected
return this.options.fileSource;
}

// Pre-process the document state to prepare for export
const json = this.#prepareDocumentForExport(comments);

Expand Down Expand Up @@ -1727,6 +1751,9 @@ export class Editor extends EventEmitter {
replacedFile: true,
});

// Reset change tracking when loading a new file
this.#resetChangeTracking();

this.#createConverter();
this.#initMedia();
this.initDefaultStyles();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@
type: tblCellSpacing.attributes['w:type'],
};
attrs['borderCollapse'] = 'separate';
} else {
attrs['borderCollapse'] = 'collapse';
}

const tblJustification = tblPr.elements.find((el) => el.name === 'w:jc');
Expand Down Expand Up @@ -166,22 +168,12 @@
const colspanNum = parseInt(colspan || 1, 10);

if (colspanNum && colspanNum > 1 && hasDefaultColWidths) {
let colwidth = [];

const colwidth = [];
for (let i = 0; i < colspanNum; i++) {
let colwidthValue = defaultColWidths[columnIndex + i];
let defaultColwidth = 100;

if (typeof colwidthValue !== 'undefined') {
colwidth.push(colwidthValue);
} else {
colwidth.push(defaultColwidth);
}
}

if (colwidth.length) {
attributes['colwidth'] = [...colwidth];
const w = defaultColWidths?.[columnIndex + i];
if (typeof w !== 'undefined') colwidth.push(w);
}
if (colwidth.length) attributes['colwidth'] = [...colwidth];
}
}

Expand All @@ -191,42 +183,22 @@
if (verticalAlign) attributes['verticalAlign'] = verticalAlign;
if (fontSize) attributes['fontSize'] = fontSize;
if (fontFamily) attributes['fontFamily'] = fontFamily['ascii'];
if (rowBorders) attributes['borders'] = { ...rowBorders };
if (inlineBorders) attributes['borders'] = Object.assign(attributes['borders'] || {}, inlineBorders);

// Tables can have vertically merged cells, indicated by the vMergeAttrs
// if (vMerge) attributes['vMerge'] = vMergeAttrs || 'merged';
if (vMergeAttrs && vMergeAttrs['w:val'] === 'restart') {
const rows = table.elements.filter((el) => el.name === 'w:tr');
const currentRowIndex = rows.findIndex((r) => r === row);
const remainingRows = rows.slice(currentRowIndex + 1);

const cellsInRow = row.elements.filter((el) => el.name === 'w:tc');
let cellIndex = cellsInRow.findIndex((el) => el === node);
let rowspan = 1;

// Iterate through all remaining rows after the current cell, and find all cells that need to be merged
for (let remainingRow of remainingRows) {
const firstCell = remainingRow.elements.findIndex((el) => el.name === 'w:tc');
const cellAtIndex = remainingRow.elements[firstCell + cellIndex];

if (!cellAtIndex) break;

const vMerge = getTableCellMergeTag(cellAtIndex);
const { attributes: currentCellMergeAttrs } = vMerge || {};
if (
(!vMerge && !currentCellMergeAttrs) ||
(currentCellMergeAttrs && currentCellMergeAttrs['w:val'] === 'restart')
) {
// We have reached the end of the vertically merged cells
break;
}
attributes['borders'] = mergeBorders({
table: referencedStyles?.borders,
row: rowBorders,
cell: inlineBorders,
});

// This cell is part of a merged cell, merge it (remove it from its row)
rowspan++;
remainingRow.elements.splice(firstCell + cellIndex, 1);
}
attributes['rowspan'] = rowspan;
if (vMerge) attributes['vMerge'] = vMergeAttrs || { 'w:val': 'continue' };
const isRestart = !!(vMergeAttrs && vMergeAttrs['w:val'] === 'restart');
const isContinue = !!(vMerge && (!vMergeAttrs || vMergeAttrs['w:val'] !== 'restart'));

if (isContinue) {
attributes['merged'] = 'continue'; // row handler will skip rendering this cell
}

if (isRestart) {
attributes['rowspan'] = computeRowspanByGrid(table, row, columnIndex);
}

return {
Expand All @@ -235,7 +207,6 @@
attrs: attributes,
};
}

const getTableCellMergeTag = (node) => {
const tcPr = node.elements.find((el) => el.name === 'w:tcPr');
const vMerge = tcPr?.elements?.find((el) => el.name === 'w:vMerge');
Expand All @@ -261,6 +232,55 @@
}
return null;
};
function findCellAtGridColumn(row, targetGridCol) {
const cells = row.elements.filter((el) => el.name === 'w:tc');
let col = 0;
for (const c of cells) {
const tcPr = c.elements?.find((el) => el.name === 'w:tcPr');
const spanTag = tcPr?.elements?.find((el) => el.name === 'w:gridSpan');
const span = parseInt(spanTag?.attributes?.['w:val'] || '1', 10);
if (col === targetGridCol) return { cell: c, span };
col += span;
}
return null;
}

function computeRowspanByGrid(table, currentRow, targetGridCol) {
const rows = table.elements.filter((el) => el.name === 'w:tr');
const startIndex = rows.findIndex((r) => r === currentRow);
let rowspan = 1;
for (let i = startIndex + 1; i < rows.length; i++) {
const match = findCellAtGridColumn(rows[i], targetGridCol);
if (!match) break;
const vMerge = getTableCellMergeTag(match.cell);
const attrs = vMerge?.attributes;
if (!vMerge) break; // no vMerge -> stop
if (attrs && attrs['w:val'] === 'restart') break; // new restart -> stop
// continuation -> extend
rowspan++;
}
return rowspan;
}

// Merge table/row/cell borders with precedence and respect 'nil'
function mergeBorders(src = {}) {
const out = {};
const order = ['table', 'row', 'cell']; // low -> high precedence
for (const side of ['top', 'right', 'bottom', 'left', 'insideH', 'insideV']) {
for (const key of order) {
const b = src[key]?.[side];
if (!b) continue;
// treat 'nil' (Word) or 'none' as hard remove
const val = b.val || b.w_val || b['w:val'];
if (val === 'nil' || val === 'none') {
delete out[side];
} else {
out[side] = { ...out[side], ...b };
}
}
}
return out;
}

const processInlineCellBorders = (borders, rowBorders) => {
if (!borders) return null;
Expand Down Expand Up @@ -403,6 +423,7 @@
const tPr = node.elements.find((el) => el.name === 'w:trPr');
const rowHeightTag = tPr?.elements?.find((el) => el.name === 'w:trHeight');
const rowHeight = rowHeightTag?.attributes['w:val'];
const rowHeightRule = rowHeightTag?.attributes?.['w:hRule'] || 'atLeast';

Check warning on line 426 in packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js

View workflow job for this annotation

GitHub Actions / Lint & Format Check

'rowHeightRule' is assigned a value but never used. Allowed unused vars must match /^_/u

const borders = {};
if (rowBorders?.insideH) borders['bottom'] = rowBorders.insideH;
Expand All @@ -417,19 +438,18 @@
const cellNodes = node.elements.filter((el) => el.name === 'w:tc');

let currentColumnIndex = 0;
const content =
cellNodes?.map((n) => {
let colWidth = gridColumnWidths?.[currentColumnIndex] || null;

const result = handleTableCellNode(n, node, table, borders, colWidth, styleTag, params, currentColumnIndex);

const tcPr = n.elements?.find((el) => el.name === 'w:tcPr');
const colspanTag = tcPr?.elements?.find((el) => el.name === 'w:gridSpan');
const colspan = parseInt(colspanTag?.attributes['w:val'] || 1, 10);
currentColumnIndex += colspan;

return result;
}) || [];
const content = [];
for (const n of cellNodes || []) {
const tcPr = n.elements?.find((el) => el.name === 'w:tcPr');
const colspanTag = tcPr?.elements?.find((el) => el.name === 'w:gridSpan');
const colspan = parseInt(colspanTag?.attributes?.['w:val'] || '1', 10);
const colWidth = gridColumnWidths?.[currentColumnIndex] || null;
const result = handleTableCellNode(n, node, table, borders, colWidth, styleTag, params, currentColumnIndex);
if (!result?.attrs?.merged || result.attrs.merged !== 'continue') {
content.push(result);
}
currentColumnIndex += colspan;
}
const newNode = {
type: 'tableRow',
content,
Expand Down
Loading