mirror of
https://github.com/nocodb/nocodb.git
synced 2026-04-25 05:35:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user