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);
}
});
}
}
12 changes: 12 additions & 0 deletions packages/grid/src/types/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { AITableField, FieldValue } from '../core';

export interface ClipboardData {
text?: string;
html?: string;
}

export interface AITableCellContent {
field: AITableField;
cellValue: FieldValue;
cellFullText: 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
77 changes: 77 additions & 0 deletions packages/grid/src/utils/clipboard/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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 isClipboardReadTextSupported = () => {
return 'clipboard' in navigator && 'readText' in navigator.clipboard;
};

export const writeToClipboard = async (data: ClipboardData) => {
try {
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);
}
} catch (error) {
console.warn('Failed to write clipboard:', error);
}
};

export const readFromClipboard = async () => {
try {
let clipboardData: ClipboardData = {};
if (isClipboardReadSupported()) {
const clipboardItems = await navigator.clipboard.read();
if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) {
for (const item of clipboardItems) {
if (item.types.includes('text/html')) {
const blob = await item.getType('text/html');
clipboardData.html = await blob.text();
}
if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain');
clipboardData.text = await blob.text();
}
}
}
} else if (isClipboardReadTextSupported()) {
const clipboardText = await navigator.clipboard.readText();
clipboardData.text = clipboardText;
} else {
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.focus();
document.execCommand('paste');
const text = textarea.value;
document.body.removeChild(textarea);
clipboardData.text = text;
}
return clipboardData;
} catch (error) {
console.warn('Failed to read clipboard:', error);
return null;
}
};
58 changes: 58 additions & 0 deletions packages/grid/src/utils/clipboard/copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { AITable, AITableFieldType, AITableQueries } from '../../core';
import { ViewOperationMap } from '../field/model';
import { transformCellValue } from '../cell';
import { AITableCellContent, ClipboardData } from '../../types';

export const aiTableSpecialAttribute = 'ai-table-fragment';

const encodeClipboardJsonData = (data: any) => {
const stringifiedData = JSON.stringify(data);
return window.btoa(encodeURIComponent(stringifiedData));
};

function formatClipboardData(data: ClipboardData[][], jsonData: string[][]): ClipboardData {
const encodeData = encodeClipboardJsonData(jsonData);
const formatClipboardData: ClipboardData = {
text: data.map((row) => row.map((column) => column.text).join('\t')).join('\r\n'),
html: `<table ${aiTableSpecialAttribute}="${encodeData}">${data.map((row) => `<tr>${row.map((column) => `<td>${column.html}</td>`).join('')}</tr>`).join('')}</table>`
};
return formatClipboardData;
}

export const buildClipboardData = (aiTable: AITable): ClipboardData | null => {
const copiedCells = Array.from(aiTable.selection().selectedCells);
const clipboardContentByRecordId = new Map<string, ClipboardData[]>();
const aiTableContentByRecordId = new Map<string, string[]>();
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 cellClipboardContent = {
text: cellTexts.join(','),
html: cellTexts.join(',')
};
if (field.type === AITableFieldType.link && cellValue && cellValue.url) {
cellClipboardContent.html = `<a href="${cellValue.url}" target="_blank">${cellValue.text}</a>`;
}
clipboardContentByRecordId.set(recordId, [...(clipboardContentByRecordId.get(recordId) || []), cellClipboardContent]);

const cellAITableContent: AITableCellContent = {
field,
cellValue,
cellFullText: cellTexts.join(',')
};
aiTableContentByRecordId.set(recordId, [...(aiTableContentByRecordId.get(recordId) || []), JSON.stringify(cellAITableContent)]);
});

const clipboardData = Array.from(clipboardContentByRecordId.values());
const aiTableContentData = Array.from(aiTableContentByRecordId.values());
return formatClipboardData(clipboardData, aiTableContentData);
};
3 changes: 3 additions & 0 deletions packages/grid/src/utils/clipboard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './clipboard';
export * from './copy';
export * from './paste';
97 changes: 97 additions & 0 deletions packages/grid/src/utils/clipboard/paste.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { AITableCellContent } from '../../types';
import { AITable, FieldValue, UpdateFieldValueOptions } from '../../core';
import { readFromClipboard, aiTableSpecialAttribute } from '../clipboard';
import { ViewOperationMap } from '../field/model';

const aiTableAttributePattern = new RegExp(`${aiTableSpecialAttribute}="(.+?)"`, 'm');

const decodeClipboardJsonData = (encoded: string) => {
const decoded = decodeURIComponent(window.atob(encoded));
return JSON.parse(decoded);
};

const readClipboardData = async (): Promise<{ pasteData: string[][]; isJson: boolean }> => {
const clipboardData = await readFromClipboard();
let pasteData: string[][];

if (clipboardData && clipboardData.html) {
const aiTableAttribute = clipboardData.html.match(aiTableAttributePattern);
if (aiTableAttribute && aiTableAttribute[1]) {
pasteData = decodeClipboardJsonData(aiTableAttribute[1]);
return {
pasteData,
isJson: true
};
}
}

if (clipboardData && clipboardData.text) {
pasteData = clipboardData.text
.split('\n')
.map((row) => row.split('\t'))
.filter((row) => row.length > 0 && row.some((cell) => cell.trim().length > 0));

return {
pasteData,
isJson: false
};
}

return {
pasteData: [],
isJson: false
};
};

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

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();
const references = aiTable.context!.references();

pasteData.forEach((row, i) => {
row.forEach((data, 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];

let value: FieldValue | null = null;
if (isJson) {
const cellContent: AITableCellContent = JSON.parse(data);
const { field, cellValue, cellFullText } = cellContent;
const originData = {
field,
cellValue
};
value = ViewOperationMap[targetField.type].pasteValue(cellFullText, targetField, originData, references);
} else {
value = ViewOperationMap[targetField.type].pasteValue(data, targetField, null, references);
}

if (value !== null) {
updateValueFn({
value,
path: [targetRecord._id, targetField._id]
});
}
});
});
};
Loading