mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
fix(sheets-ui): improve external clipboard paste (#7712)
This commit is contained in:
@@ -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, ''));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user