Merge pull request #12881 from nocodb/nc-fix/rich-text-cell-skeleton

Nc Fix: Add skeleton in rich text cell while parsing it
This commit is contained in:
Ramesh Mane
2026-01-17 17:12:15 +05:30
committed by GitHub
7 changed files with 214 additions and 52 deletions

View File

@@ -26,6 +26,7 @@ export const LongTextCellRenderer: CellRenderer = {
selected,
baseUsers,
user,
markdownLoader,
} = props
const text = value?.toString() ?? ''
@@ -87,6 +88,7 @@ export const LongTextCellRenderer: CellRenderer = {
baseUsers,
user,
getColor,
markdownLoader,
})
// Restore context after clipping

View File

@@ -2,6 +2,7 @@ import { type ColumnType, type TableType, UITypes, type UserType, type ViewType,
import { renderSingleLineText, renderSpinner, roundedRect } from '../utils/canvas'
import type { ActionManager } from '../loaders/ActionManager'
import type { ImageWindowLoader } from '../loaders/ImageLoader'
import type { MarkdownLoader } from '../loaders/markdownLoader'
import { useDetachedLongText } from '../composables/useDetachedLongText'
import { comparePath } from '../utils/groupby'
import { EmailCellRenderer } from './Email'
@@ -47,6 +48,7 @@ export function useGridCellHandler(params: {
path: Array<number>,
) => { x: number; y: number; width: number; height: number }
actionManager: ActionManager
markdownLoader: MarkdownLoader
makeCellEditable: MakeCellEditableFn
updateOrSaveRow: (
row: Row,
@@ -163,6 +165,7 @@ export function useGridCellHandler(params: {
readonly = false,
spriteLoader,
imageLoader,
markdownLoader = params.markdownLoader,
tableMetaLoader,
padding = 10,
relatedColObj,
@@ -298,6 +301,7 @@ export function useGridCellHandler(params: {
spriteLoader,
imageLoader,
actionManager,
markdownLoader,
tableMetaLoader,
isMysql,
isPg,
@@ -404,6 +408,7 @@ export function useGridCellHandler(params: {
readonly: !params.hasEditPermission.value,
updateOrSaveRow: params?.updateOrSaveRow,
actionManager,
markdownLoader: params.markdownLoader,
makeCellEditable: (row, clickedColumn, showEditCellRestrictionTooltip = ctx.event.detail === 2) =>
makeCellEditable(row, clickedColumn, showEditCellRestrictionTooltip),
isPublic: isPublic.value,
@@ -439,6 +444,7 @@ export function useGridCellHandler(params: {
readonly: !params.hasEditPermission.value,
updateOrSaveRow: params?.updateOrSaveRow,
actionManager,
markdownLoader: params.markdownLoader,
makeCellEditable,
openDetachedLongText,
allowLocalUrl: appInfo.value?.allowLocalUrl,
@@ -476,6 +482,7 @@ export function useGridCellHandler(params: {
getCellPosition: (...args) => params?.getCellPosition?.(...args, ctx.path),
updateOrSaveRow: params?.updateOrSaveRow,
actionManager,
markdownLoader: params.markdownLoader,
makeCellEditable,
setCursor,
path: ctx.path ?? [],

View File

@@ -39,6 +39,7 @@ import type { CanvasElement } from '../utils/CanvasElement'
import { ElementTypes } from '../utils/CanvasElement'
import type { RenderTagProps } from '../utils/types'
import { getSafe2DContext } from '../utils/safeCanvas'
import type { MarkdownLoader } from '../loaders/markdownLoader'
export function useCanvasRender({
width,
@@ -60,6 +61,7 @@ export function useCanvasRender({
getFillHandlerPosition,
spriteLoader,
imageLoader,
markdownLoader,
tableMetaLoader,
partialRowHeight,
vSelectedAllRecords,
@@ -131,6 +133,7 @@ export function useCanvasRender({
getFillHandlerPosition: () => FillHandlerPosition | null
imageLoader: ImageWindowLoader
spriteLoader: SpriteLoader
markdownLoader: MarkdownLoader
tableMetaLoader: TableMetaLoader
partialRowHeight: Ref<number>
vSelectedAllRecords: WritableComputedRef<boolean>
@@ -1451,6 +1454,7 @@ export function useCanvasRender({
spriteLoader,
readonly: column.readonly,
imageLoader,
markdownLoader,
tableMetaLoader,
relatedColObj: column.relatedColObj,
relatedTableMeta: column.relatedTableMeta,
@@ -1535,6 +1539,7 @@ export function useCanvasRender({
readonly: column.readonly,
spriteLoader,
imageLoader,
markdownLoader,
tableMetaLoader,
relatedColObj: column.relatedColObj,
relatedTableMeta: column.relatedTableMeta,
@@ -2480,6 +2485,7 @@ export function useCanvasRender({
pv: column.pv,
spriteLoader,
imageLoader,
markdownLoader,
relatedColObj: column.relatedColObj,
relatedTableMeta: column.relatedTableMeta,
disabled: column?.isInvalidColumn,
@@ -3337,6 +3343,7 @@ export function useCanvasRender({
tagFontFamily: '700 13px Inter',
},
getColor,
markdownLoader,
} as any)
ctx.restore()
@@ -3397,6 +3404,7 @@ export function useCanvasRender({
readonly: true,
textColor: getColor(themeV4Colors.gray['800']), // gray-800
imageLoader,
markdownLoader,
tableMetaLoader,
relatedColObj: group.relatedColumn,
relatedTableMeta: group.relatedTableMeta,
@@ -3428,6 +3436,7 @@ export function useCanvasRender({
readonly: true,
textColor: getColor(themeV4Colors.gray['800']), // gray-800
imageLoader,
markdownLoader,
meta,
tableMetaLoader,
relatedColObj: group.relatedColumn,

View File

@@ -14,6 +14,7 @@ import type { ButtonType, ColumnType, FormulaType, TableType, UserType, ViewType
import type { WritableComputedRef } from '@vue/reactivity'
import { SpriteLoader } from '../loaders/SpriteLoader'
import { ImageWindowLoader } from '../loaders/ImageLoader'
import { MarkdownLoader } from '../loaders/markdownLoader'
import { getSingleMultiselectColOptions, getUserColOptions, parseCellWidth } from '../utils/cell'
import { clearRowColouringCache, clearTextCache } from '../utils/canvas'
import {
@@ -196,6 +197,7 @@ export function useCanvasTable({
const attachmentCellDropOver = ref<AttachmentCellDropOverType | null>(null)
const spriteLoader = new SpriteLoader(() => triggerRefreshCanvas())
const imageLoader = new ImageWindowLoader(() => triggerRefreshCanvas())
const markdownLoader = new MarkdownLoader(() => triggerRefreshCanvas())
const reloadVisibleDataHook = inject(ReloadVisibleDataHookInj, undefined)
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const elementMap = new CanvasElement([])
@@ -842,6 +844,7 @@ export function useCanvasTable({
const { handleCellClick, renderCell, handleCellHover, handleCellKeyDown } = useGridCellHandler({
getCellPosition,
actionManager,
markdownLoader,
updateOrSaveRow,
makeCellEditable,
meta,
@@ -875,6 +878,7 @@ export function useCanvasTable({
isFillMode,
imageLoader,
spriteLoader,
markdownLoader,
tableMetaLoader,
baseRoleLoader,
partialRowHeight,
@@ -1568,6 +1572,7 @@ export function useCanvasTable({
// Action Manager
actionManager,
imageLoader,
markdownLoader,
baseRoleLoader,
handleCellClick,
handleCellHover,

View File

@@ -0,0 +1,86 @@
import { LRUCache } from 'lru-cache'
import type { UserType } from 'nocodb-sdk'
import type { Block } from '../utils/markdownUtils'
import { parseMarkdown } from '../utils/markdownUtils'
import { NcMarkdownParser } from '~/helpers/tiptap'
export const markdownTextCache: LRUCache<string, { blocks: Block[]; width: number }> = new LRUCache({
max: 1000,
})
export interface MarkdownParserOptions {
text: string
maxWidth: number
baseUsers?: Partial<UserType | User>[]
user?: Partial<UserType | User> | null
}
export class MarkdownLoader {
private loadingMarkdown = new Map<string, Promise<{ blocks: Block[]; width: number } | undefined>>()
private pendingLoads = 0
constructor(private onSettled?: () => void) {}
/**
* Sync render API
* - returns cached markdown if ready
* - otherwise starts async parse and returns undefined
*/
loadOrGetMarkdown(cacheKey: string, options: MarkdownParserOptions): { blocks: Block[]; width: number } | undefined {
// 1⃣ Cache hit
const cached = markdownTextCache.get(cacheKey)
if (cached) return cached
// 2⃣ Already loading
if (this.loadingMarkdown.has(cacheKey)) {
return undefined
}
// 3⃣ Start async parse
const promise = (async () => {
this.pendingLoads++
try {
const { text, maxWidth, baseUsers, user } = options
// 🔑 Async boundary (even though fn is sync)
const renderText = await Promise.resolve(NcMarkdownParser.preprocessMarkdown(text, true))
const parsedBlocks = await Promise.resolve(
parseMarkdown(renderText, {
users: baseUsers,
currentUser: user ?? undefined,
}),
)
const result: { blocks: Block[]; width: number } = {
width: maxWidth,
blocks: parsedBlocks,
}
markdownTextCache.set(cacheKey, result)
return result
} catch {
return undefined
} finally {
this.loadingMarkdown.delete(cacheKey)
this.pendingLoads--
this.onSettled?.()
}
})()
this.loadingMarkdown.set(cacheKey, promise)
return undefined
}
clearCache(): void {
this.loadingMarkdown.clear()
markdownTextCache.clear()
}
isLoading(cacheKey: string): boolean {
return this.loadingMarkdown.has(cacheKey)
}
}

View File

@@ -2,10 +2,10 @@ import { LRUCache } from 'lru-cache'
import JsBarcode from 'jsbarcode'
import type { ColumnType, UserType } from 'nocodb-sdk'
import type { SpriteLoader } from '../loaders/SpriteLoader'
import { type MarkdownLoader, markdownTextCache } from '../loaders/markdownLoader'
import type { RenderMultiLineTextProps, RenderSingleLineTextProps, RenderTagProps } from './types'
import { type Block, getFontForToken, parseMarkdown } from './markdownUtils'
import { type Block, getFontForToken } from './markdownUtils'
import { getSafe2DContext } from './safeCanvas'
import { NcMarkdownParser } from '~/helpers/tiptap'
const singleLineTextCache: LRUCache<string, { text: string; width: number; isTruncated: boolean }> = new LRUCache({
max: 1000,
@@ -15,10 +15,6 @@ const multiLineTextCache: LRUCache<string, { lines: string[]; width: number }> =
max: 1000,
})
const markdownTextCache: LRUCache<string, { blocks: Block[]; width: number }> = new LRUCache({
max: 1000,
})
const abstractTypeCache: LRUCache<string, string> = new LRUCache({
max: 1000,
})
@@ -1117,12 +1113,55 @@ export function renderBarcode(
}
}
const renderMarkdownSkeleton = (
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
{
x,
y,
width,
lineHeight,
maxLines,
getColor,
}: {
x: number
y: number // ← text baseline reference
width: number
lineHeight: number
maxLines: number
getColor: GetColorType
},
) => {
ctx.save()
const lines = Math.min(maxLines, 3)
const barHeight = Math.max(10, Math.floor(lineHeight * 0.6))
const radius = Math.min(6, barHeight / 2)
// ⬇️ Convert baseline Y → top-aligned Y
const startY = y - lineHeight / 2
for (let i = 0; i < lines; i++) {
const lineWidth = lines > 1 && i === lines - 1 ? width * 0.55 : width
const lineY = startY + i * lineHeight + (lineHeight - barHeight) / 2
ctx.beginPath()
ctx.roundRect(x, lineY, lineWidth, barHeight, radius)
ctx.fillStyle = getColor(themeV4Colors.gray['200'])
ctx.fill()
}
ctx.restore()
}
export const renderMarkdown = (
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
params: RenderMultiLineTextProps & {
baseUsers?: (Partial<UserType> | Partial<User>)[]
user?: Partial<UserType> | Partial<User>
getColor: GetColorType
markdownLoader: MarkdownLoader
},
): {
width: number
@@ -1150,6 +1189,7 @@ export const renderMarkdown = (
baseUsers,
user,
getColor,
markdownLoader,
} = params
let { maxWidth = Infinity, maxLines } = params
@@ -1167,7 +1207,7 @@ export const renderMarkdown = (
}
}
let blocks
let blocks: Block[] = []
let width = 0
const originalFontFamily = ctx.font
@@ -1175,27 +1215,6 @@ export const renderMarkdown = (
ctx.font = fontFamily
}
const cacheKey = `${text}-${fontFamily}-${maxWidth}-${maxLines}`
const cachedText = markdownTextCache.get(cacheKey)
if (cachedText) {
width = cachedText.width
blocks = cachedText.blocks
} else {
// Render 2000 characters of the text in the canvas
const processText = text.length > 2000 ? text.slice(0, 2000) : text
const renderText = NcMarkdownParser.preprocessMarkdown(processText, true)
width = maxWidth
blocks = parseMarkdown(renderText, {
users: baseUsers,
currentUser: user,
})
markdownTextCache.set(cacheKey, { blocks, width })
}
const yOffset =
verticalAlign === 'middle'
? height && (rowHeightInPx['1'] === height || isTagLabel)
@@ -1203,32 +1222,58 @@ export const renderMarkdown = (
: fontSize / 2 + (py ?? 0)
: py ?? 0
if (render) {
ctx.textAlign = textAlign
ctx.textBaseline = verticalAlign
// Render 2000 characters of the text in the canvas
const processText = text.length > 2000 ? text.slice(0, 2000) : text
if (fillStyle) {
ctx.fillStyle = fillStyle
ctx.strokeStyle = fillStyle
const cacheKey = `${processText}-${fontFamily}-${maxWidth}-${maxLines}`
const cachedText = markdownLoader.loadOrGetMarkdown(cacheKey, { text: processText, maxWidth, baseUsers, user })
if (cachedText) {
width = cachedText.width
blocks = cachedText.blocks
} else {
width = maxWidth
blocks = []
}
if (render) {
if (markdownLoader.isLoading(cacheKey)) {
renderMarkdownSkeleton(ctx, {
x,
y: y + yOffset,
width,
lineHeight,
maxLines,
getColor,
})
} else {
ctx.textAlign = textAlign
ctx.textBaseline = verticalAlign
if (fillStyle) {
ctx.fillStyle = fillStyle
ctx.strokeStyle = fillStyle
}
// Render the text lines
renderMarkdownBlocks(ctx, {
blocks,
x,
y: y + yOffset,
textAlign,
verticalAlign,
lineHeight,
maxLines,
fillStyle,
maxWidth,
mousePosition,
cellRenderStore,
fontFamily,
height,
selected,
getColor,
})
}
// Render the text lines
renderMarkdownBlocks(ctx, {
blocks,
x,
y: y + yOffset,
textAlign,
verticalAlign,
lineHeight,
maxLines,
fillStyle,
maxWidth,
mousePosition,
cellRenderStore,
fontFamily,
height,
selected,
getColor,
})
} else {
/**
* Set fontFamily is required for measureText to get currect matrics and
@@ -1275,6 +1320,7 @@ export const renderTagLabel = (
textColor = props.getColor ? props.getColor(themeV4Colors.gray['600']) : '#4a5268',
mousePosition,
spriteLoader,
markdownLoader,
text,
renderAsMarkdown,
getColor = (color) => color,
@@ -1320,6 +1366,7 @@ export const renderTagLabel = (
isTagLabel: true,
mousePosition,
spriteLoader,
markdownLoader,
cellRenderStore: props.cellRenderStore,
render: false,
getColor,
@@ -1338,6 +1385,7 @@ export const renderTagLabel = (
isTagLabel: true,
mousePosition,
spriteLoader,
markdownLoader,
cellRenderStore: props.cellRenderStore,
getColor,
})

View File

@@ -24,6 +24,7 @@ import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import type { UploadFile } from 'ant-design-vue'
import type { TooltipPlacement } from 'ant-design-vue/lib/tooltip'
import type { ImageWindowLoader } from '../components/smartsheet/grid/canvas/loaders/ImageLoader'
import type { MarkdownLoader } from '../components/smartsheet/grid/canvas/loaders/markdownLoader'
import type { SpriteLoader } from '../components/smartsheet/grid/canvas/loaders/SpriteLoader'
import type { ActionManager } from '../components/smartsheet/grid/canvas/loaders/ActionManager'
import type { TableMetaLoader } from '../components/smartsheet/grid/canvas/loaders/TableMetaLoader'
@@ -429,6 +430,7 @@ interface CellRendererOptions {
pv?: boolean
readonly?: boolean
imageLoader: ImageWindowLoader
markdownLoader: MarkdownLoader
spriteLoader: SpriteLoader
actionManager: ActionManager
tableMetaLoader: TableMetaLoader
@@ -546,6 +548,7 @@ interface CellRenderer {
makeCellEditable: MakeCellEditableFn
selected: boolean
imageLoader: ImageWindowLoader
markdownLoader: MarkdownLoader
cellRenderStore: CellRenderStore
isPublic?: boolean
openDetachedExpandedForm: (props: UseExpandedFormDetachedProps) => void
@@ -572,6 +575,7 @@ interface CellRenderer {
path?: Array<number>,
) => Promise<any>
actionManager: ActionManager
markdownLoader: MarkdownLoader
makeCellEditable: MakeCellEditableFn
cellRenderStore: CellRenderStore
openDetachedLongText: (props: UseDetachedLongTextProps) => void
@@ -598,6 +602,7 @@ interface CellRenderer {
makeCellEditable: MakeCellEditableFn
selected: boolean
imageLoader: ImageWindowLoader
markdownLoader: MarkdownLoader
cellRenderStore: CellRenderStore
setCursor: SetCursorType
path: Array<number>