Browse Source

Merge branch 'develop' into feat/keyboard-manoeuvre

pull/4482/head
Wing-Kam Wong 2 years ago
parent
commit
963c5a230f
  1. 79
      packages/nc-gui/components/smartsheet/Grid.vue
  2. 54
      packages/nc-gui/composables/useMultiSelect/cellRange.ts
  3. 29
      packages/nc-gui/composables/useMultiSelect/copyValue.ts
  4. 272
      packages/nc-gui/composables/useMultiSelect/index.ts
  5. 2
      packages/nc-gui/context/index.ts
  6. 3
      packages/nc-gui/lang/en.json

79
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 })"
>
<div v-if="!switchingTab" class="w-full h-full">
@ -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') }}
</div>
</a-menu-item>
<a-menu-item v-if="contextMenuTarget" @click="copyValue(contextMenuTarget)">
<div v-e="['a:row:copy']" class="nc-project-menu-item">
<!-- Copy -->
{{ $t('general.copy') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>

54
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
}
}

29
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 = '<table>'
let copyPlainText = ''
rows.forEach((row) => {
let copyRow = '<tr>'
cols.forEach((col) => {
let value = (col.title && row.row[col.title]) ?? ''
if (typeof value === 'object') {
value = JSON.stringify(value)
}
copyRow += `<td>${value}</td>`
copyPlainText = `${copyPlainText} ${value} \t`
})
copyHTML += `${copyRow}</tr>`
copyPlainText = `${copyPlainText.trim()}\n`
})
copyHTML += '</table>'
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 })])
}

272
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<any[]>,
data: MaybeRef<any[]>,
fields: MaybeRef<ColumnType[]>,
data: MaybeRef<Row[]>,
_editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function,
@ -26,99 +24,92 @@ export function useMultiSelect(
const editEnabled = ref(_editEnabled)
const selected = reactive<SelectedBlock>({ 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<Cell>({ 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,
}
}

2
packages/nc-gui/context/index.ts

@ -26,7 +26,7 @@ export const ReloadViewDataHookInj: InjectionKey<EventHook<boolean | void>> = Sy
export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-injection')
export const ReloadRowDataHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-row-data-injection')
export const OpenNewRecordFormHookInj: InjectionKey<EventHook<void>> = Symbol('open-new-record-form-injection')
export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')
export const FieldsInj: InjectionKey<Ref<ColumnType[]>> = Symbol('fields-injection')
export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injection')
export const SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('shared-view-password-injection')
export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url')

3
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",

Loading…
Cancel
Save