vexflow
Version:
A JavaScript library for rendering music notation and guitar tablature.
1,275 lines (1,105 loc) • 41.8 kB
text/typescript
// [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();
}
}