UNPKG

smoosic

Version:

<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i

675 lines (658 loc) 25.7 kB
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic) // Copyright (c) Aaron David Newman 2021. import { SmoSelector, SmoSelection, ModifierTab } from '../../smo/xform/selections'; import { OutlineInfo, SvgHelpers } from './svgHelpers'; import { layoutDebug } from './layoutDebug'; import { SuiScroller } from './scroller'; import { SmoSystemStaff } from '../../smo/data/systemStaff'; import { SmoMeasure, SmoVoice } from '../../smo/data/measure'; import { PasteBuffer } from '../../smo/xform/copypaste'; import { SmoNoteModifierBase, SmoLyric } from '../../smo/data/noteModifiers'; import { SvgBox } from '../../smo/data/common'; import { SmoNote } from '../../smo/data/note'; import { SmoScore, SmoModifier } from '../../smo/data/score'; import { SvgPageMap } from './svgPageMap'; /** * DI information about renderer, so we can notify renderer and it can contain * a tracker object * @param pageMap {@link SvgPageMap}: SvgPageMap - container of SVG elements and vex renderers * @param score {@link SmoScore} * @param dirty lets the caller know the display needs update * @param passState state machine in rendering part/all of the score * @param renderPromise awaits on render all * @param addToReplaceQueue adds a measure to the quick update queue * @param renderElement a little redundant with svg * @category SuiRender */ export interface SuiRendererBase { pageMap: SvgPageMap, score: SmoScore | null, dirty: boolean, passState: number, renderPromise(): Promise<any>, addToReplaceQueue(mm: SmoSelection[]): void, renderElement: Element } /** * used to perform highlights in the backgroundd * @category SuiRender */ export interface HighlightQueue { selectionCount: number, deferred: boolean } /** * Map the notes in the svg so they can respond to events and interact * with the mouse/keyboard * @category SuiRender */ export abstract class SuiMapper { renderer: SuiRendererBase; scroller: SuiScroller; // measure to selector map measureNoteMap: Record<string | number, SmoSelection> = {}; // notes currently selected. Something is always selected // modifiers (text etc.) that have been selected modifierSelections: ModifierTab[] = []; selections: SmoSelection[] = []; // The list of modifiers near the current selection localModifiers: ModifierTab[] = []; modifierIndex: number = -1; modifierSuggestion: ModifierTab | null = null; pitchIndex: number = -1; // By default, defer highlights for performance. deferHighlightMode: boolean = true; suggestion: SmoSelection | null = null; highlightQueue: HighlightQueue; mouseHintBox: OutlineInfo | null = null; selectionRects: Record<number, OutlineInfo[]> = {}; outlines: Record<string, OutlineInfo> = {}; mapping: boolean = false; constructor(renderer: SuiRendererBase, scroller: SuiScroller) { // renderer renders the music when it changes this.renderer = renderer; this.scroller = scroller; this.modifierIndex = -1; this.localModifiers = []; // index if a single pitch of a chord is selected this.pitchIndex = -1; // the current selection, which is also the copy/paste destination this.highlightQueue = { selectionCount: 0, deferred: false }; } abstract highlightSelection(): void; abstract _growSelectionRight(hold?: boolean): number; abstract _setModifierAsSuggestion(sel: ModifierTab): void; abstract _setArtifactAsSuggestion(sel: SmoSelection): void; abstract getIdleTime(): number; updateHighlight() { const self = this; if (this.selections.length === 0) { this.highlightQueue.deferred = false; this.highlightQueue.selectionCount = 0; return; } if (this.highlightQueue.selectionCount === this.selections.length) { this.highlightSelection(); this.highlightQueue.deferred = false; } else { this.highlightQueue.selectionCount = this.selections.length; setTimeout(() => { self.updateHighlight(); }, 50); } } deferHighlight() { if (!this.deferHighlightMode) { this.highlightSelection(); } const self = this; if (!this.highlightQueue.deferred) { this.highlightQueue.deferred = true; setTimeout(() => { self.updateHighlight(); }, 50); } } _createLocalModifiersList() { this.localModifiers = []; let index = 0; this.selections.forEach((sel) => { sel.note?.getGraceNotes().forEach((gg) => { this.localModifiers.push({ index, selection: sel, modifier: gg, box: gg.logicalBox ?? SvgBox.default }); index += 1; }); sel.note?.getModifiers('SmoDynamicText').forEach((dyn) => { this.localModifiers.push({ index, selection: sel, modifier: dyn, box: dyn.logicalBox ?? SvgBox.default }); index += 1; }); sel.measure.getModifiersByType('SmoVolta').forEach((volta) => { this.localModifiers.push({ index, selection: sel, modifier: volta, box: volta.logicalBox ?? SvgBox.default }); index += 1; }); sel.measure.getModifiersByType('SmoTempoText').forEach((tempo) => { this.localModifiers.push({ index, selection: sel, modifier: tempo, box: tempo.logicalBox ?? SvgBox.default }); index += 1; }); sel.staff.renderableModifiers.forEach((mod) => { if (SmoSelector.gteq(sel.selector, mod.startSelector) && SmoSelector.lteq(sel.selector, mod.endSelector) && mod.logicalBox) { const exists = this.localModifiers.find((mm) => mm.modifier.ctor === mod.ctor); if (!exists) { this.localModifiers.push({ index, selection: sel, modifier: mod, box: mod.logicalBox }); index += 1; } } }); }); } /** * When a modifier is selected graphically, update the selection list * and create a local modifier list * @param modifierTabs */ createLocalModifiersFromModifierTabs(modifierTabs: ModifierTab[]) { const selections: SmoSelection[] = []; const modMap: Record<string, boolean> = {}; modifierTabs.forEach((mt) => { if (mt.selection) { const key = SmoSelector.getNoteKey(mt.selection.selector); if (!modMap[key]) { selections.push(mt.selection); modMap[key] = true; } } }); if (selections.length) { this.selections = selections; this._createLocalModifiersList(); this.deferHighlight(); } } removeModifierSelectionBox() { if (this.outlines['staffModifier'] && this.outlines['staffModifier'].element) { this.outlines['staffModifier'].element.remove(); this.outlines['staffModifier'].element = undefined; } } // used by remove dialogs to clear removed thing clearModifierSelections() { this.modifierSelections = []; this._createLocalModifiersList(); this.modifierIndex = -1; this.removeModifierSelectionBox(); } // ### loadScore // We are loading a new score. clear the maps so we can rebuild them after // rendering loadScore() { this.measureNoteMap = {}; this.clearModifierSelections(); this.selections = []; this.highlightQueue = { selectionCount: 0, deferred: false }; } // ### _clearMeasureArtifacts // clear the measure from the measure and note maps so we can rebuild it. clearMeasureMap(measure: SmoMeasure) { const selector = { staff: measure.measureNumber.staffId, measure: measure.measureNumber.measureIndex, voice: 0, tick: 0, pitches: [] }; // Unselect selections in this measure so we can reselect them when re-tracked const ar: SmoSelection[] = []; this.selections.forEach((selection) => { if (selection.selector.staff !== selector.staff || selection.selector.measure !== selector.measure) { ar.push(selection); } }); this.selections = ar; } _copySelectionsByMeasure(staffIndex: number, measureIndex: number) { const rv = this.selections.filter((sel) => sel.selector.staff === staffIndex && sel.selector.measure === measureIndex); const ticks = rv.length < 1 ? 0 : rv.map((sel) => (sel.note as SmoNote).tickCount).reduce((a, b) => a + b); const selectors: SmoSelector[] = []; rv.forEach((sel) => { const nsel = JSON.parse(JSON.stringify(sel.selector)); if (!nsel.pitches) { nsel.pitches = []; } selectors.push(nsel); }); return { ticks, selectors }; } deleteMeasure(selection: SmoSelection) { const selCopy = this._copySelectionsByMeasure(selection.selector.staff, selection.selector.measure) .selectors; this.clearMeasureMap(selection.measure); if (selCopy.length) { selCopy.forEach((selector) => { const nsel = JSON.parse(JSON.stringify(selector)); if (selector.measure === 0) { nsel.measure += 1; } else { nsel.measure -= 1; } this.selections.push(this._getClosestTick(nsel)); }); } } _updateNoteModifier(selection: SmoSelection, modMap: Record<string, boolean>, modifier: SmoNoteModifierBase, ix: number) { if (!modMap[modifier.attrs.id] && modifier.logicalBox) { this.renderer.pageMap.addModifierTab( { modifier, selection, box: modifier.logicalBox, index: ix } ); ix += 1; const context = this.renderer.pageMap.getRendererFromModifier(modifier); modMap[modifier.attrs.id] = true; } return ix; } _updateModifiers() { let ix = 0; const modMap: Record<string, boolean> = {}; if (!this.renderer.score) { return; } this.renderer.score.textGroups.forEach((modifier) => { if (!modMap[modifier.attrs.id] && modifier.logicalBox) { this.renderer.pageMap.addModifierTab({ modifier, selection: null, box: modifier.logicalBox, index: ix }); ix += 1; } }); const keys = Object.keys(this.measureNoteMap); keys.forEach((selKey) => { const selection = this.measureNoteMap[selKey]; selection.staff.renderableModifiers.forEach((modifier) => { if (SmoSelector.contains(selection.selector, modifier.startSelector, modifier.endSelector)) { if (!modMap[modifier.attrs.id]) { if (modifier.logicalBox) { this.renderer.pageMap.addModifierTab({ modifier, selection, box: modifier.logicalBox, index: ix }); ix += 1; modMap[modifier.attrs.id] = true; } } } }); selection.measure.modifiers.forEach((modifier) => { if (modifier.attrs.id && !modMap[modifier.attrs.id] && modifier.logicalBox) { this.renderer.pageMap.addModifierTab({ modifier, selection, box: SvgHelpers.smoBox(modifier.logicalBox), index: ix }); ix += 1; modMap[modifier.attrs.id] = true; } }); selection.note?.textModifiers.forEach((modifier) => { if (modifier.logicalBox) { ix = this._updateNoteModifier(selection, modMap, modifier, ix); } }); selection.note?.graceNotes.forEach((modifier) => { ix = this._updateNoteModifier(selection, modMap, modifier, ix); }); }); } // ### _getClosestTick // given a musical selector, find the note artifact that is closest to it, // if an exact match is not available _getClosestTick(selector: SmoSelector): SmoSelection { let tickKey: string | undefined = ''; const measureKey = Object.keys(this.measureNoteMap).find((k) => SmoSelector.sameMeasure(this.measureNoteMap[k].selector, selector) && this.measureNoteMap[k].selector.tick === 0); tickKey = Object.keys(this.measureNoteMap).find((k) => SmoSelector.sameNote(this.measureNoteMap[k].selector, selector)); const firstObj = this.measureNoteMap[Object.keys(this.measureNoteMap)[0]]; if (tickKey) { return this.measureNoteMap[tickKey]; } if (measureKey) { return this.measureNoteMap[measureKey]; } return firstObj; } // ### _setModifierBoxes // Create the DOM modifiers for the lyrics and other modifiers _setModifierBoxes(measure: SmoMeasure) { const context = this.renderer.pageMap.getRenderer(measure.svg.logicalBox); measure.voices.forEach((voice: SmoVoice) => { voice.notes.forEach((smoNote: SmoNote) => { if (context) { const el = context.svg.getElementById(smoNote.renderId as string); if (el) { SvgHelpers.updateArtifactBox(context, (el as any), smoNote); // TODO: fix this, only works on the first line. smoNote.getModifiers('SmoLyric').forEach((lyrict: SmoNoteModifierBase) => { const lyric: SmoLyric = lyrict as SmoLyric; if (lyric.getText().length || lyric.isHyphenated()) { const lyricElement = context.svg.getElementById('vf-' + lyric.attrs.id) as SVGSVGElement; if (lyricElement) { SvgHelpers.updateArtifactBox(context, lyricElement, lyric as any); } } }); } smoNote.graceNotes.forEach((g) => { if (g.element) { } var gel = context.svg.getElementById('vf-' + g.renderId) as SVGSVGElement; SvgHelpers.updateArtifactBox(context, gel, g); }); smoNote.textModifiers.forEach((modifier) => { if (modifier.logicalBox && modifier.element) { SvgHelpers.updateArtifactBox(context, modifier.element, modifier as any); } }); } }); }); } /** * returns true of the selections are adjacent * @param s1 a selections * @param s2 another election * @returns */ isAdjacentSelection(s1: SmoSelection, s2: SmoSelection) { if (!this.renderer.score) { return false; } const nextSel = SmoSelection.advanceTicks(this.renderer.score, s1, 1); if (!nextSel) { return false; } return SmoSelector.eq(nextSel.selector, s2.selector); } areSelectionsAdjacent() { let selectionIx = 0; for (selectionIx = 0; this.selections.length > 1 && selectionIx < this.selections.length - 1; ++selectionIx) { if (!this.isAdjacentSelection(this.selections[selectionIx], this.selections[selectionIx + 1])) { return false; } } return true; } /** * This is the logic that stores the screen location of music after it's rendered */ mapMeasure(staff: SmoSystemStaff, measure: SmoMeasure, printing: boolean) { let voiceIx = 0; let selectedTicks = 0; // We try to restore block selections. If all the selections in this block are not adjacent, only restore individual selections // if possible let adjacentSels = this.areSelectionsAdjacent(); const lastResortSelection: SmoSelection[] = []; let selectionChanged = false; let vix = 0; let replacedSelectors = 0; if (!measure.svg.logicalBox) { return; } this._setModifierBoxes(measure); const timestamp = new Date().valueOf(); // Keep track of any current selections in this measure, we will try to restore them. const sels = this._copySelectionsByMeasure(staff.staffId, measure.measureNumber.measureIndex); this.clearMeasureMap(measure); if (sels.selectors.length) { vix = sels.selectors[0].voice; } sels.selectors.forEach((sel) => { sel.voice = vix; }); measure.voices.forEach((voice) => { let tick = 0; voice.notes.forEach((note) => { const selector = { staff: staff.staffId, measure: measure.measureNumber.measureIndex, voice: voiceIx, tick, pitches: [] }; if (measure.repeatSymbol) { // Some measures have a symbol that replaces the notes. This allows us to select // the measure const x = measure.svg.logicalBox.x + (measure.svg.logicalBox.width / voice.notes.length) * tick; const width = measure.svg.logicalBox.width / voice.notes.length; note.logicalBox = { x, y: measure.svg.logicalBox.y, width, height: measure.svg.logicalBox.height }; } // create a selection for the newly rendered note const selection = new SmoSelection({ selector, _staff: staff, _measure: measure, _note: note, _pitches: [], box: SvgHelpers.smoBox(SvgHelpers.smoBox(note.logicalBox)), type: 'rendered' }); // and add it to the map this._updateMeasureNoteMap(selection, printing); // If this note is the same location as something that was selected, reselect it if (replacedSelectors < sels.selectors.length && selection.selector.tick === sels.selectors[replacedSelectors].tick && selection.selector.voice === vix) { this.selections.push(selection); // Reselect any pitches. if (sels.selectors[replacedSelectors].pitches.length > 0) { sels.selectors[replacedSelectors].pitches.forEach((pitchIx) => { if (selection.note && selection.note.pitches.length > pitchIx) { selection.selector.pitches.push(pitchIx); } }); } const note = selection.note as SmoNote; selectedTicks += note.tickCount; replacedSelectors += 1; selectionChanged = true; } else if (adjacentSels && selectedTicks > 0 && selectedTicks < sels.ticks && selection.selector.voice === vix) { // try to select the same length of music as was previously selected. So a 1/4 to 2 1/8, both // are selected replacedSelectors += 1; this.selections.push(selection); selectedTicks += note.tickCount; } else if (this.selections.length === 0 && sels.selectors.length === 0 && lastResortSelection.length === 0) { lastResortSelection.push(selection); } tick += 1; }); voiceIx += 1; }); // We deleted all the notes that were selected, select something else if (this.selections.length === 0) { selectionChanged = true; this.selections = lastResortSelection; } // If there were selections on this measure, highlight them. if (selectionChanged) { this.deferHighlight(); } layoutDebug.setTimestamp(layoutDebug.codeRegions.MAP, new Date().valueOf() - timestamp); } _getTicksFromSelections(): number { let rv = 0; this.selections.forEach((sel) => { if (sel.note) { rv += sel.note.tickCount; } }); return rv; } _copySelections(): SmoSelector[] { const rv: SmoSelector[] = []; this.selections.forEach((sel) => { rv.push(sel.selector); }); return rv; } // ### getExtremeSelection // Get the rightmost (1) or leftmost (-1) selection getExtremeSelection(sign: number): SmoSelection { let i = 0; let rv = this.selections[0]; for (i = 1; i < this.selections.length; ++i) { const sa = this.selections[i].selector; if (sa.measure * sign > rv.selector.measure * sign) { rv = this.selections[i]; } else if (sa.measure === rv.selector.measure && sa.tick * sign > rv.selector.tick * sign) { rv = this.selections[i]; } } return rv; } _selectClosest(selector: SmoSelector) { var artifact = this._getClosestTick(selector); if (!artifact) { return; } if (this.selections.find((sel) => JSON.stringify(sel.selector) === JSON.stringify(artifact.selector))) { return; } const note = artifact.note as SmoNote; if (selector.pitches && selector.pitches.length && selector.pitches.length <= note.pitches.length) { // If the old selection had only a single pitch, try to recreate that. artifact.selector.pitches = JSON.parse(JSON.stringify(selector.pitches)); } this.selections.push(artifact); } // ### updateMap // This should be called after rendering the score. It updates the score to // graphics map and selects the first object. updateMap() { const ts = new Date().valueOf(); this.mapping = true; let tickSelected = 0; const selCopy = this._copySelections(); const ticksSelectedCopy = this._getTicksFromSelections(); const firstSelection = this.getExtremeSelection(-1); this._updateModifiers(); // Try to restore selection. If there were none, just select the fist // thing in the score const firstKey = SmoSelector.getNoteKey(SmoSelector.default); if (!selCopy.length && this.renderer.score) { // If there is nothing rendered, don't update tracker if (typeof(this.measureNoteMap[firstKey]) !== 'undefined' && !firstSelection) this.selections = [this.measureNoteMap[firstKey]]; } else if (this.areSelectionsAdjacent() && this.selections.length > 1) { // If there are adjacent selections, restore selections to the ticks that are in the score now if (!firstSelection) { layoutDebug.setTimestamp(layoutDebug.codeRegions.UPDATE_MAP, new Date().valueOf() - ts); return; } this.selections = []; this._selectClosest(firstSelection.selector); const first = this.selections[0]; tickSelected = (first.note as SmoNote).tickCount ?? 0; while (tickSelected < ticksSelectedCopy && first) { let delta: number = this._growSelectionRight(true); if (!delta) { break; } tickSelected += delta; } } this.deferHighlight(); this._createLocalModifiersList(); this.mapping = false; layoutDebug.setTimestamp(layoutDebug.codeRegions.UPDATE_MAP, new Date().valueOf() - ts); } createMousePositionBox(logicalBox: SvgBox) { const pageMap = this.renderer.pageMap; const page = pageMap.getRendererFromPoint(logicalBox); if (page) { const cof = (pageMap.zoomScale * pageMap.renderScale); const debugBox = SvgHelpers.smoBox(logicalBox); debugBox.y -= (page.box.y + 5 / cof); debugBox.x -= (page.box.x + 5 / cof) debugBox.width = 10 / cof; debugBox.height = 10 / cof; if (!this.mouseHintBox) { this.mouseHintBox = { stroke: SvgPageMap.strokes['debug-mouse-box'], classes: 'hide-print', box: debugBox, scroll: { x: 0, y: 0 }, context: page, timeOff: 1000 }; } this.mouseHintBox.context = page; this.mouseHintBox.box = debugBox; SvgHelpers.outlineRect(this.mouseHintBox); } } eraseMousePositionBox() { if (this.mouseHintBox && this.mouseHintBox.element) { this.mouseHintBox.element.remove(); this.mouseHintBox.element = undefined; } } /** * Find any musical elements at the supplied screen coordinates and set them as the selection * @param bb * @returns */ intersectingArtifact(bb: SvgBox) { const scrollState = this.scroller.scrollState; bb = SvgHelpers.boxPoints(bb.x + scrollState.x, bb.y + scrollState.y, bb.width ? bb.width : 1, bb.height ? bb.height : 1); const logicalBox = this.renderer.pageMap.clientToSvg(bb); const { selections, page } = this.renderer.pageMap.findArtifact(logicalBox); if (page) { const artifacts = selections; // const artifacts = SvgHelpers.findIntersectingArtifactFromMap(bb, this.measureNoteMap, SvgHelpers.smoBox(this.scroller.scrollState.scroll)); // TODO: handle overlapping suggestions if (!artifacts.length) { const sel = this.renderer.pageMap.findModifierTabs(logicalBox); if (sel.length) { this._setModifierAsSuggestion(sel[0]); this.eraseMousePositionBox(); } else { // no intersection, show mouse hint this.createMousePositionBox(logicalBox); } return; } const artifact = artifacts[0]; this.eraseMousePositionBox(); this._setArtifactAsSuggestion(artifact); } } _getRectangleChain(selection: SmoSelection) { const rv: number[] = []; if (!selection.note) { return rv; } rv.push(selection.measure.svg.pageIndex); rv.push(selection.measure.svg.lineIndex); rv.push(selection.measure.measureNumber.measureIndex); return rv; } _updateMeasureNoteMap(artifact: SmoSelection, printing: boolean) { const note = artifact.note as SmoNote; const noteKey = SmoSelector.getNoteKey(artifact.selector); const activeVoice = artifact.measure.getActiveVoice(); // not has not been drawn yet. if ((!artifact.box) || (!artifact.measure.svg.logicalBox)) { return; } this.measureNoteMap[noteKey] = artifact; this.renderer.pageMap.addArtifact(artifact); artifact.scrollBox = { x: artifact.box.x, y: artifact.measure.svg.logicalBox.y }; } }