优化
This commit is contained in:
@@ -48,6 +48,8 @@ import {
|
||||
HelpCircle,
|
||||
Lock,
|
||||
Unlock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
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 el = template.elements.find((x) => x.id === id);
|
||||
if (el?.locked) return;
|
||||
@@ -1535,6 +1545,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
/>
|
||||
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -2116,6 +2180,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
const actualIdx = template.elements.length - 1 - revIdx;
|
||||
const isSelected = selectedElementIds.includes(elem.id);
|
||||
const isLocked = !!elem.locked;
|
||||
const isHidden = elem.hidden === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2145,7 +2210,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
}
|
||||
}
|
||||
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">
|
||||
<span className="ps-layer-item-icon">
|
||||
@@ -2186,6 +2251,14 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</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
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
|
||||
|
||||
@@ -384,6 +384,53 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
|
||||
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||||
</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>
|
||||
<label className="ps-field-label">单元格背景</label>
|
||||
|
||||
@@ -785,6 +785,14 @@ html.app-dark-shell body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ps-layer-item-hidden {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ps-layer-item-hidden .truncate {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.ps-layer-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -212,6 +212,58 @@ function layoutHorizontalTextLines(
|
||||
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(
|
||||
value: string,
|
||||
@@ -313,7 +365,8 @@ function drawTextContentInBox(
|
||||
const vAlign = elem.verticalAlign ?? 'middle';
|
||||
|
||||
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;
|
||||
|
||||
let startX: number;
|
||||
@@ -348,7 +401,14 @@ function drawTextContentInBox(
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
|
||||
let textX = boxX + padPx.left;
|
||||
|
||||
@@ -663,6 +663,8 @@ export function getEffectiveCellStyle(
|
||||
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
|
||||
textColor: pick('textColor', elem.textColor ?? '#111827'),
|
||||
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
|
||||
textMaxLines: pick('textMaxLines', elem.textMaxLines),
|
||||
textEllipsis: pick('textEllipsis', elem.textEllipsis ?? false),
|
||||
fontFamily: pick('fontFamily', elem.fontFamily),
|
||||
textFormat: pick('textFormat', elem.textFormat ?? 'text'),
|
||||
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
|
||||
@@ -693,6 +695,8 @@ const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
|
||||
'textColor',
|
||||
'backgroundColor',
|
||||
'wordWrap',
|
||||
'textMaxLines',
|
||||
'textEllipsis',
|
||||
'fontFamily',
|
||||
'textFormat',
|
||||
'decimalPlaces',
|
||||
|
||||
10
src/types.ts
10
src/types.ts
@@ -75,6 +75,10 @@ export interface TableCellData {
|
||||
textColor?: string;
|
||||
backgroundColor?: string;
|
||||
wordWrap?: boolean;
|
||||
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制 */
|
||||
textMaxLines?: number;
|
||||
/** 超出最大行数时末行/末列显示省略号 */
|
||||
textEllipsis?: boolean;
|
||||
fontFamily?: 'sans' | 'serif' | 'mono';
|
||||
textFormat?: TextFormat;
|
||||
decimalPlaces?: number;
|
||||
@@ -116,6 +120,10 @@ export interface TemplateElement {
|
||||
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||
/** 是否自动换行,仅 type=text,默认 true */
|
||||
wordWrap?: boolean;
|
||||
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制,仅 type=text */
|
||||
textMaxLines?: number;
|
||||
/** 超出最大行数时显示省略号,仅 type=text */
|
||||
textEllipsis?: boolean;
|
||||
/** 文本排列方向,仅 type=text,默认 horizontal */
|
||||
textWritingMode?: 'horizontal' | 'vertical';
|
||||
fontWeight: 'normal' | 'bold';
|
||||
@@ -145,6 +153,8 @@ export interface TemplateElement {
|
||||
paddingBottom?: number;
|
||||
paddingLeft?: number;
|
||||
locked?: boolean;
|
||||
/** 图层是否隐藏;隐藏后在预览、导出、打印及画布中均不显示 */
|
||||
hidden?: boolean;
|
||||
/** 图片缩放方式,仅 type=image 时有效 */
|
||||
imageFit?: 'contain' | 'cover' | 'fill';
|
||||
/** 默认图片 URL 或 data URI;主图加载失败时使用,仅 type=image 时有效 */
|
||||
|
||||
@@ -1309,6 +1309,11 @@ export function getVariableRawValue(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 图层是否在画布与输出中显示 */
|
||||
export function isElementLayerVisible(elem: TemplateElement): boolean {
|
||||
return elem.hidden !== true;
|
||||
}
|
||||
|
||||
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
|
||||
export function shouldRenderElement(
|
||||
elem: TemplateElement,
|
||||
@@ -1317,6 +1322,8 @@ export function shouldRenderElement(
|
||||
defaults?: Record<string, string> | null,
|
||||
mode: ContentResolveMode = 'output'
|
||||
): boolean {
|
||||
if (!isElementLayerVisible(elem)) return false;
|
||||
|
||||
if (!isConditionalVisibility(elem)) return true;
|
||||
|
||||
const rules = resolveVisibilityRules(elem);
|
||||
|
||||
Reference in New Issue
Block a user