diff --git a/packages/nc-gui/components/smartsheet/Grid.vue b/packages/nc-gui/components/smartsheet/Grid.vue index 2229df6742..03070817a1 100644 --- a/packages/nc-gui/components/smartsheet/Grid.vue +++ b/packages/nc-gui/components/smartsheet/Grid.vue @@ -166,15 +166,8 @@ const getContainerScrollForElement = ( return scroll } -const { selectCell, selectBlock, selectedRange, clearRangeRows, startSelectRange, selected } = useMultiSelect( - fields, - data, - $$(editEnabled), - isPkAvail, - clearCell, - makeEditable, - scrollToCell, - (e: KeyboardEvent) => { +const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyValue, isCellSelected, selectedCell } = + useMultiSelect(fields, data, $$(editEnabled), isPkAvail, clearCell, makeEditable, scrollToCell, (e: KeyboardEvent) => { // ignore navigating if picker(Date, Time, DateTime, Year) // or single/multi select options is open const activePickerOrDropdownEl = document.querySelector( @@ -193,9 +186,9 @@ const { selectCell, selectBlock, selectedRange, clearRangeRows, startSelectRange const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey const altOrOptionKey = e.altKey if (e.key === ' ') { - if (selected.row !== null && !editEnabled) { + if (selectedCell.row !== null && !editEnabled) { e.preventDefault() - const row = data.value[selected.row] + const row = data.value[selectedCell.row] expandForm(row) return true } @@ -218,26 +211,26 @@ const { selectCell, selectBlock, selectedRange, clearRangeRows, startSelectRange if (cmdOrCtrl) { switch (e.key) { case 'ArrowUp': - selected.row = 0 - selected.col = selected.col ?? 0 + selectedCell.row = 0 + selectedCell.col = selectedCell.col ?? 0 scrollToCell?.() editEnabled = false return true case 'ArrowDown': - selected.row = data.value.length - 1 - selected.col = selected.col ?? 0 + selectedCell.row = data.value.length - 1 + selectedCell.col = selectedCell.col ?? 0 scrollToCell?.() editEnabled = false return true case 'ArrowRight': - selected.row = selected.row ?? 0 - selected.col = fields.value?.length - 1 + selectedCell.row = selectedCell.row ?? 0 + selectedCell.col = fields.value?.length - 1 scrollToCell?.() editEnabled = false return true case 'ArrowLeft': - selected.row = selected.row ?? 0 - selected.col = 0 + selectedCell.row = selectedCell.row ?? 0 + selectedCell.col = 0 scrollToCell?.() editEnabled = false return true @@ -266,8 +259,9 @@ const { selectCell, selectBlock, selectedRange, clearRangeRows, startSelectRange ) function scrollToCell(row?: number | null, col?: number | null) { - row = row ?? selected.row - col = col ?? selected.col + row = row ?? selectedCell.row + col = col ?? selectedCell.col + if (row !== undefined && col !== undefined && row !== null && col !== null) { // get active cell const rows = tbodyEl.value?.querySelectorAll('tr') @@ -427,10 +421,12 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => { /** On clicking outside of table reset active cell */ const smartTable = ref(null) onClickOutside(smartTable, (e) => { - clearRangeRows() - if (selected.col === null) return + // do nothing if context menu was open + if (contextMenu.value) return + clearSelectedRange() + if (selectedCell.col === null) return - const activeCol = fields.value[selected.col] + const activeCol = fields.value[selectedCell.col] if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return @@ -451,25 +447,25 @@ onClickOutside(smartTable, (e) => { return } - selected.row = null - selected.col = null + selectedCell.row = null + selectedCell.col = null }) const onNavigate = (dir: NavigateDir) => { - if (selected.row === null || selected.col === null) return + if (selectedCell.row === null || selectedCell.col === null) return editEnabled = false switch (dir) { case NavigateDir.NEXT: - if (selected.row < data.value.length - 1) { - selected.row++ + if (selectedCell.row < data.value.length - 1) { + selectedCell.row++ } else { addEmptyRow() - selected.row++ + selectedCell.row++ } break case NavigateDir.PREV: - if (selected.row > 0) { - selected.row-- + if (selectedCell.row > 0) { + selectedCell.row-- } break } @@ -729,9 +725,7 @@ watch( :key="columnObj.id" class="cell relative cursor-pointer nc-grid-cell" :class="{ - 'active': - (hasEditPermission && selected.col === colIndex && selected.row === rowIndex) || - (hasEditPermission && selectedRange(rowIndex, colIndex)), + 'active': hasEditPermission && isCellSelected(rowIndex, colIndex), 'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row), }" :data-testid="`cell-${columnObj.title}-${rowIndex}`" @@ -741,7 +735,7 @@ watch( @click="selectCell(rowIndex, colIndex)" @dblclick="makeEditable(row, columnObj)" @mousedown="startSelectRange($event, rowIndex, colIndex)" - @mouseover="selectBlock(rowIndex, colIndex)" + @mouseover="endSelectRange(rowIndex, colIndex)" @contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })" >
@@ -749,7 +743,7 @@ watch( v-if="isVirtualCol(columnObj)" v-model="row.row[columnObj.title]" :column="columnObj" - :active="selected.col === colIndex && selected.row === rowIndex" + :active="selectedCell.col === colIndex && selectedCell.row === rowIndex" :row="row" @navigate="onNavigate" /> @@ -759,10 +753,10 @@ watch( v-model="row.row[columnObj.title]" :column="columnObj" :edit-enabled=" - !!hasEditPermission && !!editEnabled && selected.col === colIndex && selected.row === rowIndex + !!hasEditPermission && !!editEnabled && selectedCell.col === colIndex && selectedCell.row === rowIndex " :row-index="rowIndex" - :active="selected.col === colIndex && selected.row === rowIndex" + :active="selectedCell.col === colIndex && selectedCell.row === rowIndex" @update:edit-enabled="editEnabled = $event" @save="updateOrSaveRow(row, columnObj.title, state)" @navigate="onNavigate" @@ -827,6 +821,13 @@ watch( {{ $t('activity.insertRow') }}
+ + +
+ + {{ $t('general.copy') }} +
+
diff --git a/packages/nc-gui/composables/useMultiSelect/cellRange.ts b/packages/nc-gui/composables/useMultiSelect/cellRange.ts new file mode 100644 index 0000000000..c059cbfe12 --- /dev/null +++ b/packages/nc-gui/composables/useMultiSelect/cellRange.ts @@ -0,0 +1,54 @@ +export interface Cell { + row: number | null + col: number | null +} + +export class CellRange { + _start: Cell | null + _end: Cell | null + + constructor(start = null, end = null) { + this._start = start + this._end = end ?? this._start + } + + get start() { + return { + row: Math.min(this._start?.row ?? NaN, this._end?.row ?? NaN), + col: Math.min(this._start?.col ?? NaN, this._end?.col ?? NaN), + } + } + + get end() { + return { + row: Math.max(this._start?.row ?? NaN, this._end?.row ?? NaN), + col: Math.max(this._start?.col ?? NaN, this._end?.col ?? NaN), + } + } + + startRange(value: Cell) { + if (value == null) { + return + } + + this._start = value + this._end = value + } + + endRange(value: Cell) { + if (value == null) { + return + } + + this._end = value + } + + clear() { + this._start = null + this._end = null + } + + isEmpty() { + return this._start == null || this._end == null + } +} diff --git a/packages/nc-gui/composables/useMultiSelect/copyValue.ts b/packages/nc-gui/composables/useMultiSelect/copyValue.ts new file mode 100644 index 0000000000..fec82fb7c9 --- /dev/null +++ b/packages/nc-gui/composables/useMultiSelect/copyValue.ts @@ -0,0 +1,29 @@ +import { UITypes } from '../../../nocodb-sdk' +import type { ColumnType } from '../../../nocodb-sdk' +import type { Row } from '~/lib' + +export const copyTable = async (rows: Row[], cols: ColumnType[]) => { + let copyHTML = '' + let copyPlainText = '' + + rows.forEach((row) => { + let copyRow = '' + cols.forEach((col) => { + let value = (col.title && row.row[col.title]) ?? '' + if (typeof value === 'object') { + value = JSON.stringify(value) + } + copyRow += `` + copyPlainText = `${copyPlainText} ${value} \t` + }) + copyHTML += `${copyRow}` + copyPlainText = `${copyPlainText.trim()}\n` + }) + copyHTML += '
${value}
' + copyPlainText.trim() + + const blobHTML = new Blob([copyHTML], { type: 'text/html' }) + const blobPlainText = new Blob([copyPlainText], { type: 'text/plain' }) + + return navigator.clipboard.write([new ClipboardItem({ [blobHTML.type]: blobHTML, [blobPlainText.type]: blobPlainText })]) +} diff --git a/packages/nc-gui/composables/useMultiSelect/index.ts b/packages/nc-gui/composables/useMultiSelect/index.ts index 9b08d29554..c27ea269ed 100644 --- a/packages/nc-gui/composables/useMultiSelect/index.ts +++ b/packages/nc-gui/composables/useMultiSelect/index.ts @@ -1,18 +1,16 @@ import type { MaybeRef } from '@vueuse/core' -import { UITypes } from 'nocodb-sdk' -import { message, reactive, ref, unref, useCopy, useEventListener, useI18n } from '#imports' - -interface SelectedBlock { - row: number | null - col: number | null -} +import type { ColumnType } from 'nocodb-sdk' +import type { Cell } from './cellRange' +import { CellRange } from './cellRange' +import { copyTable, message, reactive, ref, unref, useCopy, useEventListener, useI18n } from '#imports' +import type { Row } from '~/lib' /** * Utility to help with multi-selecting rows/cells in the smartsheet */ export function useMultiSelect( - fields: MaybeRef, - data: MaybeRef, + fields: MaybeRef, + data: MaybeRef, _editEnabled: MaybeRef, isPkAvail: MaybeRef, clearCell: Function, @@ -26,99 +24,92 @@ export function useMultiSelect( const editEnabled = ref(_editEnabled) - const selected = reactive({ row: null, col: null }) - - // save the first and the last column where the mouse is down while the value isSelectedRow is true - const selectedRows = reactive({ startCol: NaN, endCol: NaN, startRow: NaN, endRow: NaN }) - - // calculate the min and the max column where the mouse is down while the value isSelectedRow is true - const rangeRows = reactive({ minRow: NaN, maxRow: NaN, minCol: NaN, maxCol: NaN }) - - // check if mouse is down or up false=mouseup and true=mousedown - let isSelectedBlock = $ref(false) + const selectedCell = reactive({ row: null, col: null }) + const selectedRange = reactive(new CellRange()) + let isMouseDown = $ref(false) const columnLength = $computed(() => unref(fields)?.length) + async function copyValue(ctx?: Cell) { + try { + if (!selectedRange.isEmpty()) { + const cprows = unref(data).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy + const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy + + await copyTable(cprows, cpcols) + message.success(t('msg.info.copiedToClipboard')) + } else { + // if copy was called with context (right click position) - copy value from context + // else if there is just one selected cell, copy it's value + const cpRow = ctx?.row ?? selectedCell?.row + const cpCol = ctx?.col ?? selectedCell?.col + + if (cpRow != null && cpCol != null) { + const rowObj = unref(data)[cpRow] + const columnObj = unref(fields)[cpCol] + + let textToCopy = (columnObj.title && rowObj.row[columnObj.title]) || '' + if (typeof textToCopy === 'object') { + textToCopy = JSON.stringify(textToCopy) + } + await copy(textToCopy) + message.success(t('msg.info.copiedToClipboard')) + } + } + } catch { + message.error(t('msg.error.copyToClipboardError')) + } + } + function selectCell(row: number, col: number) { - clearRangeRows() - if (selected.row === row && selected.col === col) return + selectedRange.clear() + if (selectedCell.row === row && selectedCell.col === col) return editEnabled.value = false - selected.row = row - selected.col = col + selectedCell.row = row + selectedCell.col = col } - function selectBlock(row: number, col: number) { - // if selected.col and selected.row are null and isSelectedBlock is true that means you are selecting a block - if (selected.col === null || selected.row === null) { - if (isSelectedBlock) { - // save the next value after the selectionStart - selectedRows.endCol = col - selectedRows.endRow = row - } - } else if (selected.col !== col || selected.row !== row) { - // if selected.col and selected.row is not null but the selected col and row is not equal at the row and col where the mouse is clicking - // and isSelectedBlock is true that means you are selecting a block - if (isSelectedBlock) { - selected.col = null - selected.row = null - // save the next value after the selectionStart - selectedRows.endCol = col - selectedRows.endRow = row - } + function endSelectRange(row: number, col: number) { + if (!isMouseDown) { + return } + selectedCell.row = null + selectedCell.col = null + selectedRange.endRange({ row, col }) } - function selectedRange(row: number, col: number) { - if ( - !isNaN(selectedRows.startRow) && - !isNaN(selectedRows.startCol) && - !isNaN(selectedRows.endRow) && - !isNaN(selectedRows.endCol) - ) { - // check if column selection is up or down - rangeRows.minRow = Math.min(selectedRows.startRow, selectedRows.endRow) - rangeRows.maxRow = Math.max(selectedRows.startRow, selectedRows.endRow) - rangeRows.minCol = Math.min(selectedRows.startCol, selectedRows.endCol) - rangeRows.maxCol = Math.max(selectedRows.startCol, selectedRows.endCol) + function isCellSelected(row: number, col: number) { + if (selectedCell?.row === row && selectedCell?.col === col) { + return true + } - // return if the column is in between the selection - return col >= rangeRows.minCol && col <= rangeRows.maxCol && row >= rangeRows.minRow && row <= rangeRows.maxRow - } else { + if (selectedRange.isEmpty()) { return false } + + return ( + col >= selectedRange.start.col && + col <= selectedRange.end.col && + row >= selectedRange.start.row && + row <= selectedRange.end.row + ) } function startSelectRange(event: MouseEvent, row: number, col: number) { - // if editEnabled but the selected col or the selected row is not equal like the actual row or col, enabled selected multiple rows - if (unref(editEnabled) && (selected.col !== col || selected.row !== row)) { - event.preventDefault() - } else if (!unref(editEnabled)) { - // if editEnabled is not true, enabled selected multiple rows - event.preventDefault() + // if there was a right click on selected range, don't restart the selection + const leftClickButton = 0 + if (event?.button !== leftClickButton && isCellSelected(row, col)) { + return } - // clear the selection when the mouse is down - selectedRows.startCol = NaN - selectedRows.endCol = NaN - selectedRows.startRow = NaN - selectedRows.endRow = NaN - // asing where the selection start - selectedRows.startCol = col - selectedRows.startRow = row - isSelectedBlock = true - } + if (unref(editEnabled)) { + event.preventDefault() + return + } - function clearRangeRows() { - // when the selection starts or ends or when enter/arrow/tab is pressed - // this clear the previous selection - rangeRows.minCol = NaN - rangeRows.maxCol = NaN - rangeRows.minRow = NaN - rangeRows.maxRow = NaN - selectedRows.startRow = NaN - selectedRows.startCol = NaN - selectedRows.endRow = NaN - selectedRows.endCol = NaN + isMouseDown = true + selectedRange.clear() + selectedRange.startRange({ row, col }) } useEventListener(document, 'mouseup', (e) => { @@ -127,7 +118,7 @@ export function useMultiSelect( e.preventDefault() } - isSelectedBlock = false + isMouseDown = false }) const onKeyDown = async (e: KeyboardEvent) => { @@ -136,41 +127,36 @@ export function useMultiSelect( return true } - if ( - !isNaN(selectedRows.startRow) && - !isNaN(selectedRows.startCol) && - !isNaN(selectedRows.endRow) && - !isNaN(selectedRows.endCol) - ) { + if (!selectedRange.isEmpty()) { // In case the user press tabs or arrows keys - selected.row = selectedRows.startRow - selected.col = selectedRows.startCol + selectedCell.row = selectedRange.start.row + selectedCell.col = selectedRange.start.col } - if (selected.row === null || selected.col === null) return + if (selectedCell.row === null || selectedCell.col === null) return /** on tab key press navigate through cells */ switch (e.key) { case 'Tab': e.preventDefault() - clearRangeRows() + selectedRange.clear() if (e.shiftKey) { - if (selected.col > 0) { - selected.col-- + if (selectedCell.col > 0) { + selectedCell.col-- editEnabled.value = false - } else if (selected.row > 0) { - selected.row-- - selected.col = unref(columnLength) - 1 + } else if (selectedCell.row > 0) { + selectedCell.row-- + selectedCell.col = unref(columnLength) - 1 editEnabled.value = false } } else { - if (selected.col < unref(columnLength) - 1) { - selected.col++ + if (selectedCell.col < unref(columnLength) - 1) { + selectedCell.col++ editEnabled.value = false - } else if (selected.row < unref(data).length - 1) { - selected.row++ - selected.col = 0 + } else if (selectedCell.row < unref(data).length - 1) { + selectedCell.row++ + selectedCell.col = 0 editEnabled.value = false } } @@ -179,90 +165,63 @@ export function useMultiSelect( /** on enter key press make cell editable */ case 'Enter': e.preventDefault() - clearRangeRows() - makeEditable(unref(data)[selected.row], unref(fields)[selected.col]) + selectedRange.clear() + makeEditable(unref(data)[selectedCell.row], unref(fields)[selectedCell.col]) break /** on delete key press clear cell */ case 'Delete': e.preventDefault() - clearRangeRows() - await clearCell(selected as { row: number; col: number }) + selectedRange.clear() + await clearCell(selectedCell as { row: number; col: number }) break /** on arrow key press navigate through cells */ case 'ArrowRight': e.preventDefault() - clearRangeRows() - if (selected.col < unref(columnLength) - 1) { - selected.col++ + selectedRange.clear() + if (selectedCell.col < unref(columnLength) - 1) { + selectedCell.col++ scrollToActiveCell?.() editEnabled.value = false } break case 'ArrowLeft': - clearRangeRows() + selectedRange.clear() e.preventDefault() - clearRangeRows() - if (selected.col > 0) { - selected.col-- + if (selectedCell.col > 0) { + selectedCell.col-- scrollToActiveCell?.() editEnabled.value = false } break case 'ArrowUp': - clearRangeRows() + selectedRange.clear() e.preventDefault() - clearRangeRows() - if (selected.row > 0) { - selected.row-- + if (selectedCell.row > 0) { + selectedCell.row-- scrollToActiveCell?.() editEnabled.value = false } break case 'ArrowDown': - clearRangeRows() + selectedRange.clear() e.preventDefault() - clearRangeRows() - if (selected.row < unref(data).length - 1) { - selected.row++ + if (selectedCell.row < unref(data).length - 1) { + selectedCell.row++ scrollToActiveCell?.() editEnabled.value = false } break default: { - const rowObj = unref(data)[selected.row] + const rowObj = unref(data)[selectedCell.row] - const columnObj = unref(fields)[selected.col] - - let cptext = '' // variable for save the text to be copy - - if (!isNaN(rangeRows.minRow) && !isNaN(rangeRows.maxRow) && !isNaN(rangeRows.minCol) && !isNaN(rangeRows.maxCol)) { - const cprows = unref(data).slice(rangeRows.minRow, rangeRows.maxRow + 1) // slice the selected rows for copy - - const cpcols = unref(fields).slice(rangeRows.minCol, rangeRows.maxCol + 1) // slice the selected cols for copy - - cprows.forEach((row) => { - cpcols.forEach((col) => { - // todo: JSON stringify the attachment cell and LTAR contents for copy - // filter attachment cells and LATR cells from copy - if (col.uidt !== UITypes.Attachment && col.uidt !== UITypes.LinkToAnotherRecord) { - cptext = `${cptext} ${row.row[col.title]} \t` - } - }) - - cptext = `${cptext.trim()}\n` - }) - - cptext.trim() - } else { - cptext = rowObj.row[columnObj.title] || '' - } + const columnObj = unref(fields)[selectedCell.col] if ((!unref(editEnabled) && e.metaKey) || e.ctrlKey) { switch (e.keyCode) { // copy - ctrl/cmd +c case 67: - await copy(cptext) + await copyValue() break } } @@ -277,7 +236,7 @@ export function useMultiSelect( // Update not allowed for table which doesn't have primary Key return message.info(t('msg.info.updateNotAllowedWithoutPK')) } - if (makeEditable(rowObj, columnObj)) { + if (makeEditable(rowObj, columnObj) && columnObj.title) { rowObj.row[columnObj.title] = '' } // editEnabled = true @@ -291,12 +250,11 @@ export function useMultiSelect( return { selectCell, - selectBlock, - selectedRange, - clearRangeRows, startSelectRange, - selected, - selectedRows, - rangeRows, + endSelectRange, + clearSelectedRange: selectedRange.clear.bind(selectedRange), + copyValue, + isCellSelected, + selectedCell, } } diff --git a/packages/nc-gui/context/index.ts b/packages/nc-gui/context/index.ts index 21278b9876..5cbff053fa 100644 --- a/packages/nc-gui/context/index.ts +++ b/packages/nc-gui/context/index.ts @@ -26,7 +26,7 @@ export const ReloadViewDataHookInj: InjectionKey> = Sy export const ReloadViewMetaHookInj: InjectionKey> = Symbol('reload-view-meta-injection') export const ReloadRowDataHookInj: InjectionKey> = Symbol('reload-row-data-injection') export const OpenNewRecordFormHookInj: InjectionKey> = Symbol('open-new-record-form-injection') -export const FieldsInj: InjectionKey> = Symbol('fields-injection') +export const FieldsInj: InjectionKey> = Symbol('fields-injection') export const EditModeInj: InjectionKey> = Symbol('edit-mode-injection') export const SharedViewPasswordInj: InjectionKey> = Symbol('shared-view-password-injection') export const CellUrlDisableOverlayInj: InjectionKey> = Symbol('cell-url-disable-url') diff --git a/packages/nc-gui/lang/en.json b/packages/nc-gui/lang/en.json index 08b0da76e1..056c292195 100644 --- a/packages/nc-gui/lang/en.json +++ b/packages/nc-gui/lang/en.json @@ -669,7 +669,8 @@ "parameterKeyCannotBeEmpty": "Parameter key cannot be empty", "duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed", "fieldRequired": "{value} cannot be empty.", - "projectNotAccessible": "Project not accessible" + "projectNotAccessible": "Project not accessible", + "copyToClipboardError": "Failed to copy to clipboard" }, "toast": { "exportMetadata": "Project metadata exported successfully",