Desktop: Resolves #10562: Preserve table customization made on RTE (#14572)

This commit is contained in:
Keshav
2026-03-10 16:59:59 +05:30
committed by GitHub
parent 2a681008dd
commit d046bfa14b
13 changed files with 184 additions and 2 deletions

View File

@@ -45,6 +45,10 @@ describe('HtmlToMd', () => {
htmlToMdOptions.preserveColorStyles = true;
}
if (htmlFilename.indexOf('table_with') === 0 || htmlFilename.indexOf('table_default') === 0) {
htmlToMdOptions.preserveTableStyles = true;
}
const html = await readFile(htmlPath, 'utf8');
let expectedMd = await readFile(mdPath, 'utf8');

View File

@@ -0,0 +1,18 @@
<table style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th style="width: 50%;">Name</th>
<th style="width: 50%;">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td style="width: 50%;">Cell A</td>
<td style="width: 50%;">Cell B</td>
</tr>
<tr>
<td style="width: 50%;">Cell C</td>
<td style="width: 50%;">Cell D</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,4 @@
| Name | Value |
| --- | --- |
| Cell A | Cell B |
| Cell C | Cell D |

View File

@@ -0,0 +1,18 @@
<table bgcolor="#f0f0f0" cellpadding="8">
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cell A</td>
<td>Cell B</td>
</tr>
<tr>
<td>Cell C</td>
<td>Cell D</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1 @@
<div class="joplin-table-wrapper"><table bgcolor="#f0f0f0" cellpadding="8"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>Cell A</td><td>Cell B</td></tr><tr><td>Cell C</td><td>Cell D</td></tr></tbody></table></div>

View File

@@ -0,0 +1,18 @@
<table style="border-collapse: collapse">
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td style="background-color: #e03e2d">Red cell</td>
<td style="padding: 10px 15px">Padded cell</td>
</tr>
<tr>
<td style="border-color: #2dc26b; border-style: solid">Green border</td>
<td>Normal cell</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1 @@
<div class="joplin-table-wrapper"><table style="border-collapse: collapse"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td style="background-color: #e03e2d">Red cell</td><td style="padding: 10px 15px">Padded cell</td></tr><tr><td style="border-color: #2dc26b; border-style: solid">Green border</td><td>Normal cell</td></tr></tbody></table></div>

View File

@@ -1335,13 +1335,35 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onSetAttrib = (event: EditorEvent<any>) => {
// Dispatch onChange when a link is edited
// Dispatch onChange when a link or table-related formatting is edited
const target = Array.isArray(event.attrElm) ? event.attrElm[0] : event.attrElm;
if (!target) return;
if (target.nodeName === 'A') {
if (event.attrName === 'title' || event.attrName === 'href' || event.attrName === 'rel') {
onChangeHandler();
}
}
if (['TABLE', 'TR', 'TD', 'TH'].includes(target.nodeName)) {
const attributeName = (event.attrName ?? '').toLowerCase();
if (
attributeName === 'style' ||
attributeName === 'class' ||
attributeName === 'bgcolor' ||
attributeName === 'bordercolor' ||
attributeName === 'background' ||
attributeName === 'cellpadding' ||
attributeName === 'cellspacing'
) {
onChangeHandler();
}
}
};
// Table plugin fires this on structure/style changes from dialogs.
const onTableModified = () => {
onChangeHandler();
};
// Keypress means that a printable key (letter, digit, etc.) has been
@@ -1490,6 +1512,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
editor.on('TableModified', onTableModified);
return () => {
try {
@@ -1506,6 +1529,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
editor.off('TableModified', onTableModified);
} catch (error) {
console.warn('Error removing events', error);
}

View File

@@ -13,6 +13,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
newBody = htmlToMd.parse(html, {
preserveImageTagsWithSize: true,
preserveNestedTables: true,
preserveTableStyles: true,
preserveColorStyles: true,
...parseOptions,
});

View File

@@ -10,6 +10,7 @@ const convertHtmlToMarkdown = (html: string|HTMLElement) => {
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: true,
preserveNestedTables: true,
preserveTableStyles: true,
preserveColorStyles: true,
bulletListMarker: '-',
emDelimiter: '*',

View File

@@ -8,6 +8,7 @@ export interface ParseOptions {
anchorNames?: string[];
preserveImageTagsWithSize?: boolean;
preserveNestedTables?: boolean;
preserveTableStyles?: boolean;
preserveColorStyles?: boolean;
baseUrl?: string;
disableEscapeContent?: boolean;
@@ -26,6 +27,7 @@ export default class HtmlToMd {
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: !!options.preserveImageTagsWithSize,
preserveNestedTables: !!options.preserveNestedTables,
preserveTableStyles: !!options.preserveTableStyles,
preserveColorStyles: !!options.preserveColorStyles,
bulletListMarker: '-',
emDelimiter: '*',

View File

@@ -258,3 +258,5 @@ clearsign
ligne
payant
llamacpp
bgcolor
bordercolor

View File

@@ -210,6 +210,93 @@ const nodeContains = (node, types) => {
return false;
}
// Style properties that count as user customization.
// Excludes TinyMCE/Joplin defaults:
// - border-collapse: set by default on all tables
// - width: set on every cell by TinyMCE
// - text-align: converted to Markdown alignment (:---, :---:, ---:)
// - height: false positives from TinyMCE defaults
const customStyleProperties = [
'background-color', 'background',
'border-color', 'border',
'border-top', 'border-right', 'border-bottom', 'border-left',
'border-style', 'border-width',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'float', 'margin-left', 'margin-right',
];
// HTML attributes TinyMCE may set instead of CSS.
const customAttributeNames = [
'bgcolor',
'bordercolor',
'background',
];
const nodeHasCustomStyle = (node) => {
if (!node || !node.getAttribute) return false;
const styleAttr = node.getAttribute('style');
if (!styleAttr) return false;
// Extract property names from the raw style string
const properties = styleAttr.split(';')
.map(s => s.split(':')[0].trim().toLowerCase())
.filter(s => s.length > 0);
for (let i = 0; i < properties.length; i++) {
if (customStyleProperties.includes(properties[i])) return true;
}
return false;
};
const hasNonDefaultSpacingAttribute = (node, name) => {
if (!node || !node.getAttribute) return false;
const value = node.getAttribute(name);
if (value === null) return false;
const normalisedValue = `${value}`.trim().toLowerCase();
if (!normalisedValue) return false;
if (normalisedValue === '0' || normalisedValue === '0px') return false;
return true;
};
const nodeHasCustomAttributes = (node) => {
if (!node || !node.getAttribute) return false;
for (let i = 0; i < customAttributeNames.length; i++) {
const value = node.getAttribute(customAttributeNames[i]);
if (value !== null && `${value}`.trim() !== '') return true;
}
if (node.nodeName === 'TABLE') {
if (hasNonDefaultSpacingAttribute(node, 'cellpadding')) return true;
if (hasNonDefaultSpacingAttribute(node, 'cellspacing')) return true;
}
return false;
};
const nodeHasCustomFormatting = (node) => {
return nodeHasCustomStyle(node) || nodeHasCustomAttributes(node);
};
// Returns true if the table or any of its rows/cells have custom formatting.
const tableHasCustomStyles = (tableNode) => {
if (nodeHasCustomFormatting(tableNode)) return true;
const rows = tableNode.rows;
if (!rows) return false;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (nodeHasCustomFormatting(row)) return true;
for (let j = 0; j < row.childNodes.length; j++) {
const cell = row.childNodes[j];
if ((cell.nodeName === 'TD' || cell.nodeName === 'TH') && nodeHasCustomFormatting(cell)) {
return true;
}
}
}
return false;
};
const tableShouldBeHtml = (tableNode, options) => {
const possibleTags = [
'UL',
@@ -232,7 +319,8 @@ const tableShouldBeHtml = (tableNode, options) => {
if (options.preserveNestedTables) possibleTags.push('TABLE');
return nodeContains(tableNode, 'code') ||
nodeContains(tableNode, possibleTags);
nodeContains(tableNode, possibleTags) ||
(options.preserveTableStyles && tableHasCustomStyles(tableNode));
}
// Various conditions under which a table should be skipped - i.e. each cell