fix(sheets-ui): improve external clipboard paste (#7712)

This commit is contained in:
wpxp123456
2026-09-20 15:56:56 +08:00
committed by GitHub
parent 13e30364c0
commit 92035eb654
4 changed files with 138 additions and 10 deletions
@@ -434,6 +434,49 @@ describe('SheetClipboardService', () => {
expect(pastedFiles).toEqual([{ name: 'clipboard-image.png', pasteType: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT }]);
});
it('uses the same HTML and image precedence for ClipboardItem and legacy paste', async () => {
const { injector, service } = createTestContext();
selectCell(injector);
const pastedCells: string[] = [];
const pastedFiles: string[] = [];
const target = service.capturePasteTarget();
service.addClipboardHook({
id: 'html-image-precedence',
onPasteCells() {
pastedCells.push('html');
return { redos: [], undos: [] };
},
onPasteFiles(_payload: unknown, files: File[]) {
pastedFiles.push(files[0].name);
return { redos: [], undos: [] };
},
} as never);
const tableHTML = '<table><tbody><tr><td>North</td><td>South</td></tr></tbody></table>';
const preview = new File(['preview'], 'preview.png', { type: 'image/png' });
const tableItem = new MockClipboardItem({
'text/html': tableHTML,
'image/png': 'preview',
});
expect(await service.paste(tableItem as unknown as ClipboardItem, PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE, target)).toBe(true);
expect(await service.legacyPaste(tableHTML, undefined, [preview], undefined, target)).toBe(true);
expect(pastedCells).toEqual(['html', 'html']);
expect(pastedFiles).toEqual([]);
const imageHTML = '<img src="data:image/png;base64,cHJldmlldw==">';
const imageItem = new MockClipboardItem({
'text/html': imageHTML,
'image/png': 'preview',
});
expect(await service.paste(imageItem as unknown as ClipboardItem, PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE, target)).toBe(true);
expect(await service.legacyPaste(imageHTML, undefined, [preview], undefined, target)).toBe(true);
expect(pastedCells).toEqual(['html', 'html']);
expect(pastedFiles).toEqual(['clipboard-image.png', 'preview.png']);
});
it('routes formula clipboard payload to structured paste before html fallback', async () => {
const { injector, service } = createTestContext();
selectCell(injector, 5, 6);
@@ -470,12 +470,12 @@ export class SheetClipboardService extends Disposable implements ISheetClipboard
if (!target) {
return false;
}
const isFromExcel = htmlIsFromExcel(html ?? '');
const shouldUseHTMLPaste = !files?.length || !htmlContainsImage(html ?? '');
if (files && !isFromExcel) {
return this._pasteFiles(files, PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE, target);
} else if (html) {
if (html && shouldUseHTMLPaste) {
return this._pasteHTML(html, PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE, formulaClipboardPayload, target);
} else if (files?.length) {
return this._pasteFiles(files, PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE, target);
} else if (text) {
// Converts text with tabs and newlines into an HTML table
if (/[\n\t]/.test(text)) {
@@ -14,13 +14,60 @@
* limitations under the License.
*/
import type { ISheetSkeletonManagerParam } from '@univerjs/sheets';
import { describe, expect, it } from 'vitest';
import { HtmlToUSMService } from '../converter';
import { LarkPastePlugin } from '../paste-plugins/plugin-lark';
import { WordPastePlugin } from '../paste-plugins/plugin-word';
HtmlToUSMService.use(WordPastePlugin);
HtmlToUSMService.use(LarkPastePlugin);
describe('HtmlToUSMService', () => {
it('preserves Excel borders that use the legacy windowtext system color', () => {
const converter = new HtmlToUSMService({ getCurrentSkeleton: () => null });
const { cellMatrix } = converter.convert(`
<html>
<head>
<style>
td { border: none; }
.xl65 {
border-top: .5pt solid windowtext;
border-left: .5pt solid windowtext;
}
</style>
</head>
<body><table><tr><td class="xl65">11</td></tr></table></body>
</html>
`);
expect(cellMatrix.getValue(0, 0)?.s).toStrictEqual({
bd: {
l: { cl: { rgb: '#000000' }, s: 1 },
t: { cl: { rgb: '#000000' }, s: 1 },
},
});
});
it('preserves Lark ace lines as multiline text inside a table cell', () => {
const converter = new HtmlToUSMService({
getCurrentSkeleton: () => ({ skeleton: {} } as ISheetSkeletonManagerParam),
});
const { cellMatrix } = converter.convert(`
<table class="ace-table">
<tbody>
<tr>
<td><div class="ace-line">1.a</div><div class="ace-line">2.b</div><div class="ace-line">3.c</div></td>
</tr>
</tbody>
</table>
<span class="lark-record-clipboard"></span>
`);
expect(cellMatrix.getValue(0, 0)?.v).toBe('1.a\r2.b\r3.c');
expect(cellMatrix.getValue(0, 0)?.p?.body?.dataStream).toBe('1.a\r2.b\r3.c\r\n');
});
it.each([
'<p class="MsoNormal"><b>Office回归中文🙂</b></p><p class="MsoNormal"><b>第二行 0</b></p>',
'<span>Office回归中文🙂</span><br><span>第二行 0</span>',
@@ -63,6 +63,12 @@ const sheetStyleRules: string[] =
'--data-rotate',
];
const DEFAULT_WINDOW_TEXT_COLOR = '#000000';
function normalizeLegacySystemColors(cssText: string) {
return cssText.replace(/\bwindowtext\b/gi, DEFAULT_WINDOW_TEXT_COLOR);
}
function matchFilter(node: HTMLElement, filter: IStyleRule['filter']) {
const tagName = node.tagName.toLowerCase();
@@ -138,7 +144,12 @@ export class HtmlToUSMService {
const style = this._dom.querySelector('style');
if (style) {
// Must read textContent BEFORE shadow DOM moves the element, because browsers discard mso-* properties during CSS parsing.
this._parseMsoNumfmtFromCssText(style.textContent ?? '');
const rawStyleText = style.textContent ?? '';
this._parseMsoNumfmtFromCssText(rawStyleText);
// Excel emits the deprecated `windowtext` system color, which CSSOM may discard with its border declaration.
// Normalize it before reading cssRules so imported borders retain a stable workbook color.
style.textContent = normalizeLegacySystemColors(rawStyleText);
const shadowHost = document.createElement('div');
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
@@ -577,7 +588,9 @@ export class HtmlToUSMService {
return cellMatrix;
}
private _parseCellHtml(parent: Nullable<ChildNode>, nodes: NodeListOf<ChildNode>, doc: IDocumentBody, styleCache: Map<ChildNode, ITextStyle> = new Map(), styleStr: string) {
private _parseCellHtml(parent: Nullable<ChildNode>, nodes: NodeListOf<ChildNode>, doc: IDocumentBody, styleCache: Map<ChildNode, ITextStyle> = new Map(), styleStr: string): boolean {
let hasTrailingPluginParagraph = false;
for (const node of nodes) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue?.replace(/[\r\n]/g, '');
@@ -588,6 +601,9 @@ export class HtmlToUSMService {
}
doc.dataStream += text;
if (text) {
hasTrailingPluginParagraph = false;
}
if (style && Object.getOwnPropertyNames(style).length) {
doc.textRuns!.push({
@@ -607,6 +623,7 @@ export class HtmlToUSMService {
paragraphId: createParagraphId(new Set(doc.paragraphs.map((paragraph) => paragraph.paragraphId))),
});
doc.dataStream += '\r';
hasTrailingPluginParagraph = false;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const currentNodeStyle = this._getStyle(node as HTMLElement, styleStr);
const parentStyles = parent ? styleCache.get(parent) : {};
@@ -615,9 +632,27 @@ export class HtmlToUSMService {
styleCache.set(node, { ...parentStyles, ...nodeStyles });
const { childNodes } = node;
this._parseCellHtml(node, childNodes, doc, styleCache, currentNodeStyle);
const dataStreamLengthBeforeChildren = doc.dataStream.length;
const childHasTrailingPluginParagraph = this._parseCellHtml(node, childNodes, doc, styleCache, currentNodeStyle);
if (doc.dataStream.length > dataStreamLengthBeforeChildren) {
hasTrailingPluginParagraph = childHasTrailingPluginParagraph;
}
const afterProcessRule = this._afterProcessRules.find(({ filter }) =>
matchFilter(node as HTMLElement, filter)
);
if (afterProcessRule) {
const dataStreamLengthBeforeRule = doc.dataStream.length;
afterProcessRule.handler(doc, node as HTMLElement);
if (doc.dataStream.length > dataStreamLengthBeforeRule) {
hasTrailingPluginParagraph = doc.dataStream.endsWith('\r');
}
}
}
}
return hasTrailingPluginParagraph;
}
private _getCellTextAndRichText(cell: Element, styleStr: string, skeleton?: SpreadsheetSkeleton) {
@@ -656,10 +691,13 @@ export class HtmlToUSMService {
textRuns: [],
};
// Rich text parsing method, refer to the doc
this._parseCellHtml(null, cell.childNodes, newDocBody, undefined, styleStr);
const hasTrailingPluginParagraph = this._parseCellHtml(null, cell.childNodes, newDocBody, undefined, styleStr);
const cellDataStream = hasTrailingPluginParagraph
? newDocBody.dataStream.slice(0, -1)
: newDocBody.dataStream;
const documentModel = createDocumentModelWithStyle('', {});
const p = documentModel?.getSnapshot();
const singleDataStream = `${newDocBody.dataStream}\r\n`;
const singleDataStream = `${cellDataStream}\r\n`;
const documentData = {
...p,
...{
@@ -672,7 +710,7 @@ export class HtmlToUSMService {
};
documentModel?.reset(documentData);
cellRichStyle = documentModel?.getSnapshot();
cellText = newDocBody.dataStream;
cellText = cellDataStream;
} else {
cellText = decodeHTMLEntities(cellHtml.replace(/[\r\n]/g, ''));
}