vexflow
Version:
A JavaScript library for rendering music notation and guitar tablature.
494 lines (438 loc) • 15.5 kB
text/typescript
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
// @author Mohit Cheppudira
// MIT License
import { BoundingBox } from './boundingbox';
import { Font, FontInfo, FontStyle, FontWeight } from './font';
import { Registry } from './registry';
import { RenderContext } from './rendercontext';
import { Category } from './typeguard';
import { defined, prefix } from './util';
/** Element attributes. */
export interface ElementAttributes {
[name: string]: string | undefined;
id: string;
type: string;
class: string;
}
/** Element style */
export interface ElementStyle {
/**
* CSS color used for the shadow.
*
* Examples: 'red', '#ff0000', '#ff000010', 'rgb(255,0,0)'
*
* See [CSS Legal Color Values](https://www.w3schools.com/cssref/css_colors_legal.asp)
*/
shadowColor?: string;
/**
* Level of blur applied to shadows.
*
* Values that are not finite numbers greater than or equal to zero are ignored.
*/
shadowBlur?: number;
/**
* CSS color used with context fill command.
*
* Examples: 'red', '#ff0000', '#ff000010', 'rgb(255,0,0)'
*
* See [CSS Legal Color Values](https://www.w3schools.com/cssref/css_colors_legal.asp)
*/
fillStyle?: string;
/**
* CSS color used with context stroke command.
*
* Examples: 'red', '#ff0000', '#ff000010', 'rgb(255,0,0)'
*
* See [CSS Legal Color Values](https://www.w3schools.com/cssref/css_colors_legal.asp)
*/
strokeStyle?: string;
/**
* Line width, 1.0 by default.
*/
lineWidth?: number;
}
/**
* Element implements a generic base class for VexFlow, with implementations
* of general functions and properties that can be inherited by all VexFlow elements.
*
* The Element is an abstract class that needs to be subclassed to work. It handles
* style and text-font properties for the Element and any child elements, along with
* working with the Registry to create unique ids, but does not have any tools for
* formatting x or y positions or connections to a Stave.
*/
export abstract class Element {
static get CATEGORY(): string {
return Category.Element;
}
// all Element objects keep a list of children that they are responsible and which
// inherit the style of their parents.
protected children: Element[] = [];
protected static ID: number = 1000;
protected static newID(): string {
return `auto${Element.ID++}`;
}
/**
* Default font for text. This is not related to music engraving. Instead, see `Flow.setMusicFont(...fontNames)`
* to customize the font for musical symbols placed on the score.
*/
static TEXT_FONT: Required<FontInfo> = {
family: Font.SANS_SERIF,
size: Font.SIZE,
weight: FontWeight.NORMAL,
style: FontStyle.NORMAL,
};
private context?: RenderContext;
protected rendered: boolean;
protected style?: ElementStyle;
private attrs: ElementAttributes;
protected boundingBox?: BoundingBox;
protected registry?: Registry;
/**
* Some elements include text.
* The `textFont` property contains information required to style the text (i.e., font family, size, weight, and style).
* It is undefined by default, and can be set using `setFont(...)` or `resetFont()`.
*/
protected textFont?: Required<FontInfo>;
constructor() {
this.attrs = {
id: Element.newID(),
type: this.getCategory(),
class: '',
};
this.rendered = false;
// If a default registry exist, then register with it right away.
Registry.getDefaultRegistry()?.register(this);
}
/**
* Adds a child Element to the Element, which lets it inherit the
* same style as the parent when setGroupStyle() is called.
*
* Examples of children are noteheads and stems. Modifiers such
* as Accidentals are generally not set as children.
*
* Note that StaveNote calls setGroupStyle() when setStyle() is called.
*/
addChildElement(child: Element): this {
this.children.push(child);
return this;
}
getCategory(): string {
return (<typeof Element>this.constructor).CATEGORY;
}
/**
* Set the element style used to render.
*
* Example:
* ```typescript
* element.setStyle({ fillStyle: 'red', strokeStyle: 'red' });
* element.draw();
* ```
* Note: If the element draws additional sub-elements (ie.: Modifiers in a Stave),
* the style can be applied to all of them by means of the context:
* ```typescript
* element.setStyle({ fillStyle: 'red', strokeStyle: 'red' });
* element.getContext().setFillStyle('red');
* element.getContext().setStrokeStyle('red');
* element.draw();
* ```
* or using drawWithStyle:
* ```typescript
* element.setStyle({ fillStyle: 'red', strokeStyle: 'red' });
* element.drawWithStyle();
* ```
*/
setStyle(style: ElementStyle | undefined): this {
this.style = style;
return this;
}
/** Set the element & associated children style used for rendering. */
setGroupStyle(style: ElementStyle): this {
this.style = style;
this.children.forEach((child) => child.setGroupStyle(style));
return this;
}
/** Get the element style used for rendering. */
getStyle(): ElementStyle | undefined {
return this.style;
}
/** Apply the element style to `context`. */
applyStyle(
context: RenderContext | undefined = this.context,
style: ElementStyle | undefined = this.getStyle()
): this {
if (!style) return this;
if (!context) return this;
context.save();
if (style.shadowColor) context.setShadowColor(style.shadowColor);
if (style.shadowBlur) context.setShadowBlur(style.shadowBlur);
if (style.fillStyle) context.setFillStyle(style.fillStyle);
if (style.strokeStyle) context.setStrokeStyle(style.strokeStyle);
if (style.lineWidth) context.setLineWidth(style.lineWidth);
return this;
}
/** Restore the style of `context`. */
restoreStyle(
context: RenderContext | undefined = this.context,
style: ElementStyle | undefined = this.getStyle()
): this {
if (!style) return this;
if (!context) return this;
context.restore();
return this;
}
/**
* Draw the element and all its sub-elements (ie.: Modifiers in a Stave)
* with the element's style (see `getStyle()` and `setStyle()`)
*/
drawWithStyle(): void {
this.checkContext();
this.applyStyle();
this.draw();
this.restoreStyle();
}
/** Draw an element. */
// eslint-disable-next-line
abstract draw(...args: any[]): void;
/** Check if it has a class label (An element can have multiple class labels). */
hasClass(className: string): boolean {
if (!this.attrs.class) return false;
return this.attrs.class?.split(' ').indexOf(className) != -1;
}
/** Add a class label (An element can have multiple class labels). */
addClass(className: string): this {
if (this.hasClass(className)) return this;
if (!this.attrs.class) this.attrs.class = `${className}`;
else this.attrs.class = `${this.attrs.class} ${className}`;
this.registry?.onUpdate({
id: this.attrs.id,
name: 'class',
value: className,
oldValue: undefined,
});
return this;
}
/** Remove a class label (An element can have multiple class labels). */
removeClass(className: string): this {
if (!this.hasClass(className)) return this;
const arr = this.attrs.class?.split(' ');
if (arr) {
arr.splice(arr.indexOf(className));
this.attrs.class = arr.join(' ');
}
this.registry?.onUpdate({
id: this.attrs.id,
name: 'class',
value: undefined,
oldValue: className,
});
return this;
}
/** Call back from registry after the element is registered. */
onRegister(registry: Registry): this {
this.registry = registry;
return this;
}
/** Return the rendered status. */
isRendered(): boolean {
return this.rendered;
}
/** Set the rendered status. */
setRendered(rendered = true): this {
this.rendered = rendered;
return this;
}
/** Return the element attributes. */
getAttributes(): ElementAttributes {
return this.attrs;
}
/** Return an attribute, such as 'id', 'type' or 'class'. */
// eslint-disable-next-line
getAttribute(name: string): any {
return this.attrs[name];
}
/** Return associated SVGElement. */
getSVGElement(suffix: string = ''): SVGElement | undefined {
const id = prefix(this.attrs.id + suffix);
const element = document.getElementById(id);
if (element) return element as unknown as SVGElement;
}
/** Set an attribute such as 'id', 'class', or 'type'. */
setAttribute(name: string, value: string | undefined): this {
const oldID = this.attrs.id;
const oldValue = this.attrs[name];
this.attrs[name] = value;
// Register with old id to support id changes.
this.registry?.onUpdate({ id: oldID, name, value, oldValue });
return this;
}
/** Get the boundingBox. */
getBoundingBox(): BoundingBox | undefined {
return this.boundingBox;
}
/** Return the context, such as an SVGContext or CanvasContext object. */
getContext(): RenderContext | undefined {
return this.context;
}
/** Set the context to an SVGContext or CanvasContext object */
setContext(context?: RenderContext): this {
this.context = context;
return this;
}
/** Validate and return the rendering context. */
checkContext(): RenderContext {
return defined(this.context, 'NoContext', 'No rendering context attached to instance.');
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// Font Handling
/**
* Provide a CSS compatible font string (e.g., 'bold 16px Arial') that will be applied
* to text (not glyphs).
*/
set font(f: string) {
this.setFont(f);
}
/** Returns the CSS compatible font string for the text font. */
get font(): string {
return Font.toCSSString(this.textFont);
}
/**
* Set the element's text font family, size, weight, style
* (e.g., `Arial`, `10pt`, `bold`, `italic`).
*
* This attribute does not determine the font used for musical Glyphs like treble clefs.
*
* @param font is 1) a `FontInfo` object or
* 2) a string formatted as CSS font shorthand (e.g., 'bold 10pt Arial') or
* 3) a string representing the font family (at least one of `size`, `weight`, or `style` must also be provided).
* @param size a string specifying the font size and unit (e.g., '16pt'), or a number (the unit is assumed to be 'pt').
* @param weight is a string (e.g., 'bold', 'normal') or a number (100, 200, ... 900).
* @param style is a string (e.g., 'italic', 'normal').
* If no arguments are provided, then the font is set to the default font.
* Each Element subclass may specify its own default by overriding the static `TEXT_FONT` property.
*/
setFont(font?: string | FontInfo, size?: string | number, weight?: string | number, style?: string): this {
// Allow subclasses to override `TEXT_FONT`.
const defaultTextFont: Required<FontInfo> = (<typeof Element>this.constructor).TEXT_FONT;
const fontIsObject = typeof font === 'object';
const fontIsString = typeof font === 'string';
const fontIsUndefined = font === undefined;
const sizeWeightStyleAreUndefined = size === undefined && weight === undefined && style === undefined;
if (fontIsObject) {
// `font` is case 1) a FontInfo object
this.textFont = { ...defaultTextFont, ...font };
} else if (fontIsString && sizeWeightStyleAreUndefined) {
// `font` is case 2) CSS font shorthand.
this.textFont = Font.fromCSSString(font);
} else if (fontIsUndefined && sizeWeightStyleAreUndefined) {
// All arguments are undefined. Do not check for `arguments.length === 0`,
// which fails on the edge case: `setFont(undefined)`.
// TODO: See if we can remove this case entirely without introducing a visual diff.
// The else case below seems like it should be equivalent to this case.
this.textFont = { ...defaultTextFont };
} else {
// `font` is case 3) a font family string (e.g., 'Times New Roman').
// The other parameters represent the size, weight, and style.
// It is okay for `font` to be undefined while one or more of the other arguments is provided.
// Following CSS conventions, unspecified params are reset to the default.
this.textFont = Font.validate(
font ?? defaultTextFont.family,
size ?? defaultTextFont.size,
weight ?? defaultTextFont.weight,
style ?? defaultTextFont.style
);
}
return this;
}
/**
* Get the css string describing this Element's text font. e.g.,
* 'bold 10pt Arial'.
*/
getFont(): string {
if (!this.textFont) {
this.resetFont();
}
return Font.toCSSString(this.textFont);
}
/**
* Reset the text font to the style indicated by the static `TEXT_FONT` property.
* Subclasses can call this to initialize `textFont` for the first time.
*/
resetFont(): void {
this.setFont();
}
/** Return a copy of the current FontInfo object. */
get fontInfo(): Required<FontInfo> {
if (!this.textFont) {
this.resetFont();
}
// We can cast to Required<FontInfo> here, because
// we just called resetFont() above to ensure this.textFont is set.
return { ...this.textFont } as Required<FontInfo>;
}
set fontInfo(fontInfo: FontInfo) {
this.setFont(fontInfo);
}
/** Change the font size, while keeping everything else the same. */
setFontSize(size?: string | number): this {
const fontInfo = this.fontInfo;
this.setFont(fontInfo.family, size, fontInfo.weight, fontInfo.style);
return this;
}
/**
* @returns a CSS font-size string (e.g., '18pt', '12px', '1em').
* See Element.fontSizeInPixels or Element.fontSizeInPoints if you need to get a number for calculation purposes.
*/
getFontSize(): string {
return this.fontSize;
}
/**
* The size is 1) a string of the form '10pt' or '16px', compatible with the CSS font-size property.
* or 2) a number, which is interpreted as a point size (i.e. 12 == '12pt').
*/
set fontSize(size: string | number) {
this.setFontSize(size);
}
/**
* @returns a CSS font-size string (e.g., '18pt', '12px', '1em').
*/
get fontSize(): string {
let size = this.fontInfo.size;
if (typeof size === 'number') {
size = `${size}pt`;
}
return size;
}
/**
* @returns the font size in `pt`.
*/
get fontSizeInPoints(): number {
return Font.convertSizeToPointValue(this.fontSize);
}
/**
* @returns the font size in `px`.
*/
get fontSizeInPixels(): number {
return Font.convertSizeToPixelValue(this.fontSize);
}
/**
* @returns a CSS font-style string (e.g., 'italic').
*/
get fontStyle(): string {
return this.fontInfo.style;
}
set fontStyle(style: string) {
const fontInfo = this.fontInfo;
this.setFont(fontInfo.family, fontInfo.size, fontInfo.weight, style);
}
/**
* @returns a CSS font-weight string (e.g., 'bold').
* As in CSS, font-weight is always returned as a string, even if it was set as a number.
*/
get fontWeight(): string {
return this.fontInfo.weight + '';
}
set fontWeight(weight: string | number) {
const fontInfo = this.fontInfo;
this.setFont(fontInfo.family, fontInfo.size, weight, fontInfo.style);
}
}