Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(grid): support copy to clipbpard and paste to ai-table #WIK-16631 #270

Merged
merged 7 commits into from
Mar 20, 2025
4,589 changes: 1,550 additions & 3,039 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@angular/platform-browser-dynamic": "^18.1.4",
"@angular/router": "^18.1.4",
"@tethys/cdk": "^18.2.1",
"@tethys/icons": "^1.4.69",
"@tethys/icons": "^1.4.77",
"bson-objectid": "^2.0.4",
"nanoid": "^3.3.8",
"date-fns": "^3.6.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
@for (menu of menuItems(); track $index) {
@if ((menu.hidden && !menu.hidden(aiTable(), targetName(), position())) || !menu.hidden) {
@let disabled = !!(menu.disabled && menu.disabled(aiTable(), targetName(), position()));
<a
thyDropdownMenuItem
href="javascript:;"
[ngClass]="{ 'ai-table-prevent-clear-selection remove-record': !disabled }"
(click)="execute(menu)"
[thyDisabled]="disabled"
>
<thy-icon [thyIconName]="menu.icon!"></thy-icon>
<span>{{ menu.name }}</span>
</a>
@if (menu.type === 'divider') {
<thy-divider thyStyle="solid"></thy-divider>
} @else {
@let disabled = !!(menu.disabled && menu.disabled(aiTable(), targetName(), position()));
@let isRemoveRecords = menu.type === 'removeRecords';
@let isPreventClearSelection = menu.type === 'copyCells' || menu.type === 'pasteCells' || menu.type === 'removeRecords';

<a
thyDropdownMenuItem
href="javascript:;"
[ngClass]="{
'remove-record': isRemoveRecords && !disabled,
'ai-table-prevent-clear-selection': isPreventClearSelection && !disabled
}"
(click)="execute(menu)"
[thyDisabled]="disabled"
>
<thy-icon thyDropdownMenuItemIcon [thyIconName]="menu.icon!"></thy-icon>
<span thyDropdownMenuItemName>{{ menu.name }}</span>
<span thyDropdownMenuItemMeta class="text-desc">{{ menu.shortcutKey }}</span>
</a>
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { ThyDropdownAbstractMenu, ThyDropdownMenuItemDirective } from 'ngx-tethys/dropdown';
import {
ThyDropdownAbstractMenu,
ThyDropdownMenuItemDirective,
ThyDropdownMenuItemNameDirective,
ThyDropdownMenuItemIconDirective,
ThyDropdownMenuItemMetaDirective
} from 'ngx-tethys/dropdown';
import { ThyIcon } from 'ngx-tethys/icon';
import { ThyDivider } from 'ngx-tethys/divider';
import { AITable } from '../../core';
import { AITableContextMenuItem } from '../../types';
import { AITableGridSelectionService } from '../../services/selection.service';
Expand All @@ -14,7 +21,15 @@ import { AITableGridSelectionService } from '../../services/selection.service';
host: {
class: 'context-menu'
},
imports: [ThyDropdownMenuItemDirective, ThyIcon, NgClass]
imports: [
ThyDropdownMenuItemDirective,
ThyDropdownMenuItemNameDirective,
ThyDropdownMenuItemIconDirective,
ThyDropdownMenuItemMetaDirective,
ThyIcon,
NgClass,
ThyDivider
]
})
export class AITableContextMenu extends ThyDropdownAbstractMenu {
private aiTableGridSelectionService = inject(AITableGridSelectionService);
Expand Down
24 changes: 24 additions & 0 deletions packages/grid/src/grid.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
isWindows
} from './utils';
import { getMousePosition } from './utils/position';
import { buildClipboardData, writeToClipboard, writeToAITable } from './utils/clipboard';

@Component({
selector: 'ai-table-grid',
Expand Down Expand Up @@ -150,6 +151,7 @@ export class AITableGrid extends AITableGridBase implements OnInit, OnDestroy {
this.bindGlobalMousedown();
this.containerResizeListener();
this.bindWheel();
this.bindClipboardShortcuts();
});
effect(() => {
if (this.hasContainerRect() && this.horizontalBarRef() && this.verticalBarRef()) {
Expand Down Expand Up @@ -594,4 +596,26 @@ export class AITableGrid extends AITableGridBase implements OnInit, OnDestroy {
}
}
}

private bindClipboardShortcuts() {
fromEvent<KeyboardEvent>(document, 'keydown')
.pipe(
filter((event) => (event.ctrlKey || event.metaKey) && (event.key === 'c' || event.key === 'v')),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(async (event) => {
if (event.key === 'c') {
const clipboardData = buildClipboardData(this.aiTable);
if (clipboardData) {
writeToClipboard(clipboardData);
}
} else if (event.key === 'v') {
event.preventDefault();
const updateValueFn = (data: UpdateFieldValueOptions) => {
this.aiUpdateFieldValue.emit(data);
};
writeToAITable(this.aiTable, updateValueFn);
}
});
}
}
4 changes: 4 additions & 0 deletions packages/grid/src/types/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ClipboardData {
text?: string;
html?: string;
}
1 change: 1 addition & 0 deletions packages/grid/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './grid';
export * from './layout';
export * from './row';
export * from './view';
export * from './clipboard';
1 change: 1 addition & 0 deletions packages/grid/src/types/row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface AITableContextMenuItem {
type: string;
name?: string;
icon?: string;
shortcutKey?: string;
exec?: (
aiTable: AITable,
targetName: string,
Expand Down
124 changes: 124 additions & 0 deletions packages/grid/src/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { AITable, AITableField, AITableFieldType, AITableQueries, UpdateFieldValueOptions } from '../core';
import { ViewOperationMap } from './field/model';
import { transformCellValue } from './cell';
import { ClipboardData } from '../types';

export const isClipboardWriteSupported = () => {
return 'clipboard' in navigator && 'write' in navigator.clipboard;
};

export const isClipboardWriteTextSupported = () => {
return 'clipboard' in navigator && 'writeText' in navigator.clipboard;
};

export const isClipboardReadSupported = () => {
return 'clipboard' in navigator && 'read' in navigator.clipboard;
};

export const writeToClipboard = async (data: ClipboardData) => {
const { text, html } = data;
if (isClipboardWriteSupported()) {
const clipboardItem = new ClipboardItem({
'text/plain': new Blob([text!], { type: 'text/plain' }),
'text/html': new Blob([html!], { type: 'text/html' })
});
await navigator.clipboard.write([clipboardItem]);
} else if (isClipboardWriteTextSupported()) {
await navigator.clipboard.writeText(text!);
} else {
const textarea = document.createElement('textarea');
textarea.value = text!;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
};

export const readFromClipboard = async () => {
const clipboardText = await navigator.clipboard.readText();
return clipboardText;
};

export const buildClipboardData = (aiTable: AITable): ClipboardData | null => {
const copiedCells = Array.from(aiTable.selection().selectedCells);
const contentsByRecordId = new Map<string, ClipboardData[]>();
if (!copiedCells.length) {
return null;
}

copiedCells.forEach((cellPath: string) => {
const [recordId, fieldId] = cellPath.split(':');
const cellValue = AITableQueries.getFieldValue(aiTable, [recordId, fieldId]);
const field = aiTable.fieldsMap()[fieldId!];
const transformValue = transformCellValue(aiTable, field, cellValue);
const references = aiTable.context!.references();
const cellTexts: string[] = ViewOperationMap[field.type].cellFullText(transformValue, field, references);
let cellContent = {
text: cellTexts.join(','),
html: cellTexts.join(',')
};
if (field.type === AITableFieldType.link && cellValue && cellValue.url) {
cellContent.html = `<a href="${cellValue.url}" target="_blank">${cellValue.text}</a>`;
}
contentsByRecordId.set(recordId, [...(contentsByRecordId.get(recordId) || []), cellContent]);
});

const rows = Array.from(contentsByRecordId.values());
const formatClipboardData: ClipboardData = {
text: rows.map((row) => row.map((column) => column.text).join('\t')).join('\r\n'),
html: `<table>${rows.map((row) => `<tr>${row.map((column) => `<td>${column.html}</td>`).join('')}</tr>`).join('')}</table>`
};

return formatClipboardData;
};

export const writeToAITable = async (aiTable: AITable, updateValueFn: (data: UpdateFieldValueOptions) => void) => {
const selectedCells = Array.from(aiTable.selection().selectedCells);
if (!selectedCells.length) {
return;
}

const clipboardText = await readFromClipboard();
if (!clipboardText) {
return;
}
const pasteData = clipboardText
.split('\n')
.map((row) => row.split('\t'))
.filter((row) => row.length > 0 && row.some((cell) => cell.trim().length > 0));

const [firstCell] = selectedCells;
const [startRecordId, startFieldId] = firstCell.split(':');

const startRowIndex = aiTable.context!.visibleRowsIndexMap().get(startRecordId) ?? 0;
const startColIndex = aiTable.context!.visibleColumnsIndexMap().get(startFieldId) ?? 0;
const visibleFields = AITable.getVisibleFields(aiTable);
const linearRows = aiTable.context!.linearRows();

pasteData.forEach((row, i) => {
row.forEach((value, j) => {
const targetRowIndex = startRowIndex + i;
const targetColIndex = startColIndex + j;
if (targetRowIndex >= linearRows.length || targetColIndex >= visibleFields.length) {
return;
}

const targetRecord = linearRows[targetRowIndex];
const targetField = visibleFields[targetColIndex];

// TODO 完善 handlePasteData 逻辑之后,移除这个 if 判断
if (targetField.type === AITableFieldType.text) {
updateValueFn({
value: handlePasteData(value.trim(), targetField),
path: [targetRecord._id, targetField._id]
});
}
});
});
};

export const handlePasteData = (text: string, field: AITableField) => {
// TODO 处理不同 field 类型,处理粘贴数据
return text;
};
1 change: 1 addition & 0 deletions packages/grid/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './get-placeholder-cells';
export * from './get-text-width';
export * from './image-cache';
export * from './os';
export * from './clipboard';
export * from './position';
export * from './style';
export * from './text-measure';
Expand Down
2 changes: 1 addition & 1 deletion packages/state/src/action/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
AIViewTable
} from '../types';
import { createDraft, finishDraft } from 'immer';
import { AITableField, AITableFields, AITableSelectAllState, getDefaultFieldValue } from '@ai-table/grid';
import { AITableField, AITableFields, getDefaultFieldValue } from '@ai-table/grid';
import { createDefaultPositions, isPathEqual } from '../utils';

const apply = (aiTable: AIViewTable, records: AITableViewRecords, fields: AITableFields, views: AITableView[], action: AITableAction) => {
Expand Down
42 changes: 41 additions & 1 deletion packages/state/src/constants/context-menu-item.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { AITable, AITableContextMenuItem, AITableGridSelectionService } from '@ai-table/grid';
import {
AITable,
AITableContextMenuItem,
AITableGridSelectionService,
isMac,
UpdateFieldValueOptions,
writeToAITable
} from '@ai-table/grid';
import { Actions } from '../action';
import { AIViewTable } from '../types';
import { buildClipboardData, writeToClipboard } from '@ai-table/grid';

export const RemoveRecordsItem: AITableContextMenuItem = {
type: 'removeRecords',
Expand All @@ -21,3 +29,35 @@ export const RemoveRecordsItem: AITableContextMenuItem = {
aiTableGridSelectionService.clearSelection();
}
};

export const CopyCellsItem: AITableContextMenuItem = {
type: 'copyCells',
name: '复制',
shortcutKey: isMac() ? `⌘ + C` : `Ctrl + C`,
icon: 'copy',
exec: (aiTable: AITable) => {
const clipboardData = buildClipboardData(aiTable);
if (clipboardData) {
writeToClipboard(clipboardData);
}
}
};

export const PasteCellsItem: (updateValueFn: (data: UpdateFieldValueOptions) => void) => AITableContextMenuItem = (
updateValueFn: (data: UpdateFieldValueOptions) => void
) => {
return {
type: 'pasteCells',
name: '粘贴',
shortcutKey: isMac() ? `⌘ + V` : `Ctrl + V`,
icon: 'paste',
exec: async (
aiTable: AITable,
targetName: string,
position: { x: number; y: number },
aiTableGridSelectionService: AITableGridSelectionService
) => {
writeToAITable(aiTable, updateValueFn);
}
};
};
19 changes: 19 additions & 0 deletions src/app/component/common/content/content.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import {
AIViewTable,
applyActionOps,
buildRemoveFieldItem,
CopyCellsItem,
DividerMenuItem,
EditFieldPropertyItem,
CopyFieldPropertyItem,
PasteCellsItem,
RemoveRecordsItem,
updateFieldValue,
withState,
Expand Down Expand Up @@ -116,6 +118,23 @@ export class DemoTableContent {
});

contextMenuItems: AITableContextMenuItem[] = [
{
...CopyCellsItem,
disabled: (aiTable: AITable, targetName: string, position: { x: number; y: number }) => false,
hidden: (aiTable: AITable, targetname: string, position: { x: number; y: number }) => this.tableService.readonly()
},
{
...PasteCellsItem((data: UpdateFieldValueOptions) => {
this.updateFieldValue(data);
}),
disabled: (aiTable: AITable, targetName: string, position: { x: number; y: number }) => false,
hidden: (aiTable: AITable, targetname: string, position: { x: number; y: number }) => this.tableService.readonly()
},
{
...DividerMenuItem,
disabled: (aiTable: AITable, targetName: string, position: { x: number; y: number }) => false,
hidden: (aiTable: AITable, targetname: string, position: { x: number; y: number }) => this.tableService.readonly()
},
{
...RemoveRecordsItem,
disabled: (aiTable: AITable, targetName: string, position: { x: number; y: number }) => false,
Expand Down