This commit is contained in:
iqudoo
2026-06-15 16:21:23 +08:00
parent 1d506e0c71
commit fcd587bd50
7 changed files with 212 additions and 3 deletions

View File

@@ -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); }}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',

View File

@@ -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 时有效 */

View File

@@ -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);