satie
Version:
A sheet music renderer for the web
287 lines (262 loc) • 12.2 kB
text/typescript
/**
* This file is part of Satie music engraver <https://github.com/jnetterf/satie>.
* Copyright (C) Joshua Netterfield <joshua.ca> 2015 - present.
*
* Satie is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Satie is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Satie. If not, see <http://www.gnu.org/licenses/>.
*/
import * as invariant from "invariant";
import {find, cloneDeep, forEach, isPlainObject, isArray, isUndefined, isNull,
isBoolean, isNumber, isString} from "lodash";
import {IAny} from "musicxml-interfaces/operations";
import {Document, Type, IMeasure, IMeasurePart, ISegment} from "./document";
import {normalizeDivisionsInPlace} from "./engine_divisions";
import {IFactory} from "./private_factory";
import {cloneObject} from "./private_util";
import attributesMutator from "./implAttributes_attributesMutator";
import barlineMutator from "./implBarline_barlineMutator";
import chordMutator from "./implChord_chordMutator";
import printMutator from "./implPrint_printMutator";
import segmentMutator from "./implSegment_segmentMutator";
/**
* Checks whether this object is safe to JSON.stringify and JSON.parse.
* The only difference between the two should be presence of undefined values in Arrays and Objects.
* In Objects that previously had undefined values, after serializing, these keys will be removed.
* In Arrays that previously had undefined values, after serializing, these values will be replaced with null.
*/
function isSerializable(obj: any): boolean {
if (isUndefined(obj) || isNull(obj) || isBoolean(obj) || isNumber(obj) || isString(obj)) {
return true;
} else if (isArray(obj)) {
return (obj as Array<any>).every(isSerializable);
} else if (isPlainObject(obj)) {
return Object.keys(obj).every(key => isSerializable(obj[key]));
}
return false;
}
/**
* Applies an operation to the given set of measures, usually part of a document.
*
* CAREFUL: Does not touch `appliedOperations` because this function can also be used for undo. It's
* the caller's responsibility to update `appliedOperations`.
*
* @param op.p [measureUUID, ("part"|"voice")]
*/
export default function applyOp(preview: boolean, measures: IMeasure[], factory: IFactory, op: IAny,
document: Document, notEligableForPreview: () => void) {
// Operations must be entirely serializable, to be sent over the work. Serializble means it is one of:
// - a simple data type (number, string, ...)
// - a plain object (object with prototype Object), and that the same is true for all children
// - a plain array, and that the same is true for all items
invariant(isSerializable(op), "All operations must be serializable.");
let path = op.p;
if (path.length === 2 && path[0] === "measures") {
// Song-wide measure addition/removal
const localOp = {
p: op.p.slice(1),
ld: op.ld,
li: op.li,
};
applyMeasureOp(measures, factory, localOp, document);
return;
} else if (path.length === 1 && path[0] === "divisions") {
let segments: ISegment[] = [];
measures.forEach(measure => {
Object.keys(measure.parts).forEach(partName => {
const part = measure.parts[partName];
part.staves.concat(part.voices).forEach(segment => {
if (segment) {
segments.push(segment);
}
});
});
normalizeDivisionsInPlace(factory, segments, op.oi);
});
return;
}
let measureUUID = parseInt(String(path[0]), 10);
let measure = find(measures, (measure) => measure.uuid === measureUUID);
invariant(Boolean(measure), `Invalid operation path: no such measure ${path[0]}`);
invariant(path[1] === "parts",
`Invalid operation path: only parts is supported, not ${path[1]}`);
let part = measure.parts[path[2]];
invariant(Boolean(part), `Invalid operation path: no such part ${part}`);
++measure.version;
invariant(path[3] === "voices" || path[3] === "staves",
`Invalid operation path: ${path[3]} should have been "voices" or "staves`);
const cleanliness = document.cleanlinessTracking.measures[measureUUID];
if (cleanliness) {
cleanliness.clean = null;
}
if (path[3] === "voices") {
let voice = part.voices[parseInt(String(path[4]), 10)];
invariant(Boolean(voice),
`Invalid operation path: No such voice ${path.slice(0,4).join(", ")}`);
if (path.length === 6 && ((op.li && !op.ld) || (!op.li && op.ld))) {
notEligableForPreview();
segmentMutator(factory, voice, op, document);
return;
}
let element = voice[parseInt(String(path[5]), 10)];
invariant(Boolean(element),
`Invalid operation path: No such element ${path.slice(0,5).join(", ")}`);
let localOp: IAny = cloneDeep(op);
localOp.p = path.slice(6);
if (factory.modelHasType(element, Type.Chord)) {
chordMutator(element as any, localOp);
} else {
invariant(false, "Invalid operation path: No reducer for", element);
}
} else if (path[3] === "staves") {
let staff = part.staves[parseInt(String(path[4]), 10)];
invariant(Boolean(staff),
`Invalid operation path: No such staff ${path.slice(0,4).join(", ")}`);
if (path.length === 6 && ((op.li && !op.ld) || (!op.li && op.ld))) {
notEligableForPreview();
segmentMutator(factory, staff, op, document);
return;
}
let element = staff[parseInt(String(path[5]), 10)];
invariant(Boolean(element),
`Invalid operation path: No such element ${path.slice(0,5).join(", ")}`);
let localOp: IAny = cloneDeep(op);
localOp.p = path.slice(6);
if (factory.modelHasType(element, Type.Barline)) {
barlineMutator(element as any, localOp);
} else if (factory.modelHasType(element, Type.Attributes)) {
if (!preview) {
// Mark everything as dirty -- this is overkill, but finding what measures
// need to be changed is tough.
let ctMeasures = document.cleanlinessTracking.measures;
Object.keys(ctMeasures).forEach(measureName => {
if (ctMeasures[measureName]) {
ctMeasures[measureName].clean = null;
}
});
}
attributesMutator(preview, element as any, localOp);
} else if (factory.modelHasType(element, Type.Print)) {
printMutator(preview, element as any, localOp);
} else {
invariant(false, "Invalid operation path: No reducer for %s", element);
}
}
}
export function applyMeasureOp(measures: IMeasure[], factory: IFactory, op: IAny,
doc: Document) {
let ok = false;
let oldMeasure: IMeasure;
if (op.ld !== undefined && op.p.length === 1) {
ok = true;
const measureIdx = op.p[0] as number;
invariant(!isNaN(measureIdx), `Measure index ${measureIdx} must be`);
invariant(Boolean(op.ld.uuid), "uuid must be specified");
invariant(op.ld.uuid === measures[measureIdx].uuid,
`invalid uuid ${op.ld.uuid} != ${measures[measureIdx].uuid}`);
oldMeasure = measures[measureIdx];
measures.splice(measureIdx, 1);
measures.slice(measureIdx).forEach(measure => {
++measure.version;
--measure.idx;
if (!isNaN(parseInt(measure.number, 10))) {
measure.number = String(parseInt(measure.number, 10) - 1);
} else {
console.warn("Cannot change bar number for invalid measure number ", measure.number);
}
});
}
if (op.li !== undefined && op.p.length === 1) {
ok = true;
const measureIdx = op.p[0] as number;
invariant(!isNaN(measureIdx), `Measure index ${measureIdx} must be`);
invariant(Boolean(op.li.uuid), "uuid must be specified");
oldMeasure = oldMeasure || measures[measureIdx - 1] || measures[measureIdx + 1]; // note, we don't support empty docs
const oldParts = oldMeasure.parts;
const newParts: {
[id: string]: IMeasurePart;
} = cloneObject(op.li.parts) || {};
forEach(oldParts, (part, partID) => {
newParts[partID] = newParts[partID] || {
voices: [],
staves: [],
};
forEach(part.staves, (staff, staffIdx) => {
if (!staff) {
newParts[partID].staves[staffIdx] =
newParts[partID].staves[staffIdx] || null;
} else {
if (newParts[partID].staves[staffIdx]) {
newParts[partID].staves[staffIdx] =
newParts[partID].staves[staffIdx]
.map(i => factory.fromSpec(i)) || <any>[];
} else {
newParts[partID].staves[staffIdx] = [] as any;
}
let nv = newParts[partID].staves[staffIdx];
nv.divisions = staff.divisions;
nv.part = staff.part;
nv.owner = staff.owner;
nv.ownerType = staff.ownerType;
}
});
forEach(part.voices, (voice, voiceIdx) => {
if (!voice) {
newParts[partID].voices[voiceIdx] =
newParts[partID].voices[voiceIdx] || null;
} else {
if (newParts[partID].voices[voiceIdx]) {
newParts[partID].voices[voiceIdx] =
newParts[partID].voices[voiceIdx]
.map(i => {
const model = factory.fromSpec(i);
if (doc.modelHasType(model, Type.VisualCursor)) {
doc._visualCursor = model;
}
return model;
}) || <any>[];
} else {
newParts[partID].voices[voiceIdx] = [] as any;
}
let nv = newParts[partID].voices[voiceIdx];
nv.divisions = voice.divisions;
nv.part = voice.part;
nv.owner = voice.owner;
nv.ownerType = voice.ownerType;
}
});
});
let newMeasure = {
idx: measureIdx,
uuid: op.li.uuid,
number: "" + (measureIdx + 1),
implicit: false,
width: NaN,
nonControlling: false,
parts: newParts,
version: 0
};
oldMeasure.parts = oldParts;
measures.splice(measureIdx, 0, newMeasure);
measures.slice(measureIdx + 1).forEach(measure => {
++measure.idx;
++measure.version;
if (!isNaN(parseInt(measure.number, 10))) {
measure.number = String(parseInt(measure.number, 10) + 1);
} else {
console.warn("Cannot change bar number for invalid measure number ", measure.number);
}
});
measures.forEach(measure => ++measure.version);
}
invariant(ok, `Invalid operation type for applyMeasureOp's context: ${JSON.stringify(op)}`);
}