优化
This commit is contained in:
@@ -48,6 +48,8 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Lock,
|
Lock,
|
||||||
Unlock,
|
Unlock,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -570,6 +572,14 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleLayerVisibility = (idx: number) => {
|
||||||
|
const list = template.elements.map((el, i) => {
|
||||||
|
if (i !== idx) return el;
|
||||||
|
return { ...el, hidden: el.hidden ? undefined : true };
|
||||||
|
});
|
||||||
|
onChangeTemplate({ ...template, elements: list });
|
||||||
|
};
|
||||||
|
|
||||||
const deleteLayer = async (id: string) => {
|
const deleteLayer = async (id: string) => {
|
||||||
const el = template.elements.find((x) => x.id === id);
|
const el = template.elements.find((x) => x.id === id);
|
||||||
if (el?.locked) return;
|
if (el?.locked) return;
|
||||||
@@ -1535,6 +1545,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">最大行数</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
step="1"
|
||||||
|
value={selectedElem.textMaxLines ?? ''}
|
||||||
|
onCommit={(val) => {
|
||||||
|
const raw = String(val).trim();
|
||||||
|
if (!raw) {
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
textMaxLines: undefined,
|
||||||
|
textEllipsis: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Math.max(0, Math.min(99, Math.floor(Number(val) || 0)));
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
textMaxLines: n > 0 ? n : undefined,
|
||||||
|
textEllipsis: n > 0 ? selectedElem.textEllipsis : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-[#666] mt-0.5">留空表示不限制</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-5">
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-2 cursor-pointer ${
|
||||||
|
!selectedElem.textMaxLines ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedElem.textEllipsis === true}
|
||||||
|
disabled={!selectedElem.textMaxLines || selectedElem.textMaxLines <= 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
textEllipsis: e.target.checked ? true : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-[#ccc]">超出显示省略号</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2116,6 +2180,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
const actualIdx = template.elements.length - 1 - revIdx;
|
const actualIdx = template.elements.length - 1 - revIdx;
|
||||||
const isSelected = selectedElementIds.includes(elem.id);
|
const isSelected = selectedElementIds.includes(elem.id);
|
||||||
const isLocked = !!elem.locked;
|
const isLocked = !!elem.locked;
|
||||||
|
const isHidden = elem.hidden === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -2145,7 +2210,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : ''
|
className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : ''
|
||||||
}`}
|
}${isHidden ? ' ps-layer-item-hidden' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
|
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
|
||||||
<span className="ps-layer-item-icon">
|
<span className="ps-layer-item-icon">
|
||||||
@@ -2186,6 +2251,14 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleLayerVisibility(actualIdx); }}
|
||||||
|
title={isHidden ? '显示图层' : '隐藏图层'}
|
||||||
|
className={isHidden ? 'text-[#666]' : 'text-[#ccc] hover:text-white'}
|
||||||
|
>
|
||||||
|
{isHidden ? <EyeOff /> : <Eye />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
|
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
|
||||||
|
|||||||
@@ -384,6 +384,53 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
|
|||||||
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">最大行数</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
step="1"
|
||||||
|
value={textElem.textMaxLines ?? ''}
|
||||||
|
onCommit={(val) => {
|
||||||
|
const raw = String(val).trim();
|
||||||
|
if (!raw) {
|
||||||
|
patchText({ ...textElem, textMaxLines: undefined, textEllipsis: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Math.max(0, Math.min(99, Math.floor(Number(val) || 0)));
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
textMaxLines: n > 0 ? n : undefined,
|
||||||
|
textEllipsis: n > 0 ? textElem.textEllipsis : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-[#666] mt-0.5">留空表示不限制</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-5">
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-2 cursor-pointer ${
|
||||||
|
!textElem.textMaxLines ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={textElem.textEllipsis === true}
|
||||||
|
disabled={!textElem.textMaxLines || textElem.textMaxLines <= 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchText({ ...textElem, textEllipsis: e.target.checked ? true : undefined })
|
||||||
|
}
|
||||||
|
className="ps-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-[#ccc]">超出显示省略号</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2">
|
<div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">单元格背景</label>
|
<label className="ps-field-label">单元格背景</label>
|
||||||
|
|||||||
@@ -785,6 +785,14 @@ html.app-dark-shell body {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-hidden {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-hidden .truncate {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-layer-item-icon {
|
.ps-layer-item-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -212,6 +212,58 @@ function layoutHorizontalTextLines(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEXT_ELLIPSIS = '…';
|
||||||
|
|
||||||
|
function truncateLineWithEllipsis(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
line: string,
|
||||||
|
maxWidth: number,
|
||||||
|
ellipsis = TEXT_ELLIPSIS
|
||||||
|
): string {
|
||||||
|
if (!line) return ellipsis;
|
||||||
|
if (ctx.measureText(line).width <= maxWidth) return line;
|
||||||
|
let truncated = line;
|
||||||
|
while (truncated.length > 0 && ctx.measureText(truncated + ellipsis).width > maxWidth) {
|
||||||
|
truncated = truncated.slice(0, -1);
|
||||||
|
}
|
||||||
|
return truncated.length > 0 ? truncated + ellipsis : ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHorizontalLineLimit(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
lines: string[],
|
||||||
|
maxLines: number | undefined,
|
||||||
|
ellipsis: boolean,
|
||||||
|
writableW: number
|
||||||
|
): string[] {
|
||||||
|
if (!maxLines || maxLines <= 0 || lines.length <= maxLines) return lines;
|
||||||
|
const visible = lines.slice(0, maxLines);
|
||||||
|
if (ellipsis) {
|
||||||
|
const lastIdx = visible.length - 1;
|
||||||
|
visible[lastIdx] = truncateLineWithEllipsis(ctx, visible[lastIdx], writableW - 4);
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVerticalColumnLimit(
|
||||||
|
columns: string[][],
|
||||||
|
maxLines: number | undefined,
|
||||||
|
ellipsis: boolean
|
||||||
|
): string[][] {
|
||||||
|
if (!maxLines || maxLines <= 0 || columns.length <= maxLines) return columns;
|
||||||
|
const visible = columns.slice(0, maxLines);
|
||||||
|
if (ellipsis && columns.length > maxLines) {
|
||||||
|
const lastCol = [...visible[visible.length - 1]];
|
||||||
|
if (lastCol.length > 0) {
|
||||||
|
lastCol[lastCol.length - 1] = TEXT_ELLIPSIS;
|
||||||
|
visible[visible.length - 1] = lastCol;
|
||||||
|
} else {
|
||||||
|
visible[visible.length - 1] = [TEXT_ELLIPSIS];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
/** 竖排:按列组织字符,列高受限时换列 */
|
/** 竖排:按列组织字符,列高受限时换列 */
|
||||||
function layoutVerticalTextColumns(
|
function layoutVerticalTextColumns(
|
||||||
value: string,
|
value: string,
|
||||||
@@ -313,7 +365,8 @@ function drawTextContentInBox(
|
|||||||
const vAlign = elem.verticalAlign ?? 'middle';
|
const vAlign = elem.verticalAlign ?? 'middle';
|
||||||
|
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
|
let columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
|
||||||
|
columns = applyVerticalColumnLimit(columns, elem.textMaxLines, elem.textEllipsis === true);
|
||||||
const totalColumnsWidth = columns.length * columnWidth;
|
const totalColumnsWidth = columns.length * columnWidth;
|
||||||
|
|
||||||
let startX: number;
|
let startX: number;
|
||||||
@@ -348,7 +401,14 @@ function drawTextContentInBox(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
let lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
||||||
|
lines = applyHorizontalLineLimit(
|
||||||
|
ctx,
|
||||||
|
lines,
|
||||||
|
elem.textMaxLines,
|
||||||
|
elem.textEllipsis === true,
|
||||||
|
writableW
|
||||||
|
);
|
||||||
const totalTextHeight = lines.length * lineHeight;
|
const totalTextHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
let textX = boxX + padPx.left;
|
let textX = boxX + padPx.left;
|
||||||
|
|||||||
@@ -663,6 +663,8 @@ export function getEffectiveCellStyle(
|
|||||||
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
|
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
|
||||||
textColor: pick('textColor', elem.textColor ?? '#111827'),
|
textColor: pick('textColor', elem.textColor ?? '#111827'),
|
||||||
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
|
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
|
||||||
|
textMaxLines: pick('textMaxLines', elem.textMaxLines),
|
||||||
|
textEllipsis: pick('textEllipsis', elem.textEllipsis ?? false),
|
||||||
fontFamily: pick('fontFamily', elem.fontFamily),
|
fontFamily: pick('fontFamily', elem.fontFamily),
|
||||||
textFormat: pick('textFormat', elem.textFormat ?? 'text'),
|
textFormat: pick('textFormat', elem.textFormat ?? 'text'),
|
||||||
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
|
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
|
||||||
@@ -693,6 +695,8 @@ const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
|
|||||||
'textColor',
|
'textColor',
|
||||||
'backgroundColor',
|
'backgroundColor',
|
||||||
'wordWrap',
|
'wordWrap',
|
||||||
|
'textMaxLines',
|
||||||
|
'textEllipsis',
|
||||||
'fontFamily',
|
'fontFamily',
|
||||||
'textFormat',
|
'textFormat',
|
||||||
'decimalPlaces',
|
'decimalPlaces',
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -75,6 +75,10 @@ export interface TableCellData {
|
|||||||
textColor?: string;
|
textColor?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
wordWrap?: boolean;
|
wordWrap?: boolean;
|
||||||
|
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制 */
|
||||||
|
textMaxLines?: number;
|
||||||
|
/** 超出最大行数时末行/末列显示省略号 */
|
||||||
|
textEllipsis?: boolean;
|
||||||
fontFamily?: 'sans' | 'serif' | 'mono';
|
fontFamily?: 'sans' | 'serif' | 'mono';
|
||||||
textFormat?: TextFormat;
|
textFormat?: TextFormat;
|
||||||
decimalPlaces?: number;
|
decimalPlaces?: number;
|
||||||
@@ -116,6 +120,10 @@ export interface TemplateElement {
|
|||||||
verticalAlign?: 'top' | 'middle' | 'bottom';
|
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||||
/** 是否自动换行,仅 type=text,默认 true */
|
/** 是否自动换行,仅 type=text,默认 true */
|
||||||
wordWrap?: boolean;
|
wordWrap?: boolean;
|
||||||
|
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制,仅 type=text */
|
||||||
|
textMaxLines?: number;
|
||||||
|
/** 超出最大行数时显示省略号,仅 type=text */
|
||||||
|
textEllipsis?: boolean;
|
||||||
/** 文本排列方向,仅 type=text,默认 horizontal */
|
/** 文本排列方向,仅 type=text,默认 horizontal */
|
||||||
textWritingMode?: 'horizontal' | 'vertical';
|
textWritingMode?: 'horizontal' | 'vertical';
|
||||||
fontWeight: 'normal' | 'bold';
|
fontWeight: 'normal' | 'bold';
|
||||||
@@ -145,6 +153,8 @@ export interface TemplateElement {
|
|||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
paddingLeft?: number;
|
paddingLeft?: number;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
/** 图层是否隐藏;隐藏后在预览、导出、打印及画布中均不显示 */
|
||||||
|
hidden?: boolean;
|
||||||
/** 图片缩放方式,仅 type=image 时有效 */
|
/** 图片缩放方式,仅 type=image 时有效 */
|
||||||
imageFit?: 'contain' | 'cover' | 'fill';
|
imageFit?: 'contain' | 'cover' | 'fill';
|
||||||
/** 默认图片 URL 或 data URI;主图加载失败时使用,仅 type=image 时有效 */
|
/** 默认图片 URL 或 data URI;主图加载失败时使用,仅 type=image 时有效 */
|
||||||
|
|||||||
@@ -1309,6 +1309,11 @@ export function getVariableRawValue(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 图层是否在画布与输出中显示 */
|
||||||
|
export function isElementLayerVisible(elem: TemplateElement): boolean {
|
||||||
|
return elem.hidden !== true;
|
||||||
|
}
|
||||||
|
|
||||||
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
|
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
|
||||||
export function shouldRenderElement(
|
export function shouldRenderElement(
|
||||||
elem: TemplateElement,
|
elem: TemplateElement,
|
||||||
@@ -1317,6 +1322,8 @@ export function shouldRenderElement(
|
|||||||
defaults?: Record<string, string> | null,
|
defaults?: Record<string, string> | null,
|
||||||
mode: ContentResolveMode = 'output'
|
mode: ContentResolveMode = 'output'
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (!isElementLayerVisible(elem)) return false;
|
||||||
|
|
||||||
if (!isConditionalVisibility(elem)) return true;
|
if (!isConditionalVisibility(elem)) return true;
|
||||||
|
|
||||||
const rules = resolveVisibilityRules(elem);
|
const rules = resolveVisibilityRules(elem);
|
||||||
|
|||||||
Reference in New Issue
Block a user