UNPKG

cheetah-grid

Version:

Cheetah Grid is a high performance grid engine that works on canvas

533 lines (492 loc) 16.5 kB
import * as inlineUtils from "../../element/inlines"; import * as utils from "../../columns/type/columnUtils"; import type { ActionAreaPredicate, CellAddress, CellContext, DataSourceAPI, FieldDef, GridCanvasHelperAPI, ListGridAPI, MaybePromise, RectProps, TreeColumnOption, TreeDataValue, } from "../../ts-types"; import type { DrawCellInfo, GridInternal } from "../../ts-types-internal"; import { Column } from "./Column"; import { Rect } from "../../internal/Rect"; import { TreeLineKind } from "../../ts-types-internal"; import { TreeStyle } from "../style/TreeStyle"; import { getFontSize } from "../../internal/canvases"; import { getTreeColumnStateId } from "../../internal/symbolManager"; import { isPromise } from "../../internal/utils"; type NormalizedTreeData = { /** The caption of the record */ caption: string; /** An array of path indicating the hierarchy */ path: unknown[]; nodeType?: "leaf" | "branch"; }; type TreeInfo = { getLines: () => TreeLineKind[]; caption: string; path: unknown[]; }; const _ = getTreeColumnStateId(); export class TreeColumn<T> extends Column<T> { private _cache: boolean; constructor(option: TreeColumnOption = {}) { super(option); this._cache = option.cache != null ? option.cache : false; } get StyleClass(): typeof TreeStyle { return TreeStyle; } clearCache(grid: ListGridAPI<T>): void { const internal = grid as GridInternal<T>; if (!internal[_]) return; delete internal[_].cache; } get drawnIconActionArea(): ActionAreaPredicate { return (param) => { const internal = param.grid as GridInternal<T>; const state = internal[_]; if (!state?.drawnIcons) return false; const drawnIcons = state.drawnIcons as DrawnIcons; return drawnIcons.area(param); }; } onDrawCell( cellValue: MaybePromise<unknown>, info: DrawCellInfo<T>, context: CellContext, grid: GridInternal<T> ): void | Promise<void> { const state = grid[_] || (grid[_] = {}); if (this._cache && !state.cache) { const cache = state.cache || (state.cache = new Map()); const { col, row } = context; const field = grid.getField(col, row) as FieldDef<T>; if (!cache.has(field)) { cache.set(field, new TreeColumnInfo(grid, field)); } } return super.onDrawCell(cellValue, info, context, grid); } clone(): TreeColumn<T> { return new TreeColumn(this); } get cache(): boolean { return this._cache; } getCopyCellValue(value: unknown): unknown { const treeData = getTreeDataFromValue(value as TreeDataValue); return treeData.caption; } drawInternal( value: unknown, context: CellContext, style: TreeStyle, helper: GridCanvasHelperAPI, grid: GridInternal<T>, { drawCellBase, getIcon }: DrawCellInfo<T> ): void { const { textAlign, textBaseline, bgColor, padding, color, font, textOverflow, } = style; if (bgColor) { drawCellBase({ bgColor, }); } const state = grid[_] || (grid[_] = {}); if (state.drawnIcons) { const drawnIcons = state.drawnIcons as DrawnIcons; drawnIcons.delete(context); } const { col, row } = context; const field = grid.getField(col, row) as FieldDef<T>; const tci: TreeColumnInfo<T> = ((this._cache ? state.cache?.get(field) : null) ?? new TreeColumnInfo(grid, field)) as TreeColumnInfo<T>; const info = tci.getInfo(value as TreeDataValue, row); helper.testFontLoad(font, info.caption, context); utils.loadIcons(getIcon(), context, helper, (icons, context) => { const rect = context.getRect(); const basePadding = helper.toBoxPixelArray(padding || 0, context, font); const nestLevel = info.path.length; helper.drawWithClip(context, (ctx) => { const fontSize = getFontSize(ctx, font); const indentSize = fontSize.width; const top = rect.top + basePadding[0]; const left = rect.left + basePadding[3]; const height = rect.height - basePadding[0] - basePadding[2]; const lineBaseline = textBaseline ?? (ctx.textBaseline || "middle"); // Calculate horizontal line position let hLineY: number = top + height / 2; if ( lineBaseline === "bottom" || lineBaseline === "alphabetic" || lineBaseline === "ideographic" ) { // bottom hLineY = top + height - fontSize.height / 2; } else if (textBaseline === "middle") { hLineY = top + height / 2; } else { // top hLineY = top + fontSize.height / 2; } // Get the tree icon const definedTreeIcon = style.treeIcon || helper.getStyleProperty(helper.theme.tree.treeIcon, col, row, ctx); const treeIcon = definedTreeIcon === "none" ? null : definedTreeIcon === "chevron_right" || definedTreeIcon === "expand_more" ? { name: definedTreeIcon, width: fontSize.width } : definedTreeIcon; // Calculate icon rect let iconRect: Rect | null = null; if (treeIcon) { ctx.save(); try { const treeLineLeft = left + indentSize * (nestLevel - 1); const vLineX = treeLineLeft + indentSize / 2; const size = inlineUtils.iconOf(treeIcon).width({ ctx }); iconRect = new Rect( vLineX - size / 2, hLineY - size / 2, size, size ); } finally { ctx.restore(); } // It preserves the position of the drawn icon // because it is used for the `area` option of the `Action` class. const drawnIcons = (state.drawnIcons || (state.drawnIcons = new DrawnIcons())) as DrawnIcons; drawnIcons.set(context, iconRect); } // Get tree line color const lineStyle = style.lineStyle || helper.theme.tree.lineStyle; if (lineStyle !== "none") { const lineWidth = style.lineWidth || helper.theme.tree.lineWidth; const lineColor = style.lineColor || helper.getColor(helper.theme.tree.lineColor, col, row, ctx); ctx.save(); try { ctx.strokeStyle = lineColor; ctx.lineWidth = lineWidth; ctx.lineCap = "round"; if (iconRect) { // Clip icon area ctx.beginPath(); ctx.rect(0, 0, ctx.canvas.width, ctx.canvas.height); // Draw a square in the opposite direction to hollow out the inside. ctx.rect( iconRect.right, iconRect.top, -iconRect.width, iconRect.height ); // // For debug // ctx.fillStyle = "#0002"; // ctx.fill(); ctx.clip(); } // Draw tree lines info.getLines().forEach((line: TreeLineKind, index: number) => { const treeLineLeft = left + indentSize * index; const vLineX = treeLineLeft + indentSize / 2; const treeLineRight = treeLineLeft + indentSize; if (line !== TreeLineKind.none) { ctx.beginPath(); if (line === TreeLineKind.vertical) { ctx.moveTo(vLineX, rect.top); ctx.lineTo(vLineX, rect.bottom); } else if (line === TreeLineKind.last) { ctx.moveTo(vLineX, rect.top); ctx.lineTo(vLineX, hLineY); ctx.lineTo(treeLineRight, hLineY); } else if (line === TreeLineKind.start) { ctx.moveTo(treeLineRight, hLineY); ctx.lineTo(vLineX, hLineY); ctx.lineTo(vLineX, rect.bottom); } else if (line === TreeLineKind.verticalBranch) { ctx.moveTo(vLineX, rect.top); ctx.lineTo(vLineX, rect.bottom); ctx.moveTo(vLineX, hLineY); ctx.lineTo(treeLineRight, hLineY); } else if (line === TreeLineKind.horizontal) { ctx.moveTo(treeLineLeft, hLineY); ctx.lineTo(treeLineRight, hLineY); } else if (line === TreeLineKind.horizontalBranch) { ctx.moveTo(treeLineLeft, hLineY); ctx.lineTo(treeLineRight, hLineY); ctx.moveTo(vLineX, hLineY); ctx.lineTo(vLineX, rect.bottom); } else if (line === TreeLineKind.lone) { ctx.moveTo(vLineX, hLineY); ctx.lineTo(treeLineRight, hLineY); } ctx.stroke(); } }); } finally { ctx.restore(); } } if (treeIcon) { // Draw tree icon const iconLeftOffset = indentSize * (nestLevel - 1); const iconWidth = indentSize; const iconPadding = basePadding.slice(0); iconPadding[3] += iconLeftOffset; iconPadding[1] = rect.width - iconWidth - iconPadding[3]; // padding right helper.text("", context, { textAlign: "center", textBaseline, color, font, icons: [treeIcon], padding: iconPadding, }); } const textPadding = basePadding.slice(0); textPadding[3] += nestLevel * indentSize; // Tree indent padding helper.text(info.caption, context, { textAlign, textBaseline, color, font, padding: textPadding, textOverflow, icons, }); }); }); } } /** * If the cell is a TreeColumn, gets the tree node information from the given cell. */ export function getTreeNodeInfoAt<T>({ grid, col, row, }: CellAddress & { grid: ListGridAPI<T>; }): { hasChildren: boolean; nodeType: "leaf" | "branch" } { const field = grid.getField(col, row); if (!field) return { hasChildren: false, nodeType: "leaf" }; const { dataSource } = grid; const currIndex = grid.getRecordIndexByRow(row); const value = dataSource.getField(currIndex, field); const treeData = getTreeDataFromValue(value); const hasChildren = hasChildrenByRecord( treeData, dataSource, field, currIndex ); return { nodeType: hasChildren ? "branch" : treeData.nodeType || "leaf", hasChildren, }; } function hasChildrenByRecord<T>( treeData: NormalizedTreeData, dataSource: DataSourceAPI<T>, field: FieldDef<T>, recordIndex: number ): boolean { const next = dataSource.getField(recordIndex + 1, field); if (!next || isPromise(next)) return false; const nextParentPath = getParentPath(next); return ( treeData.path.length === nextParentPath.length && nextParentPath.every((p, i) => p === treeData.path[i]) ); } class TreeColumnInfo<T> { private _cache: Record< number, { hasNextSiblings: (boolean | undefined)[]; } > = {}; private _grid: ListGridAPI<T>; private _field: FieldDef<T>; constructor(grid: ListGridAPI<T>, field: FieldDef<T>) { this._grid = grid; this._field = field; } getInfo(value: TreeDataValue, row: number): TreeInfo { const { _field: field, _grid: grid, _cache: cache } = this; const currIndex = grid.getRecordIndexByRow(row); const { dataSource } = grid; const treeData = getTreeDataFromValue(value); const hasChildren = hasChildrenByRecord( treeData, dataSource, field, currIndex ); return { caption: treeData.caption, path: treeData.path, getLines() { const currPath: unknown[] = []; const parentPath = treeData.path.slice(0, -1); const parentLines: TreeLineKind[] = parentPath.map((p, index) => { currPath.push(p); const isLast = index === parentPath.length - 1; if (hasNextSiblingWithCache(currPath)) { return isLast ? TreeLineKind.verticalBranch : TreeLineKind.vertical; } else { return isLast ? TreeLineKind.last : TreeLineKind.none; } }); let selfLine: TreeLineKind; if (hasChildren) { selfLine = parentPath.length > 0 ? TreeLineKind.horizontalBranch : TreeLineKind.start; } else { selfLine = parentPath.length > 0 ? TreeLineKind.horizontal : TreeLineKind.lone; } return parentLines.concat(selfLine); }, }; function hasNextSiblingWithCache(parentPath: unknown[]): boolean { const has = hasNextSiblingFromCache(currIndex, parentPath.length); if (has != null) { return has; } const result = hasNextSibling(parentPath); for (let index = currIndex; index < result.end; index++) { setNextSiblingToCache(index, parentPath.length, result.has); } return result.has; } function hasNextSiblingFromCache( index: number, level: number ): boolean | undefined { const { hasNextSiblings } = cache[index] || (cache[index] = { hasNextSiblings: [] }); return hasNextSiblings[level]; } function setNextSiblingToCache( index: number, level: number, value: boolean ): void { const { hasNextSiblings } = cache[index] || (cache[index] = { hasNextSiblings: [] }); hasNextSiblings[level] = value; } function hasNextSibling(parentPath: unknown[]): { end: number; has: boolean; } { const startIndex = currIndex + 1; for (let index = startIndex; index < dataSource.length; index++) { const data = dataSource.getField(index, field); if (isPromise(data)) return { end: index, has: false }; const nextPath = getParentPath(data); if (!nextPath.length) return { end: index, has: false }; if (parentPath.every((p, i) => p === nextPath[i])) { // All matches! if (parentPath.length < nextPath.length) { // It's a child. // e.g. // ├ target // │ ├ next const has = hasNextSiblingFromCache(index, parentPath.length); if (has != null) return { end: index, has }; continue; } // There is next sibling. // e.g. // ├ target // │ ├ x // │ └ x // └ next return { end: index, has: true }; } // There is no next sibling. // e.g. // │ └ target // │ ├ x // │ └ x // └ next return { end: index, has: false }; } // There is no next sibling. return { end: dataSource.length, has: false }; } } } function getTreeDataFromValue(value: TreeDataValue): NormalizedTreeData { if (value != null) { if (Array.isArray(value)) { return getTreeDataFromValue({ path: value }); } else { if (Array.isArray(value.path)) return { caption: String( value.caption ?? value.path[value.path.length - 1] ?? "" ), path: value.path, nodeType: value.nodeType as never, }; if (typeof value.path === "function") return getTreeDataFromValue({ ...value, path: value.path() }); } } return { caption: String(value ?? ""), path: [value] }; } function getParentPath(value: TreeDataValue): unknown[] { return getTreeDataFromValue(value).path.slice(0, -1); } class DrawnIcons { private _drawnIcons = new Map<string, RectProps>(); set(cell: CellAddress, clipRect: RectProps) { this._drawnIcons.set(`${cell.col}:${cell.row}`, clipRect); } delete(cell: CellAddress) { this._drawnIcons.delete(`${cell.col}:${cell.row}`); } area({ col, row, pointInDrawingCanvas: point, }: Parameters<ActionAreaPredicate>[0]): boolean { const key = `${col}:${row}`; const rect = this._drawnIcons.get(key); if (!rect) { return false; } return ( rect.left <= point.x && point.x <= rect.right && rect.top <= point.y && point.y <= rect.bottom ); } }