Skip to content

Commit 75af5bb

Browse files
committed
feat(layout): 重构布局构建逻辑以简化节点处理和提高可读性
- 在 SNLayoutBuilder 中重构布局构建方法,合并页面和乐谱构建逻辑,提升代码的清晰度和可维护性。 - 更新节点的宽度和高度计算逻辑,确保根据子节点内容动态调整,增强布局的灵活性。 - 优化多个布局节点类的文档注释,提升代码可读性,帮助开发者更好地理解布局结构。 该变更提升了布局构建的能力,增强了代码的可维护性和可扩展性。
1 parent 7866b85 commit 75af5bb

28 files changed

Lines changed: 1298 additions & 1594 deletions

packages/simple-notation/src/layout/builder.ts

Lines changed: 18 additions & 1053 deletions
Large diffs are not rendered by default.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { SNParserNode } from '@data/node';
2+
import type { SNParserLyric } from '@data/node';
3+
import type { SNLayoutElement } from '@layout/node';
4+
import { ScoreConfig } from '@manager/config';
5+
import { transformMeasureElement } from '../trans';
6+
import { calculateNodeHeight } from './calculate-height';
7+
8+
/**
9+
* 构建元素的子元素(如歌词)
10+
* @param children - 元素的子节点数组(如歌词)
11+
* @param parentLayoutElement - 父布局元素节点
12+
* @param _parentX - 父元素的X坐标(预留参数,暂未使用)
13+
* @param _parentWidth - 父元素的宽度(预留参数,暂未使用)
14+
* @param scoreConfig - 乐谱配置
15+
*/
16+
export function buildElementChildren(
17+
children: SNParserNode[],
18+
parentLayoutElement: SNLayoutElement,
19+
_parentX: number,
20+
_parentWidth: number,
21+
scoreConfig: ScoreConfig,
22+
): void {
23+
if (!children?.length) return;
24+
25+
// 过滤出歌词节点
26+
const lyrics = children.filter(
27+
(child) => child.type === 'lyric',
28+
) as SNParserLyric[];
29+
30+
if (lyrics.length === 0) return;
31+
32+
// 按 verseNumber 分组歌词
33+
const lyricsByVerse = new Map<number, SNParserLyric[]>();
34+
for (const lyric of lyrics) {
35+
if (lyric.skip) continue; // 跳过标记为 skip 的歌词
36+
37+
const verseNumber = lyric.verseNumber || 0;
38+
if (!lyricsByVerse.has(verseNumber)) {
39+
lyricsByVerse.set(verseNumber, []);
40+
}
41+
lyricsByVerse.get(verseNumber)!.push(lyric);
42+
}
43+
44+
// 为每个歌词行创建布局元素
45+
// 歌词的Y坐标应该在音符下方,不同 verseNumber 的歌词应该垂直排列
46+
const lyricLineHeight = 20; // 每行歌词的高度(可后续做成配置项)
47+
const lyricBaseOffset = 30; // 歌词距离音符的基偏移(可后续做成配置项)
48+
49+
for (const [verseNumber, verseLyrics] of lyricsByVerse.entries()) {
50+
// 对每个 verseNumber,可能有多个歌词(如 multi-word 的情况)
51+
// 这里我们为每个歌词创建一个布局元素
52+
for (const lyric of verseLyrics) {
53+
// 使用 transformMeasureElement 转换歌词元素
54+
const lyricLayoutElement = transformMeasureElement(
55+
lyric,
56+
scoreConfig,
57+
parentLayoutElement,
58+
);
59+
60+
if (!lyricLayoutElement || !lyricLayoutElement.layout) continue;
61+
62+
// 设置歌词的位置
63+
if (lyric.targetId && parentLayoutElement.parent) {
64+
// 从measure(parentLayoutElement的父元素)中查找对应的音符元素
65+
const measureElement = parentLayoutElement.parent;
66+
if (measureElement.children) {
67+
const targetNote = measureElement.children.find(
68+
(child) =>
69+
child.data?.id === lyric.targetId && child.data?.type === 'note',
70+
);
71+
if (targetNote?.layout) {
72+
const noteWidth =
73+
typeof targetNote.layout.width === 'number'
74+
? targetNote.layout.width
75+
: 0;
76+
const noteCx = Math.max(0, noteWidth / 2);
77+
lyricLayoutElement.layout.x = noteCx;
78+
}
79+
}
80+
}
81+
// 歌词宽度根据文本内容自适应(这里先设置为文本宽度,后续可以根据实际文本计算)
82+
lyricLayoutElement.layout.width = Math.max(
83+
30,
84+
lyric.syllable.length * 12, // 粗略估算:每个字符12px
85+
);
86+
87+
// Y坐标:音符下方,根据 verseNumber 垂直排列
88+
if (parentLayoutElement.layout) {
89+
// 歌词的Y坐标 = 父元素的Y + 父元素的高度 + 基偏移 + verseNumber * 行高
90+
const parentY =
91+
typeof parentLayoutElement.layout.y === 'number'
92+
? parentLayoutElement.layout.y
93+
: 0;
94+
const parentHeight =
95+
typeof parentLayoutElement.layout.height === 'number'
96+
? parentLayoutElement.layout.height
97+
: 0;
98+
99+
lyricLayoutElement.layout.y =
100+
parentY +
101+
parentHeight +
102+
lyricBaseOffset +
103+
verseNumber * lyricLineHeight;
104+
}
105+
106+
// 歌词元素添加后,立即更新父节点(Measure Element)的高度
107+
calculateNodeHeight(parentLayoutElement);
108+
}
109+
}
110+
111+
// 所有歌词元素添加完成后,更新父节点(Measure Element)的高度
112+
// 确保父节点高度包含所有歌词
113+
calculateNodeHeight(parentLayoutElement);
114+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { SNParserNode } from '@data/node';
2+
import type { SNLayoutElement } from '@layout/node';
3+
import { ScoreConfig } from '@manager/config';
4+
import { getTimeUnitFromNode, measureDuration } from '@core/utils/time-unit';
5+
import { transformMeasureElement } from '../trans';
6+
import { buildElementChildren } from './build-element-children';
7+
import { calculateNodeHeight } from './calculate-height';
8+
9+
/**
10+
* 构建 Measure 内部的元素(叶子节点)
11+
* 按照元素的tick(duration)按比例分配小节宽度,并添加左右padding避免元素顶在小节线上
12+
* @param elements - Measure 的子元素(Note/Rest/Lyric/Tuplet)
13+
* @param parentNode - 父节点(Element,即小节)
14+
* @param scoreConfig - 乐谱配置
15+
*/
16+
export function buildMeasureElements(
17+
elements: SNParserNode[],
18+
parentNode: SNLayoutElement,
19+
scoreConfig: ScoreConfig,
20+
): void {
21+
if (!elements?.length) return;
22+
23+
// 获取小节的总duration(通过小节节点获取timeUnit和timeSignature)
24+
const measureNode = parentNode.data as SNParserNode;
25+
if (!measureNode) return;
26+
27+
const timeUnit = getTimeUnitFromNode(measureNode);
28+
const timeSignature = measureNode.getTimeSignature();
29+
const measureTotalTicks = measureDuration(timeSignature, timeUnit);
30+
31+
// 获取小节的实际宽度(已经设置好的)
32+
const measureWidth =
33+
typeof parentNode.layout?.width === 'number' ? parentNode.layout.width : 0;
34+
35+
// 左右padding,避免元素顶在小节线上
36+
const horizontalPadding = 8; // 可后续做成配置项
37+
const usableWidth = Math.max(0, measureWidth - horizontalPadding * 2);
38+
39+
// 过滤出有 duration 的元素(note、rest等),忽略没有 duration 的元素(如 tie、某些装饰元素)
40+
const elementsWithDuration = elements.filter(
41+
(el) => el.duration && el.duration > 0,
42+
);
43+
44+
// 计算所有有 duration 的元素的总 ticks
45+
const totalElementsTicks = elementsWithDuration.reduce(
46+
(sum, el) => sum + (el.duration || 0),
47+
0,
48+
);
49+
50+
// 如果元素的总 ticks 不等于小节的总 ticks,需要调整比例
51+
// 使用元素实际的总 ticks 来计算比例,确保元素能正确分布
52+
const ticksForRatio =
53+
totalElementsTicks > 0 ? totalElementsTicks : measureTotalTicks;
54+
55+
// 计算每个元素的起始位置和宽度(基于tick比例)
56+
let currentTickOffset = 0;
57+
for (let i = 0; i < elements.length; i++) {
58+
const dataElement = elements[i];
59+
const elementDuration = dataElement.duration || 0;
60+
61+
// 使用 transformMeasureElement 转换元素
62+
const layoutElement = transformMeasureElement(
63+
dataElement,
64+
scoreConfig,
65+
parentNode,
66+
);
67+
68+
if (!layoutElement || !layoutElement.layout) continue;
69+
70+
// 对于没有 duration 的元素(如 tie),跳过位置计算,保持默认位置
71+
if (elementDuration <= 0) {
72+
// Y坐标使用父节点的padding.top
73+
if (parentNode.layout) {
74+
const parentPadding = parentNode.layout.padding ?? {
75+
top: 0,
76+
right: 0,
77+
bottom: 0,
78+
left: 0,
79+
};
80+
layoutElement.layout.y = parentPadding.top;
81+
}
82+
83+
// 处理元素的 children(歌词等)
84+
if (dataElement.children?.length) {
85+
// 对于没有 duration 的元素,使用默认位置和宽度
86+
const defaultX =
87+
typeof layoutElement.layout.x === 'number'
88+
? layoutElement.layout.x
89+
: 0;
90+
const defaultWidth =
91+
typeof layoutElement.layout.width === 'number'
92+
? layoutElement.layout.width
93+
: 20;
94+
buildElementChildren(
95+
dataElement.children,
96+
layoutElement,
97+
defaultX,
98+
defaultWidth,
99+
scoreConfig,
100+
);
101+
}
102+
continue;
103+
}
104+
105+
// 计算元素在小节内的位置(基于tick比例)
106+
const startRatio = currentTickOffset / ticksForRatio;
107+
const durationRatio = elementDuration / ticksForRatio;
108+
109+
// 计算元素的实际位置和宽度
110+
const elementX = horizontalPadding + startRatio * usableWidth;
111+
const elementWidth = durationRatio * usableWidth;
112+
113+
// 更新元素的布局信息
114+
layoutElement.layout.x = elementX;
115+
layoutElement.layout.width = Math.max(10, elementWidth); // 最小宽度10px
116+
117+
// 更新累计的tick偏移
118+
currentTickOffset += elementDuration;
119+
120+
// Y坐标使用父节点的padding.top(由布局计算)
121+
if (parentNode.layout) {
122+
const parentPadding = parentNode.layout.padding ?? {
123+
top: 0,
124+
right: 0,
125+
bottom: 0,
126+
left: 0,
127+
};
128+
layoutElement.layout.y = parentPadding.top;
129+
}
130+
131+
// 处理元素的 children(歌词等)
132+
if (dataElement.children?.length) {
133+
buildElementChildren(
134+
dataElement.children,
135+
layoutElement,
136+
layoutElement.layout.x,
137+
layoutElement.layout.width,
138+
scoreConfig,
139+
);
140+
}
141+
142+
// 子节点添加后,立即更新父节点(Measure Element)的高度
143+
calculateNodeHeight(parentNode);
144+
}
145+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { SNParserNode } from '@data/node';
2+
import type { SNLayoutLine } from '@layout/node';
3+
import { ScoreConfig } from '@manager/config';
4+
import { transformMeasure } from '../trans';
5+
import { buildMeasureElements } from './build-measure-elements';
6+
import { finalizeNodeLayout } from './finalize-node-layout';
7+
import { calculateNodeHeight } from './calculate-height';
8+
import { computeMeasureWidthByTicks } from './utils';
9+
10+
/**
11+
* 构建 Measure 节点
12+
* @param measures - Measure 节点数组
13+
* @param parentNode - 父节点(Line)
14+
* @param shouldStretch - 是否拉伸小节以撑满整行(非最后一行时)
15+
* @param availableWidth - 可用宽度(用于拉伸计算)
16+
* @param scoreConfig - 乐谱配置
17+
*/
18+
export function buildMeasures(
19+
measures: SNParserNode[],
20+
parentNode: SNLayoutLine,
21+
shouldStretch = false,
22+
availableWidth = 0,
23+
scoreConfig: ScoreConfig,
24+
): void {
25+
// 先计算所有小节的基础宽度
26+
const baseWidths: number[] = [];
27+
for (const measure of measures) {
28+
const baseWidth = computeMeasureWidthByTicks(measure);
29+
baseWidths.push(baseWidth);
30+
}
31+
32+
// 计算总宽度
33+
const totalBaseWidth = baseWidths.reduce((sum, w) => sum + w, 0);
34+
35+
// 如果需要拉伸且总宽度小于可用宽度,计算拉伸比例
36+
let stretchRatio = 1;
37+
if (shouldStretch && totalBaseWidth > 0 && availableWidth > totalBaseWidth) {
38+
stretchRatio = availableWidth / totalBaseWidth;
39+
}
40+
41+
// 构建每个小节
42+
for (let i = 0; i < measures.length; i++) {
43+
const measure = measures[i];
44+
// 使用 transformMeasure 转换 Measure 为 Element
45+
const element = transformMeasure(measure, scoreConfig, parentNode);
46+
47+
if (!element) continue;
48+
49+
// 应用拉伸后的宽度
50+
const finalWidth = Math.round(baseWidths[i] * stretchRatio);
51+
if (element.layout) {
52+
element.layout.width = finalWidth;
53+
}
54+
55+
// 构建 Measure 内部的元素(Note/Rest/Lyric等)
56+
buildMeasureElements(
57+
(measure.children || []) as SNParserNode[],
58+
element,
59+
scoreConfig,
60+
);
61+
62+
// 子节点构建完成后,计算 Element 的布局信息
63+
finalizeNodeLayout(element);
64+
65+
// 子节点添加后,立即更新父节点(Line)的高度
66+
calculateNodeHeight(parentNode);
67+
}
68+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { SNParserScore } from '@data/node';
2+
import type { SNLayoutRoot } from '@layout/node';
3+
import { LayoutConfig, ScoreConfig } from '@manager/config';
4+
import { transformPage } from '../trans';
5+
import { buildScores } from './build-scores';
6+
import { finalizeNodeLayout } from './finalize-node-layout';
7+
8+
/**
9+
* 构建页面节点(分页模式)
10+
* @param scores - Score 节点数组
11+
* @param parentNode - 父节点
12+
* @param layoutConfig - 布局配置
13+
* @param scoreConfig - 乐谱配置
14+
*/
15+
export function buildPages(
16+
scores: SNParserScore[],
17+
parentNode: SNLayoutRoot,
18+
layoutConfig: LayoutConfig,
19+
scoreConfig: ScoreConfig,
20+
): void {
21+
for (const score of scores) {
22+
// 使用 transformPage 转换 Score 为 Page
23+
const page = transformPage(score, layoutConfig, parentNode);
24+
25+
// 构建 Score 节点(在 Page 内部)
26+
buildScores([score], page, layoutConfig, scoreConfig);
27+
28+
// 子节点构建完成后,计算 Page 的布局信息
29+
finalizeNodeLayout(page);
30+
}
31+
}

0 commit comments

Comments
 (0)