88 type SNLayoutNode ,
99} from '@layout/node' ;
1010import { LayoutConfig , ScoreConfig } from '@manager/config' ;
11+ import { getTimeUnitFromNode , measureDuration } from '@core/utils/time-unit' ;
1112import {
1213 RootTransformer ,
1314 ScoreTransformer ,
@@ -253,9 +254,7 @@ export class SNLayoutBuilder {
253254
254255 if ( voiceMeasures . length === 0 ) return ;
255256
256- // 计算小节间距
257- const measureConfig = this . scoreConfig . getMeasure ( ) ;
258- const measureGap = measureConfig . spacing . measureGap || 10 ;
257+ // 小节之间不应该有间隔(measureGap = 0)
259258
260259 // 1) 计算每个小节在每个 Voice 中的实际宽度,并取同一小节索引的最大值,确保跨声部对齐
261260 const maxMeasureCount = Math . max (
@@ -267,7 +266,7 @@ export class SNLayoutBuilder {
267266 for ( const { measures } of voiceMeasures ) {
268267 const m = measures [ i ] ;
269268 if ( ! m ) continue ;
270- const w = this . computeMeasureLayoutWidth ( m ) ;
269+ const w = this . computeMeasureWidthByTicks ( m ) ;
271270 if ( w > maxWidth ) maxWidth = w ;
272271 }
273272 // 后备:至少给一个基础宽度,避免0
@@ -283,9 +282,8 @@ export class SNLayoutBuilder {
283282 const start = cursor ;
284283 while ( cursor < maxMeasureCount ) {
285284 const nextWidth = maxWidthsByIndex [ cursor ] ;
286- const addWidth = ( lineWidth === 0 ? 0 : measureGap ) + nextWidth ;
287- if ( lineWidth + addWidth <= availableWidth ) {
288- lineWidth += addWidth ;
285+ if ( lineWidth + nextWidth <= availableWidth ) {
286+ lineWidth += nextWidth ;
289287 cursor ++ ;
290288 } else {
291289 break ;
@@ -326,7 +324,8 @@ export class SNLayoutBuilder {
326324
327325 this . calculateNodeWidth ( line ) ;
328326 if ( lineMeasures . length > 0 ) {
329- this . buildMeasures ( lineMeasures , line ) ;
327+ // 对于非最后一行,需要拉伸小节以撑满整行
328+ this . buildMeasures ( lineMeasures , line , ! isLastLine , availableWidth ) ;
330329 }
331330 this . calculateNodePosition ( line ) ;
332331 } ) ;
@@ -357,6 +356,41 @@ export class SNLayoutBuilder {
357356 return Math . max ( 0 , width - padding . left - padding . right ) ;
358357 }
359358
359+ /**
360+ * 向上查找拍号(若未设置则返回 4/4)
361+ */
362+ private getTimeSignatureFromNode ( node : SNParserNode ) : {
363+ numerator : number ;
364+ denominator : number ;
365+ } {
366+ let current : SNParserNode | undefined = node ;
367+ while ( current ) {
368+ const props = ( current . props as any ) || { } ;
369+ if (
370+ props . timeSignature &&
371+ typeof props . timeSignature . numerator === 'number'
372+ ) {
373+ return props . timeSignature ;
374+ }
375+ current = current . parent as SNParserNode | undefined ;
376+ }
377+ return { numerator : 4 , denominator : 4 } ;
378+ }
379+
380+ /**
381+ * 基于 ticks 计算小节理想宽度(像素)
382+ */
383+ private computeMeasureWidthByTicks (
384+ measure : SNParserNode ,
385+ pxPerBeat = 40 ,
386+ ) : number {
387+ const timeUnit = getTimeUnitFromNode ( measure ) ;
388+ const timeSignature = this . getTimeSignatureFromNode ( measure ) ;
389+ const totalTicks : number = measureDuration ( timeSignature , timeUnit ) ;
390+ const pxPerTick = pxPerBeat / timeUnit . ticksPerBeat ;
391+ return Math . max ( 20 , Math . round ( totalTicks * pxPerTick ) ) ;
392+ }
393+
360394 /**
361395 * 计算单个 Measure 的布局宽度(基于其子元素宽度汇总)
362396 *
@@ -365,34 +399,7 @@ export class SNLayoutBuilder {
365399 * finalizeNodeLayout 计算得到该 Measure Element 的宽度。
366400 * 该临时节点不会加入实际的布局树。
367401 */
368- private computeMeasureLayoutWidth ( measure : SNParserNode ) : number {
369- // 创建临时 Line(不挂到树上)
370- const tempLine = new SNLayoutLine ( 'temp-line' ) ;
371- tempLine . updateLayout ( {
372- x : 0 ,
373- y : 0 ,
374- width : 0 ,
375- height : 0 ,
376- padding : { top : 0 , right : 0 , bottom : 0 , left : 0 } ,
377- } ) ;
378-
379- // 使用现有转换器构建临时 Measure Element
380- const tempElement = this . measureTransformer . transform ( measure , tempLine ) ;
381- if ( ! tempElement ) return 0 ;
382-
383- // 构建子元素
384- this . buildMeasureElements (
385- ( measure . children || [ ] ) as SNParserNode [ ] ,
386- tempElement ,
387- ) ;
388-
389- // 完成临时 Element 的布局计算
390- this . finalizeNodeLayout ( tempElement ) ;
391-
392- return typeof tempElement . layout ?. width === 'number'
393- ? tempElement . layout . width
394- : 0 ;
395- }
402+ // 已废弃:改为使用 computeMeasureWidthByTicks
396403
397404 /**
398405 * 构建 Measure 节点
@@ -401,17 +408,49 @@ export class SNLayoutBuilder {
401408 *
402409 * @param measures - Measure 节点数组
403410 * @param parentNode - 父节点(Line)
411+ * @param shouldStretch - 是否拉伸小节以撑满整行(非最后一行时)
412+ * @param availableWidth - 可用宽度(用于拉伸计算)
404413 */
405414 private buildMeasures (
406415 measures : SNParserNode [ ] ,
407416 parentNode : SNLayoutLine ,
417+ shouldStretch = false ,
418+ availableWidth = 0 ,
408419 ) : void {
420+ // 先计算所有小节的基础宽度
421+ const baseWidths : number [ ] = [ ] ;
409422 for ( const measure of measures ) {
423+ const baseWidth = this . computeMeasureWidthByTicks ( measure ) ;
424+ baseWidths . push ( baseWidth ) ;
425+ }
426+
427+ // 计算总宽度
428+ const totalBaseWidth = baseWidths . reduce ( ( sum , w ) => sum + w , 0 ) ;
429+
430+ // 如果需要拉伸且总宽度小于可用宽度,计算拉伸比例
431+ let stretchRatio = 1 ;
432+ if (
433+ shouldStretch &&
434+ totalBaseWidth > 0 &&
435+ availableWidth > totalBaseWidth
436+ ) {
437+ stretchRatio = availableWidth / totalBaseWidth ;
438+ }
439+
440+ // 构建每个小节
441+ for ( let i = 0 ; i < measures . length ; i ++ ) {
442+ const measure = measures [ i ] ;
410443 // 使用 MeasureTransformer 转换 Measure 为 Element
411444 const element = this . measureTransformer . transform ( measure , parentNode ) ;
412445
413446 if ( ! element ) continue ;
414447
448+ // 应用拉伸后的宽度
449+ const finalWidth = Math . round ( baseWidths [ i ] * stretchRatio ) ;
450+ if ( element . layout ) {
451+ element . layout . width = finalWidth ;
452+ }
453+
415454 // 构建 Measure 内部的元素(Note/Rest/Lyric等)
416455 this . buildMeasureElements (
417456 ( measure . children || [ ] ) as SNParserNode [ ] ,
@@ -426,26 +465,105 @@ export class SNLayoutBuilder {
426465 /**
427466 * 构建 Measure 内部的元素(叶子节点)
428467 *
429- * 这些是最底层的元素,有固定的宽高,不需要计算
468+ * 按照元素的tick(duration)按比例分配小节宽度,并添加左右padding避免元素顶在小节线上
430469 *
431470 * @param elements - Measure 的子元素(Note/Rest/Lyric/Tuplet)
432- * @param parentNode - 父节点(Element)
471+ * @param parentNode - 父节点(Element,即小节 )
433472 */
434473 private buildMeasureElements (
435474 elements : SNParserNode [ ] ,
436475 parentNode : SNLayoutElement ,
437476 ) : void {
438- for ( const dataElement of elements ) {
477+ if ( ! elements || elements . length === 0 ) return ;
478+
479+ // 获取小节的总duration(通过小节节点获取timeUnit和timeSignature)
480+ const measureNode = parentNode . data as SNParserNode ;
481+ if ( ! measureNode ) return ;
482+
483+ const timeUnit = getTimeUnitFromNode ( measureNode ) ;
484+ const timeSignature = this . getTimeSignatureFromNode ( measureNode ) ;
485+ const measureTotalTicks = measureDuration ( timeSignature , timeUnit ) ;
486+
487+ // 获取小节的实际宽度(已经设置好的)
488+ const measureWidth =
489+ typeof parentNode . layout ?. width === 'number'
490+ ? parentNode . layout . width
491+ : 0 ;
492+
493+ // 左右padding,避免元素顶在小节线上
494+ const horizontalPadding = 8 ; // 可后续做成配置项
495+ const usableWidth = Math . max ( 0 , measureWidth - horizontalPadding * 2 ) ;
496+
497+ // 过滤出有 duration 的元素(note、rest等),忽略没有 duration 的元素(如 tie、某些装饰元素)
498+ const elementsWithDuration = elements . filter (
499+ ( el ) => el . duration && el . duration > 0 ,
500+ ) ;
501+
502+ // 计算所有有 duration 的元素的总 ticks
503+ const totalElementsTicks = elementsWithDuration . reduce (
504+ ( sum , el ) => sum + ( el . duration || 0 ) ,
505+ 0 ,
506+ ) ;
507+
508+ // 如果元素的总 ticks 不等于小节的总 ticks,需要调整比例
509+ // 使用元素实际的总 ticks 来计算比例,确保元素能正确分布
510+ const ticksForRatio =
511+ totalElementsTicks > 0 ? totalElementsTicks : measureTotalTicks ;
512+
513+ // 计算每个元素的起始位置和宽度(基于tick比例)
514+ let currentTickOffset = 0 ;
515+ for ( let i = 0 ; i < elements . length ; i ++ ) {
516+ const dataElement = elements [ i ] ;
517+ const elementDuration = dataElement . duration || 0 ;
518+
439519 // 使用 MeasureTransformer 转换元素
440520 const layoutElement = this . measureTransformer . transformElement (
441521 dataElement ,
442522 parentNode ,
443523 ) ;
444524
445- if ( ! layoutElement ) continue ;
525+ if ( ! layoutElement || ! layoutElement . layout ) continue ;
526+
527+ // 对于没有 duration 的元素(如 tie),跳过位置计算,保持默认位置
528+ if ( elementDuration <= 0 ) {
529+ // Y坐标使用父节点的padding.top
530+ if ( parentNode . layout ) {
531+ const parentPadding = parentNode . layout . padding || {
532+ top : 0 ,
533+ right : 0 ,
534+ bottom : 0 ,
535+ left : 0 ,
536+ } ;
537+ layoutElement . layout . y = parentPadding . top ;
538+ }
539+ continue ;
540+ }
541+
542+ // 计算元素在小节内的位置(基于tick比例)
543+ const startRatio = currentTickOffset / ticksForRatio ;
544+ const durationRatio = elementDuration / ticksForRatio ;
446545
447- // 叶子节点:直接计算位置(宽高已在转换器中设置)
448- this . calculateNodePosition ( layoutElement ) ;
546+ // 计算元素的实际位置和宽度
547+ const elementX = horizontalPadding + startRatio * usableWidth ;
548+ const elementWidth = durationRatio * usableWidth ;
549+
550+ // 更新元素的布局信息
551+ layoutElement . layout . x = elementX ;
552+ layoutElement . layout . width = Math . max ( 10 , elementWidth ) ; // 最小宽度10px
553+
554+ // 更新累计的tick偏移
555+ currentTickOffset += elementDuration ;
556+
557+ // Y坐标使用父节点的padding.top(由布局计算)
558+ if ( parentNode . layout ) {
559+ const parentPadding = parentNode . layout . padding || {
560+ top : 0 ,
561+ right : 0 ,
562+ bottom : 0 ,
563+ left : 0 ,
564+ } ;
565+ layoutElement . layout . y = parentPadding . top ;
566+ }
449567 }
450568 }
451569
@@ -501,27 +619,34 @@ export class SNLayoutBuilder {
501619 // Block和Line:撑满父级宽度
502620 node . calculateWidth ( ) ;
503621 } else if ( node instanceof SNLayoutElement ) {
504- // Element:根据子节点计算宽度
505- if ( node . children && node . children . length > 0 ) {
506- const childrenMaxWidth = node . calculateChildrenMaxWidth ( ) ;
507- const padding = node . layout . padding || {
508- top : 0 ,
509- right : 0 ,
510- bottom : 0 ,
511- left : 0 ,
512- } ;
513- node . layout . width =
514- childrenMaxWidth > 0
515- ? childrenMaxWidth + padding . left + padding . right
516- : 20 ;
517- } else {
518- // 叶子元素:使用已有宽度或默认值
519- if (
520- ! node . layout . width ||
521- typeof node . layout . width !== 'number' ||
522- node . layout . width === 0
523- ) {
524- node . layout . width = 20 ;
622+ // Element:如果已有固定宽度,尊重之;否则根据子节点计算
623+ const hasFixedWidth =
624+ node . layout . width !== null &&
625+ typeof node . layout . width === 'number' &&
626+ node . layout . width > 0 ;
627+
628+ if ( ! hasFixedWidth ) {
629+ if ( node . children && node . children . length > 0 ) {
630+ const childrenMaxWidth = node . calculateChildrenMaxWidth ( ) ;
631+ const padding = node . layout . padding || {
632+ top : 0 ,
633+ right : 0 ,
634+ bottom : 0 ,
635+ left : 0 ,
636+ } ;
637+ node . layout . width =
638+ childrenMaxWidth > 0
639+ ? childrenMaxWidth + padding . left + padding . right
640+ : 20 ;
641+ } else {
642+ // 叶子元素:使用已有宽度或默认值
643+ if (
644+ ! node . layout . width ||
645+ typeof node . layout . width !== 'number' ||
646+ node . layout . width === 0
647+ ) {
648+ node . layout . width = 20 ;
649+ }
525650 }
526651 }
527652 }
0 commit comments