Browse Source

feat: vertical fill using handle

Signed-off-by: mertmit <mertmit99@gmail.com>
pull/5896/head
mertmit 1 year ago
parent
commit
c2d028ec79
  1. 103
      packages/nc-gui/components/smartsheet/Grid.vue
  2. 2
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  3. 217
      packages/nc-gui/composables/useMultiSelect/index.ts

103
packages/nc-gui/components/smartsheet/Grid.vue

@ -2,6 +2,7 @@
import { nextTick } from '@vue/runtime-core'
import type { ColumnReqType, ColumnType, GridType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { UseElementBoundingReturn } from '@vueuse/core'
import {
ActiveViewInj,
CellUrlDisableOverlayInj,
@ -109,6 +110,7 @@ const expandedFormRowState = ref<Record<string, any>>()
const gridWrapper = ref<HTMLElement>()
const tableHeadEl = ref<HTMLElement>()
const tableBodyEl = ref<HTMLElement>()
const fillHandle = ref<HTMLElement>()
const isAddingColumnAllowed = $computed(() => !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value)
@ -207,6 +209,7 @@ const {
resetSelectedRange,
makeActive,
selectedRange,
isCellInFillRange,
} = useMultiSelect(
meta,
fields,
@ -348,6 +351,7 @@ const {
await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title)
},
bulkUpdateRows,
fillHandle,
)
function scrollToCell(row?: number | null, col?: number | null) {
@ -650,9 +654,9 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
}
})
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
/** On clicking outside of table reset active cell */
onClickOutside(tableBodyEl, (e) => {
// do nothing if context menu was open
if (contextMenu.value) return
@ -666,7 +670,7 @@ onClickOutside(tableBodyEl, (e) => {
// ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// or single/multi select options
const activePickerOrDropdownEl = document.querySelector(
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active',
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active,.nc-fill-handle',
)
if (
e.target &&
@ -881,10 +885,51 @@ function addEmptyRow(row?: number) {
nextTick().then(() => {
clearSelectedRange()
makeActive(row ?? data.value.length - 1, 0)
selectedRange.startRange({ row: activeCell.row!, col: activeCell.col! })
scrollToCell?.()
})
return rowObj
}
const tableRect = useElementBounding(smartTable)
const { height: tableHeight, width: tableWidth } = tableRect
const { x: gridX, y: gridY } = useScroll(gridWrapper)
const fillHandleTop = ref()
const fillHandleLeft = ref()
const cellRefs = ref<{ el: HTMLElement }[]>([])
let cellRect: UseElementBoundingReturn | null = null
const refreshFillHandle = () => {
const cellRef = cellRefs.value.find(
(cell) =>
cell.el.dataset.rowIndex === String(selectedRange.end.row) && cell.el.dataset.colIndex === String(selectedRange.end.col),
)
if (cellRef) {
cellRect = useElementBounding(cellRef.el)
if (!cellRect || !tableRect) return
if (selectedRange.end.col === 0) {
fillHandleTop.value = cellRect.top.value - tableRect.top.value + cellRect.height.value - 4.5 + gridY.value
fillHandleLeft.value = cellRect.left.value - tableRect.left.value + cellRect.width.value - 4.5
return
}
fillHandleTop.value = cellRect.top.value - tableRect.top.value + cellRect.height.value - 4.5 + gridY.value
fillHandleLeft.value = cellRect.left.value - tableRect.left.value + cellRect.width.value - 4.5 + gridX.value
}
}
watch(
() => `${selectedRange.end.row}-${selectedRange.end.col}`,
(n, o) => {
if (n !== o) {
if (gridWrapper.value) {
refreshFillHandle()
}
}
},
)
</script>
<template>
@ -895,7 +940,17 @@ function addEmptyRow(row?: number) {
</div>
</general-overlay>
<div ref="gridWrapper" class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-dull">
<div
ref="gridWrapper"
class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-dull"
:class="{
relative:
!readOnly &&
!isLocked &&
(!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null)) &&
((!selectedRange.isEmpty() && selectedRange.end.col !== 0) || (selectedRange.isEmpty() && activeCell.col !== 0)),
}"
>
<a-dropdown
v-model:visible="contextMenu"
:trigger="isSqlView ? [] : ['contextmenu']"
@ -1041,6 +1096,7 @@ function addEmptyRow(row?: number) {
<SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields"
:key="columnObj.id"
ref="cellRefs"
class="cell relative nc-grid-cell"
:class="{
'cursor-pointer': hasEditPermission,
@ -1048,11 +1104,14 @@ function addEmptyRow(row?: number) {
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1,
'filling': isCellInFillRange(rowIndex, colIndex),
}"
:data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
:data-title="columnObj.title"
:data-row-index="rowIndex"
:data-col-index="colIndex"
@mousedown="handleMouseDown($event, rowIndex, colIndex)"
@mouseover="handleMouseOver($event, rowIndex, colIndex)"
@click="handleCellClick($event, rowIndex, colIndex)"
@ -1184,6 +1243,28 @@ function addEmptyRow(row?: number) {
</a-menu>
</template>
</a-dropdown>
<div
class="table-overlay absolute top-0 left-0 pointer-events-none overflow-hidden"
:style="{ height: `${tableHeight}px`, width: `${tableWidth}px` }"
>
<!-- Fill Handle -->
<div
v-show="
!readOnly &&
!isLocked &&
!editEnabled &&
(!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null))
"
ref="fillHandle"
class="nc-fill-handle absolute w-[8px] h-[8px] rounded-full bg-red-500 !pointer-events-auto"
:class="
(!selectedRange.isEmpty() && selectedRange.end.col !== 0) || (selectedRange.isEmpty() && activeCell.col !== 0)
? 'z-3'
: 'z-4'
"
:style="{ top: `${fillHandleTop}px`, left: `${fillHandleLeft}px`, cursor: 'crosshair' }"
/>
</div>
</div>
<div
@ -1293,6 +1374,22 @@ function addEmptyRow(row?: number) {
@apply border-1 border-solid text-primary border-current bg-primary bg-opacity-5;
}
td.filling::after {
content: '';
position: absolute;
z-index: 3;
height: calc(100% + 2px);
width: calc(100% + 2px);
left: -1px;
top: -1px;
pointer-events: none;
}
// todo: replace with css variable
td.filling::after {
@apply border-1 border-solid text-accent border-current;
}
//td.active::before {
// content: '';
// z-index:4;

2
packages/nc-gui/components/smartsheet/TableDataCell.vue

@ -8,6 +8,8 @@ const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook)
provide(CurrentCellInj, el)
defineExpose({ el })
</script>
<template>

217
packages/nc-gui/composables/useMultiSelect/index.ts

@ -48,6 +48,7 @@ export function useMultiSelect(
keyEventHandler?: Function,
syncCellData?: Function,
bulkUpdateRows?: Function,
fillHandle?: MaybeRef<HTMLElement | undefined>,
) {
const meta = ref(_meta)
@ -65,8 +66,12 @@ export function useMultiSelect(
let isMouseDown = $ref(false)
let fillMode = $ref(false)
const selectedRange = reactive(new CellRange())
const fillRange = reactive(new CellRange())
const activeCell = reactive<Nullable<Cell>>({ row: null, col: null })
const columnLength = $computed(() => unref(fields)?.length)
@ -159,23 +164,33 @@ export function useMultiSelect(
return textToCopy
}
const copyTable = async (rows: Row[], cols: ColumnType[]) => {
let copyHTML = '<table>'
let copyPlainText = ''
const serializeRange = (rows: Row[], cols: ColumnType[]) => {
let html = '<table>'
let text = ''
const json: string[][] = []
rows.forEach((row, i) => {
let copyRow = '<tr>'
const jsonRow: string[] = []
cols.forEach((col, i) => {
const value = valueToCopy(row, col)
copyRow += `<td>${value}</td>`
copyPlainText = `${copyPlainText}${value}${cols.length - 1 !== i ? '\t' : ''}`
text = `${text}${value}${cols.length - 1 !== i ? '\t' : ''}`
jsonRow.push(col.uidt === UITypes.LongText ? value.replace(/^"/, '').replace(/"$/, '').replace(/""/g, '"') : value)
})
copyHTML += `${copyRow}</tr>`
html += `${copyRow}</tr>`
if (rows.length - 1 !== i) {
copyPlainText = `${copyPlainText}\n`
text = `${text}\n`
}
json.push(jsonRow)
})
copyHTML += '</table>'
html += '</table>'
return { html, text, json }
}
const copyTable = async (rows: Row[], cols: ColumnType[]) => {
const { html: copyHTML, text: copyPlainText } = serializeRange(rows, cols)
const blobHTML = new Blob([copyHTML], { type: 'text/html' })
const blobPlainText = new Blob([copyPlainText], { type: 'text/plain' })
@ -229,7 +244,65 @@ export function useMultiSelect(
)
}
function isCellInFillRange(row: number, col: number) {
if (fillRange.start === null || fillRange.end === null) {
return false
}
if (
col >= selectedRange.start.col &&
col <= selectedRange.end.col &&
row >= selectedRange.start.row &&
row <= selectedRange.end.row
) {
return false
}
return col >= fillRange.start.col && col <= fillRange.end.col && row >= fillRange.start.row && row <= fillRange.end.row
}
const isPasteable = (row?: Row, col?: ColumnType, showInfo = false) => {
if (!row || !col) {
if (showInfo) {
message.info('Please select a cell to paste')
}
return false
}
// skip pasting virtual columns (including LTAR columns for now) and system columns
if (isVirtualCol(col) || isSystemColumn(col)) {
if (showInfo) {
message.info(t('msg.info.pasteNotSupported'))
}
return false
}
// skip pasting auto increment columns
if (col.ai) {
if (showInfo) {
message.info(t('msg.info.autoIncFieldNotEditable'))
}
return false
}
// skip pasting primary key columns
if (col.pk && !row.rowMeta.new) {
if (showInfo) {
message.info(t('msg.info.editingPKnotSupported'))
}
return false
}
return true
}
function handleMouseOver(event: MouseEvent, row: number, col: number) {
if (fillMode) {
fillRange.endRange({ row, col: selectedRange.end.col })
scrollToCell?.(row, col)
return
}
if (!isMouseDown) {
return
}
@ -290,6 +363,82 @@ export function useMultiSelect(
}
const handleMouseUp = (_event: MouseEvent) => {
if (fillMode) {
fillMode = false
if (fillRange._start === null || fillRange._end === null) return
if (selectedRange.start !== null && selectedRange.end !== null) {
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
const rawMatrix = serializeRange(cprows, cpcols).json
const fillDirection = fillRange._start.row <= fillRange._end.row ? 1 : -1
let fillIndex = fillDirection === 1 ? 0 : rawMatrix.length - 1
const rowsToPaste: Row[] = []
const propsToPaste: string[] = []
for (
let row = fillRange._start.row;
fillDirection === 1 ? row <= fillRange._end.row : row >= fillRange._end.row;
row += fillDirection
) {
if (isCellSelected(row, selectedRange.start.col)) {
continue
}
const rowObj = unref(data)[row]
let pasteIndex = 0
for (let col = fillRange.start.col; col <= fillRange.end.col; col++) {
const colObj = unref(fields)[col]
if (!isPasteable(rowObj, colObj)) {
pasteIndex++
continue
}
propsToPaste.push(colObj.title!)
const pasteValue = convertCellData(
{
value: rawMatrix[fillIndex][pasteIndex],
to: colObj.uidt as UITypes,
column: colObj,
appInfo: unref(appInfo),
},
isMysql(meta.value?.base_id),
true,
)
if (pasteValue !== undefined) {
rowObj.row[colObj.title!] = pasteValue
rowsToPaste.push(rowObj)
}
pasteIndex++
}
if (fillDirection === 1) {
fillIndex = fillIndex < rawMatrix.length - 1 ? fillIndex + 1 : 0
} else {
fillIndex = fillIndex >= 1 ? fillIndex - 1 : rawMatrix.length - 1
}
}
bulkUpdateRows?.(rowsToPaste, propsToPaste).then(() => {
fillRange.clear()
})
} else {
fillRange.clear()
}
return
}
if (isMouseDown) {
isMouseDown = false
// timeout is needed, because we want to set cell as active AFTER all the child's click handler's called
@ -530,41 +679,6 @@ export function useMultiSelect(
const clearSelectedRange = selectedRange.clear.bind(selectedRange)
const isPasteable = (row?: Row, col?: ColumnType, showInfo = false) => {
if (!row || !col) {
if (showInfo) {
message.info('Please select a cell to paste')
}
return false
}
// skip pasting virtual columns (including LTAR columns for now) and system columns
if (isVirtualCol(col) || isSystemColumn(col)) {
if (showInfo) {
message.info(t('msg.info.pasteNotSupported'))
}
return false
}
// skip pasting auto increment columns
if (col.ai) {
if (showInfo) {
message.info(t('msg.info.autoIncFieldNotEditable'))
}
return false
}
// skip pasting primary key columns
if (col.pk && !row.rowMeta.new) {
if (showInfo) {
message.info(t('msg.info.editingPKnotSupported'))
}
return false
}
return true
}
const handlePaste = async (e: ClipboardEvent) => {
if (isDrawerOrModalExist()) {
return
@ -751,10 +865,27 @@ export function useMultiSelect(
}
}
function fillHandleMouseDown(event: MouseEvent) {
if (event?.button !== MAIN_MOUSE_PRESSED) {
return
}
fillMode = true
if (selectedRange._start && selectedRange._end) {
fillRange.startRange({ row: selectedRange._start?.row, col: selectedRange.start.col })
fillRange.endRange({ row: selectedRange._end?.row, col: selectedRange.end.col })
}
event.preventDefault()
}
useEventListener(document, 'keydown', handleKeyDown)
useEventListener(document, 'mouseup', handleMouseUp)
useEventListener(document, 'paste', handlePaste)
useEventListener(fillHandle, 'mousedown', fillHandleMouseDown)
return {
isCellActive,
handleMouseDown,
@ -767,5 +898,7 @@ export function useMultiSelect(
resetSelectedRange,
selectedRange,
makeActive,
fillHandleMouseDown,
isCellInFillRange,
}
}

Loading…
Cancel
Save