/** * Copyright 2023-present DreamNum Co., Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { ICellRenderContext, IPaddingData } from '@univerjs/core'; import type { IBaseDataValidationWidget } from '@univerjs/data-validation'; import type { IMouseEvent, IPointerEvent, SpreadsheetSkeleton, UniverRenderingContext, UniverRenderingContext2D } from '@univerjs/engine-render'; import type { ListValidator } from '@univerjs/sheets-data-validation'; import type { IShowDataValidationDropdownParams } from '../../commands/operations/data-validation.operation'; import { DataValidationRenderMode, DataValidationType, DEFAULT_STYLES, HorizontalAlign, ICommandService, Inject, IUniverInstanceService, LocaleService, UniverInstanceType, VerticalAlign, WrapStrategy } from '@univerjs/core'; import { CURSOR_TYPE, DocSimpleSkeleton, getCurrentTypeOfRenderer, getFontStyleString, IRenderManagerService, Rect, Text } from '@univerjs/engine-render'; import { getCellValueOrigin, SheetDataValidationModel } from '@univerjs/sheets-data-validation'; import { ShowDataValidationDropdown } from '../../commands/operations/data-validation.operation'; import { DROP_DOWN_DEFAULT_COLOR, DROP_DOWN_ICON_COLOR } from '../../const'; /** * padding in Capsule */ const PADDING_H = 4; const ICON_SIZE = 4; const ICON_PLACE = 14; const PADDING_V = 1; /** * margin for Capsule, that means distance between capsule and cell border */ const MARGIN_H = 6; const MARGIN_V = 3; const RADIUS_BG = 8; const downPath = new Path2D('M3.32201 4.84556C3.14417 5.05148 2.85583 5.05148 2.67799 4.84556L0.134292 1.90016C-0.152586 1.56798 0.0505937 1 0.456301 1L5.5437 1C5.94941 1 6.15259 1.56798 5.86571 1.90016L3.32201 4.84556Z'); function calcPadding(cellWidth: number, cellHeight: number, fontWidth: number, fontHeight: number, vt: VerticalAlign, ht: HorizontalAlign, margin = true) { let paddingTop = 0; const realMargin = margin ? MARGIN_V : 0; switch (vt) { case VerticalAlign.BOTTOM: paddingTop = cellHeight - fontHeight - realMargin; break; case VerticalAlign.MIDDLE: paddingTop = (cellHeight - fontHeight) / 2; break; default: paddingTop = realMargin; break; } paddingTop = Math.max(MARGIN_V, paddingTop); let paddingLeft = 0; switch (ht) { case HorizontalAlign.CENTER: paddingLeft = (cellWidth - fontWidth) / 2; break; case HorizontalAlign.RIGHT: paddingLeft = (cellWidth - fontWidth); break; default: break; } paddingLeft = Math.max(MARGIN_H, paddingLeft); return { paddingLeft, paddingTop, }; } export interface IDropdownInfo { top: number; left: number; width: number; height: number; } export class DropdownWidget implements IBaseDataValidationWidget { private _dropdownInfoMap: Map> = new Map(); constructor( @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, @Inject(LocaleService) private readonly _localeService: LocaleService, @ICommandService private readonly _commandService: ICommandService, @Inject(IRenderManagerService) private readonly _renderManagerService: IRenderManagerService, @Inject(SheetDataValidationModel) private readonly _dataValidationModel: SheetDataValidationModel ) { // super } zIndex?: number | undefined; private _ensureMap(subUnitId: string) { let map = this._dropdownInfoMap.get(subUnitId); if (!map) { map = new Map(); this._dropdownInfoMap.set(subUnitId, map); } return map; } private _generateKey(row: number, col: number) { return `${row}.${col}`; } private _drawDownIcon( ctx: UniverRenderingContext2D, cellBounding: { startX: number; startY: number }, cellWidth: number, cellHeight: number, fontHeight: number, vt: VerticalAlign, pd: IPaddingData ) { const { t = DEFAULT_STYLES.pd.t, b = DEFAULT_STYLES.pd.b } = pd; const left = cellWidth - ICON_PLACE; let top; switch (vt) { case VerticalAlign.MIDDLE: top = (cellHeight - ICON_SIZE) / 2; break; case VerticalAlign.BOTTOM: top = (cellHeight - b - fontHeight - MARGIN_V) + (fontHeight / 2 - ICON_SIZE / 2); break; default: top = t + MARGIN_V + (fontHeight / 2 - ICON_SIZE / 2); break; } ctx.save(); ctx.translateWithPrecision(cellBounding.startX + left, cellBounding.startY + top); ctx.fillStyle = DROP_DOWN_ICON_COLOR; ctx.fill(downPath); ctx.restore(); } // eslint-disable-next-line max-lines-per-function, complexity drawWith(ctx: UniverRenderingContext2D, info: ICellRenderContext, skeleton: SpreadsheetSkeleton): void { const { primaryWithCoord, row, col, style, data, subUnitId } = info; const _cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord; const _row = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo.startRow : row; const _col = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo.startColumn : col; const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, _row, _col); if (!rule) { return; } const validator = this._dataValidationModel.getValidator(rule.type) as ListValidator; if (!validator) { return; } const fontRenderExtension = data?.fontRenderExtension; const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {}; if (!rule || !validator || !validator || validator.id.indexOf(DataValidationType.LIST) !== 0) { return; } if (!validator.skipDefaultFontRender(rule)) { return; } const cellBounding = { startX: _cellBounding.startX + leftOffset, endX: _cellBounding.endX - rightOffset, startY: _cellBounding.startY + topOffset, endY: _cellBounding.endY - downOffset, }; const cellWidth = cellBounding.endX - cellBounding.startX; const cellHeight = cellBounding.endY - cellBounding.startY; const map = this._ensureMap(subUnitId); const key = this._generateKey(row, col); const colorMap = validator.getListWithColorMap(rule); const value = getCellValueOrigin(data); const valueStr = `${value ?? ''}`; const activeColor = colorMap[valueStr]; let { tb, vt, ht, pd } = style || {}; tb = tb ?? WrapStrategy.WRAP; vt = vt ?? VerticalAlign.BOTTOM; ht = ht ?? DEFAULT_STYLES.ht; pd = pd ?? DEFAULT_STYLES.pd; const fontStyle = getFontStyleString(style).fontCache; if (rule.renderMode === DataValidationRenderMode.ARROW) { const { l = DEFAULT_STYLES.pd.l, t = DEFAULT_STYLES.pd.t, r = DEFAULT_STYLES.pd.r, b = DEFAULT_STYLES.pd.b } = pd; const realWidth = cellWidth - l - r - ICON_PLACE - 4; const textSkeleton = new DocSimpleSkeleton( valueStr, fontStyle, Boolean(tb === WrapStrategy.WRAP), realWidth, Infinity ); textSkeleton.calculate(); const fontWidth = textSkeleton.getTotalWidth(); const fontHeight = textSkeleton.getTotalHeight(); const { paddingTop, paddingLeft } = calcPadding(realWidth, cellHeight - t - b, fontWidth, fontHeight, vt, ht, true); this._drawDownIcon(ctx, cellBounding, cellWidth, cellHeight, fontHeight, vt, pd); ctx.save(); ctx.translateWithPrecision(cellBounding.startX + l, cellBounding.startY + t); ctx.beginPath(); ctx.rect(0, 0, cellWidth - l - r, cellHeight - t - b); ctx.clip(); ctx.translateWithPrecision(0, paddingTop); ctx.save(); ctx.translateWithPrecision(paddingLeft, 0); ctx.beginPath(); // Ensure clipping area matches the actual text rendering area ctx.rect(0, 0, realWidth, fontHeight); ctx.clip(); Text.drawWith(ctx, { text: valueStr, fontStyle, width: realWidth, height: fontHeight, color: style?.cl?.rgb, strokeLine: Boolean(style?.st?.s), underline: Boolean(style?.ul?.s), warp: tb === WrapStrategy.WRAP, hAlign: HorizontalAlign.LEFT, }, textSkeleton); ctx.restore(); ctx.restore(); map.set(key, { left: cellBounding.endX - ICON_PLACE + skeleton.rowHeaderWidth, top: cellBounding.startY + t + skeleton.columnHeaderHeight, width: ICON_PLACE, height: cellHeight - t - b, }); } else { // Capsule ctx.save(); ctx.translateWithPrecision(cellBounding.startX, cellBounding.startY); ctx.beginPath(); ctx.rect(0, 0, cellWidth, cellHeight); ctx.clip(); const realWidth = cellWidth - (MARGIN_H * 2) - PADDING_H - ICON_PLACE - 4; const textSkeleton = new DocSimpleSkeleton( valueStr, fontStyle, Boolean(tb === WrapStrategy.WRAP), realWidth, Infinity ); textSkeleton.calculate(); const fontWidth = textSkeleton.getTotalWidth(); const fontHeight = textSkeleton.getTotalHeight(); const rectHeight = fontHeight + (PADDING_V * 2); const rectWidth = Math.max(cellWidth - MARGIN_H * 2, 1); const { paddingTop } = calcPadding(rectWidth, cellHeight, fontWidth, rectHeight, vt, ht); ctx.translateWithPrecision(MARGIN_H, paddingTop); Rect.drawWith(ctx as UniverRenderingContext, { width: rectWidth, height: rectHeight, fill: activeColor || DROP_DOWN_DEFAULT_COLOR, radius: RADIUS_BG, }); ctx.save(); ctx.translateWithPrecision(PADDING_H, PADDING_V); ctx.beginPath(); // Use actual font height for clipping, ensuring wrapped text is not cut off // The clipping height should match the available text area height ctx.rect(0, 0, realWidth, fontHeight); ctx.clip(); // Remove redundant paddingLeft translation as it's already handled by text alignment // ctx.translateWithPrecision(paddingLeft, 0); Text.drawWith(ctx, { text: valueStr, fontStyle, width: realWidth, height: fontHeight, color: style?.cl?.rgb, strokeLine: Boolean(style?.st?.s), underline: Boolean(style?.ul?.s), warp: tb === WrapStrategy.WRAP, hAlign: HorizontalAlign.LEFT, }, textSkeleton); ctx.restore(); ctx.translateWithPrecision(realWidth + PADDING_H + 4, ((fontHeight) - ICON_SIZE) / 2); ctx.fillStyle = DROP_DOWN_ICON_COLOR; ctx.fill(downPath); ctx.restore(); map.set(key, { left: cellBounding.startX + MARGIN_H + skeleton.rowHeaderWidth, top: cellBounding.startY + paddingTop + skeleton.columnHeaderHeight, width: rectWidth, height: rectHeight, }); } } calcCellAutoHeight(info: ICellRenderContext): number | undefined { const { primaryWithCoord, style, data, row, col } = info; const _cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord; const fontRenderExtension = data?.fontRenderExtension; const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {}; const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, row, col); if (!rule) { return; } if (rule.renderMode === DataValidationRenderMode.TEXT) { return undefined; } const cellBounding = { startX: _cellBounding.startX + leftOffset, endX: _cellBounding.endX - rightOffset, startY: _cellBounding.startY + topOffset, endY: _cellBounding.endY - downOffset, }; const cellWidth = cellBounding.endX - cellBounding.startX; const value = getCellValueOrigin(data); const valueStr = `${value ?? ''}`; let { tb, pd } = style || {}; const { t = DEFAULT_STYLES.pd.t, b = DEFAULT_STYLES.pd.b } = (pd ?? {}); tb = tb ?? WrapStrategy.WRAP; if (rule.renderMode === DataValidationRenderMode.ARROW) { const { l = DEFAULT_STYLES.pd.l, r = DEFAULT_STYLES.pd.r } = (pd ?? {}); const realWidth = cellWidth - l - r - ICON_PLACE - 4; const skeleton = new DocSimpleSkeleton( valueStr, getFontStyleString(style).fontCache, Boolean(tb === WrapStrategy.WRAP), realWidth, Infinity ); skeleton.calculate(); return skeleton.getTotalHeight() + t + b + (MARGIN_V * 2); } else { const realWidth = Math.max(cellWidth - (MARGIN_H * 2) - PADDING_H - ICON_PLACE - 4, 10); const skeleton = new DocSimpleSkeleton( valueStr, getFontStyleString(style).fontCache, Boolean(tb === WrapStrategy.WRAP), realWidth, Infinity ); skeleton.calculate(); return skeleton.getTotalHeight() + (MARGIN_V * 2) + (PADDING_V * 2); } } calcCellAutoWidth(info: ICellRenderContext): number | undefined { const { primaryWithCoord, style, data, row, col } = info; const cellRange = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord; const fontRenderExtension = data?.fontRenderExtension; const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {}; const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, row, col); if (!rule) { return; } if (rule.renderMode === DataValidationRenderMode.TEXT) { return; } const cellBounding = { startX: cellRange.startX + leftOffset, endX: cellRange.endX - rightOffset, startY: cellRange.startY + topOffset, endY: cellRange.endY - downOffset, }; const cellWidth = cellBounding.endX - cellBounding.startX; const value = getCellValueOrigin(data); const valueStr = `${value ?? ''}`; let { tb, pd } = style || {}; const { l = DEFAULT_STYLES.pd.l, r = DEFAULT_STYLES.pd.r } = (pd ?? {}); tb = tb ?? WrapStrategy.WRAP; let paddingAll = MARGIN_H * 2 + ICON_PLACE; switch (rule.renderMode) { case DataValidationRenderMode.ARROW: paddingAll = ICON_PLACE + 4 + r + l; break; case DataValidationRenderMode.CUSTOM: // + 1 is must, or last character will be cut paddingAll = ICON_PLACE + MARGIN_H * 2 + PADDING_H * 2 + r + l + RADIUS_BG / 2 + 1; break; // default is CUSTOM default: paddingAll = ICON_PLACE + MARGIN_H * 2 + PADDING_H * 2 + r + l + RADIUS_BG / 2 + 1; } const widthForTextLayout = cellWidth - paddingAll; const skeleton = new DocSimpleSkeleton( valueStr, getFontStyleString(style).fontCache, Boolean(tb === WrapStrategy.WRAP), widthForTextLayout, Infinity ); skeleton.calculate(); return skeleton.getTotalWidth() + paddingAll; } isHit(position: { x: number; y: number }, info: ICellRenderContext) { const { subUnitId, row, col } = info; const map = this._ensureMap(subUnitId); const dropdownInfo = map.get(this._generateKey(row, col)); const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, row, col); if (!rule) { return false; } if (!dropdownInfo) { return false; } if (rule.renderMode === DataValidationRenderMode.TEXT) { return false; } const { top, left, width, height } = dropdownInfo; const { x, y } = position; if (x >= left && (x <= (left + width)) && y >= top && (y <= (top + height))) { return true; } return false; }; onPointerDown(info: ICellRenderContext, evt: IPointerEvent | IMouseEvent) { if (evt.button === 2) { return; } const { unitId, subUnitId, row, col } = info; const params: IShowDataValidationDropdownParams = { unitId: unitId!, subUnitId, row, column: col, }; this._commandService.executeCommand(ShowDataValidationDropdown.id, params); }; onPointerEnter(_info: ICellRenderContext, _evt: IPointerEvent | IMouseEvent) { getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService) ?.mainComponent ?.setCursor(CURSOR_TYPE.POINTER); } onPointerLeave(_info: ICellRenderContext, _evt: IPointerEvent | IMouseEvent) { getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService) ?.mainComponent ?.setCursor(CURSOR_TYPE.DEFAULT); } }