Files
nocodb/packages/nc-gui/components/smartsheet/grid/canvas/composables/useCanvasRender.ts

1702 lines
54 KiB
TypeScript

import type { WritableComputedRef } from '@vue/reactivity'
import { AllAggregations, type ColumnType, type TableType } from 'nocodb-sdk'
import type { Composer } from 'vue-i18n'
import { isBoxHovered, renderCheckbox, renderIconButton, renderSingleLineText, roundedRect, truncateText } from '../utils/canvas'
import type { ImageWindowLoader } from '../loaders/ImageLoader'
import type { SpriteLoader } from '../loaders/SpriteLoader'
import { renderIcon } from '../../../header/CellIcon'
import { renderIcon as renderVIcon } from '../../../header/VirtualCellIcon'
import type { TableMetaLoader } from '../loaders/TableMetaLoader'
import { ADD_NEW_COLUMN_WIDTH, COLUMN_HEADER_HEIGHT_IN_PX, MAX_SELECTED_ROWS, ROW_META_COLUMN_WIDTH } from '../utils/constants'
import { parseCellWidth } from '../utils/cell'
export function useCanvasRender({
width,
height,
columns,
colSlice,
scrollLeft,
scrollTop,
rowSlice,
rowHeight,
cachedRows,
activeCell,
dragOver,
hoverRow,
selection,
isAiFillMode,
isFillMode,
getFillHandlerPosition,
spriteLoader,
imageLoader,
tableMetaLoader,
partialRowHeight,
vSelectedAllRecords,
isRowDraggingEnabled,
isAddingColumnAllowed,
isAddingEmptyRowAllowed,
selectedRows,
isDragging,
draggedRowIndex,
targetRowIndex,
mousePosition,
renderCell,
meta,
editEnabled,
totalWidth,
totalRows,
t,
readOnly,
isFieldEditAllowed,
setCursor,
totalColumnsWidth,
}: {
width: Ref<number>
height: Ref<number>
rowHeight: Ref<number>
columns: ComputedRef<CanvasGridColumn[]>
colSlice: Ref<{ start: number; end: number }>
rowSlice: Ref<{ start: number; end: number }>
activeCell: Ref<{ row: number; column: number }>
scrollLeft: Ref<number>
scrollTop: Ref<number>
cachedRows: Ref<Map<number, Row>>
dragOver: Ref<{ id: string; index: number } | null>
hoverRow: Ref<number>
totalWidth: ComputedRef<number>
selection: Ref<CellRange>
isAiFillMode: ComputedRef<boolean>
isRowDraggingEnabled: ComputedRef<boolean>
isAddingColumnAllowed: ComputedRef<boolean>
isAddingEmptyRowAllowed: ComputedRef<boolean>
isFillMode: Ref<boolean>
getFillHandlerPosition: () => FillHandlerPosition | null
imageLoader: ImageWindowLoader
spriteLoader: SpriteLoader
tableMetaLoader: TableMetaLoader
partialRowHeight: Ref<number>
vSelectedAllRecords: WritableComputedRef<boolean>
selectedRows: Ref<Row[]>
isDragging: Ref<boolean>
draggedRowIndex: Ref<number | null>
targetRowIndex: Ref<number | null>
mousePosition: { x: number; y: number }
renderCell: (ctx: CanvasRenderingContext2D, column: ColumnType, options: any) => void
meta: ComputedRef<TableType>
editEnabled: Ref<CanvasEditEnabledType>
totalRows: Ref<number>
t: Composer['t']
readOnly: Ref<boolean>
isFillHandleDisabled: ComputedRef<boolean>
isFieldEditAllowed: ComputedRef<boolean>
isDataEditAllowed: ComputedRef<boolean>
setCursor: SetCursorType
totalColumnsWidth: ComputedRef<number>
}) {
const canvasRef = ref<HTMLCanvasElement>()
const colResizeHoveredColIds = ref(new Set())
const { tryShowTooltip } = useTooltipStore()
const { isMobileMode } = useGlobal()
const isLocked = inject(IsLockedInj, ref(false))
const drawShimmerEffect = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, rowIdx: number) => {
ctx.save()
width = Math.min(width, rowIdx % 2 === 0 ? 124 : 144) - 24
ctx.beginPath()
ctx.roundRect(x + 12, y + 7.5, width, 16, 10)
ctx.fillStyle = '#E7E7E9'
ctx.fill()
ctx.clip()
ctx.restore()
}
function renderHeader(
ctx: CanvasRenderingContext2D,
activeState?: {
x: number
y: number
width: number
height: number
} | null,
) {
const canvasWidth = width.value
// ctx.textAlign is previously set during the previous render calls and that carries over here
// causing the misalignment. Resetting textAlign fixes it.
ctx.textAlign = 'left'
const plusColumnWidth = ADD_NEW_COLUMN_WIDTH
const columnsWidth =
totalColumnsWidth.value + (isAddingColumnAllowed.value && !isMobileMode.value ? plusColumnWidth : 0) - scrollLeft.value
// Header background
ctx.fillStyle = '#f4f4f5'
ctx.fillRect(0, 0, columnsWidth, 32)
// Header borders
ctx.strokeStyle = '#e7e7e9'
ctx.lineWidth = 1
// Bottom border
ctx.beginPath()
ctx.moveTo(0, 32)
ctx.lineTo(columnsWidth, 32)
ctx.stroke()
const { start: startColIndex, end: endColIndex } = colSlice.value
const visibleCols = columns.value.slice(startColIndex, endColIndex)
let initialOffset = 1
for (let i = 0; i < startColIndex; i++) {
initialOffset += parseCellWidth(columns.value[i]?.width)
}
// Regular columns
ctx.fillStyle = '#6a7184'
ctx.font = '550 12px Manrope'
ctx.textBaseline = 'middle'
ctx.imageSmoothingEnabled = false
let xOffset = initialOffset
for (const column of visibleCols) {
const colObj = column.columnObj
const width = parseCellWidth(column.width)
if (column.fixed) {
xOffset += width
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, 0)
ctx.lineTo(xOffset - scrollLeft.value, 32)
ctx.stroke()
continue
}
const rightPadding = 8
let iconSpace = rightPadding
iconSpace += 16
if (column.isInvalidColumn?.isInvalid && !column.isInvalidColumn?.ignoreTooltip) {
iconSpace += 18
}
if (column?.columnObj?.description?.length) {
iconSpace += 18
}
const iconConfig = (
column?.virtual ? renderVIcon(column.columnObj, column.relatedColObj) : renderIcon(column.columnObj, column.abstractType)
) as any
if (column.uidt) {
spriteLoader.renderIcon(ctx, {
icon: column?.virtual ? iconConfig?.icon : iconConfig,
size: 13,
color: iconConfig?.hex ?? '#6a7184',
x: xOffset + 8 - scrollLeft.value,
y: 8,
})
}
const isRequired = column.virtual ? isVirtualColRequired(colObj, meta.value?.columns || []) : colObj?.rqd && !colObj?.cdf
const availableTextWidth = width - (26 + iconSpace + (isRequired ? 4 : 0))
const truncatedText = truncateText(ctx, column.title!, availableTextWidth)
ctx.fillText(truncatedText, xOffset + 26 - scrollLeft.value, 16)
if (isRequired) {
ctx.save()
ctx.fillStyle = '#EF4444'
ctx.fillText('*', xOffset + 28 - scrollLeft.value + ctx.measureText(truncatedText).width, 16)
ctx.restore()
}
let rightOffset = xOffset + width - rightPadding
if (isFieldEditAllowed.value && !colObj?.readonly) {
rightOffset -= 16
spriteLoader.renderIcon(ctx, {
icon: 'chevronDown',
size: 14,
color: '#6a7184',
x: rightOffset - scrollLeft.value,
y: 9,
})
} else if (meta.value?.synced && colObj?.readonly) {
rightOffset -= 16
spriteLoader.renderIcon(ctx, {
icon: 'refresh',
size: 14,
color: '#6a7184',
x: rightOffset - scrollLeft.value,
y: 9,
})
}
if (column.isInvalidColumn?.isInvalid && !column.isInvalidColumn?.ignoreTooltip) {
rightOffset -= 18
spriteLoader.renderIcon(ctx, {
icon: 'alertTriangle',
size: 14,
color: '#FF928C',
x: rightOffset - scrollLeft.value,
y: 9,
})
}
if (column?.columnObj?.description?.length) {
rightOffset -= 18
spriteLoader.renderIcon(ctx, {
icon: 'ncInfo',
size: 13,
color: '#6B7280',
x: rightOffset - scrollLeft.value,
y: 9,
})
}
xOffset += width
const resizeHandleWidth = 10
const isNearEdge =
mousePosition && Math.abs(xOffset - scrollLeft.value - mousePosition.x) <= resizeHandleWidth && mousePosition.y <= 32
if (isNearEdge && !isLocked.value) {
colResizeHoveredColIds.value.add(column.id)
ctx.strokeStyle = '#9CDAFA'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, 0)
ctx.lineTo(xOffset - scrollLeft.value, 32)
ctx.stroke()
// Reset for regular column separator
ctx.strokeStyle = '#e7e7e9'
ctx.lineWidth = 1
} else {
colResizeHoveredColIds.value.delete(column.id)
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, 0)
ctx.lineTo(xOffset - scrollLeft.value, 32)
ctx.stroke()
}
}
if (isAddingColumnAllowed.value && !isMobileMode.value) {
ctx.fillStyle = '#F9F9FA'
ctx.fillRect(xOffset - scrollLeft.value, 0, plusColumnWidth, 32)
spriteLoader.renderIcon(ctx, {
icon: 'ncPlus',
size: 16,
color: '#6a7184',
x: xOffset + plusColumnWidth / 2 - 8 - scrollLeft.value,
y: 8,
})
ctx.beginPath()
ctx.moveTo(xOffset + plusColumnWidth - scrollLeft.value, 0)
ctx.lineTo(xOffset + plusColumnWidth - scrollLeft.value, 32)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, 32)
ctx.lineTo(xOffset + plusColumnWidth - scrollLeft.value, 32)
ctx.stroke()
}
const fillHandler = getFillHandlerPosition()
// The issue is the border gets drawn over the active state border.
// For quick hack, we skip rendering border over the y values of the active state to avoid the overlap.
if (
(activeState &&
xOffset - scrollLeft.value >= activeState.x &&
xOffset - scrollLeft.value <= activeState.x + activeState.width) ||
(fillHandler && xOffset - scrollLeft.value + 1 >= fillHandler.x && xOffset - scrollLeft.value - 1 <= fillHandler.x)
) {
// Draw line above active state
ctx.strokeStyle = '#f4f4f5'
if (fillHandler && activeState?.y) {
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, 32)
ctx.lineTo(xOffset - scrollLeft.value, activeState.y)
ctx.stroke()
}
if (fillHandler) {
// draw line between active state and fill handler
if (!isFillMode.value && !selection.value.isSingleCell()) {
ctx.beginPath()
if (selection.value.start.col !== selection.value.end.col) {
ctx.moveTo(xOffset - scrollLeft.value, activeState ? activeState.y : 32)
} else {
let y = activeState ? activeState.y + activeState.height : 32
// Adjust y position if fill handler is in the same active cell and multiple rows are selected
if (y === fillHandler.y) y -= fillHandler.size / 2
ctx.moveTo(xOffset - scrollLeft.value, y)
}
ctx.lineTo(xOffset - scrollLeft.value, fillHandler.y - fillHandler.size / 2)
ctx.stroke()
}
// Draw line below the fill handler
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, fillHandler.y + fillHandler.size / 2)
ctx.lineTo(xOffset - scrollLeft.value, (rowSlice.value.end - rowSlice.value.start + 1) * rowHeight.value + 32)
ctx.stroke()
} else if (activeState?.y && activeState?.height) {
// Draw line below active state
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, activeState.y + activeState.height)
ctx.lineTo(xOffset - scrollLeft.value, (rowSlice.value.end - rowSlice.value.start + 1) * rowHeight.value + 32)
ctx.stroke()
}
} else if (visibleCols.filter((f) => !f.fixed).length) {
// Draw full line if not intersecting with active state
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, 32)
ctx.lineTo(
xOffset - scrollLeft.value,
(rowSlice.value.end - rowSlice.value.start + 1) * rowHeight.value + 33 - partialRowHeight.value,
)
ctx.stroke()
}
// Fixed columns
const fixedCols = columns.value.filter((col) => col.fixed)
if (fixedCols.length) {
xOffset = 0.5
fixedCols.forEach((column) => {
const width = parseCellWidth(column.width)
const rightPadding = 8
let iconSpace = rightPadding
const colObj = column.columnObj
iconSpace += 16
if (column.isInvalidColumn?.isInvalid && !column.isInvalidColumn?.ignoreTooltip) {
iconSpace += 18
}
if (column?.columnObj?.description?.length) {
iconSpace += 18
}
// Background
ctx.fillStyle = '#f4f4f5'
ctx.fillRect(xOffset, 0, width, 32)
ctx.fillStyle = '#6a7184'
const iconConfig = (
column?.virtual
? renderVIcon(column.columnObj, column.relatedColObj)
: renderIcon(column.columnObj, column.abstractType)
) as any
if (column.uidt) {
spriteLoader.renderIcon(ctx, {
icon: column?.virtual ? iconConfig?.icon : iconConfig,
size: 13,
color: iconConfig?.hex ?? '#6a7184',
x: xOffset + 8,
y: 8,
})
}
const isRequired = column.virtual ? isVirtualColRequired(colObj, meta.value?.columns || []) : colObj?.rqd && !colObj?.cdf
const availableTextWidth = width - (26 + iconSpace + (isRequired ? 4 : 0))
const truncatedText = truncateText(ctx, column.title!, availableTextWidth)
const x = xOffset + (column.uidt ? 26 : 10)
const y = 16
if (column.id === 'row_number') {
if (
!readOnly.value &&
(vSelectedAllRecords.value || isBoxHovered({ x: 0, y: 0, width: canvasWidth, height: 32 }, mousePosition))
) {
const checkSize = 16
const isCheckboxHovered = isBoxHovered({ x, y: y - 8, width: checkSize, height: checkSize }, mousePosition)
renderCheckbox(
ctx,
x,
y - 8,
vSelectedAllRecords.value,
false,
spriteLoader,
isCheckboxHovered ? '#3366FF' : '#D9D9D9',
)
} else {
ctx.fillText(truncatedText, x, y)
}
} else {
ctx.fillText(truncatedText, x, y)
if (isRequired) {
ctx.save()
ctx.fillStyle = '#EF4444'
ctx.fillText('*', xOffset + 28 + ctx.measureText(truncatedText).width, 16)
ctx.restore()
}
}
let rightOffset = xOffset + width - rightPadding
if (column.uidt && isFieldEditAllowed.value && !colObj?.readonly) {
// Chevron down
rightOffset -= 16
spriteLoader.renderIcon(ctx, {
icon: 'chevronDown',
size: 14,
color: '#6a7184',
x: rightOffset,
y: 9,
})
} else if (meta.value?.synced && colObj?.readonly) {
rightOffset -= 16
spriteLoader.renderIcon(ctx, {
icon: 'refresh',
size: 14,
color: '#6a7184',
x: rightOffset,
y: 9,
})
}
// Error icon if invalid
if (column.isInvalidColumn?.isInvalid && !column.isInvalidColumn?.ignoreTooltip) {
rightOffset -= 18
spriteLoader.renderIcon(ctx, {
icon: 'alertTriangle',
size: 14,
color: '#FF928C',
x: rightOffset,
y: 9,
})
}
// Info icon if has description
if (column?.columnObj?.description?.length) {
rightOffset -= 18
spriteLoader.renderIcon(ctx, {
icon: 'ncInfo',
size: 13,
color: '#6B7280',
x: rightOffset,
y: 9,
})
}
xOffset += width
// Border
const resizeHandleWidth = 10
const isNearEdge = mousePosition && Math.abs(xOffset - mousePosition.x) <= resizeHandleWidth && mousePosition.y <= 32
// Right border for row number field
if (column.id === 'row_number') {
ctx.strokeStyle = '#e7e7e9'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(xOffset, 0)
ctx.lineTo(xOffset, 32)
ctx.stroke()
}
if (isNearEdge && column.id !== 'row_number' && !isLocked.value) {
colResizeHoveredColIds.value.add(column.id)
ctx.strokeStyle = '#9CDAFA'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(xOffset, 0)
ctx.lineTo(xOffset, 32)
ctx.stroke()
// Reset for regular column separator
ctx.strokeStyle = '#e7e7e9'
ctx.lineWidth = 1
} else {
colResizeHoveredColIds.value.delete(column.id)
}
})
if (scrollLeft.value) {
ctx.strokeStyle = '#D5D5D9'
ctx.beginPath()
ctx.moveTo(xOffset, 0)
ctx.lineTo(xOffset, 32)
ctx.stroke()
ctx.fillStyle = 'rgba(0, 0, 0, 0.04)'
ctx.rect(xOffset, 0, 4, 32)
ctx.fill()
}
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}
const renderActiveState = (
ctx: CanvasRenderingContext2D,
activeState: { x: number; y: number; width: number; height: number; col: CanvasGridColumn } | null,
) => {
if (!activeState) return
const fixedWidth = columns.value.filter((col) => col.fixed).reduce((sum, col) => sum + parseCellWidth(col.width), 0)
const isInFixedArea = activeState.x <= fixedWidth
if (activeState.col.fixed || !isInFixedArea) {
ctx.strokeStyle = '#3366ff'
ctx.lineWidth = 2
roundedRect(ctx, activeState.x, activeState.y, activeState.width, activeState.height, 2)
ctx.lineWidth = 1
return
}
// For non-fixed columns in fixed area, render only the part that extends beyond fixed area
if (isInFixedArea) {
if (activeState.x + activeState.width <= fixedWidth) {
return
}
const adjustedState = {
...activeState,
x: fixedWidth,
width: activeState.width - (fixedWidth - activeState.x),
}
ctx.strokeStyle = '#3366ff'
ctx.lineWidth = 2
// add extra 1px offset to x, since there is an additional border separating fixed and non-fixed columns
roundedRect(ctx, adjustedState.x + 1, adjustedState.y, adjustedState.width, adjustedState.height, 2)
ctx.lineWidth = 1
}
}
const calculateXPosition = (colIndex: number) => {
let xPos = 0
for (let i = 0; i < colIndex; i++) {
xPos += parseCellWidth(columns.value[i]?.width)
}
// add additional 1 px if the column is non-fixed since there is a border between fixed and non-fixed columns
return xPos + (columns.value[colIndex]?.fixed ? 0 : 1)
}
const calculateSelectionWidth = (startCol: number, endCol: number) => {
let width = 0
let includeNonFixed = false
let isIncludeFixed = false
for (let i = startCol; i <= endCol; i++) {
width += parseCellWidth(columns.value[i]?.width)
includeNonFixed = includeNonFixed || !columns.value[i]!.fixed
isIncludeFixed = isIncludeFixed || columns.value[i]!.fixed
}
// add additional 1 px if the columns include both fixed and non-fixed columns
return width + (includeNonFixed && isIncludeFixed ? 1 : 0)
}
const renderFillHandle = (ctx: CanvasRenderingContext2D) => {
const fillHandler = getFillHandlerPosition()
if (!fillHandler) return true
let fixedWidth = 0
for (const col of columns.value) {
if (!col.fixed) continue
fixedWidth += parseCellWidth(col.width)
}
const isInFixedColumn = fillHandler.x <= fixedWidth
// Don't render if the handle is in fixed column area but the column itself isn't fixed
if (isInFixedColumn && !fillHandler.fixedCol) {
return false
}
if (isFillMode.value) {
const startY = -partialRowHeight.value + 33 + (selection.value.start.row - rowSlice.value.start) * rowHeight.value
ctx.setLineDash([2, 2])
ctx.strokeStyle = isAiFillMode.value ? '#9751d7' : '#3366ff'
ctx.strokeRect(
calculateXPosition(selection.value.start.col) - scrollLeft.value,
startY,
calculateSelectionWidth(selection.value.start.col, selection.value.end.col),
(selection.value.end.row - selection.value.start.row + 1) * rowHeight.value,
)
ctx.setLineDash([])
}
ctx.fillStyle = isAiFillMode.value ? '#9751d7' : '#ff4a3f'
ctx.beginPath()
ctx.arc(fillHandler.x + (fillHandler.fixedCol ? 0 : 1), fillHandler.y, fillHandler.size / 2, 0, Math.PI * 2)
ctx.fill()
// check if the fill handle is hovered
const isHovered =
mousePosition &&
mousePosition.x >= fillHandler.x - fillHandler.size / 2 &&
mousePosition.x <= fillHandler.x + fillHandler.size / 2 &&
mousePosition.y >= fillHandler.y - fillHandler.size / 2 &&
mousePosition.y <= fillHandler.y + fillHandler.size / 2
if (isHovered) {
setCursor('crosshair')
}
return true
}
const renderRowMeta = (
ctx: CanvasRenderingContext2D,
row: Row,
{
xOffset,
yOffset,
width,
}: {
xOffset: number
yOffset: number
width: number
},
) => {
const isHover = hoverRow.value === row.rowMeta.rowIndex
ctx.fillStyle = isHover ? '#F9F9FA' : '#ffffff'
if (row.rowMeta.selected) ctx.fillStyle = '#F6F7FE'
ctx.fillRect(xOffset, yOffset, width, rowHeight.value)
let currentX = xOffset + 4
const isChecked = row.rowMeta?.selected || vSelectedAllRecords.value
const isDisabled = (!row.rowMeta.selected && selectedRows.value.length >= MAX_SELECTED_ROWS) || vSelectedAllRecords.value
let isCheckboxRendered = false
if (isChecked || (selectedRows.value.length && isHover)) {
const isCheckboxHovered = isHover && mousePosition.x >= currentX && mousePosition.x <= currentX + 24 && !isDisabled
if (!readOnly.value && (isChecked || isHover)) {
renderCheckbox(
ctx,
currentX + 6,
yOffset + (rowHeight.value - 16) / 2,
isChecked,
isDisabled,
spriteLoader,
isCheckboxHovered ? '#3366FF' : '#D9D9D9',
)
currentX += 30
isCheckboxRendered = true
}
} else {
if (!readOnly.value && isHover && isRowDraggingEnabled.value) {
const isHovered = isBoxHovered(
{ x: currentX, y: yOffset + (rowHeight.value - 16) / 2, width: 24, height: 16 },
mousePosition,
)
if (isHovered) {
roundedRect(ctx, currentX, yOffset + (rowHeight.value - 20) / 2, 20, 20, 4, {
backgroundColor: isHovered ? '#F4F4F5' : 'transparent',
})
}
spriteLoader.renderIcon(ctx, {
icon: 'ncDrag',
size: 16,
x: currentX + 2,
y: yOffset + (rowHeight.value - 16) / 2,
color: isHovered ? '#3265FF' : '#6B7280',
})
currentX += 24
} else if (!isHover) {
ctx.font = '500 12px Manrope'
ctx.fillStyle = '#6B7280'
ctx.textBaseline = 'middle'
ctx.textAlign = 'left'
const len = ctx.measureText(totalRows.value.toString()).width
ctx.fillText((row.rowMeta.rowIndex! + 1).toString(), currentX + 8, yOffset + rowHeight.value / 2)
currentX += Math.max(24, len + 16)
} else {
// add 6px padding to the left of the row meta column if the row number is not rendered
currentX += 6
}
}
if (isHover && !isCheckboxRendered) {
if (!readOnly.value) {
const isCheckboxHovered = isHover && mousePosition.x >= currentX && mousePosition.x <= currentX + 24 && !isDisabled
renderCheckbox(
ctx,
currentX,
yOffset + (rowHeight.value - 16) / 2,
isChecked,
isDisabled,
spriteLoader,
isCheckboxHovered ? '#3366FF' : '#D9D9D9',
)
currentX += 24
}
}
ctx.font = '500 12px Manrope'
ctx.fillStyle = '#6B7280'
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
if (row.rowMeta?.commentCount) {
const commentCount = row.rowMeta.commentCount.toString()
ctx.font = '600 12px Manrope'
const textMetrics = ctx.measureText(commentCount)
const maxX = ROW_META_COLUMN_WIDTH
if (maxX - currentX < textMetrics.width + 8) {
currentX = maxX - textMetrics.width - 8
}
const bubbleHeight = 20
const bubbleWidth = textMetrics.width + 8
ctx.beginPath()
const x = currentX
const y = yOffset + (rowHeight.value - bubbleHeight) / 2
const radius = {
topLeft: 4,
topRight: 4,
bottomLeft: 0,
bottomRight: 4,
}
ctx.beginPath()
ctx.moveTo(x + radius.topLeft, y)
ctx.lineTo(x + bubbleWidth - radius.topRight, y)
ctx.arcTo(x + bubbleWidth, y, x + bubbleWidth, y + radius.topRight, radius.topRight)
ctx.lineTo(x + bubbleWidth, y + bubbleHeight - radius.bottomRight)
ctx.arcTo(x + bubbleWidth, y + bubbleHeight, x + bubbleWidth - radius.bottomRight, y + bubbleHeight, radius.bottomRight)
ctx.lineTo(x, y + bubbleHeight)
ctx.lineTo(x, y + radius.topLeft)
ctx.arcTo(x, y, x + radius.topLeft, y, radius.topLeft)
ctx.closePath()
ctx.fillStyle = '#EEF2FF'
ctx.fill()
ctx.strokeStyle = '#3366FF'
ctx.lineWidth = 1
ctx.stroke()
ctx.fillStyle = '#3366FF'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(commentCount, x + bubbleWidth / 2, y + bubbleHeight / 2)
} else if (isHover) {
const box = { x: currentX, y: yOffset + (rowHeight.value - 14) / 2, height: 14, width: 14 }
if (!isBoxHovered(box, mousePosition)) {
spriteLoader.renderIcon(ctx, {
icon: 'maximize',
size: 14,
x: currentX,
y: yOffset + (rowHeight.value - 14) / 2,
color: '#6B7280',
})
} else {
renderIconButton(ctx, {
buttonX: box.x - 2,
buttonY: box.y - 2,
buttonSize: 18,
icon: 'maximize',
iconData: {
size: 14,
xOffset: 2,
yOffset: 2,
},
borderRadius: 4,
spriteLoader,
})
}
}
}
function renderRows(ctx: CanvasRenderingContext2D) {
const { end: endRowIndex } = rowSlice.value
const { start: startColIndex, end: endColIndex } = colSlice.value
const startRowIndex = Math.floor(scrollTop.value / rowHeight.value)
const visibleCols = columns.value.slice(startColIndex, endColIndex)
let yOffset = -partialRowHeight.value + 33
let activeState: { col: any; x: number; y: number; width: number; height: number } | null = null
let initialXOffset = 1
for (let i = 0; i < startColIndex; i++) {
initialXOffset += parseCellWidth(columns.value[i]?.width)
}
const renderRedBorders: {
rowIndex: number
column: CanvasGridColumn
}[] = []
const adjustedWidth =
totalWidth.value - scrollLeft.value - 256 < width.value ? totalWidth.value - scrollLeft.value - 256 : width.value
let warningRow: { row: Row; yOffset: number } | null = null
for (let rowIdx = startRowIndex; rowIdx < endRowIndex; rowIdx++) {
if (yOffset + rowHeight.value > 0 && yOffset < height.value) {
let row = cachedRows.value.get(rowIdx)
if (rowIdx === draggedRowIndex.value) {
ctx.globalAlpha = 0.5
}
ctx.fillStyle = hoverRow.value === rowIdx ? '#F9F9FA' : '#ffffff'
ctx.fillRect(0, yOffset, adjustedWidth, rowHeight.value)
if (row) {
const pk = extractPkFromRow(row.row, meta.value?.columns ?? [])
let xOffset = initialXOffset
visibleCols.forEach((column, colIdx) => {
const width = parseCellWidth(column.width)
const absoluteColIdx = startColIndex + colIdx
const isCellEditEnabled =
editEnabled.value && activeCell.value.row === rowIdx && activeCell.value.column === absoluteColIdx
if (column.fixed) {
xOffset += width
return
}
if (row.rowMeta.selected || selection.value.isCellInRange({ row: rowIdx, col: absoluteColIdx })) {
ctx.fillStyle = '#F6F7FE'
ctx.fillRect(xOffset - scrollLeft.value, yOffset, width, rowHeight.value)
}
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, yOffset)
ctx.lineTo(xOffset - scrollLeft.value, yOffset + rowHeight.value)
ctx.stroke()
// add white background color for active cell
if (startColIndex + colIdx === activeCell.value.column && rowIdx === activeCell.value.row) {
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(xOffset - scrollLeft.value, yOffset, width, rowHeight.value)
}
const isActive = activeCell.value.row === rowIdx && activeCell.value.column === absoluteColIdx
if (isActive) {
activeState = {
col: column,
x: xOffset - scrollLeft.value,
y: yOffset,
width,
height: rowHeight.value,
}
}
const value = row.row[column.title]
if (isColumnRequiredAndNull(column.columnObj, row.row)) {
renderRedBorders.push({ rowIndex: rowIdx, column })
}
ctx.save()
renderCell(ctx, column.columnObj, {
value,
x: xOffset - scrollLeft.value,
y: yOffset,
width,
height: rowHeight.value,
row: row.row,
selected: isActive,
pv: column.pv,
spriteLoader,
readonly: column.readonly,
imageLoader,
tableMetaLoader,
relatedColObj: column.relatedColObj,
relatedTableMeta: column.relatedTableMeta,
disabled: column?.isInvalidColumn,
mousePosition,
pk,
skipRender: isCellEditEnabled,
})
ctx.restore()
xOffset += width
})
const fixedCols = columns.value.filter((col) => col.fixed)
if (fixedCols.length) {
xOffset = 0
fixedCols.forEach((column) => {
const width = parseCellWidth(column.width)
const colIdx = columns.value.findIndex((col) => col.id === column.id)
const isCellEditEnabled = editEnabled.value && activeCell.value.row === rowIdx && activeCell.value.column === colIdx
if (row.rowMeta.selected || selection.value.isCellInRange({ row: rowIdx, col: colIdx })) {
ctx.fillStyle = '#F6F7FE'
ctx.fillRect(xOffset, yOffset, width, rowHeight.value)
} else {
ctx.fillStyle = hoverRow.value === rowIdx ? '#F9F9FA' : '#ffffff'
ctx.fillRect(xOffset, yOffset, width, rowHeight.value)
}
// add white background color for active cell
// For Fixed columns, do not need to add startColIndex
if (colIdx === activeCell.value.column && rowIdx === activeCell.value.row) {
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(xOffset, yOffset, width, rowHeight.value)
}
if (column.id === 'row_number') {
renderRowMeta(ctx, row, { xOffset, yOffset, width })
} else {
const value = row.row[column.title]
const isActive = activeCell.value.row === rowIdx && activeCell.value.column === colIdx
if (isActive) {
activeState = {
col: column,
x: xOffset,
y: yOffset,
width,
height: rowHeight.value,
}
}
ctx.save()
if (isColumnRequiredAndNull(column.columnObj, row.row)) {
renderRedBorders.push({ rowIndex: rowIdx, column })
}
renderCell(ctx, column.columnObj, {
value,
x: xOffset,
y: yOffset,
width,
height: rowHeight.value,
row: row.row,
selected: isActive,
pv: column.pv,
readonly: column.readonly,
spriteLoader,
imageLoader,
tableMetaLoader,
relatedColObj: column.relatedColObj,
relatedTableMeta: column.relatedTableMeta,
mousePosition,
disabled: column?.isInvalidColumn,
pk,
skipRender: isCellEditEnabled,
})
ctx.restore()
}
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(xOffset, yOffset)
ctx.lineTo(xOffset, yOffset + rowHeight.value)
ctx.stroke()
xOffset += width
})
if (scrollLeft.value) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.04)'
ctx.rect(xOffset, yOffset, 4, rowHeight.value)
ctx.fill()
ctx.strokeStyle = '#D5D5D9'
ctx.beginPath()
ctx.moveTo(xOffset, yOffset)
ctx.lineTo(xOffset, yOffset + rowHeight.value)
ctx.stroke()
}
if (!visibleCols.filter((f) => !f.fixed).length) {
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(xOffset, yOffset)
ctx.lineTo(xOffset, yOffset + rowHeight.value)
ctx.stroke()
}
ctx.fillStyle = 'transparent'
ctx.strokeStyle = 'white'
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
} else {
row = {
row: {},
rowMeta: {
rowIndex: rowIdx,
selected: false,
commentCount: 0,
},
oldRow: {},
}
let xOffset = initialXOffset
visibleCols.forEach((column, colIdx) => {
const width = parseCellWidth(column.width)
const absoluteColIdx = startColIndex + colIdx
if (column.fixed) {
xOffset += width
return
}
if (selection.value.isCellInRange({ row: rowIdx, col: absoluteColIdx })) {
ctx.fillStyle = '#F6F7FE'
ctx.fillRect(xOffset - scrollLeft.value, yOffset, width, rowHeight.value)
}
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(xOffset - scrollLeft.value, yOffset)
ctx.lineTo(xOffset - scrollLeft.value, yOffset + rowHeight.value)
ctx.stroke()
const isActive = activeCell.value.row === rowIdx && activeCell.value.column === absoluteColIdx
if (isActive) {
activeState = {
col: column,
x: xOffset - scrollLeft.value,
y: yOffset,
width,
height: rowHeight.value,
}
}
xOffset += width
})
const fixedCols = columns.value.filter((col) => col.fixed)
if (fixedCols.length) {
xOffset = 0
fixedCols.forEach((column) => {
const width = parseCellWidth(column.width)
const colIdx = columns.value.findIndex((col) => col.id === column.id)
if (selection.value.isCellInRange({ row: rowIdx, col: colIdx })) {
ctx.fillStyle = '#F6F7FE'
ctx.fillRect(xOffset, yOffset, width, rowHeight.value)
} else {
ctx.fillStyle = hoverRow.value === rowIdx ? '#F9F9FA' : '#ffffff'
ctx.fillRect(xOffset, yOffset, width, rowHeight.value)
}
if (column.id === 'row_number') {
renderRowMeta(ctx, row!, { xOffset, yOffset, width })
} else {
const isActive = activeCell.value.row === rowIdx && activeCell.value.column === colIdx
if (isActive) {
activeState = {
col: column,
x: xOffset,
y: yOffset,
width,
height: rowHeight.value,
}
}
drawShimmerEffect(ctx, xOffset, yOffset, width, rowIdx)
}
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(xOffset, yOffset)
ctx.lineTo(xOffset, yOffset + rowHeight.value)
ctx.stroke()
xOffset += width
})
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(xOffset, yOffset)
ctx.lineTo(xOffset, yOffset + rowHeight.value)
ctx.stroke()
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}
if (rowIdx === draggedRowIndex.value) {
ctx.globalAlpha = 1
}
// Bottom border for each row
ctx.strokeStyle = '#e7e7e9'
ctx.beginPath()
ctx.moveTo(0, yOffset + rowHeight.value)
ctx.lineTo(adjustedWidth, yOffset + rowHeight.value)
ctx.stroke()
if (row?.rowMeta.isValidationFailed || row?.rowMeta.isRowOrderUpdated) {
warningRow = { row, yOffset }
}
yOffset += rowHeight.value
}
}
// Add New Row
if (isAddingEmptyRowAllowed.value && !isMobileMode.value) {
const isNewRowHovered = isBoxHovered(
{
x: 0,
y: yOffset,
height: COLUMN_HEADER_HEIGHT_IN_PX,
width: adjustedWidth,
},
mousePosition,
)
ctx.fillStyle = isNewRowHovered ? '#F9F9FA' : '#ffffff'
ctx.fillRect(0, yOffset, adjustedWidth, COLUMN_HEADER_HEIGHT_IN_PX)
// Bottom border for new row
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(0, yOffset + COLUMN_HEADER_HEIGHT_IN_PX)
ctx.lineTo(adjustedWidth, yOffset + COLUMN_HEADER_HEIGHT_IN_PX)
ctx.stroke()
spriteLoader.renderIcon(ctx, {
icon: 'ncPlus',
color: isNewRowHovered ? '#000000' : '#4a5268',
x: 16,
y: yOffset + 9,
size: 14,
})
}
if (warningRow) {
const orange = '#fcbe3a'
// Warning top border
ctx.strokeStyle = 'orange'
ctx.beginPath()
ctx.moveTo(0, warningRow.yOffset - 2)
ctx.lineTo(adjustedWidth, warningRow.yOffset)
ctx.lineWidth = 2
ctx.stroke()
// Warning bottom border
ctx.strokeStyle = 'orange'
ctx.beginPath()
ctx.moveTo(0, warningRow.yOffset + rowHeight.value)
ctx.lineTo(adjustedWidth, warningRow.yOffset + rowHeight.value)
ctx.lineWidth = 2
ctx.stroke()
roundedRect(ctx, 0, warningRow.yOffset + rowHeight.value, 90, 25, { bottomRight: 6 }, { backgroundColor: orange })
renderSingleLineText(ctx, {
text: warningRow.row.rowMeta.isValidationFailed ? 'Row filtered' : 'Row moved',
x: 10,
y: warningRow.yOffset + rowHeight.value,
py: 7,
fillStyle: '#1f293a',
fontSize: 12,
fontFamily: '600 12px Manrope',
})
}
renderActiveState(ctx, activeState)
for (const { rowIndex, column } of renderRedBorders) {
if (editEnabled.value?.column?.id === column.id && editEnabled.value?.rowIndex === rowIndex) continue
const yOffset = -partialRowHeight.value + 33 + (rowIndex - rowSlice.value.start) * rowHeight.value
const xOffset = calculateXPosition(columns.value.findIndex((c) => c.id === column.id))
const width = parseCellWidth(column.width)
const fixedWidth = columns.value.filter((col) => col.fixed).reduce((sum, col) => sum + parseCellWidth(col.width), 1)
const isInFixedArea = xOffset - scrollLeft.value <= fixedWidth
ctx.strokeStyle = '#ff4a3f'
ctx.lineWidth = 2
if (column.fixed || !isInFixedArea) {
roundedRect(ctx, column.fixed ? xOffset : xOffset - scrollLeft.value, yOffset, width, rowHeight.value, 2)
} else if (isInFixedArea) {
if (xOffset + width <= fixedWidth) {
continue
}
const adjustedX = fixedWidth
const adjustedWidth = xOffset + width - fixedWidth - scrollLeft.value
roundedRect(ctx, adjustedX + 1, yOffset, adjustedWidth, rowHeight.value, 2)
}
ctx.lineWidth = 1
}
renderFillHandle(ctx)
return activeState
}
const renderColumnDragIndicator = (ctx: CanvasRenderingContext2D) => {
if (!dragOver.value) return
let xPosition = 0
for (let i = 0; i < dragOver.value.index; i++) {
xPosition += parseCellWidth(columns.value[i]?.width)
}
const width = parseCellWidth(columns.value[dragOver.value.index - 1]?.width)
// Draw a Ghost Column
ctx.fillStyle = '#f4f4f5'
ctx.globalAlpha = 0.6
ctx.fillRect(xPosition - scrollLeft.value, 0, width, height.value)
ctx.globalAlpha = 1
ctx.strokeStyle = '#3366ff'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(xPosition - scrollLeft.value, 0)
ctx.lineTo(xPosition - scrollLeft.value, height.value)
ctx.stroke()
}
function renderAggregations(ctx: CanvasRenderingContext2D) {
const AGGREGATION_HEIGHT = 36
const { start: startColIndex, end: endColIndex } = colSlice.value
// Background
ctx.fillStyle = '#F9F9FA'
ctx.fillRect(0, height.value - AGGREGATION_HEIGHT, width.value, AGGREGATION_HEIGHT)
// Top border
ctx.beginPath()
ctx.strokeStyle = '#E7E7E9'
ctx.moveTo(0, height.value - AGGREGATION_HEIGHT)
ctx.lineTo(width.value, height.value - AGGREGATION_HEIGHT)
ctx.stroke()
let initialOffset = 0
for (let i = 0; i < startColIndex; i++) {
initialOffset += parseCellWidth(columns.value[i]?.width)
}
const visibleCols = columns.value.slice(startColIndex, endColIndex)
let xOffset = initialOffset
visibleCols.forEach((column) => {
const width = parseCellWidth(column.width)
if (column.fixed) {
xOffset += width
return
}
const isHovered = isBoxHovered(
{
x: xOffset - scrollLeft.value,
y: height.value - AGGREGATION_HEIGHT,
width,
height: AGGREGATION_HEIGHT,
},
mousePosition,
)
ctx.fillStyle = isHovered ? '#F4F4F5' : '#F9F9FA'
if (column.agg_fn && ![AllAggregations.None].includes(column.agg_fn as any)) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset - scrollLeft.value, height.value - AGGREGATION_HEIGHT, width, AGGREGATION_HEIGHT)
ctx.fill()
ctx.clip()
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
ctx.font = '600 12px Manrope'
const aggWidth = ctx.measureText(column.aggregation).width
if (column.agg_prefix) {
ctx.font = '400 12px Manrope'
ctx.fillStyle = '#6a7184'
ctx.fillText(
column.agg_prefix,
xOffset + width - aggWidth - 16 - scrollLeft.value,
height.value - AGGREGATION_HEIGHT / 2,
)
}
ctx.font = '600 12px Manrope'
ctx.fillStyle = '#4a5268'
ctx.fillText(column.aggregation, xOffset + width - 8 - scrollLeft.value, height.value - AGGREGATION_HEIGHT / 2)
ctx.restore()
} else if (isHovered) {
if (!isLocked.value) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset - scrollLeft.value, height.value - AGGREGATION_HEIGHT, width, AGGREGATION_HEIGHT)
ctx.fill()
ctx.clip()
ctx.font = '600 10px Manrope'
ctx.fillStyle = '#6a7184'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
const rightEdge = xOffset + width - 8 - scrollLeft.value
const textY = height.value - AGGREGATION_HEIGHT / 2
ctx.fillText('Summary', rightEdge, textY)
const textLen = ctx.measureText('Summary').width
spriteLoader.renderIcon(ctx, {
icon: 'chevronDown',
size: 14,
color: '#6a7184',
x: rightEdge - textLen - 18,
y: textY - 7,
})
}
ctx.restore()
}
ctx.beginPath()
ctx.strokeStyle = '#f4f4f5'
ctx.moveTo(xOffset - scrollLeft.value, height.value - AGGREGATION_HEIGHT)
ctx.lineTo(xOffset - scrollLeft.value, height.value)
ctx.stroke()
xOffset += width
})
const fixedCols = columns.value.filter((col) => col.fixed)
if (fixedCols.length) {
xOffset = 0
const rowNumberCol = fixedCols.find((col) => col.id === 'row_number')
const firstFixedCol = fixedCols.find((col) => col.id !== 'row_number')
if (rowNumberCol && firstFixedCol) {
const mergedWidth = parseCellWidth(rowNumberCol.width) + parseCellWidth(firstFixedCol.width)
const isHovered = isBoxHovered(
{
x: xOffset,
y: height.value - AGGREGATION_HEIGHT,
width: mergedWidth,
height: AGGREGATION_HEIGHT,
},
mousePosition,
)
ctx.fillStyle = isHovered ? '#F4F4F5' : '#F9F9FA'
ctx.fillRect(xOffset, height.value - AGGREGATION_HEIGHT, mergedWidth, AGGREGATION_HEIGHT)
ctx.fillStyle = '#6a7184'
ctx.textBaseline = 'middle'
let availWidth = mergedWidth - 16
if (firstFixedCol.agg_fn && ![AllAggregations.None].includes(firstFixedCol.agg_fn as any)) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset, height.value - AGGREGATION_HEIGHT, mergedWidth, AGGREGATION_HEIGHT)
ctx.clip()
ctx.textAlign = 'right'
ctx.font = '600 12px Manrope'
const aggWidth = ctx.measureText(firstFixedCol.aggregation).width
if (firstFixedCol.agg_prefix) {
ctx.font = '400 12px Manrope'
ctx.fillStyle = '#6a7184'
ctx.fillText(firstFixedCol.agg_prefix, mergedWidth - aggWidth - 16, height.value - AGGREGATION_HEIGHT / 2)
const w = ctx.measureText(firstFixedCol.agg_prefix).width
availWidth -= w
}
ctx.font = '600 12px Manrope'
ctx.fillStyle = '#4a5268'
ctx.fillText(firstFixedCol.aggregation, mergedWidth - 8, height.value - AGGREGATION_HEIGHT / 2)
const w = ctx.measureText(firstFixedCol.aggregation).width
availWidth -= w
ctx.restore()
} else if (isHovered) {
if (!isLocked.value) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset, height.value - AGGREGATION_HEIGHT, mergedWidth, AGGREGATION_HEIGHT)
ctx.clip()
ctx.font = '600 10px Manrope'
ctx.textAlign = 'right'
const rightEdge = xOffset + mergedWidth - 8
const textY = height.value - AGGREGATION_HEIGHT / 2
ctx.fillText('Summary', rightEdge, textY)
const textLen = ctx.measureText('Summary').width
availWidth -= textLen
spriteLoader.renderIcon(ctx, {
icon: 'chevronDown',
size: 14,
color: '#6a7184',
x: rightEdge - textLen - 18,
y: textY - 7,
})
}
availWidth -= 18
ctx.restore()
}
renderSingleLineText(ctx, {
text: `${Intl.NumberFormat('en', { notation: 'compact' }).format(totalRows.value)} ${
totalRows.value !== 1 ? t('objects.records') : t('objects.record')
}`,
x: xOffset + 8,
y: height.value - AGGREGATION_HEIGHT + 2,
fillStyle: '#6a7184',
textAlign: 'left',
fontSize: 12,
maxWidth: availWidth - 16,
fontFamily: '500 12px Manrope',
})
// Not exactly sure, but height.value becomes zero, randomly when scroll
if (height.value) {
tryShowTooltip({
mousePosition,
text: `${totalRows.value} ${totalRows.value !== 1 ? t('objects.records') : t('objects.record')}`,
rect: {
x: xOffset,
y: height.value - AGGREGATION_HEIGHT,
width: availWidth - 16,
height: AGGREGATION_HEIGHT,
},
})
}
ctx.strokeStyle = '#e7e7e9'
ctx.beginPath()
ctx.moveTo(xOffset, height.value - AGGREGATION_HEIGHT)
ctx.lineTo(xOffset, height.value)
ctx.stroke()
xOffset += mergedWidth
fixedCols.slice(2).forEach((column) => {
const width = parseCellWidth(column.width)
const isHovered = isBoxHovered(
{
x: xOffset,
y: height.value - AGGREGATION_HEIGHT,
width,
height: AGGREGATION_HEIGHT,
},
mousePosition,
)
ctx.fillStyle = '#F9F9FA'
ctx.fillRect(xOffset, height.value - AGGREGATION_HEIGHT, width, AGGREGATION_HEIGHT)
if (column.aggregation) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset, height.value - AGGREGATION_HEIGHT, width, AGGREGATION_HEIGHT)
ctx.clip()
ctx.font = '600 12px Manrope'
const aggWidth = ctx.measureText(column.aggregation).width
if (column.agg_prefix) {
ctx.font = '400 12px Manrope'
ctx.fillStyle = '#6a7184'
ctx.fillText(column.agg_prefix, xOffset + width - aggWidth - 16, height.value - AGGREGATION_HEIGHT / 2)
}
ctx.font = '600 12px Manrope'
ctx.fillStyle = '#4a5268'
ctx.fillText(column.aggregation, xOffset + width - 8, height.value - AGGREGATION_HEIGHT / 2)
ctx.restore()
} else if (isHovered) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset, height.value - AGGREGATION_HEIGHT, width, AGGREGATION_HEIGHT)
ctx.clip()
ctx.font = '600 10px Manrope'
ctx.fillStyle = '#6a7184'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
const rightEdge = xOffset + width - 8
const textY = height.value - AGGREGATION_HEIGHT / 2
ctx.fillText('Summary', rightEdge, textY)
const textLen = ctx.measureText('Summary').width
if (!isLocked.value) {
spriteLoader.renderIcon(ctx, {
icon: 'chevronDown',
size: 14,
color: '#6a7184',
x: rightEdge - textLen - 18,
y: textY - 7,
})
}
ctx.restore()
}
ctx.strokeStyle = '#e7e7e9'
ctx.beginPath()
ctx.moveTo(xOffset, height.value - AGGREGATION_HEIGHT)
ctx.lineTo(xOffset, height.value)
ctx.stroke()
xOffset += width
})
ctx.strokeStyle = '#f4f4f5'
ctx.beginPath()
ctx.moveTo(xOffset, height.value - AGGREGATION_HEIGHT)
ctx.lineTo(xOffset, height.value)
ctx.stroke()
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}
}
const renderRowDragPreview = (ctx: CanvasRenderingContext2D) => {
if (!isDragging.value || draggedRowIndex.value === null || targetRowIndex.value === null) return
const targetRowLine = (targetRowIndex.value - rowSlice.value.start) * rowHeight.value - partialRowHeight.value + 32
// First render the blue line indicator
ctx.strokeStyle = '#3366ff'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(0, targetRowLine)
ctx.lineTo(ctx.canvas.width, targetRowLine)
ctx.stroke()
// Then render the preview row
ctx.save()
const targetY = mousePosition.y
const previewWidth = 500
const xPos = mousePosition.x
// Apply tilt transform
ctx.translate(xPos + previewWidth / 2, targetY)
ctx.rotate((0.5 * Math.PI) / 180)
ctx.translate(-(xPos + previewWidth / 2), -targetY)
ctx.beginPath()
ctx.roundRect(xPos, targetY - rowHeight.value / 2, previewWidth, rowHeight.value, 6)
ctx.clip()
ctx.shadowColor = 'rgba(0, 0, 0, 0.10)'
ctx.shadowBlur = 16
ctx.shadowOffsetY = 12
ctx.shadowOffsetX = 0
ctx.save()
ctx.shadowColor = 'rgba(0, 0, 0, 0.06)'
ctx.shadowBlur = 6
ctx.shadowOffsetY = 4
ctx.fillStyle = '#ffffff'
ctx.fill()
ctx.restore()
ctx.strokeStyle = '#D5D5D9'
ctx.lineWidth = 1
ctx.stroke()
const row = cachedRows.value.get(draggedRowIndex.value)
if (row) {
let xOffset = xPos
columns.value.forEach((column) => {
const width = parseCellWidth(column.width)
if (xOffset - xPos < previewWidth) {
ctx.save()
renderCell(ctx, column.columnObj, {
value: row.row[column.title],
x: xOffset,
y: targetY - rowHeight.value / 2,
width: Math.min(width, previewWidth - (xOffset - xPos)),
height: rowHeight.value,
row: row.row,
selected: false,
readonly: true,
pv: column.pv,
spriteLoader,
imageLoader,
relatedColObj: column.relatedColObj,
relatedTableMeta: column.relatedTableMeta,
disabled: column?.isInvalidColumn,
mousePosition: { x: -1, y: -1 },
pk: extractPkFromRow(row.row, meta.value?.columns ?? []),
})
ctx.restore()
}
xOffset += width
})
}
ctx.restore()
}
function renderCanvas() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
canvas.width = width.value * dpr
canvas.height = height.value * dpr
canvas.style.width = `${width.value}px`
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width.value, canvas.height)
const activeState = renderRows(ctx)
renderHeader(ctx, activeState)
renderColumnDragIndicator(ctx)
renderRowDragPreview(ctx)
renderAggregations(ctx)
}
return {
canvasRef,
renderActiveState,
renderCanvas,
colResizeHoveredColIds,
}
}