From 0b473d9133af774f226539f20c66be285e5db76e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 24 Aug 2026 19:49:09 +0800 Subject: [PATCH 01/13] fix: support copy fallback on http Use event clipboard data when async clipboard is unavailable. This keeps copy working in non-secure contexts. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...issue-5274-http-copy_2026-08-24-15-00.json | 11 +++++ packages/vtable/src/event/event.ts | 40 ++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json diff --git a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json new file mode 100644 index 0000000000..49a8fe711d --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: support copy through event clipboard data when async clipboard is unavailable (GitHub #5274)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index bae7c2fd28..ce9b7fdce0 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -794,6 +794,12 @@ export class EventManager { if (isValid(data)) { e.preventDefault(); + const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; + if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, e)) { + this.afterCopyData(data, isCut); + return; + } + // 确保表格元素获得焦点,避免Document is not focused错误 const element = table.getElement(); if (element && element !== document.activeElement) { @@ -863,11 +869,7 @@ export class EventManager { this.fallbackCopyToClipboard(data, e); } - table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { - cellRange: table.stateManager.select.ranges, - copyData: data, - isCut - }); + this.afterCopyData(data, isCut, false); } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 @@ -880,6 +882,34 @@ export class EventManager { } } + private afterCopyData(data: string, isCut: boolean, updateCopyCellBorder: boolean = true): void { + const table = this.table; + table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { + cellRange: table.stateManager.select.ranges, + copyData: data, + isCut + }); + if (updateCopyCellBorder && table.keyboardOptions?.showCopyCellBorder) { + setActiveCellRangeState(table); + table.clearSelected(); + } + } + + private setCopyDataToEventClipboard(data: string, e: KeyboardEvent): boolean { + const clipboardData = (e as unknown as ClipboardEvent).clipboardData; + if (!clipboardData) { + return false; + } + + try { + clipboardData.setData('text/plain', data); + return true; + } catch (error) { + console.warn('事件剪贴板写入失败,使用降级方案:', error); + return false; + } + } + // 降级复制方案 private fallbackCopyToClipboard(data: string, e: KeyboardEvent): void { try { From 0e73de9390d3e1a4b2d81d52ef96a9398ab28a31 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 14:41:35 +0800 Subject: [PATCH 02/13] fix: refine http clipboard fallback Preserve cut ranges in the synchronous clipboard fallback path and keep text/html output aligned with the async clipboard branch. Co-Authored-By: Claude Sonnet 4.6 --- packages/vtable/src/event/event.ts | 35 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index ce9b7fdce0..50efcf0fd4 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -795,8 +795,9 @@ export class EventManager { e.preventDefault(); const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; - if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, e)) { - this.afterCopyData(data, isCut); + const dataHTML = this.getCopyDataHTML(data); + if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { + this.afterCopyData(data, isCut, !isCut); return; } @@ -832,19 +833,6 @@ export class EventManager { try { // 尝试使用 ClipboardItem(支持富文本) if (window.ClipboardItem) { - let htmlValues = data; - if ( - table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) - table.options.keyboardOptions?.getCopyCellValue?.html - ) { - htmlValues = this.table.getCopyValue( - table.options.keyboardOptions?.getCopyCellValue.html as ( - col: number, - row: number - ) => string | number - ); - } - const dataHTML = setDataToHTML(htmlValues); await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([dataHTML], { type: 'text/html' }), @@ -895,7 +883,21 @@ export class EventManager { } } - private setCopyDataToEventClipboard(data: string, e: KeyboardEvent): boolean { + private getCopyDataHTML(data: string): string { + const table = this.table; + let htmlValues = data; + if ( + table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) + table.options.keyboardOptions?.getCopyCellValue?.html + ) { + htmlValues = this.table.getCopyValue( + table.options.keyboardOptions?.getCopyCellValue.html as (col: number, row: number) => string | number + ); + } + return setDataToHTML(htmlValues); + } + + private setCopyDataToEventClipboard(data: string, dataHTML: string, e: KeyboardEvent): boolean { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { return false; @@ -903,6 +905,7 @@ export class EventManager { try { clipboardData.setData('text/plain', data); + clipboardData.setData('text/html', dataHTML); return true; } catch (error) { console.warn('事件剪贴板写入失败,使用降级方案:', error); From 1863020e6af106a511063acda624176cd808a4dc Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 15:16:12 +0800 Subject: [PATCH 03/13] fix: handle clipboard html fallback Keep fallback paste aligned with HTML clipboard data and let plain text copy continue when custom HTML generation fails. Co-Authored-By: Claude Sonnet 4.6 --- packages/vtable/src/event/event.ts | 212 +++++++++++++++++------------ 1 file changed, 123 insertions(+), 89 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 50efcf0fd4..207675842f 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -832,7 +832,7 @@ export class EventManager { try { // 尝试使用 ClipboardItem(支持富文本) - if (window.ClipboardItem) { + if (window.ClipboardItem && dataHTML) { await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([dataHTML], { type: 'text/html' }), @@ -883,21 +883,31 @@ export class EventManager { } } - private getCopyDataHTML(data: string): string { + private getCopyDataHTML(data: string): string | null { const table = this.table; let htmlValues = data; - if ( - table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) - table.options.keyboardOptions?.getCopyCellValue?.html - ) { - htmlValues = this.table.getCopyValue( - table.options.keyboardOptions?.getCopyCellValue.html as (col: number, row: number) => string | number - ); + try { + if ( + table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致) + table.options.keyboardOptions?.getCopyCellValue?.html + ) { + htmlValues = this.table.getCopyValue( + table.options.keyboardOptions?.getCopyCellValue.html as (col: number, row: number) => string | number + ); + } + return setDataToHTML(htmlValues); + } catch (error) { + console.warn('复制HTML数据生成失败,使用纯文本内容:', error); + try { + return setDataToHTML(data); + } catch (fallbackError) { + console.warn('复制HTML降级数据生成失败:', fallbackError); + return null; + } } - return setDataToHTML(htmlValues); } - private setCopyDataToEventClipboard(data: string, dataHTML: string, e: KeyboardEvent): boolean { + private setCopyDataToEventClipboard(data: string, dataHTML: string | null, e: KeyboardEvent): boolean { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { return false; @@ -905,7 +915,13 @@ export class EventManager { try { clipboardData.setData('text/plain', data); - clipboardData.setData('text/html', dataHTML); + if (dataHTML) { + try { + clipboardData.setData('text/html', dataHTML); + } catch (error) { + console.warn('事件剪贴板HTML写入失败,保留纯文本数据:', error); + } + } return true; } catch (error) { console.warn('事件剪贴板写入失败,使用降级方案:', error); @@ -1099,7 +1115,16 @@ export class EventManager { const clipboardData = e.clipboardData || (window as any).clipboardData || window.Clipboard; if (clipboardData) { - const pastedData = clipboardData.getData('text') || clipboardData.getData('Text'); + const htmlData = clipboardData.getData('text/html'); + if (htmlData) { + const pasted = await this.processPastedHTML(htmlData); + if (pasted) { + return; + } + } + + const pastedData = + clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text'); if (pastedData) { await this.processPastedText(pastedData, col, row); return; @@ -1110,6 +1135,89 @@ export class EventManager { } } + private async processPastedHTML(pastedData: string): Promise { + // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 + const regex = /]*>([\s\S]*?)<\/tr>/g; // for webpack3 + // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 + const cellRegex = /]*>([\s\S]*?)<\/td>/g; // for webpack3 + const table = this.table; + const ranges = table.stateManager.select.ranges; + const selectRangeLength = ranges.length; + const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); + const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); + const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + let pasteValuesColCount = 0; + let pasteValuesRowCount = 0; + let values: (string | number)[][] = []; + + if (!pastedData || !/(标签中的内容 + const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取标签及其内容 + const rowValues = cellMatches.map(cellMatch => { + return ( + cellMatch[1] + .replace(/(<(?!br)([^>]+)>)/gi, '') // 除了
标签以外的所有 HTML 标签都替换为空字符串 + .replace(/[\r\n]?/gim, '\n') // 将字符串中的
标签以及其后可能存在的空白字符和斜杠都替换为换行符 \n + // .replace(/
/g, '\n') // 替换
标签为换行符 + // .replace(/<(?:.|\n)*?>/gm, '') // 去除HTML标签 + //将字符串中的 HTML 实体字符转换为原始的字符 + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /gi, '\t') + .replace(/ /g, ' ') + ); + // .trim(); // 去除首尾空格 + }); + values.push(rowValues); + pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); + } + + pasteValuesRowCount = values.length ?? 0; + values = this.handlePasteValues( + values, + pasteValuesRowCount, + pasteValuesColCount, + maxRow - row + 1, + maxCol - col + 1 + ); + let processedValues; + // 检查是否支持公式处理(针对vtable-sheet) + if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { + // 使用复制时记录的源位置(而不是当前的选中位置) + processedValues = table.options.keyboardOptions.processFormulaBeforePaste( + values, + this.copySourceRange.startCol, + this.copySourceRange.startRow, + col, + row + ); + } + + const changedCellResults = await (table as ListTableAPI).changeCellValues( + col, + row, + processedValues ? processedValues : values, + true + ); + if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { + table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { + col, + row, + pasteData: processedValues ? processedValues : values, + changedCellResults + }); + } + return true; + } + // 处理粘贴的文本数据 private async processPastedText(pastedData: string, col: number, row: number): Promise { const table = this.table; @@ -1211,84 +1319,10 @@ export class EventManager { } } private pasteHtmlToTable(item: ClipboardItem) { - // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 - const regex = /]*>([\s\S]*?)<\/tr>/g; // for webpack3 - // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 - const cellRegex = /]*>([\s\S]*?)<\/td>/g; // for webpack3 - const table = this.table; - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - let pasteValuesColCount = 0; - let pasteValuesRowCount = 0; - let values: (string | number)[][] = []; item.getType('text/html').then((blob: any) => { blob.text().then(async (pastedData: any) => { - // 解析html数据 - if (pastedData && /(标签中的内容 - const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取标签中的内容 - const rowValues = cellMatches.map(cellMatch => { - return ( - cellMatch[1] - .replace(/(<(?!br)([^>]+)>)/gi, '') // 除了
标签以外的所有 HTML 标签都替换为空字符串 - .replace(/[\r\n]?/gim, '\n') // 将字符串中的
标签以及其后可能存在的空白字符和斜杠都替换为换行符 \n - // .replace(/
/g, '\n') // 替换
标签为换行符 - // .replace(/<(?:.|\n)*?>/gm, '') // 去除HTML标签 - //将字符串中的 HTML 实体字符转换为原始的字符 - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/ /gi, '\t') - .replace(/ /g, ' ') - ); - // .trim(); // 去除首尾空格 - }); - values.push(rowValues); - pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); - } - pasteValuesRowCount = values.length ?? 0; - values = this.handlePasteValues( - values, - pasteValuesRowCount, - pasteValuesColCount, - maxRow - row + 1, - maxCol - col + 1 - ); - let processedValues; - // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { - // 使用复制时记录的源位置(而不是当前的选中位置) - processedValues = table.options.keyboardOptions.processFormulaBeforePaste( - values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, - col, - row - ); - } - - const changedCellResults = await (table as ListTableAPI).changeCellValues( - col, - row, - processedValues ? processedValues : values, - true - ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues ? processedValues : values, - changedCellResults - }); - } - } else { + const pasted = await this.processPastedHTML(pastedData); + if (!pasted) { navigator.clipboard.read().then(clipboardItems => { for (const item of clipboardItems) { if (item.types.includes('text/plain')) { From 495e251d312ddf5bdc2d34d6484275ad6e75b8ed Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 16:41:13 +0800 Subject: [PATCH 04/13] fix: stabilize clipboard html paste handling Snapshot paste targets before async reads and decode copied HTML cell content through DOM parsing so entity values round-trip correctly. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/event/event.ts | 223 ++++++++++++----------------- packages/vtable/src/event/util.ts | 2 +- 2 files changed, 93 insertions(+), 132 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 207675842f..02d24e6ed1 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -38,6 +38,13 @@ import { bindDBClickAutoColumnWidthEvent } from './self-event-listener/base-tabl import { browser } from '../tools/helper'; import { clearActiveCellRangeState, setActiveCellRangeState } from '../tools/style'; +type PasteRange = { + col: number; + row: number; + maxCol: number; + maxRow: number; +}; + export class EventManager { table: BaseTableAPI; // _col: number; @@ -798,6 +805,12 @@ export class EventManager { const dataHTML = this.getCopyDataHTML(data); if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { this.afterCopyData(data, isCut, !isCut); + if (isCut && table.keyboardOptions?.showCopyCellBorder) { + window.setTimeout(() => { + setActiveCellRangeState(table); + table.clearSelected(); + }, 0); + } return; } @@ -1004,16 +1017,20 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { + const pasteRange = this.getPasteRange(); + if (!pasteRange) { + return; + } if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.executePaste(e); + this.executePaste(e, pasteRange); return; } this.checkClipboardChanged() .then(changed => { // 执行粘贴操作,并根据剪贴板是否变化决定是否清空选中区域 - this.executePaste(e); + this.executePaste(e, pasteRange); if (!changed) { this.clearCutArea(this.table as ListTableAPI); } @@ -1031,7 +1048,7 @@ export class EventManager { }) .catch(() => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 - this.executePaste(e); + this.executePaste(e, pasteRange); // 执行完粘贴操作后,重置剪切状态 if (this.cutWaitPaste) { this.cutWaitPaste = false; @@ -1044,12 +1061,12 @@ export class EventManager { } }); } - private async executePaste(e: any) { + private async executePaste(e: any, pasteRange: PasteRange) { const table = this.table; if ((table as ListTableAPI).editorManager?.editingEditor) { return; } - if ((table as ListTableAPI).changeCellValues && table.stateManager.select.ranges?.length > 0) { + if ((table as ListTableAPI).changeCellValues) { try { // 优先使用现代剪贴板API if (navigator.clipboard && navigator.clipboard.read) { @@ -1061,11 +1078,14 @@ export class EventManager { for (const item of clipboardItems) { // 优先处理 html 格式数据 if (item.types.includes('text/html')) { - await this.pasteHtmlToTable(item); - handled = true; - break; - } else if (item.types.includes('text/plain')) { - await this.pasteTextToTable(item); + const pasted = await this.pasteHtmlToTable(item, pasteRange); + if (pasted) { + handled = true; + break; + } + } + if (item.types.includes('text/plain')) { + await this.pasteTextToTable(item, pasteRange); handled = true; break; } @@ -1073,21 +1093,21 @@ export class EventManager { if (!handled) { // 如果没有处理任何数据,使用降级方案 - await this.fallbackPasteFromClipboard(e); + await this.fallbackPasteFromClipboard(e, pasteRange); } } catch (clipboardError) { console.warn('现代剪贴板API读取失败,使用降级方案:', clipboardError); // 降级到传统方法 - await this.fallbackPasteFromClipboard(e); + await this.fallbackPasteFromClipboard(e, pasteRange); } } else { // 不支持现代剪贴板API,使用降级方案 - await this.fallbackPasteFromClipboard(e); + await this.fallbackPasteFromClipboard(e, pasteRange); } } catch (error) { console.error('粘贴操作失败:', error); // 最后的降级方案 - await this.fallbackPasteFromClipboard(e); + await this.fallbackPasteFromClipboard(e, pasteRange); } } if (table.keyboardOptions?.showCopyCellBorder) { @@ -1095,12 +1115,24 @@ export class EventManager { } } + private getPasteRange(): PasteRange | null { + const ranges = this.table.stateManager.select.ranges; + if (!ranges?.length) { + return null; + } + const selectRangeLength = ranges.length; + const range = ranges[selectRangeLength - 1]; + return { + col: Math.min(range.start.col, range.end.col), + row: Math.min(range.start.row, range.end.row), + maxCol: Math.max(range.start.col, range.end.col), + maxRow: Math.max(range.start.row, range.end.row) + }; + } + // 降级粘贴方案 - private async fallbackPasteFromClipboard(e: any): Promise { + private async fallbackPasteFromClipboard(e: any, pasteRange: PasteRange): Promise { const table = this.table; - const ranges = table.stateManager.select.ranges; - const col = Math.min(ranges[0].start.col, ranges[0].end.col); - const row = Math.min(ranges[0].start.row, ranges[0].end.row); try { // 确保表格元素获得焦点 @@ -1117,16 +1149,20 @@ export class EventManager { if (clipboardData) { const htmlData = clipboardData.getData('text/html'); if (htmlData) { - const pasted = await this.processPastedHTML(htmlData); - if (pasted) { - return; + try { + const pasted = await this.processPastedHTML(htmlData, pasteRange); + if (pasted) { + return; + } + } catch (error) { + console.warn('降级粘贴HTML数据失败,尝试纯文本粘贴:', error); } } const pastedData = clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text'); if (pastedData) { - await this.processPastedText(pastedData, col, row); + await this.processPastedText(pastedData, pasteRange); return; } } @@ -1135,18 +1171,19 @@ export class EventManager { } } - private async processPastedHTML(pastedData: string): Promise { + private decodeHTMLCellValue(cellHTML: string): string { + const template = document.createElement('template'); + template.innerHTML = cellHTML.replace(/]*>[\r\n]?/gim, '\n'); + return template.content.textContent ?? ''; + } + + private async processPastedHTML(pastedData: string, pasteRange: PasteRange): Promise { // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 - const regex = /]*>([\s\S]*?)<\/tr>/g; // for webpack3 + const regex = /]*>([\s\S]*?)<\/tr>/gi; // for webpack3 // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 - const cellRegex = /]*>([\s\S]*?)<\/td>/g; // for webpack3 + const cellRegex = /]*>([\s\S]*?)<\/t[dh]>/gi; // for webpack3 const table = this.table; - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + const { col, row, maxCol, maxRow } = pasteRange; let pasteValuesColCount = 0; let pasteValuesRowCount = 0; let values: (string | number)[][] = []; @@ -1159,21 +1196,12 @@ export class EventManager { const matches = Array.from(pastedData.matchAll(regex)) as RegExpMatchArray[]; for (const match of matches) { const rowContent = match[1]; // 获取标签中的内容 - const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取标签及其内容 + const cellMatches: RegExpMatchArray[] = Array.from(rowContent.matchAll(cellRegex)); // 获取/标签及其内容 + if (!cellMatches.length) { + continue; + } const rowValues = cellMatches.map(cellMatch => { - return ( - cellMatch[1] - .replace(/(<(?!br)([^>]+)>)/gi, '') // 除了
标签以外的所有 HTML 标签都替换为空字符串 - .replace(/[\r\n]?/gim, '\n') // 将字符串中的
标签以及其后可能存在的空白字符和斜杠都替换为换行符 \n - // .replace(/
/g, '\n') // 替换
标签为换行符 - // .replace(/<(?:.|\n)*?>/gm, '') // 去除HTML标签 - //将字符串中的 HTML 实体字符转换为原始的字符 - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/ /gi, '\t') - .replace(/ /g, ' ') - ); + return this.decodeHTMLCellValue(cellMatch[1]); // .trim(); // 去除首尾空格 }); values.push(rowValues); @@ -1181,6 +1209,9 @@ export class EventManager { } pasteValuesRowCount = values.length ?? 0; + if (!pasteValuesRowCount || !pasteValuesColCount) { + return false; + } values = this.handlePasteValues( values, pasteValuesRowCount, @@ -1219,8 +1250,9 @@ export class EventManager { } // 处理粘贴的文本数据 - private async processPastedText(pastedData: string, col: number, row: number): Promise { + private async processPastedText(pastedData: string, pasteRange: PasteRange): Promise { const table = this.table; + const { col, row } = pasteRange; const rows = pastedData.split('\n'); // 将数据拆分为行 const values: (string | number)[][] = []; @@ -1318,95 +1350,24 @@ export class EventManager { }, 50); } } - private pasteHtmlToTable(item: ClipboardItem) { - item.getType('text/html').then((blob: any) => { - blob.text().then(async (pastedData: any) => { - const pasted = await this.processPastedHTML(pastedData); - if (!pasted) { - navigator.clipboard.read().then(clipboardItems => { - for (const item of clipboardItems) { - if (item.types.includes('text/plain')) { - item.getType('text/plain').then((blob: Blob) => { - blob.text().then(data => this._pasteValue(data)); - }); - } - } - }); - } - }); - }); - } - - private async _pasteValue(pastedData: string) { - const table = this.table; - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - let pasteValuesColCount = 0; - let pasteValuesRowCount = 0; - let values: (string | number)[][] = []; - const rows = pastedData.split('\n'); // 将数据拆分为行 - rows.forEach(function (rowCells: any, rowIndex: number) { - const cells = rowCells.split('\t'); // 将行数据拆分为单元格 - const rowValues: (string | number)[] = []; - values.push(rowValues); - cells.forEach(function (cell: string, cellIndex: number) { - // 去掉单元格数据末尾的 '\r' - if (cellIndex === cells.length - 1) { - cell = cell.trim(); - } - rowValues.push(cell); - }); - pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0); - }); - pasteValuesRowCount = values.length ?? 0; - values = this.handlePasteValues( - values, - pasteValuesRowCount, - pasteValuesColCount, - maxRow - row + 1, - maxCol - col + 1 - ); - let processedValues; - // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { - // 利用复制时记录的源位置,对粘贴的数据进行公式处理 - processedValues = table.options.keyboardOptions.processFormulaBeforePaste( - values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, - col, - row - ); - } - // 保持与 navigator.clipboard.read 中的操作一致 - const changedCellResults = await (table as ListTableAPI).changeCellValues( - col, - row, - processedValues ? processedValues : values, - true - ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues ? processedValues : values, - changedCellResults - }); + private async pasteHtmlToTable(item: ClipboardItem, pasteRange: PasteRange): Promise { + try { + const blob = await item.getType('text/html'); + const pastedData = await blob.text(); + const pasted = await this.processPastedHTML(pastedData, pasteRange); + if (pasted) { + return true; + } + } catch (error) { + console.warn('Paste html operation failed:', error); } + return false; } - private async pasteTextToTable(item: ClipboardItem) { + + private async pasteTextToTable(item: ClipboardItem, pasteRange: PasteRange) { const table = this.table; // 如果只有 'text/plain' - const ranges = table.stateManager.select.ranges; - const selectRangeLength = ranges.length; - const col = Math.min(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); - const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col); - const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row); + const { col, row, maxCol, maxRow } = pasteRange; try { const blob = await item.getType('text/plain'); diff --git a/packages/vtable/src/event/util.ts b/packages/vtable/src/event/util.ts index 0469ac0a13..fcc388a116 100644 --- a/packages/vtable/src/event/util.ts +++ b/packages/vtable/src/event/util.ts @@ -134,7 +134,7 @@ export function setDataToHTML(data: string) { cells.forEach(function (cell: string, cellIndex: number) { // 单元格数据处理 const parsedCellData = !cell - ? ' ' + ? '' : cell .toString() .replace(/&/g, '&') // replace & with & to prevent XSS attacks From 143266da88146942c5c1bb3268dd37b131196cc5 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 17:30:16 +0800 Subject: [PATCH 05/13] fix: normalize pasted html cell content Handle CRLF after br tags, detect table markup case-insensitively, and normalize decoded non-breaking spaces. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/event/event.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 02d24e6ed1..913dc8649e 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -1173,8 +1173,8 @@ export class EventManager { private decodeHTMLCellValue(cellHTML: string): string { const template = document.createElement('template'); - template.innerHTML = cellHTML.replace(/]*>[\r\n]?/gim, '\n'); - return template.content.textContent ?? ''; + template.innerHTML = cellHTML.replace(/]*>(?:\r\n|\r|\n)?/gim, '\n'); + return (template.content.textContent ?? '').replace(/\u00a0/g, ' '); } private async processPastedHTML(pastedData: string, pasteRange: PasteRange): Promise { @@ -1188,7 +1188,7 @@ export class EventManager { let pasteValuesRowCount = 0; let values: (string | number)[][] = []; - if (!pastedData || !/( Date: Tue, 25 Aug 2026 19:04:38 +0800 Subject: [PATCH 06/13] fix: harden clipboard cut paste state Snapshot copy and paste state before async clipboard work. Only clear cut sources after a confirmed paste write. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...issue-5274-http-copy_2026-08-24-15-00.json | 2 +- packages/vtable/src/event/event.ts | 310 +++++++++++------- packages/vtable/src/tools/style.ts | 4 +- 3 files changed, 193 insertions(+), 123 deletions(-) diff --git a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json index 49a8fe711d..2145611016 100644 --- a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json +++ b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@visactor/vtable", - "comment": "fix: support copy through event clipboard data when async clipboard is unavailable (GitHub #5274)", + "comment": "fix: support copy and paste through event clipboard data when the async Clipboard API is unavailable, and normalize pasted HTML cell content (GitHub #5274)", "type": "patch" } ], diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 913dc8649e..76f4618bdd 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -45,6 +45,22 @@ type PasteRange = { maxRow: number; }; +type ClipboardRange = { + start: { col: number; row: number }; + end: { col: number; row: number }; +}; + +type CopySnapshot = { + ranges: ClipboardRange[]; + copySourceRange: { startCol: number; startRow: number } | null; + cellInfos: CellInfo[][] | null; +}; + +type EventClipboardData = { + html: string; + text: string; +}; + export class EventManager { table: BaseTableAPI; // _col: number; @@ -776,23 +792,15 @@ export class EventManager { this._enableTableScroll = false; } - async handleCopy(e: KeyboardEvent, isCut: boolean = false) { + async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; !isCut && (this.cutWaitPaste = false); - this.copySourceRange = null; - // 记录复制时的源位置(用于公式相对引用调整) - const sourceRanges = table.stateManager.select.ranges; - if (sourceRanges && sourceRanges.length === 1) { - // 只有一个选区的时候才需要采取解析公式(和excel一致),才需要记录源位置 - const sourceRange = sourceRanges[0]; - this.copySourceRange = { - startCol: Math.min(sourceRange.start.col, sourceRange.end.col), - startRow: Math.min(sourceRange.start.row, sourceRange.end.row) - }; - } else if (!sourceRanges?.length) { + const copySnapshot = this.getCopySnapshot(isCut); + this.copySourceRange = copySnapshot?.copySourceRange ?? null; + if (!copySnapshot) { this.copySourceRange = null; // 没有选中区域,直接返回,不进行复制操作 - return; + return null; } const data = this.table.getCopyValue( @@ -800,18 +808,25 @@ export class EventManager { ); if (isValid(data)) { e.preventDefault(); + let copySucceeded = false; const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; - const dataHTML = this.getCopyDataHTML(data); - if (!canUseAsyncClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { - this.afterCopyData(data, isCut, !isCut); + const canWriteRichClipboard = window.isSecureContext && !!navigator.clipboard?.write && !!window.ClipboardItem; + const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; + const dataHTML = + !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; + if (hasEventClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { + if (isCut) { + this.lastClipboardContent = data; + } + this.afterCopyData(data, isCut, copySnapshot, !isCut); if (isCut && table.keyboardOptions?.showCopyCellBorder) { window.setTimeout(() => { - setActiveCellRangeState(table); + setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); }, 0); } - return; + return copySnapshot; } // 确保表格元素获得焦点,避免Document is not focused错误 @@ -845,57 +860,104 @@ export class EventManager { try { // 尝试使用 ClipboardItem(支持富文本) - if (window.ClipboardItem && dataHTML) { + if (canWriteRichClipboard && dataHTML) { await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([dataHTML], { type: 'text/html' }), 'text/plain': new Blob([data], { type: 'text/plain' }) }) ]); + copySucceeded = true; } else { // 降级到纯文本 await navigator.clipboard.writeText(data); + copySucceeded = true; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 - this.fallbackCopyToClipboard(data, e); + copySucceeded = this.fallbackCopyToClipboard(data); } } else { // 没有权限,使用降级方案 - this.fallbackCopyToClipboard(data, e); + copySucceeded = this.fallbackCopyToClipboard(data); } } else { // 不支持现代剪贴板API,使用降级方案 - this.fallbackCopyToClipboard(data, e); + copySucceeded = this.fallbackCopyToClipboard(data); } - this.afterCopyData(data, isCut, false); + if (!copySucceeded) { + return null; + } + + if (isCut) { + this.lastClipboardContent = data; + } + this.afterCopyData(data, isCut, copySnapshot, false); } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 - this.fallbackCopyToClipboard(data, e); + if (!this.fallbackCopyToClipboard(data)) { + return null; + } + if (isCut) { + this.lastClipboardContent = data; + } + this.afterCopyData(data, isCut, copySnapshot, false); } + } else { + return null; } if (table.keyboardOptions?.showCopyCellBorder) { - setActiveCellRangeState(table); + setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); } + return copySnapshot; } - private afterCopyData(data: string, isCut: boolean, updateCopyCellBorder: boolean = true): void { + private afterCopyData( + data: string, + isCut: boolean, + copySnapshot: CopySnapshot, + updateCopyCellBorder: boolean = true + ): void { const table = this.table; table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { - cellRange: table.stateManager.select.ranges, + cellRange: copySnapshot.ranges, copyData: data, isCut }); if (updateCopyCellBorder && table.keyboardOptions?.showCopyCellBorder) { - setActiveCellRangeState(table); + setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); } } + private getCopySnapshot(includeCellInfos: boolean): CopySnapshot | null { + const ranges = this.table.stateManager.select.ranges; + if (!ranges?.length) { + return null; + } + + const clonedRanges = ranges.map(range => ({ + start: { col: range.start.col, row: range.start.row }, + end: { col: range.end.col, row: range.end.row } + })); + const sourceRange = ranges.length === 1 ? ranges[0] : null; + + return { + ranges: clonedRanges, + copySourceRange: sourceRange + ? { + startCol: Math.min(sourceRange.start.col, sourceRange.end.col), + startRow: Math.min(sourceRange.start.row, sourceRange.end.row) + } + : null, + cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null + }; + } + private getCopyDataHTML(data: string): string | null { const table = this.table; let htmlValues = data; @@ -943,14 +1005,8 @@ export class EventManager { } // 降级复制方案 - private fallbackCopyToClipboard(data: string, e: KeyboardEvent): void { + private fallbackCopyToClipboard(data: string): boolean { try { - // 尝试使用旧的 clipboardData API (在事件处理函数中直接设置) - if ((e as any).clipboardData) { - (e as any).clipboardData.setData('text/plain', data); - return; - } - // 确保当前文档有焦点 if (document.activeElement && document.activeElement !== document.body) { (document.activeElement as HTMLElement).blur(); @@ -977,6 +1033,7 @@ export class EventManager { if (!successful) { console.warn('execCommand复制返回false,可能不被支持'); } + return successful; } catch (execError) { console.warn('execCommand复制失败:', execError); } finally { @@ -985,16 +1042,17 @@ export class EventManager { } catch (error) { console.error('降级复制方案失败:', error); } + return false; } async handleCut(e: KeyboardEvent) { - this.handleCopy(e, true); + const copySnapshot = await this.handleCopy(e, true); + if (!copySnapshot) { + return; + } this.cutWaitPaste = true; - this.cutCellRange = this.table.getSelectedCellInfos(); - this.cutRanges = this.table.stateManager.select.ranges?.map(r => ({ - start: { col: r.start.col, row: r.start.row }, - end: { col: r.end.col, row: r.end.row } - })); + this.cutCellRange = copySnapshot.cellInfos; + this.cutRanges = copySnapshot.ranges; // 设置自动超时,防止剪切状态无限期保持 if (this.clipboardCheckTimer) { clearTimeout(this.clipboardCheckTimer); @@ -1004,10 +1062,7 @@ export class EventManager { this.clipboardCheckTimer = window.setTimeout(() => { if (this.cutWaitPaste) { // 剪切操作超时,重置剪切状态 - this.cutWaitPaste = false; - this.cutCellRange = null; - this.cutRanges = null; - this.clipboardCheckTimer = null; + this.resetCutState(); } }, 30000); // 30秒超时 @@ -1021,51 +1076,37 @@ export class EventManager { if (!pasteRange) { return; } + const eventClipboardData = this.getEventClipboardData(e); if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.executePaste(e, pasteRange); + this.executePaste(pasteRange, eventClipboardData); return; } - this.checkClipboardChanged() - .then(changed => { + this.checkClipboardChanged(eventClipboardData) + .then(async changed => { // 执行粘贴操作,并根据剪贴板是否变化决定是否清空选中区域 - this.executePaste(e, pasteRange); - if (!changed) { + const pasted = await this.executePaste(pasteRange, eventClipboardData); + if (!changed && pasted) { this.clearCutArea(this.table as ListTableAPI); + this.resetCutState(); } - // 执行完粘贴操作后,重置剪切状态 - if (this.cutWaitPaste) { - this.cutWaitPaste = false; - this.cutCellRange = null; - this.cutRanges = null; - // 清除定时器 - if (this.clipboardCheckTimer) { - clearTimeout(this.clipboardCheckTimer); - this.clipboardCheckTimer = null; - } + if (changed) { + this.resetCutState(); } }) - .catch(() => { + .catch(async () => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 - this.executePaste(e, pasteRange); - // 执行完粘贴操作后,重置剪切状态 - if (this.cutWaitPaste) { - this.cutWaitPaste = false; - this.cutCellRange = null; - // 清除定时器 - if (this.clipboardCheckTimer) { - clearTimeout(this.clipboardCheckTimer); - this.clipboardCheckTimer = null; - } - } + await this.executePaste(pasteRange, eventClipboardData); + this.resetCutState(); }); } - private async executePaste(e: any, pasteRange: PasteRange) { + private async executePaste(pasteRange: PasteRange, eventClipboardData: EventClipboardData | null): Promise { const table = this.table; if ((table as ListTableAPI).editorManager?.editingEditor) { - return; + return false; } + let pasted = false; if ((table as ListTableAPI).changeCellValues) { try { // 优先使用现代剪贴板API @@ -1085,34 +1126,35 @@ export class EventManager { } } if (item.types.includes('text/plain')) { - await this.pasteTextToTable(item, pasteRange); - handled = true; + handled = await this.pasteTextToTable(item, pasteRange); break; } } if (!handled) { // 如果没有处理任何数据,使用降级方案 - await this.fallbackPasteFromClipboard(e, pasteRange); + handled = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } + pasted = handled; } catch (clipboardError) { console.warn('现代剪贴板API读取失败,使用降级方案:', clipboardError); // 降级到传统方法 - await this.fallbackPasteFromClipboard(e, pasteRange); + pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } } else { // 不支持现代剪贴板API,使用降级方案 - await this.fallbackPasteFromClipboard(e, pasteRange); + pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } } catch (error) { console.error('粘贴操作失败:', error); // 最后的降级方案 - await this.fallbackPasteFromClipboard(e, pasteRange); + pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } } if (table.keyboardOptions?.showCopyCellBorder) { clearActiveCellRangeState(table); } + return pasted; } private getPasteRange(): PasteRange | null { @@ -1131,7 +1173,22 @@ export class EventManager { } // 降级粘贴方案 - private async fallbackPasteFromClipboard(e: any, pasteRange: PasteRange): Promise { + private getEventClipboardData(e: KeyboardEvent): EventClipboardData | null { + const clipboardData = (e as unknown as ClipboardEvent).clipboardData || (window as any).clipboardData; + if (!clipboardData) { + return null; + } + return { + html: clipboardData.getData('text/html') || '', + text: clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text') || '' + }; + } + + // 降级粘贴方案 + private async fallbackPasteFromClipboard( + eventClipboardData: EventClipboardData | null, + pasteRange: PasteRange + ): Promise { const table = this.table; try { @@ -1143,38 +1200,32 @@ export class EventManager { await new Promise(resolve => setTimeout(resolve, 10)); } - // 尝试从事件对象获取剪贴板数据 - const clipboardData = e.clipboardData || (window as any).clipboardData || window.Clipboard; - - if (clipboardData) { - const htmlData = clipboardData.getData('text/html'); - if (htmlData) { + if (eventClipboardData) { + if (eventClipboardData.html) { try { - const pasted = await this.processPastedHTML(htmlData, pasteRange); + const pasted = await this.processPastedHTML(eventClipboardData.html, pasteRange); if (pasted) { - return; + return true; } } catch (error) { console.warn('降级粘贴HTML数据失败,尝试纯文本粘贴:', error); } } - const pastedData = - clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text'); - if (pastedData) { - await this.processPastedText(pastedData, pasteRange); - return; + if (eventClipboardData.text) { + return await this.processPastedText(eventClipboardData.text, pasteRange); } } } catch (error) { console.error('降级粘贴方案失败:', error); } + return false; } private decodeHTMLCellValue(cellHTML: string): string { const template = document.createElement('template'); - template.innerHTML = cellHTML.replace(/]*>(?:\r\n|\r|\n)?/gim, '\n'); - return (template.content.textContent ?? '').replace(/\u00a0/g, ' '); + template.innerHTML = cellHTML.replace(/]*>(?:\r\n|\r|\n)?/gim, '\n').replace(/ /gi, ' '); + return template.content.textContent ?? ''; } private async processPastedHTML(pastedData: string, pasteRange: PasteRange): Promise { @@ -1238,19 +1289,12 @@ export class EventManager { processedValues ? processedValues : values, true ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues ? processedValues : values, - changedCellResults - }); - } + this.firePastedDataEvent(col, row, processedValues ? processedValues : values, changedCellResults); return true; } // 处理粘贴的文本数据 - private async processPastedText(pastedData: string, pasteRange: PasteRange): Promise { + private async processPastedText(pastedData: string, pasteRange: PasteRange): Promise { const table = this.table; const { col, row } = pasteRange; const rows = pastedData.split('\n'); // 将数据拆分为行 @@ -1288,13 +1332,29 @@ export class EventManager { processedValues ? processedValues : values, true ); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { + this.firePastedDataEvent(col, row, processedValues ? processedValues : values, changedCellResults); + return true; + } + + private firePastedDataEvent( + col: number, + row: number, + pasteData: (string | number)[][], + changedCellResults: any + ): void { + const table = this.table; + if (!table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { + return; + } + try { table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { col, row, - pasteData: processedValues ? processedValues : values, + pasteData, changedCellResults }); + } catch (error) { + console.warn('PASTED_DATA listener failed:', error); } } // 清空选中区域的内容 @@ -1312,10 +1372,13 @@ export class EventManager { } // 检查剪贴板内容是否被其他应用更改 - private async checkClipboardChanged(): Promise { + private async checkClipboardChanged(eventClipboardData: EventClipboardData | null): Promise { // 如果不支持读取剪贴板,则无法检测变化 if (!navigator.clipboard || !navigator.clipboard.readText) { - return false; + if (eventClipboardData?.text) { + return eventClipboardData.text !== this.lastClipboardContent; + } + throw new Error('Clipboard readText is unavailable'); } try { @@ -1327,8 +1390,20 @@ export class EventManager { return currentContent !== this.lastClipboardContent; } catch (err) { console.warn('检查剪贴板状态失败:', err); - // 出错时假设剪贴板未变化 - return false; + throw err; + } + } + + private resetCutState(): void { + if (!this.cutWaitPaste) { + return; + } + this.cutWaitPaste = false; + this.cutCellRange = null; + this.cutRanges = null; + if (this.clipboardCheckTimer) { + clearTimeout(this.clipboardCheckTimer); + this.clipboardCheckTimer = null; } } @@ -1364,7 +1439,7 @@ export class EventManager { return false; } - private async pasteTextToTable(item: ClipboardItem, pasteRange: PasteRange) { + private async pasteTextToTable(item: ClipboardItem, pasteRange: PasteRange): Promise { const table = this.table; // 如果只有 'text/plain' const { col, row, maxCol, maxRow } = pasteRange; @@ -1387,18 +1462,13 @@ export class EventManager { const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, processedValues, true); - if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) { - table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, { - col, - row, - pasteData: processedValues, - changedCellResults - }); - } + this.firePastedDataEvent(col, row, processedValues, changedCellResults); + return true; } catch (error) { // 静默处理粘贴错误,保持原有行为 console.warn('Paste operation failed:', error); } + return false; } private parsePastedData(pastedData: string): (string | number)[][] { diff --git a/packages/vtable/src/tools/style.ts b/packages/vtable/src/tools/style.ts index 1ebaa64b43..4fa7e3da7f 100644 --- a/packages/vtable/src/tools/style.ts +++ b/packages/vtable/src/tools/style.ts @@ -39,8 +39,8 @@ export function isZeroStyle(style: number | number[]) { } // 设置复制区域的状态 类似excel的虚线框 -export function setActiveCellRangeState(table: BaseTableAPI) { - const selectRanges = table.stateManager.select.ranges; +export function setActiveCellRangeState(table: BaseTableAPI, ranges = table.stateManager.select.ranges) { + const selectRanges = ranges; const setRanges = []; for (let i = 0; i < selectRanges.length; i++) { const range = selectRanges[i]; From b43e85575d869bbad23a30af3836f03c07ebf561 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 25 Aug 2026 19:48:08 +0800 Subject: [PATCH 07/13] fix: harden clipboard paste transactions Bind clipboard paste to a single data snapshot and cut operation. Only clear source cells that were not overwritten by a successful paste. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...issue-5274-http-copy_2026-08-24-15-00.json | 2 +- packages/vtable/src/event/event.ts | 561 +++++++++++------- 2 files changed, 340 insertions(+), 223 deletions(-) diff --git a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json index 2145611016..5f13961b03 100644 --- a/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json +++ b/common/changes/@visactor/vtable/fix-issue-5274-http-copy_2026-08-24-15-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@visactor/vtable", - "comment": "fix: support copy and paste through event clipboard data when the async Clipboard API is unavailable, and normalize pasted HTML cell content (GitHub #5274)", + "comment": "fix: prefer event clipboard data for copy and cut, fall back to it for paste when the async Clipboard API is unavailable, preserve cut source cells until paste succeeds, and normalize pasted HTML cell content (GitHub #5274)", "type": "patch" } ], diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 76f4618bdd..dbbace2a4a 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -54,6 +54,8 @@ type CopySnapshot = { ranges: ClipboardRange[]; copySourceRange: { startCol: number; startRow: number } | null; cellInfos: CellInfo[][] | null; + sourceCells: SourceCell[]; + clipboardSignature: string; }; type EventClipboardData = { @@ -61,6 +63,33 @@ type EventClipboardData = { text: string; }; +type SourceCell = { + col: number; + row: number; + recordIndex?: number | number[]; + field?: any; +}; + +type CutState = { + id: number; + ranges: ClipboardRange[]; + cellInfos: CellInfo[][] | null; + sourceCells: SourceCell[]; + clipboardSignature: string; + copySourceRange: { startCol: number; startRow: number } | null; +}; + +type PasteContext = { + pasteRange: PasteRange; + copySourceRange: { startCol: number; startRow: number } | null; +}; + +type PasteWriteResult = { + pasted: boolean; + changedCellResults: boolean[][]; + changedCells: SourceCell[]; +}; + export class EventManager { table: BaseTableAPI; // _col: number; @@ -103,7 +132,9 @@ export class EventManager { /** 剪切后等待粘贴 */ cutWaitPaste: boolean = false; private clipboardCheckTimer: number | null = null; // 剪贴板检测定时器 - private cutOperationTime: number = 0; // 记录剪切操作的时间 + private cutOperationId: number = 0; + private clipboardOperationId: number = 0; + private activeCutState: CutState | null = null; lastClipboardContent: string = ''; // 最后一次复制/剪切的内容 cutCellRange: CellInfo[][] | null = null; cutRanges: CellRange[] | null = null; @@ -794,9 +825,12 @@ export class EventManager { async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; - !isCut && (this.cutWaitPaste = false); + const operationId = ++this.clipboardOperationId; + if (!isCut) { + this.resetCutState(); + } const copySnapshot = this.getCopySnapshot(isCut); - this.copySourceRange = copySnapshot?.copySourceRange ?? null; + this.copySourceRange = null; if (!copySnapshot) { this.copySourceRange = null; // 没有选中区域,直接返回,不进行复制操作 @@ -815,11 +849,17 @@ export class EventManager { const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; const dataHTML = !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; + const plainClipboardData = canWriteRichClipboard || hasEventClipboard ? data : this.getCopyFormulaPlainData(data); if (hasEventClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { if (isCut) { this.lastClipboardContent = data; + copySnapshot.clipboardSignature = this.getClipboardSignature({ html: dataHTML ?? '', text: data }); } + this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, !isCut); + if (operationId !== this.clipboardOperationId) { + return null; + } if (isCut && table.keyboardOptions?.showCopyCellBorder) { window.setTimeout(() => { setActiveCellRangeState(table, copySnapshot.ranges); @@ -870,7 +910,7 @@ export class EventManager { copySucceeded = true; } else { // 降级到纯文本 - await navigator.clipboard.writeText(data); + await navigator.clipboard.writeText(plainClipboardData); copySucceeded = true; } } catch (clipboardError) { @@ -892,9 +932,17 @@ export class EventManager { } if (isCut) { - this.lastClipboardContent = data; + this.lastClipboardContent = plainClipboardData; + copySnapshot.clipboardSignature = this.getClipboardSignature({ + html: canWriteRichClipboard ? dataHTML ?? '' : '', + text: plainClipboardData + }); } + this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); + if (operationId !== this.clipboardOperationId) { + return null; + } } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 @@ -903,8 +951,13 @@ export class EventManager { } if (isCut) { this.lastClipboardContent = data; + copySnapshot.clipboardSignature = this.getClipboardSignature({ html: '', text: data }); } + this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); + if (operationId !== this.clipboardOperationId) { + return null; + } } } else { return null; @@ -923,11 +976,15 @@ export class EventManager { updateCopyCellBorder: boolean = true ): void { const table = this.table; - table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { - cellRange: copySnapshot.ranges, - copyData: data, - isCut - }); + try { + table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, { + cellRange: this.cloneRanges(copySnapshot.ranges), + copyData: data, + isCut + }); + } catch (error) { + console.warn('COPY_DATA listener failed:', error); + } if (updateCopyCellBorder && table.keyboardOptions?.showCopyCellBorder) { setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); @@ -954,10 +1011,60 @@ export class EventManager { startRow: Math.min(sourceRange.start.row, sourceRange.end.row) } : null, - cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null + cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null, + sourceCells: this.getSourceCells(clonedRanges), + clipboardSignature: '' }; } + private cloneRanges(ranges: ClipboardRange[]): ClipboardRange[] { + return ranges.map(range => ({ + start: { col: range.start.col, row: range.start.row }, + end: { col: range.end.col, row: range.end.row } + })); + } + + private getSourceCells(ranges: ClipboardRange[]): SourceCell[] { + const table = this.table as ListTableAPI; + const sourceCells: SourceCell[] = []; + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i]; + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + for (let row = startRow; row <= endRow; row++) { + for (let col = startCol; col <= endCol; col++) { + const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); + const recordIndex = + recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; + const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; + sourceCells.push({ col, row, recordIndex, field }); + } + } + } + return sourceCells; + } + + private getClipboardSignature(data: EventClipboardData | null): string { + return data ? `${data.html || ''}\n---vtable-clipboard---\n${data.text || ''}` : ''; + } + + private getCopyFormulaPlainData(data: string): string { + const table = this.table; + if (table.stateManager.select.ranges.length !== 1 || !table.options.keyboardOptions?.getCopyCellValue?.html) { + return data; + } + try { + return this.table.getCopyValue( + table.options.keyboardOptions.getCopyCellValue.html as (col: number, row: number) => string | number + ); + } catch (error) { + console.warn('复制公式纯文本数据生成失败,使用显示值:', error); + return data; + } + } + private getCopyDataHTML(data: string): string | null { const table = this.table; let htmlValues = data; @@ -1050,9 +1157,18 @@ export class EventManager { if (!copySnapshot) { return; } + const cutState: CutState = { + id: ++this.cutOperationId, + ranges: this.cloneRanges(copySnapshot.ranges), + cellInfos: copySnapshot.cellInfos, + sourceCells: copySnapshot.sourceCells, + clipboardSignature: copySnapshot.clipboardSignature, + copySourceRange: copySnapshot.copySourceRange + }; this.cutWaitPaste = true; - this.cutCellRange = copySnapshot.cellInfos; - this.cutRanges = copySnapshot.ranges; + this.activeCutState = cutState; + this.cutCellRange = cutState.cellInfos; + this.cutRanges = cutState.ranges; // 设置自动超时,防止剪切状态无限期保持 if (this.clipboardCheckTimer) { clearTimeout(this.clipboardCheckTimer); @@ -1066,8 +1182,7 @@ export class EventManager { } }, 30000); // 30秒超时 - // 保存剪贴板内容以便后续检测变化 - this.saveClipboardContent(); + this.lastClipboardContent = cutState.clipboardSignature; } // 执行实际的粘贴操作 @@ -1077,84 +1192,88 @@ export class EventManager { return; } const eventClipboardData = this.getEventClipboardData(e); + const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.executePaste(pasteRange, eventClipboardData); + this.readClipboardDataForPaste(eventClipboardData).then(clipboardData => { + this.executePaste(clipboardData, { + pasteRange, + copySourceRange: pasteCopySourceRange + }); + }); return; } - this.checkClipboardChanged(eventClipboardData) - .then(async changed => { - // 执行粘贴操作,并根据剪贴板是否变化决定是否清空选中区域 - const pasted = await this.executePaste(pasteRange, eventClipboardData); - if (!changed && pasted) { - this.clearCutArea(this.table as ListTableAPI); - this.resetCutState(); + const cutState = this.activeCutState; + if (!cutState) { + this.executePaste(eventClipboardData, { pasteRange, copySourceRange: pasteCopySourceRange }); + return; + } + + this.readClipboardDataForPaste(eventClipboardData) + .then(async clipboardData => { + const changed = this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; + const pasteResult = await this.executePaste(clipboardData, { + pasteRange, + copySourceRange: changed ? null : cutState.copySourceRange + }); + if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { + await this.clearCutArea(cutState, pasteResult.changedCells); } - if (changed) { + if (this.activeCutState?.id === cutState.id) { this.resetCutState(); } }) .catch(async () => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 - await this.executePaste(pasteRange, eventClipboardData); - this.resetCutState(); + await this.executePaste(eventClipboardData, { + pasteRange, + copySourceRange: pasteCopySourceRange + }); + if (this.activeCutState?.id === cutState.id) { + this.resetCutState(); + } }); } - private async executePaste(pasteRange: PasteRange, eventClipboardData: EventClipboardData | null): Promise { + private async executePaste( + clipboardData: EventClipboardData | null, + pasteContext: PasteContext + ): Promise { const table = this.table; + const emptyResult = this.getEmptyPasteResult(); if ((table as ListTableAPI).editorManager?.editingEditor) { - return false; + return emptyResult; } - let pasted = false; - if ((table as ListTableAPI).changeCellValues) { - try { - // 优先使用现代剪贴板API - if (navigator.clipboard && navigator.clipboard.read) { - try { - // 读取剪切板数据 - const clipboardItems = await navigator.clipboard.read(); - let handled = false; - - for (const item of clipboardItems) { - // 优先处理 html 格式数据 - if (item.types.includes('text/html')) { - const pasted = await this.pasteHtmlToTable(item, pasteRange); - if (pasted) { - handled = true; - break; - } - } - if (item.types.includes('text/plain')) { - handled = await this.pasteTextToTable(item, pasteRange); - break; + let pasteResult = emptyResult; + try { + if ((table as ListTableAPI).changeCellValues) { + try { + if (clipboardData?.html) { + try { + const htmlResult = await this.processPastedHTML(clipboardData.html, pasteContext); + if (htmlResult.pasted) { + pasteResult = htmlResult; + return pasteResult; } + } catch (error) { + console.warn('粘贴HTML数据失败:', error); + return emptyResult; } - - if (!handled) { - // 如果没有处理任何数据,使用降级方案 - handled = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); - } - pasted = handled; - } catch (clipboardError) { - console.warn('现代剪贴板API读取失败,使用降级方案:', clipboardError); - // 降级到传统方法 - pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); } - } else { - // 不支持现代剪贴板API,使用降级方案 - pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); + if (clipboardData?.text) { + pasteResult = await this.processPastedText(clipboardData.text, pasteContext); + return pasteResult; + } + } catch (error) { + console.error('粘贴操作失败:', error); } - } catch (error) { - console.error('粘贴操作失败:', error); - // 最后的降级方案 - pasted = await this.fallbackPasteFromClipboard(eventClipboardData, pasteRange); + } + return pasteResult; + } finally { + if (table.keyboardOptions?.showCopyCellBorder) { + clearActiveCellRangeState(table); } } - if (table.keyboardOptions?.showCopyCellBorder) { - clearActiveCellRangeState(table); - } - return pasted; } private getPasteRange(): PasteRange | null { @@ -1178,48 +1297,51 @@ export class EventManager { if (!clipboardData) { return null; } - return { - html: clipboardData.getData('text/html') || '', - text: clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text') || '' - }; - } - - // 降级粘贴方案 - private async fallbackPasteFromClipboard( - eventClipboardData: EventClipboardData | null, - pasteRange: PasteRange - ): Promise { - const table = this.table; - try { - // 确保表格元素获得焦点 - const element = table.getElement(); - if (element && element !== document.activeElement) { - element.focus(); - // 短暂延迟,确保焦点设置完成 - await new Promise(resolve => setTimeout(resolve, 10)); - } + return { + html: clipboardData.getData('text/html') || '', + text: + clipboardData.getData('text/plain') || clipboardData.getData('text') || clipboardData.getData('Text') || '' + }; + } catch (error) { + console.warn('读取事件剪贴板数据失败:', error); + return null; + } + } - if (eventClipboardData) { - if (eventClipboardData.html) { - try { - const pasted = await this.processPastedHTML(eventClipboardData.html, pasteRange); - if (pasted) { - return true; - } - } catch (error) { - console.warn('降级粘贴HTML数据失败,尝试纯文本粘贴:', error); + private async readClipboardDataForPaste( + eventClipboardData: EventClipboardData | null + ): Promise { + if (eventClipboardData?.html || eventClipboardData?.text) { + return eventClipboardData; + } + if (navigator.clipboard?.read) { + try { + const clipboardItems = await navigator.clipboard.read(); + const clipboardData: EventClipboardData = { html: '', text: '' }; + for (const item of clipboardItems) { + if (!clipboardData.html && item.types.includes('text/html')) { + clipboardData.html = await (await item.getType('text/html')).text(); + } + if (!clipboardData.text && item.types.includes('text/plain')) { + clipboardData.text = await (await item.getType('text/plain')).text(); } } - - if (eventClipboardData.text) { - return await this.processPastedText(eventClipboardData.text, pasteRange); + if (clipboardData.html || clipboardData.text) { + return clipboardData; } + } catch (error) { + console.warn('现代剪贴板API读取失败,使用事件剪贴板数据:', error); } - } catch (error) { - console.error('降级粘贴方案失败:', error); } - return false; + if (navigator.clipboard?.readText) { + try { + return { html: '', text: await navigator.clipboard.readText() }; + } catch (error) { + console.warn('剪贴板纯文本读取失败,使用事件剪贴板数据:', error); + } + } + return eventClipboardData; } private decodeHTMLCellValue(cellHTML: string): string { @@ -1228,19 +1350,20 @@ export class EventManager { return template.content.textContent ?? ''; } - private async processPastedHTML(pastedData: string, pasteRange: PasteRange): Promise { + private async processPastedHTML(pastedData: string, pasteContext: PasteContext): Promise { // const regex = /]*>(.*?)<\/tr>/gs; // 匹配标签及其内容 const regex = /]*>([\s\S]*?)<\/tr>/gi; // for webpack3 // const cellRegex = /]*>(.*?)<\/td>/gs; // 匹配标签及其内容 const cellRegex = /]*>([\s\S]*?)<\/t[dh]>/gi; // for webpack3 const table = this.table; + const pasteRange = pasteContext.pasteRange; const { col, row, maxCol, maxRow } = pasteRange; let pasteValuesColCount = 0; let pasteValuesRowCount = 0; let values: (string | number)[][] = []; if (!pastedData || !/ { + private async processPastedText(pastedData: string, pasteContext: PasteContext): Promise { const table = this.table; + const pasteRange = pasteContext.pasteRange; const { col, row } = pasteRange; const rows = pastedData.split('\n'); // 将数据拆分为行 const values: (string | number)[][] = []; @@ -1314,26 +1439,27 @@ export class EventManager { }); let processedValues; // 检查是否支持公式处理(针对vtable-sheet) - if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) { + if (table.options.keyboardOptions?.processFormulaBeforePaste && pasteContext.copySourceRange) { // 利用复制时记录的源位置,对粘贴的数据进行公式处理 processedValues = table.options.keyboardOptions.processFormulaBeforePaste( values, - this.copySourceRange.startCol, - this.copySourceRange.startRow, + pasteContext.copySourceRange.startCol, + pasteContext.copySourceRange.startRow, col, row ); } + const valuesToPaste = processedValues ? processedValues : values; + const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); // 保持与 navigator.clipboard.read 中的操作一致 - const changedCellResults = await (table as ListTableAPI).changeCellValues( - col, - row, - processedValues ? processedValues : values, - true - ); - this.firePastedDataEvent(col, row, processedValues ? processedValues : values, changedCellResults); - return true; + const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + const changedCells = this.getChangedCells(targetCells, changedCellResults); + if (!changedCells.length) { + return this.getEmptyPasteResult(changedCellResults); + } + this.firePastedDataEvent(col, row, valuesToPaste, changedCellResults); + return { pasted: true, changedCellResults, changedCells }; } private firePastedDataEvent( @@ -1357,48 +1483,103 @@ export class EventManager { console.warn('PASTED_DATA listener failed:', error); } } + private getEmptyPasteResult(changedCellResults: boolean[][] = []): PasteWriteResult { + return { + pasted: false, + changedCellResults, + changedCells: [] + }; + } + + private getPasteTargetCells(pasteRange: PasteRange, values: (string | number)[][]): SourceCell[][] { + const targetCells: SourceCell[][] = []; + for (let rowIndex = 0; rowIndex < values.length; rowIndex++) { + targetCells[rowIndex] = []; + for (let colIndex = 0; colIndex < values[rowIndex].length; colIndex++) { + targetCells[rowIndex][colIndex] = this.getCellIdentity(pasteRange.col + colIndex, pasteRange.row + rowIndex); + } + } + return targetCells; + } + + private getChangedCells(targetCells: SourceCell[][], changedCellResults: boolean[][]): SourceCell[] { + const changedCells: SourceCell[] = []; + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + if (changedCellResults[rowIndex][colIndex] && targetCells[rowIndex]?.[colIndex]) { + changedCells.push(targetCells[rowIndex][colIndex]); + } + } + } + return changedCells; + } + + private getCellIdentity(col: number, row: number): SourceCell { + const table = this.table as ListTableAPI; + const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); + const recordIndex = recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; + const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; + return { col, row, recordIndex, field }; + } + // 清空选中区域的内容 - private clearCutArea(table: ListTableAPI): void { + private async clearCutArea(cutState: CutState, changedCells: SourceCell[]): Promise { try { - const ranges = this.cutRanges; - if (!ranges || ranges.length === 0) { - return; + const table = this.table as ListTableAPI; + const changedKeys = new Set(); + changedCells.forEach(cell => { + changedKeys.add(this.getCellKey(cell)); + changedKeys.add(this.getCoordKey(cell)); + }); + const ranges: CellRange[] = []; + for (let i = 0; i < cutState.sourceCells.length; i++) { + const sourceCell = cutState.sourceCells[i]; + if (changedKeys.has(this.getCellKey(sourceCell)) || changedKeys.has(this.getCoordKey(sourceCell))) { + continue; + } + const currentAddress = this.getCurrentCellAddress(sourceCell); + if (!currentAddress) { + continue; + } + ranges.push({ + start: { col: currentAddress.col, row: currentAddress.row }, + end: { col: currentAddress.col, row: currentAddress.row } + }); + } + if (ranges.length) { + await table.changeCellValuesByRanges(ranges, ''); } - - table.changeCellValuesByRanges(ranges, ''); } catch (error) { console.error('清空单元格内容失败', error); + throw error; } } - // 检查剪贴板内容是否被其他应用更改 - private async checkClipboardChanged(eventClipboardData: EventClipboardData | null): Promise { - // 如果不支持读取剪贴板,则无法检测变化 - if (!navigator.clipboard || !navigator.clipboard.readText) { - if (eventClipboardData?.text) { - return eventClipboardData.text !== this.lastClipboardContent; + private getCurrentCellAddress(sourceCell: SourceCell): { col: number; row: number } | null { + const table = this.table as ListTableAPI; + if (sourceCell.recordIndex !== undefined && sourceCell.field !== undefined && table.getCellAddrByFieldRecord) { + const address = table.getCellAddrByFieldRecord(sourceCell.field, sourceCell.recordIndex); + if (address) { + return address; } - throw new Error('Clipboard readText is unavailable'); } + return { col: sourceCell.col, row: sourceCell.row }; + } - try { - const currentContent = await navigator.clipboard.readText(); - // console.log('当前剪贴板内容:', currentContent); - // console.log('上次保存的剪贴板内容:', this.lastClipboardContent); - - // 比较当前剪贴板内容与剪切时保存的内容 - return currentContent !== this.lastClipboardContent; - } catch (err) { - console.warn('检查剪贴板状态失败:', err); - throw err; + private getCellKey(cell: SourceCell): string { + if (cell.recordIndex !== undefined && cell.field !== undefined) { + return `record:${String(cell.recordIndex)}:${String(cell.field)}`; } + return this.getCoordKey(cell); + } + + private getCoordKey(cell: SourceCell): string { + return `coord:${cell.col}:${cell.row}`; } private resetCutState(): void { - if (!this.cutWaitPaste) { - return; - } this.cutWaitPaste = false; + this.activeCutState = null; this.cutCellRange = null; this.cutRanges = null; if (this.clipboardCheckTimer) { @@ -1407,70 +1588,6 @@ export class EventManager { } } - // 保存剪贴板内容 - private saveClipboardContent(): void { - // 尝试获取剪贴板内容 - if (navigator.clipboard && navigator.clipboard.readText) { - // 延迟一点以确保剪贴板内容已更新 - setTimeout(() => { - navigator.clipboard - .readText() - .then(text => { - this.lastClipboardContent = text; - console.log('已保存剪贴板状态'); - }) - .catch(err => { - console.warn('无法读取剪贴板内容:', err); - }); - }, 50); - } - } - private async pasteHtmlToTable(item: ClipboardItem, pasteRange: PasteRange): Promise { - try { - const blob = await item.getType('text/html'); - const pastedData = await blob.text(); - const pasted = await this.processPastedHTML(pastedData, pasteRange); - if (pasted) { - return true; - } - } catch (error) { - console.warn('Paste html operation failed:', error); - } - return false; - } - - private async pasteTextToTable(item: ClipboardItem, pasteRange: PasteRange): Promise { - const table = this.table; - // 如果只有 'text/plain' - const { col, row, maxCol, maxRow } = pasteRange; - - try { - const blob = await item.getType('text/plain'); - const pastedData = await blob.text(); - const values = this.parsePastedData(pastedData); - - const pasteValuesRowCount = values.length; - const pasteValuesColCount = Math.max(...values.map(row => row.length), 0); - - const processedValues = this.handlePasteValues( - values, - pasteValuesRowCount, - pasteValuesColCount, - maxRow - row + 1, - maxCol - col + 1 - ); - - const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, processedValues, true); - - this.firePastedDataEvent(col, row, processedValues, changedCellResults); - return true; - } catch (error) { - // 静默处理粘贴错误,保持原有行为 - console.warn('Paste operation failed:', error); - } - return false; - } - private parsePastedData(pastedData: string): (string | number)[][] { const rows = pastedData.replace(/\r(?!\n)/g, '\r\n').split('\r\n'); // 文本中的换行符格式进行统一处理 const values: (string | number)[][] = []; From de2f2ba555558ad79ed58df1c982de91c481ac50 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 11:00:11 +0800 Subject: [PATCH 08/13] fix: guard cut cleanup Clear cut sources only after all copied source cells migrate. Co-authored-by: Claude Sonnet 4.6 --- packages/vtable/src/event/event.ts | 147 ++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 32 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index dbbace2a4a..c2c5e7ec8d 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -55,6 +55,8 @@ type CopySnapshot = { copySourceRange: { startCol: number; startRow: number } | null; cellInfos: CellInfo[][] | null; sourceCells: SourceCell[]; + sourceColCount: number; + sourceRowCount: number; clipboardSignature: string; }; @@ -68,6 +70,7 @@ type SourceCell = { row: number; recordIndex?: number | number[]; field?: any; + value?: any; }; type CutState = { @@ -75,6 +78,8 @@ type CutState = { ranges: ClipboardRange[]; cellInfos: CellInfo[][] | null; sourceCells: SourceCell[]; + sourceColCount: number; + sourceRowCount: number; clipboardSignature: string; copySourceRange: { startCol: number; startRow: number } | null; }; @@ -82,12 +87,14 @@ type CutState = { type PasteContext = { pasteRange: PasteRange; copySourceRange: { startCol: number; startRow: number } | null; + cutState?: CutState | null; }; type PasteWriteResult = { pasted: boolean; changedCellResults: boolean[][]; changedCells: SourceCell[]; + migratedSourceCells: SourceCell[]; }; export class EventManager { @@ -843,6 +850,7 @@ export class EventManager { if (isValid(data)) { e.preventDefault(); let copySucceeded = false; + let copiedClipboardData: EventClipboardData | null = null; const canUseAsyncClipboard = window.isSecureContext && !!navigator.clipboard?.writeText; const canWriteRichClipboard = window.isSecureContext && !!navigator.clipboard?.write && !!window.ClipboardItem; @@ -851,9 +859,10 @@ export class EventManager { !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; const plainClipboardData = canWriteRichClipboard || hasEventClipboard ? data : this.getCopyFormulaPlainData(data); if (hasEventClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { + copiedClipboardData = { html: dataHTML ?? '', text: data }; if (isCut) { - this.lastClipboardContent = data; - copySnapshot.clipboardSignature = this.getClipboardSignature({ html: dataHTML ?? '', text: data }); + this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, !isCut); @@ -908,23 +917,34 @@ export class EventManager { }) ]); copySucceeded = true; + copiedClipboardData = { html: dataHTML, text: data }; } else { // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); copySucceeded = true; + copiedClipboardData = { html: '', text: plainClipboardData }; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 copySucceeded = this.fallbackCopyToClipboard(data); + if (copySucceeded) { + copiedClipboardData = { html: '', text: data }; + } } } else { // 没有权限,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(data); + if (copySucceeded) { + copiedClipboardData = { html: '', text: data }; + } } } else { // 不支持现代剪贴板API,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(data); + if (copySucceeded) { + copiedClipboardData = { html: '', text: data }; + } } if (!copySucceeded) { @@ -932,11 +952,8 @@ export class EventManager { } if (isCut) { - this.lastClipboardContent = plainClipboardData; - copySnapshot.clipboardSignature = this.getClipboardSignature({ - html: canWriteRichClipboard ? dataHTML ?? '' : '', - text: plainClipboardData - }); + this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); @@ -1002,6 +1019,8 @@ export class EventManager { end: { col: range.end.col, row: range.end.row } })); const sourceRange = ranges.length === 1 ? ranges[0] : null; + const sourceCells = this.getSourceCells(clonedRanges); + const sourceSize = this.getSourceSize(clonedRanges, sourceCells.length); return { ranges: clonedRanges, @@ -1012,7 +1031,9 @@ export class EventManager { } : null, cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null, - sourceCells: this.getSourceCells(clonedRanges), + sourceCells, + sourceColCount: sourceSize.colCount, + sourceRowCount: sourceSize.rowCount, clipboardSignature: '' }; } @@ -1039,13 +1060,27 @@ export class EventManager { const recordIndex = recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; - sourceCells.push({ col, row, recordIndex, field }); + sourceCells.push({ col, row, recordIndex, field, value: table.getCellOriginValue?.(col, row) }); } } } return sourceCells; } + private getSourceSize(ranges: ClipboardRange[], sourceCellCount: number): { colCount: number; rowCount: number } { + if (ranges.length === 1) { + const range = ranges[0]; + return { + colCount: Math.abs(range.end.col - range.start.col) + 1, + rowCount: Math.abs(range.end.row - range.start.row) + 1 + }; + } + return { + colCount: sourceCellCount, + rowCount: 1 + }; + } + private getClipboardSignature(data: EventClipboardData | null): string { return data ? `${data.html || ''}\n---vtable-clipboard---\n${data.text || ''}` : ''; } @@ -1162,6 +1197,8 @@ export class EventManager { ranges: this.cloneRanges(copySnapshot.ranges), cellInfos: copySnapshot.cellInfos, sourceCells: copySnapshot.sourceCells, + sourceColCount: copySnapshot.sourceColCount, + sourceRowCount: copySnapshot.sourceRowCount, clipboardSignature: copySnapshot.clipboardSignature, copySourceRange: copySnapshot.copySourceRange }; @@ -1215,12 +1252,19 @@ export class EventManager { const changed = this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; const pasteResult = await this.executePaste(clipboardData, { pasteRange, - copySourceRange: changed ? null : cutState.copySourceRange + copySourceRange: changed ? null : cutState.copySourceRange, + cutState: changed ? null : cutState }); if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { - await this.clearCutArea(cutState, pasteResult.changedCells); + if (pasteResult.migratedSourceCells.length >= cutState.sourceCells.length) { + await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); + if (this.activeCutState?.id === cutState.id) { + this.resetCutState(); + } + } + return; } - if (this.activeCutState?.id === cutState.id) { + if (changed && this.activeCutState?.id === cutState.id) { this.resetCutState(); } }) @@ -1257,7 +1301,6 @@ export class EventManager { } } catch (error) { console.warn('粘贴HTML数据失败:', error); - return emptyResult; } } if (clipboardData?.text) { @@ -1410,11 +1453,12 @@ export class EventManager { const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); const changedCells = this.getChangedCells(targetCells, changedCellResults); + const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); if (!changedCells.length) { return this.getEmptyPasteResult(changedCellResults); } this.firePastedDataEvent(col, row, valuesToPaste, changedCellResults); - return { pasted: true, changedCellResults, changedCells }; + return { pasted: true, changedCellResults, changedCells, migratedSourceCells }; } // 处理粘贴的文本数据 @@ -1455,11 +1499,12 @@ export class EventManager { // 保持与 navigator.clipboard.read 中的操作一致 const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); const changedCells = this.getChangedCells(targetCells, changedCellResults); + const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); if (!changedCells.length) { return this.getEmptyPasteResult(changedCellResults); } this.firePastedDataEvent(col, row, valuesToPaste, changedCellResults); - return { pasted: true, changedCellResults, changedCells }; + return { pasted: true, changedCellResults, changedCells, migratedSourceCells }; } private firePastedDataEvent( @@ -1487,7 +1532,8 @@ export class EventManager { return { pasted: false, changedCellResults, - changedCells: [] + changedCells: [], + migratedSourceCells: [] }; } @@ -1514,6 +1560,35 @@ export class EventManager { return changedCells; } + private getMigratedSourceCells(cutState: CutState | null | undefined, changedCellResults: boolean[][]): SourceCell[] { + if (!cutState?.sourceCells.length || !cutState.sourceColCount || !cutState.sourceRowCount) { + return []; + } + const migratedSourceCells: SourceCell[] = []; + const migratedKeys = new Set(); + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + if (!changedCellResults[rowIndex][colIndex]) { + continue; + } + const sourceRow = rowIndex % cutState.sourceRowCount; + const sourceCol = colIndex % cutState.sourceColCount; + const sourceIndex = sourceRow * cutState.sourceColCount + sourceCol; + const sourceCell = cutState.sourceCells[sourceIndex]; + if (!sourceCell) { + continue; + } + const sourceKey = this.getCellKey(sourceCell); + if (migratedKeys.has(sourceKey)) { + continue; + } + migratedKeys.add(sourceKey); + migratedSourceCells.push(sourceCell); + } + } + return migratedSourceCells; + } + private getCellIdentity(col: number, row: number): SourceCell { const table = this.table as ListTableAPI; const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); @@ -1523,31 +1598,34 @@ export class EventManager { } // 清空选中区域的内容 - private async clearCutArea(cutState: CutState, changedCells: SourceCell[]): Promise { + private async clearCutArea(migratedSourceCells: SourceCell[], changedTargetCells: SourceCell[]): Promise { try { const table = this.table as ListTableAPI; - const changedKeys = new Set(); - changedCells.forEach(cell => { - changedKeys.add(this.getCellKey(cell)); - changedKeys.add(this.getCoordKey(cell)); + const changedTargetKeys = new Set(); + changedTargetCells.forEach(cell => { + changedTargetKeys.add(this.getCellKey(cell)); + changedTargetKeys.add(this.getCoordKey(cell)); }); - const ranges: CellRange[] = []; - for (let i = 0; i < cutState.sourceCells.length; i++) { - const sourceCell = cutState.sourceCells[i]; - if (changedKeys.has(this.getCellKey(sourceCell)) || changedKeys.has(this.getCoordKey(sourceCell))) { + const clearedKeys = new Set(); + for (let i = 0; i < migratedSourceCells.length; i++) { + const sourceCell = migratedSourceCells[i]; + const sourceKey = this.getCellKey(sourceCell); + if ( + clearedKeys.has(sourceKey) || + changedTargetKeys.has(sourceKey) || + changedTargetKeys.has(this.getCoordKey(sourceCell)) + ) { continue; } const currentAddress = this.getCurrentCellAddress(sourceCell); if (!currentAddress) { continue; } - ranges.push({ - start: { col: currentAddress.col, row: currentAddress.row }, - end: { col: currentAddress.col, row: currentAddress.row } - }); - } - if (ranges.length) { - await table.changeCellValuesByRanges(ranges, ''); + if (!this.isSourceCellUnchanged(sourceCell, currentAddress)) { + continue; + } + await table.changeCellValues(currentAddress.col, currentAddress.row, [['']], false); + clearedKeys.add(sourceKey); } } catch (error) { console.error('清空单元格内容失败', error); @@ -1555,6 +1633,11 @@ export class EventManager { } } + private isSourceCellUnchanged(sourceCell: SourceCell, currentAddress: { col: number; row: number }): boolean { + const table = this.table as ListTableAPI; + return Object.is(table.getCellOriginValue?.(currentAddress.col, currentAddress.row), sourceCell.value); + } + private getCurrentCellAddress(sourceCell: SourceCell): { col: number; row: number } | null { const table = this.table as ListTableAPI; if (sourceCell.recordIndex !== undefined && sourceCell.field !== undefined && table.getCellAddrByFieldRecord) { From 6518849cbfd68bd8d0cce0ecb030f0c58fecb0b6 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 12:09:06 +0800 Subject: [PATCH 09/13] fix: harden clipboard transaction guards Guard async clipboard tasks and preserve failed cut transactions. Co-authored-by: Claude Sonnet 4.6 --- packages/vtable/src/event/event.ts | 334 +++++++++++++++++++++++------ 1 file changed, 266 insertions(+), 68 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index c2c5e7ec8d..4b03ad1be4 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -57,12 +57,16 @@ type CopySnapshot = { sourceCells: SourceCell[]; sourceColCount: number; sourceRowCount: number; + clipboardData: EventClipboardData | null; clipboardSignature: string; }; type EventClipboardData = { html: string; text: string; + hasHtml?: boolean; + hasText?: boolean; + readFailed?: boolean; }; type SourceCell = { @@ -80,14 +84,17 @@ type CutState = { sourceCells: SourceCell[]; sourceColCount: number; sourceRowCount: number; + clipboardData: EventClipboardData | null; clipboardSignature: string; copySourceRange: { startCol: number; startRow: number } | null; + isSingleRange: boolean; }; type PasteContext = { pasteRange: PasteRange; copySourceRange: { startCol: number; startRow: number } | null; cutState?: CutState | null; + pasteOperationId?: number; }; type PasteWriteResult = { @@ -141,6 +148,7 @@ export class EventManager { private clipboardCheckTimer: number | null = null; // 剪贴板检测定时器 private cutOperationId: number = 0; private clipboardOperationId: number = 0; + private pasteOperationId: number = 0; private activeCutState: CutState | null = null; lastClipboardContent: string = ''; // 最后一次复制/剪切的内容 cutCellRange: CellInfo[][] | null = null; @@ -805,6 +813,9 @@ export class EventManager { } /** TODO 其他的事件并么有做remove */ release() { + this.clipboardOperationId++; + this.pasteOperationId++; + this.resetCutState(); this.gesture?.release(); // remove global event listerner @@ -858,19 +869,24 @@ export class EventManager { const dataHTML = !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; const plainClipboardData = canWriteRichClipboard || hasEventClipboard ? data : this.getCopyFormulaPlainData(data); - if (hasEventClipboard && this.setCopyDataToEventClipboard(data, dataHTML, e)) { - copiedClipboardData = { html: dataHTML ?? '', text: data }; + const eventClipboardWriteData = hasEventClipboard ? this.setCopyDataToEventClipboard(data, dataHTML, e) : null; + if (eventClipboardWriteData) { + copiedClipboardData = eventClipboardWriteData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardData = copiedClipboardData; copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, !isCut); - if (operationId !== this.clipboardOperationId) { + if (!this.isClipboardOperationCurrent(operationId)) { return null; } if (isCut && table.keyboardOptions?.showCopyCellBorder) { window.setTimeout(() => { + if (!this.isClipboardOperationCurrent(operationId)) { + return; + } setActiveCellRangeState(table, copySnapshot.ranges); table.clearSelected(); }, 0); @@ -884,6 +900,9 @@ export class EventManager { element.focus(); // 短暂延迟,确保焦点设置完成 await new Promise(resolve => setTimeout(resolve, 10)); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } } try { @@ -897,6 +916,9 @@ export class EventManager { name: 'clipboard-write' as PermissionName }); hasPermission = permissionState.state === 'granted'; + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } } catch (permissionError) { // 权限查询失败,继续尝试写入 console.warn('无法查询剪贴板权限:', permissionError); @@ -908,6 +930,9 @@ export class EventManager { // 将复制的数据转为html格式 try { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } // 尝试使用 ClipboardItem(支持富文本) if (canWriteRichClipboard && dataHTML) { await navigator.clipboard.write([ @@ -916,34 +941,40 @@ export class EventManager { 'text/plain': new Blob([data], { type: 'text/plain' }) }) ]); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } copySucceeded = true; - copiedClipboardData = { html: dataHTML, text: data }; + copiedClipboardData = { html: dataHTML, text: data, hasHtml: true, hasText: true }; } else { // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } copySucceeded = true; - copiedClipboardData = { html: '', text: plainClipboardData }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 copySucceeded = this.fallbackCopyToClipboard(data); if (copySucceeded) { - copiedClipboardData = { html: '', text: data }; + copiedClipboardData = { html: '', text: data, hasText: true }; } } } else { // 没有权限,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(data); if (copySucceeded) { - copiedClipboardData = { html: '', text: data }; + copiedClipboardData = { html: '', text: data, hasText: true }; } } } else { // 不支持现代剪贴板API,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(data); if (copySucceeded) { - copiedClipboardData = { html: '', text: data }; + copiedClipboardData = { html: '', text: data, hasText: true }; } } @@ -953,11 +984,12 @@ export class EventManager { if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); + copySnapshot.clipboardData = copiedClipboardData; copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); - if (operationId !== this.clipboardOperationId) { + if (!this.isClipboardOperationCurrent(operationId)) { return null; } } catch (error) { @@ -967,12 +999,13 @@ export class EventManager { return null; } if (isCut) { - this.lastClipboardContent = data; - copySnapshot.clipboardSignature = this.getClipboardSignature({ html: '', text: data }); + this.lastClipboardContent = this.getClipboardSignature({ html: '', text: data, hasText: true }); + copySnapshot.clipboardData = { html: '', text: data, hasText: true }; + copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); - if (operationId !== this.clipboardOperationId) { + if (!this.isClipboardOperationCurrent(operationId)) { return null; } } @@ -1034,6 +1067,7 @@ export class EventManager { sourceCells, sourceColCount: sourceSize.colCount, sourceRowCount: sourceSize.rowCount, + clipboardData: null, clipboardSignature: '' }; } @@ -1082,7 +1116,44 @@ export class EventManager { } private getClipboardSignature(data: EventClipboardData | null): string { - return data ? `${data.html || ''}\n---vtable-clipboard---\n${data.text || ''}` : ''; + return data + ? JSON.stringify({ + html: data.hasHtml ? data.html : null, + text: data.hasText ? data.text : null + }) + : ''; + } + + private isClipboardOperationCurrent(operationId: number): boolean { + return operationId === this.clipboardOperationId && !this.table.isReleased; + } + + private isPasteOperationCurrent(operationId: number | undefined): boolean { + return (operationId === undefined || operationId === this.pasteOperationId) && !this.table.isReleased; + } + + private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { + if (!clipboardData || clipboardData.readFailed) { + return null; + } + const sourceData = cutState.clipboardData; + if (!sourceData) { + return this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; + } + let hasComparableType = false; + if (clipboardData.hasHtml && sourceData.hasHtml) { + hasComparableType = true; + if (clipboardData.html !== sourceData.html) { + return true; + } + } + if (clipboardData.hasText && sourceData.hasText) { + hasComparableType = true; + if (clipboardData.text !== sourceData.text) { + return true; + } + } + return hasComparableType ? false : null; } private getCopyFormulaPlainData(data: string): string { @@ -1124,25 +1195,32 @@ export class EventManager { } } - private setCopyDataToEventClipboard(data: string, dataHTML: string | null, e: KeyboardEvent): boolean { + private setCopyDataToEventClipboard( + data: string, + dataHTML: string | null, + e: KeyboardEvent + ): EventClipboardData | null { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { - return false; + return null; } try { clipboardData.setData('text/plain', data); + const copiedClipboardData: EventClipboardData = { html: '', text: data, hasText: true }; if (dataHTML) { try { clipboardData.setData('text/html', dataHTML); + copiedClipboardData.html = dataHTML; + copiedClipboardData.hasHtml = true; } catch (error) { console.warn('事件剪贴板HTML写入失败,保留纯文本数据:', error); } } - return true; + return copiedClipboardData; } catch (error) { console.warn('事件剪贴板写入失败,使用降级方案:', error); - return false; + return null; } } @@ -1199,8 +1277,10 @@ export class EventManager { sourceCells: copySnapshot.sourceCells, sourceColCount: copySnapshot.sourceColCount, sourceRowCount: copySnapshot.sourceRowCount, + clipboardData: copySnapshot.clipboardData, clipboardSignature: copySnapshot.clipboardSignature, - copySourceRange: copySnapshot.copySourceRange + copySourceRange: copySnapshot.copySourceRange, + isSingleRange: copySnapshot.ranges.length === 1 }; this.cutWaitPaste = true; this.activeCutState = cutState; @@ -1228,36 +1308,62 @@ export class EventManager { if (!pasteRange) { return; } + const pasteOperationId = ++this.pasteOperationId; const eventClipboardData = this.getEventClipboardData(e); const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { // 非剪切状态,直接粘贴 - this.readClipboardDataForPaste(eventClipboardData).then(clipboardData => { - this.executePaste(clipboardData, { - pasteRange, - copySourceRange: pasteCopySourceRange + this.readClipboardDataForPaste(eventClipboardData) + .then(clipboardData => { + if (!this.isPasteOperationCurrent(pasteOperationId)) { + return; + } + this.executePaste(clipboardData, { + pasteRange, + copySourceRange: pasteCopySourceRange, + pasteOperationId + }); + }) + .catch(error => { + console.warn('读取剪贴板数据失败:', error); }); - }); return; } const cutState = this.activeCutState; if (!cutState) { - this.executePaste(eventClipboardData, { pasteRange, copySourceRange: pasteCopySourceRange }); + this.executePaste(eventClipboardData, { pasteRange, copySourceRange: pasteCopySourceRange, pasteOperationId }); return; } this.readClipboardDataForPaste(eventClipboardData) .then(async clipboardData => { - const changed = this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; + if (!this.isPasteOperationCurrent(pasteOperationId) || this.activeCutState?.id !== cutState.id) { + return; + } + const changed = this.isClipboardDataChanged(clipboardData, cutState); + if (changed === null) { + return; + } const pasteResult = await this.executePaste(clipboardData, { pasteRange, copySourceRange: changed ? null : cutState.copySourceRange, - cutState: changed ? null : cutState + cutState: changed ? null : cutState, + pasteOperationId }); + if (!this.isPasteOperationCurrent(pasteOperationId) || this.activeCutState?.id !== cutState.id) { + return; + } if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { - if (pasteResult.migratedSourceCells.length >= cutState.sourceCells.length) { - await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); + if (!cutState.isSingleRange) { + this.resetCutState(); + return; + } + if (pasteResult.migratedSourceCells.length === cutState.sourceCells.length) { + const cleared = await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); + if (!cleared) { + return; + } if (this.activeCutState?.id === cutState.id) { this.resetCutState(); } @@ -1270,13 +1376,14 @@ export class EventManager { }) .catch(async () => { // 如果无法检测剪贴板变化(例如权限问题),则保守地执行粘贴但不清空选中区域 + if (!this.isPasteOperationCurrent(pasteOperationId)) { + return; + } await this.executePaste(eventClipboardData, { pasteRange, - copySourceRange: pasteCopySourceRange + copySourceRange: pasteCopySourceRange, + pasteOperationId }); - if (this.activeCutState?.id === cutState.id) { - this.resetCutState(); - } }); } private async executePaste( @@ -1285,7 +1392,10 @@ export class EventManager { ): Promise { const table = this.table; const emptyResult = this.getEmptyPasteResult(); - if ((table as ListTableAPI).editorManager?.editingEditor) { + if ( + !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || + (table as ListTableAPI).editorManager?.editingEditor + ) { return emptyResult; } let pasteResult = emptyResult; @@ -1301,9 +1411,12 @@ export class EventManager { } } catch (error) { console.warn('粘贴HTML数据失败:', error); + if (/ { - if (eventClipboardData?.html || eventClipboardData?.text) { + if (eventClipboardData?.readFailed || eventClipboardData?.hasHtml || eventClipboardData?.hasText) { return eventClipboardData; } if (navigator.clipboard?.read) { @@ -1364,13 +1484,23 @@ export class EventManager { const clipboardData: EventClipboardData = { html: '', text: '' }; for (const item of clipboardItems) { if (!clipboardData.html && item.types.includes('text/html')) { - clipboardData.html = await (await item.getType('text/html')).text(); + try { + clipboardData.html = await (await item.getType('text/html')).text(); + clipboardData.hasHtml = true; + } catch (error) { + console.warn('读取HTML剪贴板数据失败:', error); + } } if (!clipboardData.text && item.types.includes('text/plain')) { - clipboardData.text = await (await item.getType('text/plain')).text(); + try { + clipboardData.text = await (await item.getType('text/plain')).text(); + clipboardData.hasText = true; + } catch (error) { + console.warn('读取纯文本剪贴板数据失败:', error); + } } } - if (clipboardData.html || clipboardData.text) { + if (clipboardData.hasHtml || clipboardData.hasText) { return clipboardData; } } catch (error) { @@ -1379,12 +1509,12 @@ export class EventManager { } if (navigator.clipboard?.readText) { try { - return { html: '', text: await navigator.clipboard.readText() }; + return { html: '', text: await navigator.clipboard.readText(), hasText: true }; } catch (error) { console.warn('剪贴板纯文本读取失败,使用事件剪贴板数据:', error); } } - return eventClipboardData; + return eventClipboardData ?? { html: '', text: '', readFailed: true }; } private decodeHTMLCellValue(cellHTML: string): string { @@ -1451,7 +1581,11 @@ export class EventManager { const valuesToPaste = processedValues ? processedValues : values; const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); - const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); const changedCells = this.getChangedCells(targetCells, changedCellResults); const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); if (!changedCells.length) { @@ -1465,22 +1599,20 @@ export class EventManager { private async processPastedText(pastedData: string, pasteContext: PasteContext): Promise { const table = this.table; const pasteRange = pasteContext.pasteRange; - const { col, row } = pasteRange; - const rows = pastedData.split('\n'); // 将数据拆分为行 - const values: (string | number)[][] = []; - - rows.forEach(function (rowCells: any) { - const cells = rowCells.split('\t'); // 将行数据拆分为单元格 - const rowValues: (string | number)[] = []; - values.push(rowValues); - cells.forEach(function (cell: string, cellIndex: number) { - // 去掉单元格数据末尾的 '\r' - if (cellIndex === cells.length - 1) { - cell = cell.trim(); - } - rowValues.push(cell); - }); - }); + const { col, row, maxCol, maxRow } = pasteRange; + let values = this.parsePlainTextPastedData(pastedData); + const pasteValuesRowCount = values.length; + const pasteValuesColCount = Math.max(...values.map(rowValues => rowValues.length)); + if (!pasteValuesRowCount || !pasteValuesColCount) { + return this.getEmptyPasteResult(); + } + values = this.handlePasteValues( + values, + pasteValuesRowCount, + pasteValuesColCount, + maxRow - row + 1, + maxCol - col + 1 + ); let processedValues; // 检查是否支持公式处理(针对vtable-sheet) if (table.options.keyboardOptions?.processFormulaBeforePaste && pasteContext.copySourceRange) { @@ -1497,7 +1629,11 @@ export class EventManager { const valuesToPaste = processedValues ? processedValues : values; const targetCells = this.getPasteTargetCells(pasteRange, valuesToPaste); // 保持与 navigator.clipboard.read 中的操作一致 - const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); const changedCells = this.getChangedCells(targetCells, changedCellResults); const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); if (!changedCells.length) { @@ -1560,8 +1696,35 @@ export class EventManager { return changedCells; } + private normalizeChangedCellResults( + changedCellResults: boolean[][] | void, + pasteRange: PasteRange, + values: (string | number)[][] + ): boolean[][] { + if (Array.isArray(changedCellResults)) { + return changedCellResults; + } + const table = this.table as any; + return values.map((rowValues, rowIndex) => + rowValues.map((_value, colIndex) => { + const targetCol = pasteRange.col + colIndex; + const targetRow = pasteRange.row + rowIndex; + return ( + targetCol < table.colCount && + targetRow < table.rowCount && + (!table.isHasEditorDefine || table.isHasEditorDefine(targetCol, targetRow)) + ); + }) + ); + } + private getMigratedSourceCells(cutState: CutState | null | undefined, changedCellResults: boolean[][]): SourceCell[] { - if (!cutState?.sourceCells.length || !cutState.sourceColCount || !cutState.sourceRowCount) { + if ( + !cutState?.isSingleRange || + !cutState.sourceCells.length || + !cutState.sourceColCount || + !cutState.sourceRowCount + ) { return []; } const migratedSourceCells: SourceCell[] = []; @@ -1598,7 +1761,7 @@ export class EventManager { } // 清空选中区域的内容 - private async clearCutArea(migratedSourceCells: SourceCell[], changedTargetCells: SourceCell[]): Promise { + private async clearCutArea(migratedSourceCells: SourceCell[], changedTargetCells: SourceCell[]): Promise { try { const table = this.table as ListTableAPI; const changedTargetKeys = new Set(); @@ -1607,6 +1770,8 @@ export class EventManager { changedTargetKeys.add(this.getCoordKey(cell)); }); const clearedKeys = new Set(); + const sourceChanges: { recordIndex: number | number[]; field: any; value: string }[] = []; + const coordChanges: { col: number; row: number }[] = []; for (let i = 0; i < migratedSourceCells.length; i++) { const sourceCell = migratedSourceCells[i]; const sourceKey = this.getCellKey(sourceCell); @@ -1624,12 +1789,33 @@ export class EventManager { if (!this.isSourceCellUnchanged(sourceCell, currentAddress)) { continue; } - await table.changeCellValues(currentAddress.col, currentAddress.row, [['']], false); + if ( + sourceCell.recordIndex !== undefined && + sourceCell.field !== undefined && + (table as any).changeCellValuesByRecords + ) { + sourceChanges.push({ recordIndex: sourceCell.recordIndex, field: sourceCell.field, value: '' }); + } else { + coordChanges.push(currentAddress); + } clearedKeys.add(sourceKey); } + if (sourceChanges.length) { + (table as any).changeCellValuesByRecords(sourceChanges, { + triggerEvent: true, + autoRefresh: false + }); + } + for (let i = 0; i < coordChanges.length; i++) { + await table.changeCellValues(coordChanges[i].col, coordChanges[i].row, [['']], false); + } + if (sourceChanges.length) { + (table as any).refreshAfterSourceChange?.(); + } + return true; } catch (error) { console.error('清空单元格内容失败', error); - throw error; + return false; } } @@ -1642,9 +1828,10 @@ export class EventManager { const table = this.table as ListTableAPI; if (sourceCell.recordIndex !== undefined && sourceCell.field !== undefined && table.getCellAddrByFieldRecord) { const address = table.getCellAddrByFieldRecord(sourceCell.field, sourceCell.recordIndex); - if (address) { + if (address && address.col >= 0 && address.row >= 0) { return address; } + return null; } return { col: sourceCell.col, row: sourceCell.row }; } @@ -1665,6 +1852,9 @@ export class EventManager { this.activeCutState = null; this.cutCellRange = null; this.cutRanges = null; + if (this.table.keyboardOptions?.showCopyCellBorder) { + clearActiveCellRangeState(this.table); + } if (this.clipboardCheckTimer) { clearTimeout(this.clipboardCheckTimer); this.clipboardCheckTimer = null; @@ -1689,6 +1879,14 @@ export class EventManager { return values; } + private parsePlainTextPastedData(pastedData: string): (string | number)[][] { + const rows = pastedData.replace(/\r\n|\r/g, '\n').split('\n'); + if (rows.length > 1 && rows[rows.length - 1] === '') { + rows.pop(); + } + return rows.map(rowCells => rowCells.split('\t')); + } + private processCellValue(cell: string): string | number { if (cell.includes('\n')) { cell = cell From 399b0955549dc252f74a557a4bccc9388bd2e61a Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 14:29:26 +0800 Subject: [PATCH 10/13] fix: harden clipboard transactions --- packages/vtable/src/event/event.ts | 246 ++++++++++++++++++++++++----- 1 file changed, 205 insertions(+), 41 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 4b03ad1be4..2ec91377e9 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -149,6 +149,10 @@ export class EventManager { private cutOperationId: number = 0; private clipboardOperationId: number = 0; private pasteOperationId: number = 0; + private pasteWriteInProgress: boolean = false; + private lastCopiedClipboardData: EventClipboardData | null = null; + private fieldKeyIds: WeakMap = new WeakMap(); + private fieldKeyId: number = 0; private activeCutState: CutState | null = null; lastClipboardContent: string = ''; // 最后一次复制/剪切的内容 cutCellRange: CellInfo[][] | null = null; @@ -844,11 +848,17 @@ export class EventManager { async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; const operationId = ++this.clipboardOperationId; + this.pasteOperationId++; + if (this.pasteWriteInProgress) { + e.preventDefault(); + return null; + } if (!isCut) { this.resetCutState(); } const copySnapshot = this.getCopySnapshot(isCut); this.copySourceRange = null; + this.lastCopiedClipboardData = null; if (!copySnapshot) { this.copySourceRange = null; // 没有选中区域,直接返回,不进行复制操作 @@ -868,10 +878,19 @@ export class EventManager { const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; const dataHTML = !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; - const plainClipboardData = canWriteRichClipboard || hasEventClipboard ? data : this.getCopyFormulaPlainData(data); - const eventClipboardWriteData = hasEventClipboard ? this.setCopyDataToEventClipboard(data, dataHTML, e) : null; + const plainClipboardData = this.getCopyFormulaPlainData(data, isCut); + if (plainClipboardData === null) { + return null; + } + const eventClipboardWriteData = hasEventClipboard + ? this.setCopyDataToEventClipboard(plainClipboardData, dataHTML, e) + : null; if (eventClipboardWriteData) { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } copiedClipboardData = eventClipboardWriteData; + this.lastCopiedClipboardData = copiedClipboardData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); copySnapshot.clipboardData = copiedClipboardData; @@ -879,9 +898,6 @@ export class EventManager { } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, !isCut); - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } if (isCut && table.keyboardOptions?.showCopyCellBorder) { window.setTimeout(() => { if (!this.isClipboardOperationCurrent(operationId)) { @@ -938,14 +954,14 @@ export class EventManager { await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([dataHTML], { type: 'text/html' }), - 'text/plain': new Blob([data], { type: 'text/plain' }) + 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) }) ]); if (!this.isClipboardOperationCurrent(operationId)) { return null; } copySucceeded = true; - copiedClipboardData = { html: dataHTML, text: data, hasHtml: true, hasText: true }; + copiedClipboardData = { html: dataHTML, text: plainClipboardData, hasHtml: true, hasText: true }; } else { // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); @@ -958,30 +974,40 @@ export class EventManager { } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); // 降级到传统方法 - copySucceeded = this.fallbackCopyToClipboard(data); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } if (copySucceeded) { - copiedClipboardData = { html: '', text: data, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } } else { // 没有权限,使用降级方案 - copySucceeded = this.fallbackCopyToClipboard(data); + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); if (copySucceeded) { - copiedClipboardData = { html: '', text: data, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } } else { // 不支持现代剪贴板API,使用降级方案 - copySucceeded = this.fallbackCopyToClipboard(data); + copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); if (copySucceeded) { - copiedClipboardData = { html: '', text: data, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } if (!copySucceeded) { return null; } + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + this.lastCopiedClipboardData = copiedClipboardData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); copySnapshot.clipboardData = copiedClipboardData; @@ -989,25 +1015,26 @@ export class EventManager { } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } } catch (error) { console.error('复制操作失败:', error); // 最后的降级方案 - if (!this.fallbackCopyToClipboard(data)) { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + if (!this.fallbackCopyToClipboard(plainClipboardData)) { + return null; + } + if (!this.isClipboardOperationCurrent(operationId)) { return null; } + this.lastCopiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; if (isCut) { - this.lastClipboardContent = this.getClipboardSignature({ html: '', text: data, hasText: true }); - copySnapshot.clipboardData = { html: '', text: data, hasText: true }; + this.lastClipboardContent = this.getClipboardSignature(this.lastCopiedClipboardData); + copySnapshot.clipboardData = this.lastCopiedClipboardData; copySnapshot.clipboardSignature = this.lastClipboardContent; } this.copySourceRange = copySnapshot.copySourceRange; this.afterCopyData(data, isCut, copySnapshot, false); - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } } } else { return null; @@ -1140,6 +1167,9 @@ export class EventManager { if (!sourceData) { return this.getClipboardSignature(clipboardData) !== cutState.clipboardSignature; } + if (sourceData.hasHtml && !clipboardData.hasHtml) { + return null; + } let hasComparableType = false; if (clipboardData.hasHtml && sourceData.hasHtml) { hasComparableType = true; @@ -1156,7 +1186,18 @@ export class EventManager { return hasComparableType ? false : null; } - private getCopyFormulaPlainData(data: string): string { + private isClipboardDataSameAsLastCopy(clipboardData: EventClipboardData | null): boolean { + const sourceData = this.lastCopiedClipboardData; + if (!clipboardData || clipboardData.readFailed || !sourceData) { + return false; + } + if (sourceData.hasHtml && !clipboardData.hasHtml) { + return false; + } + return this.getClipboardSignature(clipboardData) === this.getClipboardSignature(sourceData); + } + + private getCopyFormulaPlainData(data: string, isCut: boolean): string | null { const table = this.table; if (table.stateManager.select.ranges.length !== 1 || !table.options.keyboardOptions?.getCopyCellValue?.html) { return data; @@ -1167,7 +1208,7 @@ export class EventManager { ); } catch (error) { console.warn('复制公式纯文本数据生成失败,使用显示值:', error); - return data; + return isCut ? null : data; } } @@ -1304,11 +1345,15 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { + const pasteOperationId = ++this.pasteOperationId; + if (this.pasteWriteInProgress) { + e.preventDefault(); + return; + } const pasteRange = this.getPasteRange(); if (!pasteRange) { return; } - const pasteOperationId = ++this.pasteOperationId; const eventClipboardData = this.getEventClipboardData(e); const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { @@ -1318,9 +1363,10 @@ export class EventManager { if (!this.isPasteOperationCurrent(pasteOperationId)) { return; } + const copySourceRange = this.isClipboardDataSameAsLastCopy(clipboardData) ? pasteCopySourceRange : null; this.executePaste(clipboardData, { pasteRange, - copySourceRange: pasteCopySourceRange, + copySourceRange, pasteOperationId }); }) @@ -1332,7 +1378,11 @@ export class EventManager { const cutState = this.activeCutState; if (!cutState) { - this.executePaste(eventClipboardData, { pasteRange, copySourceRange: pasteCopySourceRange, pasteOperationId }); + this.executePaste(eventClipboardData, { + pasteRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(eventClipboardData) ? pasteCopySourceRange : null, + pasteOperationId + }); return; } @@ -1355,13 +1405,10 @@ export class EventManager { return; } if (!changed && pasteResult.pasted && this.activeCutState?.id === cutState.id) { - if (!cutState.isSingleRange) { - this.resetCutState(); - return; - } if (pasteResult.migratedSourceCells.length === cutState.sourceCells.length) { const cleared = await this.clearCutArea(pasteResult.migratedSourceCells, pasteResult.changedCells); if (!cleared) { + this.resetCutState(); return; } if (this.activeCutState?.id === cutState.id) { @@ -1381,7 +1428,7 @@ export class EventManager { } await this.executePaste(eventClipboardData, { pasteRange, - copySourceRange: pasteCopySourceRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(eventClipboardData) ? pasteCopySourceRange : null, pasteOperationId }); }); @@ -1393,12 +1440,14 @@ export class EventManager { const table = this.table; const emptyResult = this.getEmptyPasteResult(); if ( + this.pasteWriteInProgress || !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || (table as ListTableAPI).editorManager?.editingEditor ) { return emptyResult; } let pasteResult = emptyResult; + this.pasteWriteInProgress = true; try { if ((table as ListTableAPI).changeCellValues) { try { @@ -1422,10 +1471,14 @@ export class EventManager { } } catch (error) { console.error('粘贴操作失败:', error); + if (pasteContext.cutState) { + this.resetCutState(); + } } } return pasteResult; } finally { + this.pasteWriteInProgress = false; if (!pasteContext.cutState && table.keyboardOptions?.showCopyCellBorder) { clearActiveCellRangeState(table); } @@ -1584,7 +1637,24 @@ export class EventManager { if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { return this.getEmptyPasteResult(); } - const rawChangedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + if (pasteContext.cutState) { + const validatedCellResults = await this.validatePasteTargetCells(pasteRange, valuesToPaste); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + if (!this.isFullPasteAllowed(valuesToPaste, validatedCellResults)) { + return this.getEmptyPasteResult(validatedCellResults); + } + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues( + col, + row, + valuesToPaste, + !pasteContext.cutState + ); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); const changedCells = this.getChangedCells(targetCells, changedCellResults); const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); @@ -1632,7 +1702,24 @@ export class EventManager { if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { return this.getEmptyPasteResult(); } - const rawChangedCellResults = await (table as ListTableAPI).changeCellValues(col, row, valuesToPaste, true); + if (pasteContext.cutState) { + const validatedCellResults = await this.validatePasteTargetCells(pasteRange, valuesToPaste); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } + if (!this.isFullPasteAllowed(valuesToPaste, validatedCellResults)) { + return this.getEmptyPasteResult(validatedCellResults); + } + } + const rawChangedCellResults = await (table as ListTableAPI).changeCellValues( + col, + row, + valuesToPaste, + !pasteContext.cutState + ); + if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { + return this.getEmptyPasteResult(); + } const changedCellResults = this.normalizeChangedCellResults(rawChangedCellResults, pasteRange, valuesToPaste); const changedCells = this.getChangedCells(targetCells, changedCellResults); const migratedSourceCells = this.getMigratedSourceCells(pasteContext.cutState, changedCellResults); @@ -1718,17 +1805,72 @@ export class EventManager { ); } + private async validatePasteTargetCells(pasteRange: PasteRange, values: (string | number)[][]): Promise { + const table = this.table as ListTableAPI; + const changedCellResults: boolean[][] = []; + for (let rowIndex = 0; rowIndex < values.length; rowIndex++) { + changedCellResults[rowIndex] = []; + for (let colIndex = 0; colIndex < values[rowIndex].length; colIndex++) { + const targetCol = pasteRange.col + colIndex; + const targetRow = pasteRange.row + rowIndex; + const tableAny = table as any; + let canChange = + targetCol < tableAny.colCount && + targetRow < tableAny.rowCount && + (!tableAny.isHasEditorDefine || tableAny.isHasEditorDefine(targetCol, targetRow)); + if (canChange) { + const editor = tableAny.getEditor?.(targetCol, targetRow); + const oldValue = table.getCellOriginValue?.(targetCol, targetRow); + const validateResult = + editor?.validateValue?.(values[rowIndex][colIndex], oldValue, { col: targetCol, row: targetRow }, table) ?? + true; + const resolvedValidateResult = + validateResult && typeof (validateResult as Promise).then === 'function' + ? await validateResult + : validateResult; + canChange = + resolvedValidateResult === true || + resolvedValidateResult === 'validate-exit' || + resolvedValidateResult === 'validate-not-exit'; + } + changedCellResults[rowIndex][colIndex] = canChange; + } + } + return changedCellResults; + } + + private isFullPasteAllowed(values: (string | number)[][], changedCellResults: boolean[][]): boolean { + return values.every((rowValues, rowIndex) => + rowValues.every((_value, colIndex) => changedCellResults[rowIndex]?.[colIndex] === true) + ); + } + private getMigratedSourceCells(cutState: CutState | null | undefined, changedCellResults: boolean[][]): SourceCell[] { - if ( - !cutState?.isSingleRange || - !cutState.sourceCells.length || - !cutState.sourceColCount || - !cutState.sourceRowCount - ) { + if (!cutState?.sourceCells.length || !cutState.sourceColCount || !cutState.sourceRowCount) { return []; } const migratedSourceCells: SourceCell[] = []; const migratedKeys = new Set(); + if (!cutState.isSingleRange) { + let sourceIndex = 0; + for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { + for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { + const sourceCell = cutState.sourceCells[sourceIndex++]; + if (!changedCellResults[rowIndex][colIndex]) { + continue; + } + if (!sourceCell) { + return []; + } + const sourceKey = this.getCellKey(sourceCell); + if (!migratedKeys.has(sourceKey)) { + migratedKeys.add(sourceKey); + migratedSourceCells.push(sourceCell); + } + } + } + return migratedSourceCells.length === cutState.sourceCells.length ? migratedSourceCells : []; + } for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { if (!changedCellResults[rowIndex][colIndex]) { @@ -1838,7 +1980,7 @@ export class EventManager { private getCellKey(cell: SourceCell): string { if (cell.recordIndex !== undefined && cell.field !== undefined) { - return `record:${String(cell.recordIndex)}:${String(cell.field)}`; + return `record:${this.getStableKeyPart(cell.recordIndex)}:${this.getStableKeyPart(cell.field)}`; } return this.getCoordKey(cell); } @@ -1847,6 +1989,28 @@ export class EventManager { return `coord:${cell.col}:${cell.row}`; } + private getStableKeyPart(value: any): string { + if (value === null) { + return 'null'; + } + const valueType = typeof value; + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') { + return `${valueType}:${JSON.stringify(value)}`; + } + if (Array.isArray(value)) { + return `array:[${value.map(item => this.getStableKeyPart(item)).join(',')}]`; + } + if (valueType === 'object' || valueType === 'function') { + let id = this.fieldKeyIds.get(value); + if (id === undefined) { + id = ++this.fieldKeyId; + this.fieldKeyIds.set(value, id); + } + return `${valueType}:#${id}`; + } + return `${valueType}:${String(value)}`; + } + private resetCutState(): void { this.cutWaitPaste = false; this.activeCutState = null; From 9f7219626cb32a0c084f83a73177ecc4d710105e Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 15:05:39 +0800 Subject: [PATCH 11/13] fix: address clipboard review issues Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/event/event.ts | 194 +++++++++++++++++++++++------ 1 file changed, 156 insertions(+), 38 deletions(-) diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 2ec91377e9..609cd59295 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -55,6 +55,7 @@ type CopySnapshot = { copySourceRange: { startCol: number; startRow: number } | null; cellInfos: CellInfo[][] | null; sourceCells: SourceCell[]; + sourceCellMatrix: SourceCellMatrix; sourceColCount: number; sourceRowCount: number; clipboardData: EventClipboardData | null; @@ -77,11 +78,14 @@ type SourceCell = { value?: any; }; +type SourceCellMatrix = (SourceCell | null)[][]; + type CutState = { id: number; ranges: ClipboardRange[]; cellInfos: CellInfo[][] | null; sourceCells: SourceCell[]; + sourceCellMatrix: SourceCellMatrix; sourceColCount: number; sourceRowCount: number; clipboardData: EventClipboardData | null; @@ -150,6 +154,7 @@ export class EventManager { private clipboardOperationId: number = 0; private pasteOperationId: number = 0; private pasteWriteInProgress: boolean = false; + private clipboardWritePromise: Promise = Promise.resolve(); private lastCopiedClipboardData: EventClipboardData | null = null; private fieldKeyIds: WeakMap = new WeakMap(); private fieldKeyId: number = 0; @@ -847,12 +852,12 @@ export class EventManager { async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; - const operationId = ++this.clipboardOperationId; - this.pasteOperationId++; if (this.pasteWriteInProgress) { e.preventDefault(); return null; } + const operationId = ++this.clipboardOperationId; + this.pasteOperationId++; if (!isCut) { this.resetCutState(); } @@ -949,27 +954,26 @@ export class EventManager { if (!this.isClipboardOperationCurrent(operationId)) { return null; } - // 尝试使用 ClipboardItem(支持富文本) - if (canWriteRichClipboard && dataHTML) { - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([dataHTML], { type: 'text/html' }), - 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) - }) - ]); + copiedClipboardData = await this.queueClipboardWrite(async () => { if (!this.isClipboardOperationCurrent(operationId)) { return null; } - copySucceeded = true; - copiedClipboardData = { html: dataHTML, text: plainClipboardData, hasHtml: true, hasText: true }; - } else { + // 尝试使用 ClipboardItem(支持富文本) + if (canWriteRichClipboard && dataHTML) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([dataHTML], { type: 'text/html' }), + 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) + }) + ]); + return { html: dataHTML, text: plainClipboardData, hasHtml: true, hasText: true }; + } // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } + return { html: '', text: plainClipboardData, hasText: true }; + }); + if (copiedClipboardData) { copySucceeded = true; - copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; } } catch (clipboardError) { console.warn('剪贴板写入失败,使用降级方案:', clipboardError); @@ -1079,8 +1083,9 @@ export class EventManager { end: { col: range.end.col, row: range.end.row } })); const sourceRange = ranges.length === 1 ? ranges[0] : null; - const sourceCells = this.getSourceCells(clonedRanges); - const sourceSize = this.getSourceSize(clonedRanges, sourceCells.length); + const sourceCellMatrix = this.getSourceCellMatrix(clonedRanges); + const sourceCells = this.getSourceCells(sourceCellMatrix); + const sourceSize = this.getSourceSize(clonedRanges, sourceCellMatrix); return { ranges: clonedRanges, @@ -1092,6 +1097,7 @@ export class EventManager { : null, cellInfos: includeCellInfos ? this.table.getSelectedCellInfos() : null, sourceCells, + sourceCellMatrix, sourceColCount: sourceSize.colCount, sourceRowCount: sourceSize.rowCount, clipboardData: null, @@ -1106,29 +1112,101 @@ export class EventManager { })); } - private getSourceCells(ranges: ClipboardRange[]): SourceCell[] { + private getSourceCellMatrix(ranges: ClipboardRange[]): SourceCellMatrix { const table = this.table as ListTableAPI; - const sourceCells: SourceCell[] = []; - for (let i = 0; i < ranges.length; i++) { - const range = ranges[i]; + const createSourceCell = (col: number, row: number): SourceCell => { + const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); + const recordIndex = recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; + const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; + return { col, row, recordIndex, field, value: table.getCellOriginValue?.(col, row) }; + }; + if (ranges.length === 1) { + const range = ranges[0]; const startCol = Math.min(range.start.col, range.end.col); const endCol = Math.max(range.start.col, range.end.col); const startRow = Math.min(range.start.row, range.end.row); const endRow = Math.max(range.start.row, range.end.row); + const sourceCellMatrix: SourceCellMatrix = []; for (let row = startRow; row <= endRow; row++) { + const rowCells: (SourceCell | null)[] = []; for (let col = startCol; col <= endCol; col++) { - const recordShowIndex = table.getRecordShowIndexByCell?.(col, row); - const recordIndex = - recordShowIndex >= 0 ? (table as any).dataSource?.getIndexKey?.(recordShowIndex) : undefined; - const field = table.internalProps?.layoutMap?.getBody?.(col, row)?.field; - sourceCells.push({ col, row, recordIndex, field, value: table.getCellOriginValue?.(col, row) }); + rowCells.push(createSourceCell(col, row)); + } + sourceCellMatrix.push(rowCells); + } + return sourceCellMatrix; + } + + let minCol = Math.min(ranges[0].start.col, ranges[0].end.col); + let maxCol = Math.max(ranges[0].start.col, ranges[0].end.col); + let minRow = Math.min(ranges[0].start.row, ranges[0].end.row); + let maxRow = Math.max(ranges[0].start.row, ranges[0].end.row); + ranges.forEach(range => { + minCol = Math.min(minCol, range.start.col, range.end.col); + maxCol = Math.max(maxCol, range.start.col, range.end.col); + minRow = Math.min(minRow, range.start.row, range.end.row); + maxRow = Math.max(maxRow, range.start.row, range.end.row); + }); + const isExistDataInRow = (row: number) => + ranges.some(range => { + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + return startRow <= row && endRow >= row; + }); + const isExistDataInCol = (col: number) => + ranges.some(range => { + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + return startCol <= col && endCol >= col; + }); + const getRangeExistDataInCell = (col: number, row: number) => + ranges.some(range => { + const startRow = Math.min(range.start.row, range.end.row); + const endRow = Math.max(range.start.row, range.end.row); + const startCol = Math.min(range.start.col, range.end.col); + const endCol = Math.max(range.start.col, range.end.col); + return startCol <= col && endCol >= col && startRow <= row && endRow >= row; + }); + const sourceCellMatrix: SourceCellMatrix = []; + for (let row = minRow; row <= maxRow; row++) { + if (!isExistDataInRow(row)) { + continue; + } + const rowCells: (SourceCell | null)[] = []; + for (let col = minCol; col <= maxCol; col++) { + if (!isExistDataInCol(col)) { + continue; + } + rowCells.push(getRangeExistDataInCell(col, row) ? createSourceCell(col, row) : null); + } + sourceCellMatrix.push(rowCells); + } + return sourceCellMatrix; + } + + private getSourceCells(sourceCellMatrix: SourceCellMatrix): SourceCell[] { + const sourceCells: SourceCell[] = []; + for (let row = 0; row < sourceCellMatrix.length; row++) { + for (let col = 0; col < sourceCellMatrix[row].length; col++) { + const sourceCell = sourceCellMatrix[row][col]; + if (sourceCell) { + sourceCells.push(sourceCell); } } } return sourceCells; } - private getSourceSize(ranges: ClipboardRange[], sourceCellCount: number): { colCount: number; rowCount: number } { + private getSourceSize( + ranges: ClipboardRange[], + sourceCellMatrix: SourceCellMatrix + ): { colCount: number; rowCount: number } { + if (sourceCellMatrix.length) { + return { + colCount: sourceCellMatrix[0]?.length ?? 0, + rowCount: sourceCellMatrix.length + }; + } if (ranges.length === 1) { const range = ranges[0]; return { @@ -1136,10 +1214,7 @@ export class EventManager { rowCount: Math.abs(range.end.row - range.start.row) + 1 }; } - return { - colCount: sourceCellCount, - rowCount: 1 - }; + return { colCount: 0, rowCount: 0 }; } private getClipboardSignature(data: EventClipboardData | null): string { @@ -1159,6 +1234,33 @@ export class EventManager { return (operationId === undefined || operationId === this.pasteOperationId) && !this.table.isReleased; } + private async queueClipboardWrite( + write: () => Promise + ): Promise { + const previousWrite = this.clipboardWritePromise; + let settleCurrentWrite: () => void = () => { + return; + }; + this.clipboardWritePromise = previousWrite + .catch(() => { + // Ignore stale write failures so the latest copy can still proceed. + }) + .then( + () => + new Promise(resolve => { + settleCurrentWrite = resolve; + }) + ); + await previousWrite.catch(() => { + // Previous clipboard write failure should not block later copy attempts. + }); + try { + return await write(); + } finally { + settleCurrentWrite(); + } + } + private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { if (!clipboardData || clipboardData.readFailed) { return null; @@ -1170,6 +1272,15 @@ export class EventManager { if (sourceData.hasHtml && !clipboardData.hasHtml) { return null; } + if (!sourceData.hasHtml && clipboardData.hasHtml) { + return true; + } + if (sourceData.hasText && !clipboardData.hasText) { + return null; + } + if (!sourceData.hasText && clipboardData.hasText) { + return true; + } let hasComparableType = false; if (clipboardData.hasHtml && sourceData.hasHtml) { hasComparableType = true; @@ -1191,7 +1302,7 @@ export class EventManager { if (!clipboardData || clipboardData.readFailed || !sourceData) { return false; } - if (sourceData.hasHtml && !clipboardData.hasHtml) { + if (!!sourceData.hasHtml !== !!clipboardData.hasHtml || !!sourceData.hasText !== !!clipboardData.hasText) { return false; } return this.getClipboardSignature(clipboardData) === this.getClipboardSignature(sourceData); @@ -1316,6 +1427,7 @@ export class EventManager { ranges: this.cloneRanges(copySnapshot.ranges), cellInfos: copySnapshot.cellInfos, sourceCells: copySnapshot.sourceCells, + sourceCellMatrix: copySnapshot.sourceCellMatrix, sourceColCount: copySnapshot.sourceColCount, sourceRowCount: copySnapshot.sourceRowCount, clipboardData: copySnapshot.clipboardData, @@ -1345,7 +1457,6 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { - const pasteOperationId = ++this.pasteOperationId; if (this.pasteWriteInProgress) { e.preventDefault(); return; @@ -1354,6 +1465,7 @@ export class EventManager { if (!pasteRange) { return; } + const pasteOperationId = ++this.pasteOperationId; const eventClipboardData = this.getEventClipboardData(e); const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { @@ -1393,6 +1505,11 @@ export class EventManager { } const changed = this.isClipboardDataChanged(clipboardData, cutState); if (changed === null) { + await this.executePaste(clipboardData, { + pasteRange, + copySourceRange: this.isClipboardDataSameAsLastCopy(clipboardData) ? pasteCopySourceRange : null, + pasteOperationId + }); return; } const pasteResult = await this.executePaste(clipboardData, { @@ -1852,15 +1969,16 @@ export class EventManager { const migratedSourceCells: SourceCell[] = []; const migratedKeys = new Set(); if (!cutState.isSingleRange) { - let sourceIndex = 0; for (let rowIndex = 0; rowIndex < changedCellResults.length; rowIndex++) { for (let colIndex = 0; colIndex < (changedCellResults[rowIndex]?.length ?? 0); colIndex++) { - const sourceCell = cutState.sourceCells[sourceIndex++]; if (!changedCellResults[rowIndex][colIndex]) { continue; } + const sourceRow = rowIndex % cutState.sourceRowCount; + const sourceCol = colIndex % cutState.sourceColCount; + const sourceCell = cutState.sourceCellMatrix[sourceRow]?.[sourceCol]; if (!sourceCell) { - return []; + continue; } const sourceKey = this.getCellKey(sourceCell); if (!migratedKeys.has(sourceKey)) { From 3da291aa0686d941a92696c68de7430a39c7f176 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 15:45:45 +0800 Subject: [PATCH 12/13] fix: harden clipboard async operations Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/ListTable.ts | 6 +- packages/vtable/src/core/record-helper.ts | 50 +++++- packages/vtable/src/event/event.ts | 154 +++++++++++++++---- packages/vtable/src/ts-types/table-engine.ts | 13 +- 4 files changed, 185 insertions(+), 38 deletions(-) diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 11ce44f6c0..b3bb28e128 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -1846,7 +1846,8 @@ export class ListTable extends BaseTable implements ListTableAPI { values: (string | number)[][], workOnEditableCell = false, triggerEvent = true, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ) { return listTableChangeCellValues( startCol, @@ -1855,7 +1856,8 @@ export class ListTable extends BaseTable implements ListTableAPI { workOnEditableCell, triggerEvent, this, - noTriggerChangeCellValuesEvent + noTriggerChangeCellValuesEvent, + shouldCancel ); } diff --git a/packages/vtable/src/core/record-helper.ts b/packages/vtable/src/core/record-helper.ts index 314dd30904..f920cfc325 100644 --- a/packages/vtable/src/core/record-helper.ts +++ b/packages/vtable/src/core/record-helper.ts @@ -164,7 +164,8 @@ export async function listTableChangeCellValues( workOnEditableCell: boolean, triggerEvent: boolean, table: ListTable, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ): Promise { const changedCellResults: boolean[][] = []; let pasteColEnd = startCol; @@ -206,8 +207,45 @@ export async function listTableChangeCellValues( changedValue: string | number; }[] = []; + const preValidatedCellResults: boolean[][] | null = shouldCancel && workOnEditableCell ? [] : null; + if (preValidatedCellResults) { + for (let i = 0; i < values.length; i++) { + if (shouldCancel?.()) { + return changedCellResults; + } + if (startRow + i > table.rowCount - 1) { + break; + } + preValidatedCellResults[i] = []; + const rowValues = values[i]; + for (let j = 0; j < rowValues.length; j++) { + if (startCol + j > table.colCount - 1) { + break; + } + let isCanChange = false; + if (table.isHasEditorDefine(startCol + j, startRow + i)) { + const editor = table.getEditor(startCol + j, startRow + i); + const oldValue = oldValues[i][j]; + const value = rowValues[j]; + const maybePromiseOrValue = + editor?.validateValue?.(value, oldValue, { col: startCol + j, row: startRow + i }, table) ?? true; + const validateResult = isPromise(maybePromiseOrValue) ? await maybePromiseOrValue : maybePromiseOrValue; + if (shouldCancel?.()) { + return changedCellResults; + } + isCanChange = + validateResult === true || validateResult === 'validate-exit' || validateResult === 'validate-not-exit'; + } + preValidatedCellResults[i][j] = isCanChange; + } + } + } + //#endregion for (let i = 0; i < values.length; i++) { + if (shouldCancel?.()) { + return changedCellResults; + } if (startRow + i > table.rowCount - 1) { break; } @@ -221,7 +259,9 @@ export async function listTableChangeCellValues( } thisRowPasteColEnd = startCol + j; let isCanChange = false; - if (workOnEditableCell === false) { + if (preValidatedCellResults) { + isCanChange = preValidatedCellResults[i]?.[j] === true; + } else if (workOnEditableCell === false) { isCanChange = true; } else { if (table.isHasEditorDefine(startCol + j, startRow + i)) { @@ -232,6 +272,9 @@ export async function listTableChangeCellValues( editor?.validateValue?.(value, oldValue, { col: startCol + j, row: startRow + i }, table) ?? true; if (isPromise(maybePromiseOrValue)) { const validateResult = await maybePromiseOrValue; + if (shouldCancel?.()) { + return changedCellResults; + } isCanChange = validateResult === true || validateResult === 'validate-exit' || validateResult === 'validate-not-exit'; } else { @@ -244,6 +287,9 @@ export async function listTableChangeCellValues( } // if ((workOnEditableCell && table.isHasEditorDefine(startCol + j, startRow + i)) || workOnEditableCell === false) { if (isCanChange) { + if (shouldCancel?.()) { + return changedCellResults; + } changedCellResults[i][j] = true; const value = rowValues[j]; const recordShowIndex = table.getRecordShowIndexByCell(startCol + j, startRow + i); diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 609cd59295..b7cf28af86 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -68,6 +68,7 @@ type EventClipboardData = { hasHtml?: boolean; hasText?: boolean; readFailed?: boolean; + internalFormulaText?: string; }; type SourceCell = { @@ -852,12 +853,12 @@ export class EventManager { async handleCopy(e: KeyboardEvent, isCut: boolean = false): Promise { const table = this.table; + const operationId = ++this.clipboardOperationId; + this.pasteOperationId++; if (this.pasteWriteInProgress) { e.preventDefault(); return null; } - const operationId = ++this.clipboardOperationId; - this.pasteOperationId++; if (!isCut) { this.resetCutState(); } @@ -883,18 +884,25 @@ export class EventManager { const hasEventClipboard = !!(e as unknown as ClipboardEvent).clipboardData; const dataHTML = !canUseAsyncClipboard || canWriteRichClipboard || hasEventClipboard ? this.getCopyDataHTML(data) : null; - const plainClipboardData = this.getCopyFormulaPlainData(data, isCut); - if (plainClipboardData === null) { + const formulaClipboardData = this.getCopyFormulaPlainData(data, isCut); + if (formulaClipboardData === null) { return null; } + const plainClipboardData = data; + const internalFormulaText = formulaClipboardData !== plainClipboardData ? formulaClipboardData : undefined; const eventClipboardWriteData = hasEventClipboard - ? this.setCopyDataToEventClipboard(plainClipboardData, dataHTML, e) + ? this.setCopyDataToEventClipboard(plainClipboardData, dataHTML, e, internalFormulaText) : null; if (eventClipboardWriteData) { - if (!this.isClipboardOperationCurrent(operationId)) { + copiedClipboardData = await this.ensureQueuedClipboardWrite( + eventClipboardWriteData, + canUseAsyncClipboard, + canWriteRichClipboard, + operationId + ); + if (!copiedClipboardData) { return null; } - copiedClipboardData = eventClipboardWriteData; this.lastCopiedClipboardData = copiedClipboardData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); @@ -966,11 +974,17 @@ export class EventManager { 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) }) ]); - return { html: dataHTML, text: plainClipboardData, hasHtml: true, hasText: true }; + return { + html: dataHTML, + text: plainClipboardData, + hasHtml: true, + hasText: true, + internalFormulaText + }; } // 降级到纯文本 await navigator.clipboard.writeText(plainClipboardData); - return { html: '', text: plainClipboardData, hasText: true }; + return { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; }); if (copiedClipboardData) { copySucceeded = true; @@ -986,21 +1000,21 @@ export class EventManager { return null; } if (copySucceeded) { - copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; } } } else { // 没有权限,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); if (copySucceeded) { - copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; } } } else { // 不支持现代剪贴板API,使用降级方案 copySucceeded = this.fallbackCopyToClipboard(plainClipboardData); if (copySucceeded) { - copiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; + copiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; } } @@ -1031,7 +1045,7 @@ export class EventManager { if (!this.isClipboardOperationCurrent(operationId)) { return null; } - this.lastCopiedClipboardData = { html: '', text: plainClipboardData, hasText: true }; + this.lastCopiedClipboardData = { html: '', text: plainClipboardData, hasText: true, internalFormulaText }; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(this.lastCopiedClipboardData); copySnapshot.clipboardData = this.lastCopiedClipboardData; @@ -1261,6 +1275,37 @@ export class EventManager { } } + private async ensureQueuedClipboardWrite( + clipboardData: EventClipboardData, + canUseAsyncClipboard: boolean, + canWriteRichClipboard: boolean, + operationId: number + ): Promise { + return this.queueClipboardWrite(async () => { + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + try { + if (canWriteRichClipboard && clipboardData.hasHtml) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([clipboardData.html], { type: 'text/html' }), + 'text/plain': new Blob([clipboardData.text], { type: 'text/plain' }) + }) + ]); + } else if (canUseAsyncClipboard) { + await navigator.clipboard.writeText(clipboardData.text); + } + } catch (error) { + console.warn('异步同步事件剪贴板数据失败,保留事件剪贴板结果:', error); + } + if (!this.isClipboardOperationCurrent(operationId)) { + return null; + } + return clipboardData; + }); + } + private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { if (!clipboardData || clipboardData.readFailed) { return null; @@ -1272,15 +1317,9 @@ export class EventManager { if (sourceData.hasHtml && !clipboardData.hasHtml) { return null; } - if (!sourceData.hasHtml && clipboardData.hasHtml) { - return true; - } if (sourceData.hasText && !clipboardData.hasText) { return null; } - if (!sourceData.hasText && clipboardData.hasText) { - return true; - } let hasComparableType = false; if (clipboardData.hasHtml && sourceData.hasHtml) { hasComparableType = true; @@ -1294,7 +1333,10 @@ export class EventManager { return true; } } - return hasComparableType ? false : null; + if (hasComparableType) { + return false; + } + return null; } private isClipboardDataSameAsLastCopy(clipboardData: EventClipboardData | null): boolean { @@ -1302,10 +1344,30 @@ export class EventManager { if (!clipboardData || clipboardData.readFailed || !sourceData) { return false; } - if (!!sourceData.hasHtml !== !!clipboardData.hasHtml || !!sourceData.hasText !== !!clipboardData.hasText) { + return this.isClipboardDataSameByCommonTypes(clipboardData, sourceData); + } + + private isClipboardDataSameByCommonTypes(clipboardData: EventClipboardData, sourceData: EventClipboardData): boolean { + if (sourceData.hasHtml && !clipboardData.hasHtml) { + return false; + } + if (sourceData.hasText && !clipboardData.hasText) { return false; } - return this.getClipboardSignature(clipboardData) === this.getClipboardSignature(sourceData); + let hasComparableType = false; + if (sourceData.hasHtml && clipboardData.hasHtml) { + hasComparableType = true; + if (sourceData.html !== clipboardData.html) { + return false; + } + } + if (sourceData.hasText && clipboardData.hasText) { + hasComparableType = true; + if (sourceData.text !== clipboardData.text) { + return false; + } + } + return hasComparableType; } private getCopyFormulaPlainData(data: string, isCut: boolean): string | null { @@ -1350,7 +1412,8 @@ export class EventManager { private setCopyDataToEventClipboard( data: string, dataHTML: string | null, - e: KeyboardEvent + e: KeyboardEvent, + internalFormulaText?: string ): EventClipboardData | null { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { @@ -1359,7 +1422,7 @@ export class EventManager { try { clipboardData.setData('text/plain', data); - const copiedClipboardData: EventClipboardData = { html: '', text: data, hasText: true }; + const copiedClipboardData: EventClipboardData = { html: '', text: data, hasText: true, internalFormulaText }; if (dataHTML) { try { clipboardData.setData('text/html', dataHTML); @@ -1457,6 +1520,7 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { + const pasteOperationId = ++this.pasteOperationId; if (this.pasteWriteInProgress) { e.preventDefault(); return; @@ -1465,7 +1529,6 @@ export class EventManager { if (!pasteRange) { return; } - const pasteOperationId = ++this.pasteOperationId; const eventClipboardData = this.getEventClipboardData(e); const pasteCopySourceRange = this.copySourceRange; if (!this.cutWaitPaste) { @@ -1556,6 +1619,7 @@ export class EventManager { ): Promise { const table = this.table; const emptyResult = this.getEmptyPasteResult(); + const pasteClipboardData = this.getPasteClipboardData(clipboardData, pasteContext); if ( this.pasteWriteInProgress || !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || @@ -1568,22 +1632,25 @@ export class EventManager { try { if ((table as ListTableAPI).changeCellValues) { try { - if (clipboardData?.html) { + if (pasteClipboardData?.html) { try { - const htmlResult = await this.processPastedHTML(clipboardData.html, pasteContext); + const htmlResult = await this.processPastedHTML(pasteClipboardData.html, pasteContext); if (htmlResult.pasted) { pasteResult = htmlResult; return pasteResult; } } catch (error) { console.warn('粘贴HTML数据失败:', error); - if (/ !this.isPasteOperationCurrent(pasteContext.pasteOperationId) ); if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { return this.getEmptyPasteResult(); @@ -1832,7 +1919,10 @@ export class EventManager { col, row, valuesToPaste, - !pasteContext.cutState + !pasteContext.cutState, + true, + undefined, + () => !this.isPasteOperationCurrent(pasteContext.pasteOperationId) ); if (!this.isPasteOperationCurrent(pasteContext.pasteOperationId)) { return this.getEmptyPasteResult(); diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index ac4ce2c794..d8f89ef3bd 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -399,7 +399,8 @@ export interface ListTableAPI extends BaseTableAPI { values: (string | number)[][], workOnEditableCell?: boolean, triggerEvent?: boolean, - noTriggerChangeCellValuesEvent?: boolean + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean ) => Promise | boolean[][]; /** * 根据源数据 records 的 index + field 修改值。 @@ -750,7 +751,15 @@ export interface PivotTableAPI extends BaseTableAPI { * @param row 粘贴数据的起始行号 * @param values 多个单元格的数据数组 */ - changeCellValues: (col: number, row: number, values: (string | number)[][], workOnEditableCell: boolean) => void; + changeCellValues: ( + col: number, + row: number, + values: (string | number)[][], + workOnEditableCell: boolean, + triggerEvent?: boolean, + noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean + ) => void; /** * 获取行表头全路径 From f4b6b9ca006b9da97ee41018b57f7f6651eecf64 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 26 Aug 2026 17:14:40 +0800 Subject: [PATCH 13/13] fix: tighten clipboard transaction guards Prevent async clipboard mirrors from overwriting event clipboard writes. Require a VTable HTML marker before restoring copied formula text. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/vtable/src/PivotTable.ts | 23 ++- packages/vtable/src/core/record-helper.ts | 82 +++++++++-- packages/vtable/src/data/DataSource.ts | 17 +-- packages/vtable/src/event/event.ts | 144 +++++++++---------- packages/vtable/src/ts-types/table-engine.ts | 2 +- 5 files changed, 169 insertions(+), 99 deletions(-) diff --git a/packages/vtable/src/PivotTable.ts b/packages/vtable/src/PivotTable.ts index de64c0e3fc..fcfbb1a5f4 100644 --- a/packages/vtable/src/PivotTable.ts +++ b/packages/vtable/src/PivotTable.ts @@ -2047,7 +2047,19 @@ export class PivotTable extends BaseTable implements PivotTableAPI { * @param values 多个单元格的数据数组 * @param workOnEditableCell 是否仅更改可编辑单元格 */ - changeCellValues(startCol: number, startRow: number, values: string[][], workOnEditableCell = false) { + changeCellValues( + startCol: number, + startRow: number, + values: (string | number)[][], + workOnEditableCell = false, + triggerEvent = true, + _noTriggerChangeCellValuesEvent?: boolean, + shouldCancel?: () => boolean + ): boolean[][] { + const changedCellResults: boolean[][] = []; + if (shouldCancel?.()) { + return changedCellResults; + } let pasteColEnd = startCol; let pasteRowEnd = startRow; // const rowCount = values.length; @@ -2078,6 +2090,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { if (startRow + i > this.rowCount - 1) { break; } + changedCellResults[i] = []; pasteRowEnd = startRow + i; const rowValues = values[i]; let thisRowPasteColEnd = startCol; @@ -2095,12 +2108,13 @@ export class PivotTable extends BaseTable implements PivotTableAPI { let newValue: string | number = value; const oldValue = oldValues[i][j]; const rawValue = beforeChangeValues[i][j]; - if (typeof rawValue === 'number' && isAllDigits(value)) { + if (typeof rawValue === 'number' && typeof value === 'string' && isAllDigits(value)) { newValue = parseFloat(value); } + changedCellResults[i][j] = true; this._changeCellValueToDataSet(startCol + j, startRow + i, oldValue, newValue); const changedValue = this.getCellOriginValue(startCol + j, startRow + i); - if (changedValue !== oldValue) { + if (changedValue !== oldValue && triggerEvent) { this.fireListeners(TABLE_EVENT_TYPE.CHANGE_CELL_VALUE, { col: startCol + j, row: startRow + i, @@ -2109,6 +2123,8 @@ export class PivotTable extends BaseTable implements PivotTableAPI { changedValue }); } + } else { + changedCellResults[i][j] = false; } } pasteColEnd = Math.max(pasteColEnd, thisRowPasteColEnd); @@ -2167,6 +2183,7 @@ export class PivotTable extends BaseTable implements PivotTableAPI { } this.scenegraph.updateNextFrame(); + return changedCellResults; } private _changeCellValueToDataSet(col: number, row: number, oldValue: string | number, newValue: string | number) { diff --git a/packages/vtable/src/core/record-helper.ts b/packages/vtable/src/core/record-helper.ts index f920cfc325..c02f7bc86c 100644 --- a/packages/vtable/src/core/record-helper.ts +++ b/packages/vtable/src/core/record-helper.ts @@ -31,6 +31,53 @@ function refreshCustomMergeCellGroups(table: ListTable) { } } +type ChangeCellTargetSnapshot = { + col: number; + row: number; + isHeader: boolean; + recordIndex?: number | number[]; + field?: any; +}; + +function isSameRecordIndex( + sourceRecordIndex: number | number[] | undefined, + currentRecordIndex: number | number[] | undefined +): boolean { + if (Array.isArray(sourceRecordIndex) || Array.isArray(currentRecordIndex)) { + return ( + Array.isArray(sourceRecordIndex) && + Array.isArray(currentRecordIndex) && + sourceRecordIndex.length === currentRecordIndex.length && + sourceRecordIndex.every((value, index) => value === currentRecordIndex[index]) + ); + } + return sourceRecordIndex === currentRecordIndex; +} + +function isTargetCellSnapshotCurrent(table: ListTable, snapshot: ChangeCellTargetSnapshot): boolean { + if (table.isHeader(snapshot.col, snapshot.row) !== snapshot.isHeader) { + return false; + } + if (snapshot.isHeader) { + return true; + } + const recordShowIndex = table.getRecordShowIndexByCell(snapshot.col, snapshot.row); + const recordIndex = recordShowIndex >= 0 ? table.dataSource.getIndexKey(recordShowIndex) : undefined; + const { field } = table.internalProps.layoutMap.getBody(snapshot.col, snapshot.row); + return isSameRecordIndex(snapshot.recordIndex, recordIndex) && snapshot.field === field; +} + +function areTargetCellSnapshotsCurrent(table: ListTable, snapshots: ChangeCellTargetSnapshot[][]): boolean { + for (let i = 0; i < snapshots.length; i++) { + for (let j = 0; j < snapshots[i].length; j++) { + if (!isTargetCellSnapshotCurrent(table, snapshots[i][j])) { + return false; + } + } + } + return true; +} + /** * 更改单元格数据 会触发change_cell_value事件 * @param col @@ -174,6 +221,7 @@ export async function listTableChangeCellValues( //#region 提前组织好未更改前的数据 const beforeChangeValues: (string | number)[][] = []; const oldValues: (string | number)[][] = []; + const targetSnapshots: ChangeCellTargetSnapshot[][] = []; let cellUpdateType: 'normal' | 'sort' | 'group'; for (let i = 0; i < values.length; i++) { @@ -183,17 +231,26 @@ export async function listTableChangeCellValues( const rowValues = values[i]; const rawRowValues: (string | number)[] = []; const oldRowValues: (string | number)[] = []; + const rowTargetSnapshots: ChangeCellTargetSnapshot[] = []; beforeChangeValues.push(rawRowValues); oldValues.push(oldRowValues); + targetSnapshots.push(rowTargetSnapshots); for (let j = 0; j < rowValues.length; j++) { if (startCol + j > table.colCount - 1) { break; } + const col = startCol + j; + const row = startRow + i; cellUpdateType = getCellUpdateType(startCol + j, startRow + i, table, cellUpdateType); - const beforeChangeValue = table.getCellRawValue(startCol + j, startRow + i); + const beforeChangeValue = table.getCellRawValue(col, row); rawRowValues.push(beforeChangeValue); - const oldValue = table.getCellOriginValue(startCol + j, startRow + i); + const oldValue = table.getCellOriginValue(col, row); oldRowValues.push(oldValue); + const isHeader = table.isHeader(col, row); + const recordShowIndex = table.getRecordShowIndexByCell(col, row); + const recordIndex = recordShowIndex >= 0 ? table.dataSource.getIndexKey(recordShowIndex) : undefined; + const { field } = table.internalProps.layoutMap.getBody(col, row); + rowTargetSnapshots.push({ col, row, isHeader, recordIndex, field }); } } @@ -240,12 +297,12 @@ export async function listTableChangeCellValues( } } } + if (shouldCancel?.() || !areTargetCellSnapshotsCurrent(table, targetSnapshots)) { + return changedCellResults; + } //#endregion for (let i = 0; i < values.length; i++) { - if (shouldCancel?.()) { - return changedCellResults; - } if (startRow + i > table.rowCount - 1) { break; } @@ -287,9 +344,6 @@ export async function listTableChangeCellValues( } // if ((workOnEditableCell && table.isHasEditorDefine(startCol + j, startRow + i)) || workOnEditableCell === false) { if (isCanChange) { - if (shouldCancel?.()) { - return changedCellResults; - } changedCellResults[i][j] = true; const value = rowValues[j]; const recordShowIndex = table.getRecordShowIndexByCell(startCol + j, startRow + i); @@ -302,7 +356,17 @@ export async function listTableChangeCellValues( if (table.isHeader(startCol + j, startRow + i)) { table.internalProps.layoutMap.updateColumnTitle(startCol + j, startRow + i, value as string); } else { - table.dataSource.changeFieldValue(value, recordShowIndex, field, startCol + j, startRow + i, table); + const changeResult = table.dataSource.changeFieldValue( + value, + recordShowIndex, + field, + startCol + j, + startRow + i, + table + ); + if (isPromise(changeResult)) { + await changeResult; + } } const changedValue = table.getCellOriginValue(startCol + j, startRow + i); if (oldValue !== changedValue && triggerEvent) { diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index da90340fc1..d24368e13f 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -732,7 +732,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { col?: number, row?: number, table?: BaseTableAPI - ): FieldData { + ): FieldData | Promise { if (field === null) { return undefined; } @@ -752,20 +752,21 @@ export class DataSource extends EventTarget implements DataSourceAPI { formatValue = parseFloat(value); } if (isPromise(record)) { - record + return record .then(record => { record[field as string | number] = formatValue; + return formatValue; }) .catch((err: Error) => { console.error('VTable Error:', err); + return undefined; }); + } + if (record) { + record[field] = formatValue; } else { - if (record) { - record[field] = formatValue; - } else { - this.records[dataIndex as number] = this.addRecordRule === 'Array' ? [] : {}; - this.records[dataIndex as number][field] = formatValue; - } + this.records[dataIndex as number] = this.addRecordRule === 'Array' ? [] : {}; + this.records[dataIndex as number][field] = formatValue; } } } diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index b7cf28af86..4b890d6a4d 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -69,6 +69,7 @@ type EventClipboardData = { hasText?: boolean; readFailed?: boolean; internalFormulaText?: string; + internalFormulaHtmlMarker?: string; }; type SourceCell = { @@ -155,8 +156,10 @@ export class EventManager { private clipboardOperationId: number = 0; private pasteOperationId: number = 0; private pasteWriteInProgress: boolean = false; + private pasteWritePromise: Promise = Promise.resolve(); private clipboardWritePromise: Promise = Promise.resolve(); private lastCopiedClipboardData: EventClipboardData | null = null; + private internalFormulaHtmlMarkerId: number = 0; private fieldKeyIds: WeakMap = new WeakMap(); private fieldKeyId: number = 0; private activeCutState: CutState | null = null; @@ -855,10 +858,6 @@ export class EventManager { const table = this.table; const operationId = ++this.clipboardOperationId; this.pasteOperationId++; - if (this.pasteWriteInProgress) { - e.preventDefault(); - return null; - } if (!isCut) { this.resetCutState(); } @@ -890,19 +889,22 @@ export class EventManager { } const plainClipboardData = data; const internalFormulaText = formulaClipboardData !== plainClipboardData ? formulaClipboardData : undefined; + const internalFormulaHtmlMarker = internalFormulaText ? this.createInternalFormulaHtmlMarker() : undefined; + const clipboardHTML = + dataHTML && internalFormulaHtmlMarker + ? this.attachInternalFormulaHtmlMarker(dataHTML, internalFormulaHtmlMarker) + : dataHTML; const eventClipboardWriteData = hasEventClipboard - ? this.setCopyDataToEventClipboard(plainClipboardData, dataHTML, e, internalFormulaText) + ? this.setCopyDataToEventClipboard( + plainClipboardData, + clipboardHTML, + e, + internalFormulaText, + internalFormulaHtmlMarker + ) : null; if (eventClipboardWriteData) { - copiedClipboardData = await this.ensureQueuedClipboardWrite( - eventClipboardWriteData, - canUseAsyncClipboard, - canWriteRichClipboard, - operationId - ); - if (!copiedClipboardData) { - return null; - } + copiedClipboardData = eventClipboardWriteData; this.lastCopiedClipboardData = copiedClipboardData; if (isCut) { this.lastClipboardContent = this.getClipboardSignature(copiedClipboardData); @@ -967,19 +969,20 @@ export class EventManager { return null; } // 尝试使用 ClipboardItem(支持富文本) - if (canWriteRichClipboard && dataHTML) { + if (canWriteRichClipboard && clipboardHTML) { await navigator.clipboard.write([ new ClipboardItem({ - 'text/html': new Blob([dataHTML], { type: 'text/html' }), + 'text/html': new Blob([clipboardHTML], { type: 'text/html' }), 'text/plain': new Blob([plainClipboardData], { type: 'text/plain' }) }) ]); return { - html: dataHTML, + html: clipboardHTML, text: plainClipboardData, hasHtml: true, hasText: true, - internalFormulaText + internalFormulaText, + internalFormulaHtmlMarker }; } // 降级到纯文本 @@ -1275,37 +1278,6 @@ export class EventManager { } } - private async ensureQueuedClipboardWrite( - clipboardData: EventClipboardData, - canUseAsyncClipboard: boolean, - canWriteRichClipboard: boolean, - operationId: number - ): Promise { - return this.queueClipboardWrite(async () => { - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } - try { - if (canWriteRichClipboard && clipboardData.hasHtml) { - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([clipboardData.html], { type: 'text/html' }), - 'text/plain': new Blob([clipboardData.text], { type: 'text/plain' }) - }) - ]); - } else if (canUseAsyncClipboard) { - await navigator.clipboard.writeText(clipboardData.text); - } - } catch (error) { - console.warn('异步同步事件剪贴板数据失败,保留事件剪贴板结果:', error); - } - if (!this.isClipboardOperationCurrent(operationId)) { - return null; - } - return clipboardData; - }); - } - private isClipboardDataChanged(clipboardData: EventClipboardData | null, cutState: CutState): boolean | null { if (!clipboardData || clipboardData.readFailed) { return null; @@ -1344,30 +1316,16 @@ export class EventManager { if (!clipboardData || clipboardData.readFailed || !sourceData) { return false; } - return this.isClipboardDataSameByCommonTypes(clipboardData, sourceData); + return this.isClipboardDataSameBySignature(clipboardData, sourceData); } - private isClipboardDataSameByCommonTypes(clipboardData: EventClipboardData, sourceData: EventClipboardData): boolean { - if (sourceData.hasHtml && !clipboardData.hasHtml) { - return false; - } - if (sourceData.hasText && !clipboardData.hasText) { - return false; - } - let hasComparableType = false; - if (sourceData.hasHtml && clipboardData.hasHtml) { - hasComparableType = true; - if (sourceData.html !== clipboardData.html) { - return false; - } - } - if (sourceData.hasText && clipboardData.hasText) { - hasComparableType = true; - if (sourceData.text !== clipboardData.text) { - return false; - } - } - return hasComparableType; + private isClipboardDataSameBySignature(clipboardData: EventClipboardData, sourceData: EventClipboardData): boolean { + return ( + sourceData.hasHtml === clipboardData.hasHtml && + sourceData.hasText === clipboardData.hasText && + (!sourceData.hasHtml || sourceData.html === clipboardData.html) && + (!sourceData.hasText || sourceData.text === clipboardData.text) + ); } private getCopyFormulaPlainData(data: string, isCut: boolean): string | null { @@ -1409,11 +1367,20 @@ export class EventManager { } } + private createInternalFormulaHtmlMarker(): string { + return `vtable-formula-${Date.now()}-${++this.internalFormulaHtmlMarkerId}`; + } + + private attachInternalFormulaHtmlMarker(html: string, marker: string): string { + return `${html}`; + } + private setCopyDataToEventClipboard( data: string, dataHTML: string | null, e: KeyboardEvent, - internalFormulaText?: string + internalFormulaText?: string, + internalFormulaHtmlMarker?: string ): EventClipboardData | null { const clipboardData = (e as unknown as ClipboardEvent).clipboardData; if (!clipboardData) { @@ -1428,6 +1395,7 @@ export class EventManager { clipboardData.setData('text/html', dataHTML); copiedClipboardData.html = dataHTML; copiedClipboardData.hasHtml = true; + copiedClipboardData.internalFormulaHtmlMarker = internalFormulaHtmlMarker; } catch (error) { console.warn('事件剪贴板HTML写入失败,保留纯文本数据:', error); } @@ -1521,10 +1489,6 @@ export class EventManager { // 执行实际的粘贴操作 handlePaste(e: KeyboardEvent): void { const pasteOperationId = ++this.pasteOperationId; - if (this.pasteWriteInProgress) { - e.preventDefault(); - return; - } const pasteRange = this.getPasteRange(); if (!pasteRange) { return; @@ -1621,13 +1585,29 @@ export class EventManager { const emptyResult = this.getEmptyPasteResult(); const pasteClipboardData = this.getPasteClipboardData(clipboardData, pasteContext); if ( - this.pasteWriteInProgress || !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || (table as ListTableAPI).editorManager?.editingEditor ) { return emptyResult; } + if (this.pasteWriteInProgress) { + await this.pasteWritePromise.catch(() => { + // Previous paste failures are handled by their own task. + }); + if ( + !this.isPasteOperationCurrent(pasteContext.pasteOperationId) || + (table as ListTableAPI).editorManager?.editingEditor + ) { + return emptyResult; + } + } let pasteResult = emptyResult; + let settlePasteWrite: () => void = () => { + return; + }; + this.pasteWritePromise = new Promise(resolve => { + settlePasteWrite = resolve; + }); this.pasteWriteInProgress = true; try { if ((table as ListTableAPI).changeCellValues) { @@ -1663,6 +1643,7 @@ export class EventManager { return pasteResult; } finally { this.pasteWriteInProgress = false; + settlePasteWrite(); if (!pasteContext.cutState && table.keyboardOptions?.showCopyCellBorder) { clearActiveCellRangeState(table); } @@ -1677,7 +1658,14 @@ export class EventManager { return clipboardData; } const sourceData = pasteContext.cutState?.clipboardData ?? this.lastCopiedClipboardData; - if (!sourceData?.internalFormulaText || !this.isClipboardDataSameByCommonTypes(clipboardData, sourceData)) { + if ( + !sourceData?.internalFormulaText || + !sourceData.internalFormulaHtmlMarker || + !sourceData.hasHtml || + !clipboardData.hasHtml || + !this.isClipboardDataSameBySignature(clipboardData, sourceData) || + !clipboardData.html.includes(sourceData.internalFormulaHtmlMarker) + ) { return clipboardData; } return { diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts index d8f89ef3bd..6e1c48afc4 100644 --- a/packages/vtable/src/ts-types/table-engine.ts +++ b/packages/vtable/src/ts-types/table-engine.ts @@ -759,7 +759,7 @@ export interface PivotTableAPI extends BaseTableAPI { triggerEvent?: boolean, noTriggerChangeCellValuesEvent?: boolean, shouldCancel?: () => boolean - ) => void; + ) => boolean[][]; /** * 获取行表头全路径