UNPKG

vexflow

Version:

A JavaScript library for rendering music notation and guitar tablature.

1,275 lines (1,105 loc) 41.8 kB
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010. // // This file implements notes for standard notation. This consists of one or // more `NoteHeads`, an optional stem, and an optional flag. // // Throughout these comments, a "note" refers to the entire `StaveNote`, // and a "key" refers to a specific pitch/notehead within a note. // // See `tests/stavenote_tests.ts` for usage examples. import { Beam } from './beam'; import { BoundingBox } from './boundingbox'; import { ElementStyle } from './element'; import { Modifier } from './modifier'; import { ModifierContextState } from './modifiercontext'; import { KeyProps, Note, NoteStruct } from './note'; import { NoteHead } from './notehead'; import { Stave } from './stave'; import { Stem, StemOptions } from './stem'; import { StemmableNote } from './stemmablenote'; import { Tables } from './tables'; import { Category } from './typeguard'; import { defined, log, midLine, RuntimeError } from './util'; function showDeprecationWarningForNoteHeads(): void { // eslint-disable-next-line console.warn( 'StaveNote.note_heads is deprecated. Use StaveNote.noteHeads instead.', 'This accessor will be removed in VexFlow 5.0.' ); } export interface StaveNoteHeadBounds { y_top: number; y_bottom: number; displaced_x?: number; non_displaced_x?: number; highest_line: number; lowest_line: number; highest_displaced_line?: number; lowest_displaced_line?: number; highest_non_displaced_line: number; lowest_non_displaced_line: number; } export interface StaveNoteFormatSettings { line: number; maxLine: number; minLine: number; isrest: boolean; stemDirection?: number; stemMax: number; stemMin: number; voice_shift: number; is_displaced: boolean; note: StaveNote; } export interface StaveNoteStruct extends NoteStruct { /** `Stem.UP` or `Stem.DOWN`. */ stem_direction?: number; auto_stem?: boolean; stem_down_x_offset?: number; stem_up_x_offset?: number; stroke_px?: number; glyph_font_scale?: number; octave_shift?: number; clef?: string; } // To enable logging for this class. Set `Vex.Flow.StaveNote.DEBUG` to `true`. // eslint-disable-next-line function L(...args: any[]) { if (StaveNote.DEBUG) log('Vex.Flow.StaveNote', args); } const isInnerNoteIndex = (note: StaveNote, index: number) => index === (note.getStemDirection() === Stem.UP ? note.keyProps.length - 1 : 0); // Helper methods for rest positioning in ModifierContext. function shiftRestVertical(rest: StaveNoteFormatSettings, note: StaveNoteFormatSettings, dir: number) { const delta = dir; rest.line += delta; rest.maxLine += delta; rest.minLine += delta; rest.note.setKeyLine(0, rest.note.getKeyLine(0) + delta); } // Called from formatNotes :: center a rest between two notes function centerRest(rest: StaveNoteFormatSettings, noteU: StaveNoteFormatSettings, noteL: StaveNoteFormatSettings) { const delta = rest.line - midLine(noteU.minLine, noteL.maxLine); rest.note.setKeyLine(0, rest.note.getKeyLine(0) - delta); rest.line -= delta; rest.maxLine -= delta; rest.minLine -= delta; } export class StaveNote extends StemmableNote { static DEBUG: boolean = false; static get CATEGORY(): string { return Category.StaveNote; } /** * @deprecated Use Stem.UP. */ static get STEM_UP(): number { return Stem.UP; } /** * @deprecated Use Stem.DOWN. */ static get STEM_DOWN(): number { return Stem.DOWN; } static get LEDGER_LINE_OFFSET(): number { return 3; } static get minNoteheadPadding(): number { const musicFont = Tables.currentMusicFont(); return musicFont.lookupMetric('noteHead.minPadding'); } /** Format notes inside a ModifierContext. */ static format(notes: StaveNote[], state: ModifierContextState): boolean { if (!notes || notes.length < 2) return false; const notesList: StaveNoteFormatSettings[] = []; for (let i = 0; i < notes.length; i++) { // Formatting uses sortedKeyProps to calculate line and minL. const props = notes[i].sortedKeyProps; const line = props[0].keyProps.line; let minL = props[props.length - 1].keyProps.line; const stemDirection = notes[i].getStemDirection(); const stemMax = notes[i].getStemLength() / 10; const stemMin = notes[i].getStemMinimumLength() / 10; let maxL; if (notes[i].isRest()) { maxL = line + notes[i].glyphProps.line_above; minL = line - notes[i].glyphProps.line_below; } else { maxL = stemDirection === 1 ? props[props.length - 1].keyProps.line + stemMax : props[props.length - 1].keyProps.line; minL = stemDirection === 1 ? props[0].keyProps.line : props[0].keyProps.line - stemMax; } notesList.push({ line: props[0].keyProps.line, // note/rest base line maxLine: maxL, // note/rest upper bounds line minLine: minL, // note/rest lower bounds line isrest: notes[i].isRest(), stemDirection: stemDirection, stemMax, // Maximum (default) note stem length; stemMin, // minimum note stem length voice_shift: notes[i].getVoiceShiftWidth(), is_displaced: notes[i].isDisplaced(), // note manually displaced note: notes[i], }); } let voices = 0; let noteU = undefined; let noteM = undefined; let noteL = undefined; const draw = [false, false, false]; for (let i = 0; i < notesList.length; i++) { draw[i] = notesList[i].note.render_options.draw == false ? false : true; } if (draw[0] && draw[1] && draw[2]) { // Three visible notes voices = 3; noteU = notesList[0]; noteM = notesList[1]; noteL = notesList[2]; } else if (draw[0] && draw[1]) { // Two visible notes, 0 & 1 voices = 2; noteU = notesList[0]; noteL = notesList[1]; } else if (draw[0] && draw[2]) { // Two visible notes, 0 & 2 voices = 2; noteU = notesList[0]; noteL = notesList[2]; } else if (draw[1] && draw[2]) { // Two visible notes, 1 & 2 voices = 2; noteU = notesList[1]; noteL = notesList[2]; } else { // No shift required for less than 2 visible notes return true; } // for two voice backward compatibility, ensure upper voice is stems up // for three voices, the voices must be in order (upper, middle, lower) if (voices === 2 && noteU.stemDirection === -1 && noteL.stemDirection === 1) { noteU = notesList[1]; noteL = notesList[0]; } const voiceXShift = Math.max(noteU.voice_shift, noteL.voice_shift); let xShift = 0; // Test for two voice note intersection if (voices === 2) { const lineSpacing = noteU.note.hasStem() && noteL.note.hasStem() && noteU.stemDirection === noteL.stemDirection ? 0.0 : 0.5; if (noteL.isrest && noteU.isrest && noteU.note.duration === noteL.note.duration) { noteL.note.render_options.draw = false; } else if (noteU.minLine <= noteL.maxLine + lineSpacing) { if (noteU.isrest) { // shift rest up shiftRestVertical(noteU, noteL, 1); } else if (noteL.isrest) { // shift rest down shiftRestVertical(noteL, noteU, -1); } else { //Instead of shifting notes, remove the appropriate flag //If we are sharing a line, switch one notes stem direction. //If we are sharing a line and in the same voice, only then offset one note const lineDiff = Math.abs(noteU.line - noteL.line); if (noteU.note.hasStem() && noteL.note.hasStem()) { const noteUHead = Tables.codeNoteHead( noteU.note.sortedKeyProps[0].keyProps.code ?? 'N', noteU.note.duration ); const noteLHead = Tables.codeNoteHead( noteL.note.sortedKeyProps[noteL.note.sortedKeyProps.length - 1].keyProps.code ?? 'N', noteL.note.duration ); if ( // If unison is not configured, shift !Tables.UNISON || // If we have different noteheads, shift noteUHead !== noteLHead || // If we have different dot values, shift noteU.note.getModifiers().filter((item) => item.getCategory() === Category.Dot && item.getIndex() === 0) .length !== noteL.note.getModifiers().filter((item) => item.getCategory() === Category.Dot && item.getIndex() === 0) .length || // If the notes are quite close but not on the same line, shift (lineDiff < 1 && lineDiff > 0) || // If styles are different, shift JSON.stringify(noteU.note.getStyle()) !== JSON.stringify(noteL.note.getStyle()) ) { xShift = voiceXShift + 2; if (noteU.stemDirection === noteL.stemDirection) { // upper voice is middle voice, so shift it right noteU.note.setXShift(xShift); } else { // shift lower voice right noteL.note.setXShift(xShift); } } else if (noteU.note.voice !== noteL.note.voice) { //If we are not in the same voice if (noteU.stemDirection === noteL.stemDirection) { if (noteU.line != noteL.line) { xShift = voiceXShift + 2; noteU.note.setXShift(xShift); } else { //same line, swap stem direction for one note if (noteL.stemDirection === 1) { noteL.stemDirection = -1; noteL.note.setStemDirection(-1); } } } } //Very close whole notes } else if (lineDiff < 1) { xShift = voiceXShift + 2; if (noteU.note.duration < noteL.note.duration) { // upper voice is shorter, so shift it right noteU.note.setXShift(xShift); } else { // shift lower voice right noteL.note.setXShift(xShift); } } else if (noteU.note.hasStem()) { noteU.stemDirection = -noteU.note.getStemDirection(); noteU.note.setStemDirection(noteU.stemDirection); } else if (noteL.note.hasStem()) { noteL.stemDirection = -noteL.note.getStemDirection(); noteL.note.setStemDirection(noteL.stemDirection); } } } // format complete state.right_shift += xShift; return true; } if (!noteM) throw new RuntimeError('InvalidState', 'noteM not defined.'); // For three voices, test if rests can be repositioned // // Special case 1 :: middle voice rest between two notes // if (noteM.isrest && !noteU.isrest && !noteL.isrest) { if (noteU.minLine <= noteM.maxLine || noteM.minLine <= noteL.maxLine) { const restHeight = noteM.maxLine - noteM.minLine; const space = noteU.minLine - noteL.maxLine; if (restHeight < space) { // center middle voice rest between the upper and lower voices centerRest(noteM, noteU, noteL); } else { xShift = voiceXShift + 2; // shift middle rest right noteM.note.setXShift(xShift); if (noteL.note.hasBeam() === false) { noteL.stemDirection = -1; noteL.note.setStemDirection(-1); } if (noteU.minLine <= noteL.maxLine && noteU.note.hasBeam() === false) { noteU.stemDirection = 1; noteU.note.setStemDirection(1); } } // format complete state.right_shift += xShift; return true; } } // Special case 2 :: all voices are rests if (noteU.isrest && noteM.isrest && noteL.isrest) { // Hide upper voice rest noteU.note.render_options.draw = false; // Hide lower voice rest noteL.note.render_options.draw = false; // format complete state.right_shift += xShift; return true; } // Test if any other rests can be repositioned if (noteM.isrest && noteU.isrest && noteM.minLine <= noteL.maxLine) { // Hide middle voice rest noteM.note.render_options.draw = false; } if (noteM.isrest && noteL.isrest && noteU.minLine <= noteM.maxLine) { // Hide middle voice rest noteM.note.render_options.draw = false; } if (noteU.isrest && noteU.minLine <= noteM.maxLine) { // shift upper voice rest up; shiftRestVertical(noteU, noteM, 1); } if (noteL.isrest && noteM.minLine <= noteL.maxLine) { // shift lower voice rest down shiftRestVertical(noteL, noteM, -1); } // If middle voice intersects upper or lower voice if (noteU.minLine <= noteM.maxLine + 0.5 || noteM.minLine <= noteL.maxLine) { // shift middle note right xShift = voiceXShift + 2; noteM.note.setXShift(xShift); if (noteL.note.hasBeam() === false) { noteL.stemDirection = -1; noteL.note.setStemDirection(-1); } if (noteU.minLine <= noteL.maxLine && noteU.note.hasBeam() === false) { noteU.stemDirection = 1; noteU.note.setStemDirection(1); } } state.right_shift += xShift; return true; } static postFormat(notes: Note[]): boolean { if (!notes) return false; notes.forEach((note) => note.postFormat()); return true; } ////////////////////////////////////////////////////////////////////////////////////////////////// // INSTANCE MEMBERS minLine: number = 0; maxLine: number = 0; protected readonly clef: string; protected readonly octave_shift?: number; protected displaced: boolean; protected dot_shiftY: number; protected use_default_head_x: boolean; protected ledgerLineStyle: ElementStyle; private _noteHeads: NoteHead[]; // Sorted variant of keyProps used internally private sortedKeyProps: { keyProps: KeyProps; index: number }[] = []; constructor(noteStruct: StaveNoteStruct) { super(noteStruct); this.ledgerLineStyle = {}; this.clef = noteStruct.clef ?? 'treble'; this.octave_shift = noteStruct.octave_shift ?? 0; // Pull note rendering properties. this.glyphProps = Tables.getGlyphProps(this.duration, this.noteType); defined( this.glyphProps, 'BadArguments', `No glyph found for duration '${this.duration}' and type '${this.noteType}'` ); // if true, displace note to right this.displaced = false; this.dot_shiftY = 0; // for displaced ledger lines this.use_default_head_x = false; // Drawing this._noteHeads = []; this.modifiers = []; this.render_options = { ...this.render_options, // font size for note heads and rests glyph_font_scale: noteStruct.glyph_font_scale || Tables.NOTATION_FONT_SCALE, // number of stroke px to the left and right of head stroke_px: noteStruct.stroke_px || StaveNote.LEDGER_LINE_OFFSET, }; this.calculateKeyProps(); this.buildStem(); // Set the stem direction if (noteStruct.auto_stem) { this.autoStem(); } else { this.setStemDirection(noteStruct.stem_direction ?? Stem.UP); } this.reset(); this.buildFlag(); } reset(): this { super.reset(); // Save prior noteHead styles & reapply them after making new noteheads. const noteHeadStyles = this._noteHeads.map((noteHead) => noteHead.getStyle()); this.buildNoteHeads(); this._noteHeads.forEach((noteHead, index) => { const noteHeadStyle = noteHeadStyles[index]; if (noteHeadStyle) noteHead.setStyle(noteHeadStyle); }); const stave = this.stave; if (stave) { this.setStave(stave); } this.calcNoteDisplacements(); return this; } setBeam(beam: Beam): this { this.beam = beam; this.calcNoteDisplacements(); // Update stem extension if a beam is assigned. if (this.stem) { this.stem.setExtension(this.getStemExtension()); } return this; } // Builds a `Stem` for the note buildStem(): this { this.setStem(new Stem({ hide: !!this.isRest() })); return this; } // Builds a `NoteHead` for each key in the note buildNoteHeads(): void { this._noteHeads = []; const stemDirection = this.getStemDirection(); const keys = this.getKeys(); let lastLine = undefined; let lineDiff = undefined; let displaced = false; // Draw notes from bottom to top. // For down-stem notes, we draw from top to bottom. let start: number; let end: number; let step: number; if (stemDirection === Stem.UP) { start = 0; end = keys.length; step = 1; } else { start = keys.length - 1; end = -1; step = -1; } for (let i = start; i !== end; i += step) { // Building noteheads rely on sortedKeNotes in order to calculate the displacements const noteProps = this.sortedKeyProps[i].keyProps; const line = noteProps.line; // Keep track of last line with a note head, so that consecutive heads // are correctly displaced. if (lastLine === undefined) { lastLine = line; } else { lineDiff = Math.abs(lastLine - line); if (lineDiff === 0 || lineDiff === 0.5) { displaced = !displaced; } else { displaced = false; this.use_default_head_x = true; } } lastLine = line; const notehead = new NoteHead({ duration: this.duration, note_type: this.noteType, displaced, stem_direction: stemDirection, custom_glyph_code: noteProps.code, glyph_font_scale: this.render_options.glyph_font_scale, x_shift: noteProps.shift_right, stem_up_x_offset: noteProps.stem_up_x_offset, stem_down_x_offset: noteProps.stem_down_x_offset, line: noteProps.line, }); this.addChildElement(notehead); this._noteHeads[this.sortedKeyProps[i].index] = notehead; } } // Automatically sets the stem direction based on the keys in the note autoStem(): void { this.setStemDirection(this.calculateOptimalStemDirection()); } calculateOptimalStemDirection(): number { // Figure out optimal stem direction based on given notes // minLine & maxLine rely on sortedKeyProps this.minLine = this.sortedKeyProps[0].keyProps.line; this.maxLine = this.sortedKeyProps[this.keyProps.length - 1].keyProps.line; const MIDDLE_LINE = 3; const decider = (this.minLine + this.maxLine) / 2; const stemDirection = decider < MIDDLE_LINE ? Stem.UP : Stem.DOWN; return stemDirection; } // Calculates and stores the properties for each key in the note calculateKeyProps(): void { let lastLine: number | undefined; for (let i = 0; i < this.keys.length; ++i) { const key = this.keys[i]; // All rests use the same position on the line. // if (this.glyph.rest) key = this.glyph.position; if (this.glyphProps.rest) this.glyphProps.position = key; const options = { octave_shift: this.octave_shift || 0, duration: this.duration }; const props = Tables.keyProperties(key, this.clef, options); if (!props) { throw new RuntimeError('BadArguments', `Invalid key for note properties: ${key}`); } // Override line placement for default rests if (props.key === 'R') { if (this.duration === '1' || this.duration === 'w') { props.line = 4; } else { props.line = 3; } } // Calculate displacement of this note const line = props.line; if (lastLine == undefined) { lastLine = line; } else { if (Math.abs(lastLine - line) === 0.5) { this.displaced = true; props.displaced = true; // Have to mark the previous note as // displaced as well, for modifier placement if (this.keyProps.length > 0) { this.keyProps[i - 1].displaced = true; } } } lastLine = line; this.keyProps.push(props); } // Sort the notes from lowest line to highest line in sortedKeyProps // Warn no longer required as keyProps remains unsorted this.keyProps.forEach((keyProps, index) => { this.sortedKeyProps.push({ keyProps, index }); }); this.sortedKeyProps.sort((a, b) => a.keyProps.line - b.keyProps.line); } // Get the `BoundingBox` for the entire note getBoundingBox(): BoundingBox { if (!this.preFormatted) { throw new RuntimeError('UnformattedNote', "Can't call getBoundingBox on an unformatted note."); } const { width: w, modLeftPx, leftDisplacedHeadPx } = this.getMetrics(); const x = this.getAbsoluteX() - modLeftPx - leftDisplacedHeadPx; let minY: number = 0; let maxY: number = 0; const halfLineSpacing = (this.getStave()?.getSpacingBetweenLines() ?? 0) / 2; const lineSpacing = halfLineSpacing * 2; if (this.isRest()) { const y = this.ys[0]; const frac = Tables.durationToFraction(this.duration); if (frac.equals(1) || frac.equals(2)) { minY = y - halfLineSpacing; maxY = y + halfLineSpacing; } else { minY = y - this.glyphProps.line_above * lineSpacing; maxY = y + this.glyphProps.line_below * lineSpacing; } } else if (this.glyphProps.stem) { const ys = this.getStemExtents(); ys.baseY += halfLineSpacing * this.getStemDirection(); minY = Math.min(ys.topY, ys.baseY); maxY = Math.max(ys.topY, ys.baseY); } else { minY = 0; maxY = 0; for (let i = 0; i < this.ys.length; ++i) { const yy = this.ys[i]; if (i === 0) { minY = yy; maxY = yy; } else { minY = Math.min(yy, minY); maxY = Math.max(yy, maxY); } } minY -= halfLineSpacing; maxY += halfLineSpacing; } return new BoundingBox(x, minY, w, maxY - minY); } // Gets the line number of the bottom note in the chord. // If `isTopNote` is `true` then get the top note's line number instead getLineNumber(isTopNote?: boolean): number { if (!this.keyProps.length) { throw new RuntimeError('NoKeyProps', "Can't get bottom note line, because note is not initialized properly."); } let resultLine = this.keyProps[0].line; // No precondition assumed for sortedness of keyProps array for (let i = 0; i < this.keyProps.length; i++) { const thisLine = this.keyProps[i].line; if (isTopNote) { if (thisLine > resultLine) resultLine = thisLine; } else { if (thisLine < resultLine) resultLine = thisLine; } } return resultLine; } /** * @returns true if this note is a type of rest. Rests don't have pitches, but take up space in the score. */ isRest(): boolean { return this.glyphProps.rest; } // Determine if the current note is a chord isChord(): boolean { return !this.isRest() && this.keys.length > 1; } // Determine if the `StaveNote` has a stem hasStem(): boolean { return this.glyphProps.stem; } hasFlag(): boolean { return super.hasFlag() && !this.isRest(); } getStemX(): number { if (this.noteType === 'r') { return this.getCenterGlyphX(); } else { // We adjust the origin of the stem because we want the stem left-aligned // with the notehead if stemmed-down, and right-aligned if stemmed-up return super.getStemX() + (this.stem_direction ? Stem.WIDTH / (2 * -this.stem_direction) : 0); } } // Get the `y` coordinate for text placed on the top/bottom of a // note at a desired `text_line` getYForTopText(textLine: number): number { const extents = this.getStemExtents(); return Math.min( this.checkStave().getYForTopText(textLine), extents.topY - this.render_options.annotation_spacing * (textLine + 1) ); } getYForBottomText(textLine: number): number { const extents = this.getStemExtents(); return Math.max( this.checkStave().getYForTopText(textLine), extents.baseY + this.render_options.annotation_spacing * textLine ); } // Sets the current note to the provided `stave`. This applies // `y` values to the `NoteHeads`. setStave(stave: Stave): this { super.setStave(stave); const ys = this._noteHeads.map((notehead) => { notehead.setStave(stave); return notehead.getY(); }); this.setYs(ys); if (this.stem) { const { y_top, y_bottom } = this.getNoteHeadBounds(); this.stem.setYBounds(y_top, y_bottom); } return this; } // Check if note is shifted to the right isDisplaced(): boolean { return this.displaced; } // Sets whether shift note to the right. `displaced` is a `boolean` setNoteDisplaced(displaced: boolean): this { this.displaced = displaced; return this; } // Get the starting `x` coordinate for a `StaveTie` getTieRightX(): number { let tieStartX = this.getAbsoluteX(); tieStartX += this.getGlyphWidth() + this.x_shift + this.rightDisplacedHeadPx; if (this.modifierContext) tieStartX += this.modifierContext.getRightShift(); return tieStartX; } // Get the ending `x` coordinate for a `StaveTie` getTieLeftX(): number { let tieEndX = this.getAbsoluteX(); tieEndX += this.x_shift - this.leftDisplacedHeadPx; return tieEndX; } // Get the stave line on which to place a rest getLineForRest(): number { let restLine = this.keyProps[0].line; if (this.keyProps.length > 1) { const lastLine = this.keyProps[this.keyProps.length - 1].line; const top = Math.max(restLine, lastLine); const bot = Math.min(restLine, lastLine); restLine = midLine(top, bot); } return restLine; } // Get the default `x` and `y` coordinates for the provided `position` // and key `index` getModifierStartXY( position: number, index: number, options: { forceFlagRight?: boolean } = {} ): { x: number; y: number } { if (!this.preFormatted) { throw new RuntimeError('UnformattedNote', "Can't call GetModifierStartXY on an unformatted note"); } if (this.ys.length === 0) { throw new RuntimeError('NoYValues', 'No Y-Values calculated for this note.'); } const { ABOVE, BELOW, LEFT, RIGHT } = Modifier.Position; let x = 0; if (position === LEFT) { // FIXME: Left modifier padding, move to font file x = -1 * 2; } else if (position === RIGHT) { // FIXME: Right modifier padding, move to font file x = this.getGlyphWidth() + this.x_shift + 2; if ( this.stem_direction === Stem.UP && this.hasFlag() && (options.forceFlagRight || isInnerNoteIndex(this, index)) ) { x += this?.flag?.getMetrics().width ?? 0; } } else if (position === BELOW || position === ABOVE) { x = this.getGlyphWidth() / 2; } return { x: this.getAbsoluteX() + x, y: this.ys[index], }; } // Sets the style of the complete StaveNote, including all keys // and the stem. setStyle(style: ElementStyle): this { return super.setGroupStyle(style); } setStemStyle(style: ElementStyle): this { const stem = this.getStem(); if (stem) stem.setStyle(style); return this; } getStemStyle(): ElementStyle | undefined { return this.stem?.getStyle(); } setLedgerLineStyle(style: ElementStyle): void { this.ledgerLineStyle = style; } getLedgerLineStyle(): ElementStyle { return this.ledgerLineStyle; } setFlagStyle(style: ElementStyle): void { this.flag?.setStyle(style); } getFlagStyle(): ElementStyle | undefined { return this.flag?.getStyle(); } // Sets the notehead at `index` to the provided coloring `style`. // // `style` is an `object` with the following properties: `shadowColor`, // `shadowBlur`, `fillStyle`, `strokeStyle` setKeyStyle(index: number, style: ElementStyle): this { this._noteHeads[index].setStyle(style); return this; } setKeyLine(index: number, line: number): this { this.keyProps[index].line = line; this.reset(); return this; } getKeyLine(index: number): number { return this.keyProps[index].line; } // Get the width of the note if it is displaced. Used for `Voice` // formatting getVoiceShiftWidth(): number { // TODO: may need to accommodate for dot here. return this.getGlyphWidth() * (this.displaced ? 2 : 1); } // Calculates and sets the extra pixels to the left or right // if the note is displaced. calcNoteDisplacements(): void { this.setLeftDisplacedHeadPx(this.displaced && this.stem_direction === Stem.DOWN ? this.getGlyphWidth() : 0); // For upstems with flags, the extra space is unnecessary, since it's taken // up by the flag. this.setRightDisplacedHeadPx( !this.hasFlag() && this.displaced && this.stem_direction === Stem.UP ? this.getGlyphWidth() : 0 ); } // Pre-render formatting preFormat(): void { if (this.preFormatted) return; let noteHeadPadding = 0; if (this.modifierContext) { this.modifierContext.preFormat(); // If there are no modifiers on this note, make sure there is adequate padding // between the notes. if (this.modifierContext.getWidth() === 0) { noteHeadPadding = StaveNote.minNoteheadPadding; } } let width = this.getGlyphWidth() + this.leftDisplacedHeadPx + this.rightDisplacedHeadPx + noteHeadPadding; // For upward flagged notes, the width of the flag needs to be added if (this.shouldDrawFlag() && this.stem_direction === Stem.UP) { width += this.getGlyphWidth(); // TODO: Add flag width as a separate metric } this.setWidth(width); this.preFormatted = true; } /** * @typedef {Object} noteHeadBounds * @property {number} y_top the highest notehead bound * @property {number} y_bottom the lowest notehead bound * @property {number|Null} displaced_x the starting x for displaced noteheads * @property {number|Null} non_displaced_x the starting x for non-displaced noteheads * @property {number} highest_line the highest notehead line in traditional music line * numbering (bottom line = 1, top line = 5) * @property {number} lowest_line the lowest notehead line * @property {number|false} highest_displaced_line the highest staff line number * for a displaced notehead * @property {number|false} lowest_displaced_line * @property {number} highest_non_displaced_line * @property {number} lowest_non_displaced_line */ /** * Get the staff line and y value for the highest & lowest noteheads * @returns {noteHeadBounds} */ getNoteHeadBounds(): StaveNoteHeadBounds { // Top and bottom Y values for stem. let yTop: number = +Infinity; let yBottom: number = -Infinity; let nonDisplacedX: number | undefined; let displacedX: number | undefined; let highestLine = this.checkStave().getNumLines(); let lowestLine = 1; let highestDisplacedLine: number | undefined; let lowestDisplacedLine: number | undefined; let highestNonDisplacedLine = highestLine; let lowestNonDisplacedLine = lowestLine; this._noteHeads.forEach((notehead) => { const line: number = notehead.getLine(); const y = notehead.getY(); yTop = Math.min(y, yTop); yBottom = Math.max(y, yBottom); if (displacedX === undefined && notehead.isDisplaced()) { displacedX = notehead.getAbsoluteX(); } if (nonDisplacedX === undefined && !notehead.isDisplaced()) { nonDisplacedX = notehead.getAbsoluteX(); } highestLine = Math.max(line, highestLine); lowestLine = Math.min(line, lowestLine); if (notehead.isDisplaced()) { highestDisplacedLine = highestDisplacedLine === undefined ? line : Math.max(line, highestDisplacedLine); lowestDisplacedLine = lowestDisplacedLine === undefined ? line : Math.min(line, lowestDisplacedLine); } else { highestNonDisplacedLine = Math.max(line, highestNonDisplacedLine); lowestNonDisplacedLine = Math.min(line, lowestNonDisplacedLine); } }, this); return { y_top: yTop, y_bottom: yBottom, displaced_x: displacedX, non_displaced_x: nonDisplacedX, highest_line: highestLine, lowest_line: lowestLine, highest_displaced_line: highestDisplacedLine, lowest_displaced_line: lowestDisplacedLine, highest_non_displaced_line: highestNonDisplacedLine, lowest_non_displaced_line: lowestNonDisplacedLine, }; } // Get the starting `x` coordinate for the noteheads getNoteHeadBeginX(): number { return this.getAbsoluteX() + this.x_shift; } // Get the ending `x` coordinate for the noteheads getNoteHeadEndX(): number { const xBegin = this.getNoteHeadBeginX(); return xBegin + this.getGlyphWidth(); } get noteHeads(): NoteHead[] { return this._noteHeads.slice(); } /** @deprecated use StaveNote.noteHeads instead. */ get note_heads(): NoteHead[] { showDeprecationWarningForNoteHeads(); return this.noteHeads; } // Draw the ledger lines between the stave and the highest/lowest keys drawLedgerLines(): void { const stave = this.checkStave(); const { glyphProps, render_options: { stroke_px }, } = this; const ctx = this.checkContext(); const width = glyphProps.getWidth() + stroke_px * 2; const doubleWidth = 2 * (glyphProps.getWidth() + stroke_px) - Stem.WIDTH / 2; if (this.isRest()) return; if (!ctx) { throw new RuntimeError('NoCanvasContext', "Can't draw without a canvas context."); } const { highest_line, lowest_line, highest_displaced_line, highest_non_displaced_line, lowest_displaced_line, lowest_non_displaced_line, displaced_x, non_displaced_x, } = this.getNoteHeadBounds(); // Early out if there are no ledger lines to draw. if (highest_line < 6 && lowest_line > 0) return; const min_x = Math.min(displaced_x ?? 0, non_displaced_x ?? 0); const drawLedgerLine = (y: number, normal: boolean, displaced: boolean) => { let x; if (displaced && normal) x = min_x - stroke_px; else if (normal) x = (non_displaced_x ?? 0) - stroke_px; else x = (displaced_x ?? 0) - stroke_px; const ledgerWidth = normal && displaced ? doubleWidth : width; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ledgerWidth, y); ctx.stroke(); }; const style = { ...stave.getDefaultLedgerLineStyle(), ...this.getLedgerLineStyle() }; this.applyStyle(ctx, style); // Draw ledger lines below the staff: for (let line = 6; line <= highest_line; ++line) { const normal = non_displaced_x !== undefined && line <= highest_non_displaced_line; const displaced = highest_displaced_line !== undefined && line <= highest_displaced_line; drawLedgerLine(stave.getYForNote(line), normal, displaced); } // Draw ledger lines above the staff: for (let line = 0; line >= lowest_line; --line) { const normal = non_displaced_x !== undefined && line >= lowest_non_displaced_line; const displaced = lowest_displaced_line !== undefined && line >= lowest_displaced_line; drawLedgerLine(stave.getYForNote(line), normal, displaced); } this.restoreStyle(ctx, style); } // Draw all key modifiers drawModifiers(noteheadParam: NoteHead): void { const ctx = this.checkContext(); for (let i = 0; i < this.modifiers.length; i++) { const modifier = this.modifiers[i]; const index = modifier.checkIndex(); const notehead = this._noteHeads[index]; if (notehead == noteheadParam) { const noteheadStyle = notehead.getStyle(); notehead.applyStyle(ctx, noteheadStyle); modifier.setContext(ctx); modifier.drawWithStyle(); notehead.restoreStyle(ctx, noteheadStyle); } } } shouldDrawFlag(): boolean { const hasStem = this.stem !== undefined; const hasFlag = this.glyphProps.flag == true; const hasNoBeam = this.beam === undefined; return hasStem && hasFlag && hasNoBeam; } // Draw the flag for the note drawFlag(): void { const ctx = this.checkContext(); if (!ctx) { throw new RuntimeError('NoCanvasContext', "Can't draw without a canvas context."); } if (this.shouldDrawFlag()) { const { y_top, y_bottom } = this.getNoteHeadBounds(); // eslint-disable-next-line const noteStemHeight = this.stem!.getHeight(); const flagX = this.getStemX(); // What's with the magic +/- 2 // ANSWER: a corner of the note stem pokes out beyond the tip of the flag. // The extra +/- 2 pushes the flag glyph outward so it covers the stem entirely. // Alternatively, we could shorten the stem. const flagY = this.getStemDirection() === Stem.DOWN ? // Down stems are below the note head and have flags on the right. y_top - noteStemHeight + 2 - (this.glyphProps ? this.glyphProps.stem_down_extension : 0) * this.getStaveNoteScale() - (this.flag?.getMetrics().y_shift ?? 0) * (1 - this.getStaveNoteScale()) : // Up stems are above the note head and have flags on the right. y_bottom - noteStemHeight - 2 + (this.glyphProps ? this.glyphProps.stem_up_extension : 0) * this.getStaveNoteScale() - (this.flag?.getMetrics().y_shift ?? 0) * (1 - this.getStaveNoteScale()); // Draw the Flag this.flag?.render(ctx, flagX, flagY); } } // Draw the NoteHeads drawNoteHeads(): void { const ctx = this.checkContext(); this._noteHeads.forEach((notehead) => { notehead.applyStyle(ctx); ctx.openGroup('notehead', notehead.getAttribute('id'), { pointerBBox: true }); notehead.setContext(ctx).draw(); this.drawModifiers(notehead); ctx.closeGroup(); notehead.restoreStyle(ctx); }); } drawStem(stemOptions?: StemOptions): void { // GCR TODO: I can't find any context in which this is called with the stemStruct // argument in the codebase or tests. Nor can I find a case where super.drawStem // is called at all. Perhaps these should be removed? const ctx = this.checkContext(); if (stemOptions) { this.setStem(new Stem(stemOptions)); } // If we will render a flag, we shorten the stem so that the tip // does not poke through the flag. if (this.shouldDrawFlag() && this.stem) { this.stem.adjustHeightForFlag(); } if (this.stem) { this.stem.setContext(ctx).draw(); } } /** Primarily used as the scaling factor for grace notes, GraceNote will return the required scale. */ getStaveNoteScale(): number { return 1.0; } /** * Override stemmablenote stem extension to adjust for distance from middle line. */ getStemExtension(): number { const super_stem_extension = super.getStemExtension(); if (!this.glyphProps.stem) { return super_stem_extension; } const stem_direction = this.getStemDirection(); if (stem_direction !== this.calculateOptimalStemDirection()) { return super_stem_extension; // no adjustment for manually set stem direction. } let mid_line_distance; const MIDDLE_LINE = 3; if (stem_direction === Stem.UP) { // Note that the use of maxLine here instead of minLine might // seem counterintuitive, but in the case of (say) treble clef // chord(F2, E4) stem up, we do not want to extend the stem because // of F2, when a normal octave-length stem above E4 is fine. // // maxLine and minLine are set in calculateOptimalStemDirection() so // will be known. mid_line_distance = MIDDLE_LINE - this.maxLine; } else { mid_line_distance = this.minLine - MIDDLE_LINE; } // how many lines more than an octave is the relevant notehead? const lines_over_octave_from_mid_line = mid_line_distance - 3.5; if (lines_over_octave_from_mid_line <= 0) { return super_stem_extension; } const stave = this.getStave(); let spacing_between_lines = 10; if (stave != undefined) { spacing_between_lines = stave.getSpacingBetweenLines(); } return super_stem_extension + lines_over_octave_from_mid_line * spacing_between_lines; } // Draws all the `StaveNote` parts. This is the main drawing method. draw(): void { if (this.render_options.draw === false) return; if (this.ys.length === 0) { throw new RuntimeError('NoYValues', "Can't draw note without Y values."); } const ctx = this.checkContext(); const xBegin = this.getNoteHeadBeginX(); const shouldRenderStem = this.hasStem() && !this.beam; // Format note head x positions this._noteHeads.forEach((notehead) => notehead.setX(xBegin)); if (this.stem) { // Format stem x positions const stemX = this.getStemX(); this.stem.setNoteHeadXBounds(stemX, stemX); } L('Rendering ', this.isChord() ? 'chord :' : 'note :', this.keys); // Apply the overall style -- may be contradicted by local settings: this.applyStyle(); ctx.openGroup('stavenote', this.getAttribute('id')); this.drawLedgerLines(); if (shouldRenderStem) this.drawStem(); this.drawNoteHeads(); this.drawFlag(); ctx.closeGroup(); this.restoreStyle(); this.setRendered(); } }