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

1,472 lines (1,387 loc) 46.2 kB
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic) // Copyright (c) Aaron David Newman 2021. import { SuiInlineText, SuiTextBlock } from './textRender'; import { SuiRenderState } from './renderState'; import { SuiScroller } from './scroller'; import { layoutDebug } from './layoutDebug'; import { PromiseHelpers } from '../../common/promiseHelpers'; import { OutlineInfo, StrokeInfo, SvgHelpers } from './svgHelpers'; import { SmoScoreText, SmoTextGroup } from '../../smo/data/scoreText'; import { SmoLyric } from '../../smo/data/noteModifiers'; import { SmoSelector } from '../../smo/xform/selections'; import { SvgBox, KeyEvent, RemoveElementLike, ElementLike } from '../../smo/data/common'; import { SmoNote } from '../../smo/data/note'; import { SmoScore } from '../../smo/data/score'; import { SmoSelection } from '../../smo/xform/selections'; import { SvgPage } from './svgPageMap'; import { SuiScoreViewOperations } from './scoreViewOperations'; import { SvgPageMap } from './svgPageMap'; import { VexFlow, getChordSymbolGlyphFromCode } from '../../common/vex'; const VF = VexFlow; declare var $: any; /** * Basic parameters to create a text editor * @category SuiRender * @param context Vex renderer context * @param scroller * @param x initial x position * @param y initial y position * @param text initial text */ export interface SuiTextEditorParams { pageMap: SvgPageMap, context: SvgPage, scroller: SuiScroller, x: number, y: number, text: string } /** * @category SuiRender */ export interface SuiLyricEditorParams extends SuiTextEditorParams { lyric: SmoLyric } /** * @category SuiRender */ export interface SuiTextSessionParams { scroller: SuiScroller; renderer: SuiRenderState; scoreText: SmoScoreText; text: string; x: number; y: number; textGroup: SmoTextGroup; } /** * @category SuiRender */ export interface SuiLyricSessionParams { score: SmoScore; renderer: SuiRenderState; scroller: SuiScroller; view: SuiScoreViewOperations; verse: number; selector: SmoSelector; } export type SuiTextStrokeName = 'text-suggestion' | 'text-selection' | 'text-highlight' | 'text-drag' | 'inactive-text'; /** * The heirarchy of text editing objects goes: * * `dialog -> component -> session -> editor` * * Editors and Sessions are defined in this module. * ### editor * handles low-level events and renders the preview using one * of the text layout objects. * ### session * creates and destroys editors, e.g. for lyrics that have a Different * editor instance for each note. * * ## SuiTextEditor * The base text editor handles the positioning and inserting * of text blocks into the text area. The derived class shoud interpret key events. * A container class will manage the session for starting/stopping the editor * and retrieving the results into the target object. * @category SuiRender * */ export class SuiTextEditor { static get States(): Record<string, number> { return { RUNNING: 1, STOPPING: 2, STOPPED: 4, PENDING_EDITOR: 8 }; } // parsers use this convention to represent text types (superscript) static textTypeToChar(textType: number): string { if (textType === SuiInlineText.textTypes.superScript) { return '^'; } if (textType === SuiInlineText.textTypes.subScript) { return '%'; } return ''; } static textTypeFromChar(char: string): number { if (char === '^') { return SuiInlineText.textTypes.superScript; } if (char === '%') { return SuiInlineText.textTypes.subScript; } return SuiInlineText.textTypes.normal; } svgText: SuiInlineText | null = null; context: SvgPage; outlineInfo: OutlineInfo | null = null; pageMap: SvgPageMap; x: number = 0; y: number = 0; text: string; textPos: number = 0; selectionStart: number = -1; selectionLength: number = -1; empty: boolean = true; scroller: SuiScroller; suggestionIndex: number = -1; cursorState: boolean = false; cursorRunning: boolean = false; textType: number = SuiInlineText.textTypes.normal; fontWeight: string = 'normal'; fontFamily: string = 'Merriweather'; fontSize: number = 14; state: number = SuiTextEditor.States.RUNNING; suggestionRect: OutlineInfo | null = null; constructor(params: SuiTextEditorParams) { this.scroller = params.scroller; this.context = params.context; this.x = params.x; this.y = params.y; this.text = params.text; this.pageMap = params.pageMap; } static get strokes(): Record<SuiTextStrokeName, StrokeInfo> { return { 'text-suggestion': { strokeName: 'text-suggestion', stroke: '#cce', strokeWidth: 1, strokeDasharray: '4,1', fill: 'none', opacity: 1.0 }, 'text-selection': { strokeName: 'text-selection', stroke: '#99d', strokeWidth: 1, fill: 'none', strokeDasharray: '', opacity: 1.0 }, 'text-highlight': { strokeName: 'text-highlight', stroke: '#dd9', strokeWidth: 1, strokeDasharray: '4,1', fill: 'none', opacity: 1.0 }, 'text-drag': { strokeName: 'text-drag', stroke: '#d99', strokeWidth: 1, strokeDasharray: '2,1', fill: '#eee', opacity: 0.3 }, 'inactive-text': { strokeName: 'inactive-text', stroke: '#fff', strokeWidth: 1, strokeDasharray: '', fill: '#ddd', opacity: 0.3 } }; } // ### _suggestionParameters // Create the svg text outline parameters _suggestionParameters(box: SvgBox, strokeName: SuiTextStrokeName): OutlineInfo { const outlineStroke = SuiTextEditor.strokes[strokeName]; if (!this.suggestionRect) { this.suggestionRect = { context: this.context, box, classes: '', stroke: outlineStroke, scroll: this.scroller.scrollState, timeOff: 1000 }; }; this.suggestionRect.box = SvgHelpers.smoBox(box); return this.suggestionRect; } // ### _expandSelectionToSuggestion // Expand the selection to include the character the user clicked on. _expandSelectionToSuggestion() { if (this.suggestionIndex < 0) { return; } if (this.selectionStart < 0) { this._setSelectionToSugggestion(); return; } else if (this.selectionStart > this.suggestionIndex) { const oldStart = this.selectionStart; this.selectionStart = this.suggestionIndex; this.selectionLength = (oldStart - this.selectionStart) + this.selectionLength; } else if (this.selectionStart < this.suggestionIndex && this.selectionStart > this.selectionStart + this.selectionLength) { this.selectionLength = (this.suggestionIndex - this.selectionStart) + 1; } this._updateSelections(); } // ### _setSelectionToSugggestion // Set the selection to the character the user clicked on. _setSelectionToSugggestion() { this.selectionStart = this.suggestionIndex; this.selectionLength = 1; this.suggestionIndex = -1; this._updateSelections(); } rerender() { this.svgText?.unrender(); this.svgText?.render(); } // ### handleMouseEvent // Handle hover/click behavior for the text under edit. // Returns: true if the event was handled here handleMouseEvent(ev: any): boolean { let handled = false; if (this.svgText === null) { return false; } const clientBox = SvgHelpers.boxPoints( ev.clientX + this.scroller.scrollState.x, ev.clientY + this.scroller.scrollState.y, 1, 1); const logicalBox = this.pageMap.clientToSvg(clientBox); var blocks = this.svgText.getIntersectingBlocks(logicalBox); // The mouse is not over the text if (!blocks.length) { if (this.suggestionRect) { SvgHelpers.eraseOutline(this.suggestionRect); } // If the user clicks and there was a previous selection, treat it as selected if (ev.type === 'click' && this.suggestionIndex >= 0) { if (ev.shiftKey) { this._expandSelectionToSuggestion(); } else { this._setSelectionToSugggestion(); } handled = true; this.rerender(); } return handled; } handled = true; // outline the text that is hovered. Since mouse is a point // there should only be 1 blocks.forEach((block) => { SvgHelpers.outlineRect(this._suggestionParameters(block.box, 'text-suggestion')); this.suggestionIndex = block.index; }); // if the user clicked on it, add it to the selection. if (ev.type === 'click') { if (this.suggestionRect) { SvgHelpers.eraseOutline(this.suggestionRect); } if (ev.shiftKey) { this._expandSelectionToSuggestion(); } else { this._setSelectionToSugggestion(); } const npos = this.selectionStart + this.selectionLength; if (npos >= 0 && npos <= this.svgText.blocks.length) { this.textPos = npos; } this.rerender(); } return handled; } // ### _serviceCursor // Flash the cursor as a background task _serviceCursor() { if (this.cursorState) { this.svgText?.renderCursorAt(this.textPos - 1, this.textType); } else { this.svgText?.removeCursor(); } this.cursorState = !this.cursorState; } // ### _refreshCursor // If the text position changes, update the cursor position right away // don't wait for blink. _refreshCursor() { this.svgText?.removeCursor(); this.cursorState = true; this._serviceCursor(); } get _endCursorCondition(): boolean { return this.cursorRunning === false; } _cursorPreResolve() { this.svgText?.removeCursor(); } _cursorPoll() { this._serviceCursor(); } // ### startCursorPromise // Used by the calling logic to start the cursor. // returns a promise that can be pended when the editing ends. startCursorPromise(): Promise<void> { var self = this; this.cursorRunning = true; this.cursorState = true; self.svgText?.renderCursorAt(this.textPos, SuiInlineText.textTypes.normal); return PromiseHelpers.makePromise(() => this._endCursorCondition, () => this._cursorPreResolve(), () => this._cursorPoll(), 333); } stopCursor() { this.cursorRunning = false; } // ### setTextPos // Set the text position within the editor space and update the cursor setTextPos(val: number) { this.textPos = val; this._refreshCursor(); } // ### moveCursorRight // move cursor right within the block of text. moveCursorRight() { if (this.svgText === null) { return; } if (this.textPos <= this.svgText.blocks.length) { this.setTextPos(this.textPos + 1); } } // ### moveCursorRight // move cursor left within the block of text. moveCursorLeft() { if (this.textPos > 0) { this.setTextPos(this.textPos - 1); } } // ### moveCursorRight // highlight the text selections _updateSelections() { let i = 0; const end = this.selectionStart + this.selectionLength; const start = this.selectionStart; this.svgText?.blocks.forEach((block) => { const val = start >= 0 && i >= start && i < end; this.svgText!.setHighlight(block, val); ++i; }); } // ### _checkGrowSelectionLeft // grow selection within the bounds _checkGrowSelectionLeft() { if (this.selectionStart > 0) { this.selectionStart -= 1; this.selectionLength += 1; } } // ### _checkGrowSelectionRight // grow selection within the bounds _checkGrowSelectionRight() { if (this.svgText === null) { return; } const end = this.selectionStart + this.selectionLength; if (end < this.svgText.blocks.length) { this.selectionLength += 1; } } // ### growSelectionLeft // handle the selection keys growSelectionLeft() { if (this.selectionStart === -1) { this.moveCursorLeft(); this.selectionStart = this.textPos; this.selectionLength = 1; } else if (this.textPos === this.selectionStart) { this.moveCursorLeft(); this._checkGrowSelectionLeft(); } this._updateSelections(); } // ### growSelectionRight // handle the selection keys growSelectionRight() { if (this.selectionStart === -1) { this.selectionStart = this.textPos; this.selectionLength = 1; this.moveCursorRight(); } else if (this.selectionStart + this.selectionLength === this.textPos) { this._checkGrowSelectionRight(); this.moveCursorRight(); } this._updateSelections(); } // ### _clearSelections // Clear selected text _clearSelections() { this.selectionStart = -1; this.selectionLength = 0; } // ### deleteSelections // delete the selected blocks of text/glyphs deleteSelections() { let i = 0; const blockPos = this.selectionStart; for (i = 0; i < this.selectionLength; ++i) { this.svgText?.removeBlockAt(blockPos); // delete shifts blocks so keep index the same. } this.setTextPos(blockPos); this.selectionStart = -1; this.selectionLength = 0; } // ### parseBlocks // THis can be overridden by the base class to create the correct combination // of text and glyph blocks based on the underlying text parseBlocks() { let i = 0; this.svgText = new SuiInlineText({ context: this.context, startX: this.x, startY: this.y, fontFamily: this.fontFamily, fontSize: this.fontSize, fontWeight: this.fontWeight, scroller: this.scroller, purpose: SuiInlineText.textPurposes.edit, fontStyle: 'normal', pageMap: this.pageMap }); for (i = 0; i < this.text.length; ++i) { const def = SuiInlineText.blockDefaults; def.text = this.text[i] this.svgText.addTextBlockAt(i, def); this.empty = false; } this.textPos = this.text.length; this.state = SuiTextEditor.States.RUNNING; this.rerender(); } // ### evKey // Handle key events that filter down to the editor async evKey(evdata: KeyEvent): Promise<boolean> { const removeCurrent = () => { if (this.svgText) { this.svgText.element?.remove(); this.svgText.element = null; } } if (evdata.code === 'ArrowRight') { if (evdata.shiftKey) { this.growSelectionRight(); } else { this.moveCursorRight(); } this.rerender(); return true; } if (evdata.code === 'ArrowLeft') { if (evdata.shiftKey) { this.growSelectionLeft(); } else { this.moveCursorLeft(); } this.rerender(); return true; } if (evdata.code === 'Backspace') { removeCurrent(); if (this.selectionStart >= 0) { this.deleteSelections(); } else { if (this.textPos > 0) { this.selectionStart = this.textPos - 1; this.selectionLength = 1; this.deleteSelections(); } } this.rerender(); return true; } if (evdata.code === 'Delete') { removeCurrent(); if (this.selectionStart >= 0) { this.deleteSelections(); } else { if (this.textPos > 0 && this.svgText !== null && this.textPos < this.svgText.blocks.length) { this.selectionStart = this.textPos; this.selectionLength = 1; this.deleteSelections(); } } this.rerender(); return true; } if (evdata.key.charCodeAt(0) >= 33 && evdata.key.charCodeAt(0) <= 126 && evdata.key.length === 1) { removeCurrent(); const isPaste = evdata.ctrlKey && evdata.key === 'v'; let text = evdata.key; if (isPaste) { text = await navigator.clipboard.readText(); } if (this.empty) { this.svgText?.removeBlockAt(0); this.empty = false; const def = SuiInlineText.blockDefaults; def.text = text; this.svgText?.addTextBlockAt(0, def); this.setTextPos(1); } else { if (this.selectionStart >= 0) { this.deleteSelections(); } const def = SuiInlineText.blockDefaults; def.text = text; def.textType = this.textType; this.svgText?.addTextBlockAt(this.textPos, def); this.setTextPos(this.textPos + 1); } this.rerender(); return true; } return false; } } /** * @category SuiRender */ export class SuiTextBlockEditor extends SuiTextEditor { // ### ctor // ### args // params: {lyric: SmoLyric,...} constructor(params: SuiTextEditorParams) { super(params); $(this.context.svg).find('g.vf-text-highlight').remove(); this.parseBlocks(); } _highlightEditor() { if (this.svgText === null || this.svgText.blocks.length === 0) { return; } const bbox = this.svgText.getLogicalBox(); const outlineStroke = SuiTextEditor.strokes['text-highlight']; if (this.outlineInfo && this.outlineInfo.element) { this.outlineInfo.element.remove(); } this.outlineInfo = { context: this.context, box: bbox, classes: '', stroke: outlineStroke, scroll: this.scroller.scrollState, timeOff: 0 }; SvgHelpers.outlineRect(this.outlineInfo); } getText(): string { if (this.svgText !== null) { return this.svgText.getText(); } return ''; } async evKey(evdata: any): Promise<boolean> { if (evdata.key.charCodeAt(0) === 32) { if (this.empty) { this.svgText?.removeBlockAt(0); this.empty = false; const def = SuiInlineText.blockDefaults; def.text = ' '; this.svgText?.addTextBlockAt(0, def); this.setTextPos(1); } else { if (this.selectionStart >= 0) { this.deleteSelections(); } const def = SuiInlineText.blockDefaults; def.text = ' '; def.textType = this.textType; this.svgText?.addTextBlockAt(this.textPos, def); this.setTextPos(this.textPos + 1); } this.rerender(); evdata.preventDefault(); // prevent browser scroll return true; } const rv = super.evKey(evdata); this._highlightEditor(); return rv; } stopEditor() { this.state = SuiTextEditor.States.STOPPING; $(this.context.svg).find('g.vf-text-highlight').remove(); this.stopCursor(); this.svgText?.unrender(); } } export class SuiLyricEditor extends SuiTextEditor { static get States() { return { RUNNING: 1, STOPPING: 2, STOPPED: 4 }; } parseBlocks() { let i = 0; const def = SuiInlineText.defaults; def.context = this.context; def.startX = this.x; def.startY = this.y; def.scroller = this.scroller; this.svgText = new SuiInlineText(def); for (i = 0; i < this.text.length; ++i) { const blockP = SuiInlineText.blockDefaults; blockP.text = this.text[i]; this.svgText.addTextBlockAt(i, blockP); this.empty = false; } this.textPos = this.text.length; this.state = SuiTextEditor.States.RUNNING; this.rerender(); } getText(): string { if (this.svgText !== null) { return this.svgText.getText(); } return ''; } lyric: SmoLyric; state: number = SuiTextEditor.States.PENDING_EDITOR; // ### ctor // ### args // params: {lyric: SmoLyric,...} constructor(params: SuiLyricEditorParams) { super(params); this.text = params.lyric.getText(); if (params.lyric.isHyphenated()) { this.text += '-'; } this.lyric = params.lyric; this.parseBlocks(); } stopEditor() { this.state = SuiTextEditor.States.STOPPING; this.stopCursor(); if (this.svgText !== null) { this.svgText.unrender(); } } } /** * @category SuiRender */ export class SuiChordEditor extends SuiTextEditor { static get States() { return { RUNNING: 1, STOPPING: 2, STOPPED: 4 }; } static get SymbolModifiers() { return { NONE: 1, SUBSCRIPT: 2, SUPERSCRIPT: 3 }; } // ### toTextTypeChar // Given an old text type and a desited new text type, // return what the new text type character should be static toTextTypeChar(oldTextType: number, newTextType: number): string { const tt = SuiInlineText.getTextTypeResult(oldTextType, newTextType); return SuiTextEditor.textTypeToChar(tt); } static toTextTypeTransition(oldTextType: number, result: number): string { const tt = SuiInlineText.getTextTypeTransition(oldTextType, result); return SuiTextEditor.textTypeToChar(tt); } setTextType(textType: number) { this.textType = textType; } // Handle the case where user changed super/subscript in the middle of the // string. _updateSymbolModifiers() { let change = this.textPos; let render = false; let i = 0; for (i = this.textPos; this.svgText !== null && i < this.svgText.blocks.length; ++i) { const block = this.svgText!.blocks[i]; if (block.textType !== this.textType && block.textType !== change) { change = block.textType; block.textType = this.textType; render = true; } else { break; } } if (render) { this.rerender(); } } _setSymbolModifier(char: string): boolean { if (['^', '%'].indexOf(char) < 0) { return false; } const currentTextType = this.textType; const transitionType = SuiTextEditor.textTypeFromChar(char); this.textType = SuiInlineText.getTextTypeResult(currentTextType, transitionType); this._updateSymbolModifiers(); return true; } parseBlocks() { let readGlyph = false; let curGlyph = ''; let blockIx = 0; // so we skip modifier characters let i = 0; const params = SuiInlineText.defaults; params.context = this.context; params.startX = this.x; params.startY = this.y; params.scroller = this.scroller; this.svgText = new SuiInlineText(params); for (i = 0; i < this.text.length; ++i) { const char = this.text[i]; const isSymbolModifier = this._setSymbolModifier(char); if (char === '@') { if (!readGlyph) { readGlyph = true; curGlyph = ''; } else { this._addGlyphAt(blockIx, curGlyph); blockIx += 1; readGlyph = false; } } else if (!isSymbolModifier) { if (readGlyph) { curGlyph = curGlyph + char; } else { const blockP = SuiInlineText.blockDefaults; blockP.text = char; blockP.textType = this.textType; this.svgText.addTextBlockAt(blockIx, blockP); blockIx += 1; } } this.empty = false; } this.textPos = blockIx; this.state = SuiTextEditor.States.RUNNING; this.rerender(); } // ### getText // Get the text value that we persist getText(): string { if (this.svgText === null || this.svgText.blocks.length < 1) { return ''; } let text = ''; let textType = this.svgText.blocks[0].textType; this.svgText.blocks.forEach((block) => { if (block.textType !== textType) { text += SuiChordEditor.toTextTypeTransition(textType, block.textType); textType = block.textType; } if (block.symbolType === SuiInlineText.symbolTypes.GLYPH) { text += '@' + block.glyphCode + '@'; } else { text += block.text; } }); return text; } _addGlyphAt(ix: number, code: string) { if (this.selectionStart >= 0) { this.deleteSelections(); } const blockP = SuiInlineText.blockDefaults; blockP.glyphCode = code; blockP.textType = this.textType; this.svgText?.addGlyphBlockAt(ix, blockP); this.textPos += 1; } unrender() { if (this.svgText) { this.svgText.element?.remove(); } } async evKey(evdata: KeyEvent): Promise<boolean> { let edited = false; if (this._setSymbolModifier(evdata.key)) { return true; } // Dialog gives us a specific glyph code if (evdata.key[0] === '@' && evdata.key.length > 2) { this.unrender(); const glyph = evdata.key.substr(1, evdata.key.length - 2); this._addGlyphAt(this.textPos, getChordSymbolGlyphFromCode(glyph)); this.rerender(); edited = true; } else if (VF.ChordSymbol.glyphs[evdata.key[0]]) { // glyph shortcut like 'b' this.unrender(); // hack: vexflow 5 broke this this._addGlyphAt(this.textPos, evdata.key[0]); this.rerender(); edited = true; } else { // some ordinary key edited = await super.evKey(evdata); } if (this.svgText !== null && this.svgText.blocks.length > this.textPos && this.textPos >= 0) { this.textType = this.svgText.blocks[this.textPos].textType; } return edited; } lyric: SmoLyric; // ### ctor // ### args // params: {lyric: SmoLyric,...} constructor(params: SuiLyricEditorParams) { super(params); this.text = params.lyric.text; this.lyric = params.lyric; this.textType = SuiInlineText.textTypes.normal; this.parseBlocks(); } stopEditor() { this.state = SuiTextEditor.States.STOPPING; this.stopCursor(); this.svgText?.unrender(); } // ### _markStopped // Indicate this editor session is done running _markStopped() { this.state = SuiTextEditor.States.STOPPED; } } export interface SuiDragSessionParams { context: SvgPageMap; scroller: SuiScroller; textGroup: SmoTextGroup; } /** * @category SuiRender */ export class SuiDragSession { pageMap: SvgPageMap; page: SvgPage; scroller: SuiScroller; outlineBox: SvgBox; textObject: SuiTextBlock; dragging: boolean = false; outlineRect: OutlineInfo | null = null; textGroup: SmoTextGroup; constructor(params: SuiDragSessionParams) { this.textGroup = params.textGroup; this.pageMap = params.context; this.scroller = params.scroller; this.page = this.pageMap.getRendererFromModifier(this.textGroup); // create a temporary text object for dragging this.textObject = SuiTextBlock.fromTextGroup(this.textGroup, this.page, this.pageMap, this.scroller); // SuiTextBlock this.dragging = false; this.outlineBox = this.textObject.getLogicalBox(); } _outlineBox() { const outlineStroke = SuiTextEditor.strokes['text-drag']; const x = this.outlineBox.x - this.page.box.x; const y = this.outlineBox.y - this.page.box.y; if (!this.outlineRect) { this.outlineRect = { context: this.page, box: SvgHelpers.boxPoints(x , y + this.outlineBox.height, this.outlineBox.width, this.outlineBox.height), classes: 'text-drag', stroke: outlineStroke, scroll: this.scroller.scrollState, timeOff: 1000 }; } this.outlineRect.box = SvgHelpers.boxPoints(x , y + this.outlineBox.height, this.outlineBox.width, this.outlineBox.height), SvgHelpers.outlineRect(this.outlineRect); } unrender() { this.textGroup.elements.forEach((el: ElementLike) => { RemoveElementLike(el); }); this.textGroup.elements = []; this.textObject.unrender(); } scrolledClientBox(x: number, y: number) { return { x: x + this.scroller.scrollState.x, y: y + this.scroller.scrollState.y, width: 1, height: 1 }; } checkBounds() { if (this.outlineBox.y < this.outlineBox.height) { this.outlineBox.y = this.outlineBox.height; } if (this.outlineBox.x < 0) { this.outlineBox.x = 0; } if (this.outlineBox.x > this.page.box.x + this.page.box.width - this.outlineBox.width) { this.outlineBox.x = this.page.box.x + this.page.box.width - this.outlineBox.width; } if (this.outlineBox.y > this.page.box.y + this.page.box.height) { this.outlineBox.y = this.page.box.y + this.page.box.height; } } startDrag(e: any) { const evBox = this.scrolledClientBox(e.clientX, e.clientY); const svgMouseBox = this.pageMap.clientToSvg(evBox); svgMouseBox.y -= this.outlineBox.height; if (layoutDebug.mask & layoutDebug.values['dragDebug']) { layoutDebug.updateDragDebug(svgMouseBox, this.outlineBox, 'start'); } if (!SvgHelpers.doesBox1ContainBox2(this.outlineBox, svgMouseBox)) { return; } this.dragging = true; this.outlineBox = svgMouseBox; const currentBox = this.textObject.getLogicalBox(); this.outlineBox.width = currentBox.width; this.outlineBox.height = currentBox.height; this.unrender(); this.checkBounds(); this._outlineBox(); } mouseMove(e: any) { if (!this.dragging) { return; } const evBox = this.scrolledClientBox(e.clientX, e.clientY); const svgMouseBox = this.pageMap.clientToSvg(evBox); svgMouseBox.y -= this.outlineBox.height; this.outlineBox = SvgHelpers.smoBox(svgMouseBox); const currentBox = this.textObject.getLogicalBox(); this.outlineBox.width = currentBox.width; this.outlineBox.height = currentBox.height; this.checkBounds(); this.textObject.offsetStartX(this.outlineBox.x - currentBox.x); this.textObject.offsetStartY(this.outlineBox.y - currentBox.y); this.textObject.render(); if (layoutDebug.mask & layoutDebug.values['dragDebug']) { layoutDebug.updateDragDebug(svgMouseBox, this.outlineBox, 'drag'); } if (this.outlineRect) { SvgHelpers.eraseOutline(this.outlineRect); this.outlineRect = null; } this._outlineBox(); } endDrag() { // this.textObject.render(); const newBox = this.textObject.getLogicalBox(); const curBox = this.textGroup.logicalBox ?? SvgBox.default; if (layoutDebug.mask & layoutDebug.values['dragDebug']) { layoutDebug.updateDragDebug(curBox, newBox, 'end'); } this.textGroup.offsetX(newBox.x - curBox.x); this.textGroup.offsetY(newBox.y - curBox.y + this.outlineBox.height); this.dragging = false; if (this.outlineRect) { SvgHelpers.eraseOutline(this.outlineRect); this.outlineRect = null; } } } /** * session for editing plain text * @category SuiRender */ export class SuiTextSession { static get States() { return { RUNNING: 1, STOPPING: 2, STOPPED: 4, PENDING_EDITOR: 8 }; } scroller: SuiScroller; scoreText: SmoScoreText; text: string; x: number; y: number; textGroup: SmoTextGroup; fontFamily: string = ''; fontWeight: string = ''; fontSize: number = 14; state: number = SuiTextEditor.States.PENDING_EDITOR; editor: SuiTextBlockEditor | null = null; renderer: SuiRenderState; cursorPromise: Promise<any> | null = null; constructor(params: SuiTextSessionParams) { this.scroller = params.scroller; this.renderer = params.renderer; this.scoreText = params.scoreText; this.text = this.scoreText.text; this.x = params.x; this.y = params.y; this.textGroup = params.textGroup; this.renderer = params.renderer; // Create a text group if one was not a startup parameter if (!this.textGroup) { this.textGroup = new SmoTextGroup(SmoTextGroup.defaults); } // Create a scoreText if one was not a startup parameter, or // get it from the text group if (!this.scoreText) { if (this.textGroup && this.textGroup.textBlocks.length) { this.scoreText = this.textGroup.textBlocks[0].text; } else { const stDef = SmoScoreText.defaults; stDef.x = this.x; stDef.y = this.y; this.scoreText = new SmoScoreText(stDef); this.textGroup.addScoreText(this.scoreText, SmoTextGroup.relativePositions.RIGHT); } } this.fontFamily = SmoScoreText.familyString(this.scoreText.fontInfo.family); this.fontWeight = SmoScoreText.weightString(this.scoreText.fontInfo.weight); this.fontSize = SmoScoreText.fontPointSize(this.scoreText.fontInfo.size); this.text = this.scoreText.text; } // ### _isRefreshed // renderer has partially rendered text(promise condition) get _isRefreshed(): boolean { return this.renderer.dirty === false; } get isStopped(): boolean { return this.state === SuiTextEditor.States.STOPPED; } get isRunning(): boolean { return this.state === SuiTextEditor.States.RUNNING; } _markStopped() { this.state = SuiTextEditor.States.STOPPED; } // ### _isRendered // renderer has rendered text(promise condition) get _isRendered(): boolean { return this.renderer.passState === SuiRenderState.passStates.clean; } _removeScoreText() { const selector = '#' + this.scoreText.attrs.id; $(selector).remove(); } // ### _startSessionForNote // Start the lyric session startSession() { const context = this.renderer.pageMap.getRenderer({ x: this.x, y: this.y }); if (context) { this.editor = new SuiTextBlockEditor({ x: this.x, y: this.y, scroller: this.scroller, context: context, text: this.scoreText.text, pageMap: this.renderer.pageMap }); this.cursorPromise = this.editor.startCursorPromise(); this.state = SuiTextEditor.States.RUNNING; this._removeScoreText(); } } // ### _startSessionForNote // Stop the lyric session, return promise for done stopSession(): Promise<void> { if (this.editor) { this.scoreText.text = this.editor.getText(); this.scoreText.tryParseUnicode(); // convert unicode chars this.editor.stopEditor(); } return PromiseHelpers.makePromise(()=> this._isRendered,() => this._markStopped(), null, 100); } // ### evKey // Key handler (pass to editor) async evKey(evdata: KeyEvent): Promise<boolean> { if (this.state !== SuiTextEditor.States.RUNNING || this.editor === null) { return false; } const rv = await this.editor.evKey(evdata); if (rv) { this._removeScoreText(); } return rv; } handleMouseEvent(ev: any) { if (this.isRunning && this.editor !== null) { this.editor.handleMouseEvent(ev); } } } /** * Manage editor for lyrics, jupmping from note to note if asked * @category SuiRender */ export class SuiLyricSession { static get States() { return { RUNNING: 1, STOPPING: 2, STOPPED: 4, PENDING_EDITOR: 8 }; } score: SmoScore; renderer: SuiRenderState; scroller: SuiScroller; view: SuiScoreViewOperations; parser: number; verse: number; selector: SmoSelector; selection: SmoSelection | null; note: SmoNote | null = null; originalText: string; lyric: SmoLyric | null = null; text: string = ''; editor: SuiLyricEditor | null = null; state: number = SuiTextEditor.States.PENDING_EDITOR; cursorPromise: Promise<any> | null = null; constructor(params: SuiLyricSessionParams) { this.score = params.score; this.renderer = params.renderer; this.scroller = params.scroller; this.view = params.view; this.parser = SmoLyric.parsers.lyric; this.verse = params.verse; this.selector = params.selector; this.selection = SmoSelection.noteFromSelector(this.score, this.selector); if (this.selection !== null) { this.note = this.selection.note; } this.originalText = ''; } // ### _setLyricForNote // Get the text from the editor and update the lyric with it. _setLyricForNote() { this.lyric = null; if (!this.note) { return; } const lar = this.note.getLyricForVerse(this.verse, SmoLyric.parsers.lyric); if (lar.length) { this.lyric = lar[0] as SmoLyric; } if (!this.lyric) { const scoreFont = this.score.fonts.find((fn) => fn.name === 'lyrics'); const fontInfo = JSON.parse(JSON.stringify(scoreFont)); const lyricD = SmoLyric.defaults; lyricD.text = ''; lyricD.verse = this.verse; lyricD.fontInfo = fontInfo; this.lyric = new SmoLyric(lyricD); } this.text = this.lyric.text; this.originalText = this.text; // this.view.addOrUpdateLyric(this.selection.selector, this.lyric); } // ### _endLyricCondition // Lyric editor has stopped running (promise condition) get _endLyricCondition(): boolean { return this.editor !== null && this.editor.state !== SuiTextEditor.States.RUNNING; } // ### _endLyricCondition // renderer has partially rendered text(promise condition) get _isRefreshed(): boolean { return this.renderer.renderStateRendered; } // ### _isRendered // renderer has rendered text(promise condition) get _isRendered(): boolean { return this.renderer.renderStateClean; } get _pendingEditor(): boolean { return this.state !== SuiTextEditor.States.PENDING_EDITOR; } // ### _hideLyric // Hide the lyric so you only see the editor. _hideLyric() { if (this.lyric !== null && this.lyric.selector) { $(this.lyric.selector).remove(); } } get isStopped(): boolean { return this.state === SuiTextEditor.States.STOPPED; } get isRunning(): boolean { return this.state === SuiTextEditor.States.RUNNING; } // ### _markStopped // Indicate this editor session is done running _markStopped() { this.state = SuiTextEditor.States.STOPPED; } // ### _startSessionForNote // Start the lyric editor for a note (current selected note) _startSessionForNote() { if (this.lyric === null || this.note === null || this.note.logicalBox === null) { return; } let startX = this.note.logicalBox.x; let startY = this.note.logicalBox.y + this.note.logicalBox.height + SmoScoreText.fontPointSize(this.lyric.fontInfo.size); this.lyric.skipRender = true; const lyricRendered = this.lyric.text.length > 0; if (this.lyric.logicalBox !== null) { startX = this.lyric.logicalBox.x; startY = this.lyric.logicalBox.y + this.lyric.logicalBox.height; } const context = this.view.renderer.pageMap.getRenderer({ x: startX, y: startY }); if (context) { this.editor = new SuiLyricEditor({ context, lyric: this.lyric, x: startX, y: startY, scroller: this.scroller, text: this.lyric.getText(), pageMap: this.renderer.pageMap }); this.state = SuiTextEditor.States.RUNNING; if (!lyricRendered && this.editor !== null && this.editor.svgText !== null) { const delta = 2 * this.editor.svgText.maxFontHeight(1.0) * (this.lyric.verse + 1); this.editor.svgText.offsetStartY(delta); } this.cursorPromise = this.editor.startCursorPromise(); this._hideLyric(); } } // ### _startSessionForNote // Start the lyric session startSession() { this._setLyricForNote(); this._startSessionForNote(); this.state = SuiTextEditor.States.RUNNING; } // ### _startSessionForNote // Stop the lyric session, return promise for done async stopSession() { if (this.editor && !this._endLyricCondition) { await this._updateLyricFromEditor(); this.editor.stopEditor(); } return PromiseHelpers.makePromise(() => this._isRendered, () => this._markStopped(), null, 100); } // ### _advanceSelection // Based on a skip character, move the editor forward/back one note. async _advanceSelection(isShift: boolean) { const nextSelection = isShift ? SmoSelection.lastNoteSelectionFromSelector(this.score, this.selector) : SmoSelection.nextNoteSelectionFromSelector(this.score, this.selector); if (nextSelection) { this.selector = nextSelection.selector; this.selection = nextSelection; this.note = nextSelection.note; this._setLyricForNote(); const conditionArray: any = []; this.state = SuiTextEditor.States.PENDING_EDITOR; conditionArray.push(PromiseHelpers.makePromiseObj(() => this._endLyricCondition, null, null, 100)); conditionArray.push(PromiseHelpers.makePromiseObj(() => this._isRefreshed,() => this._startSessionForNote(), null, 100)); await PromiseHelpers.promiseChainThen(conditionArray); } } // ### advanceSelection // external interfoace to move to next/last note async advanceSelection(isShift: boolean) { if (this.isRunning) { await this._updateLyricFromEditor(); await this._advanceSelection(isShift); } } async removeLyric() { if (this.selection && this.lyric) { await this.view.removeLyric(this.selection.selector, this.lyric); this.lyric.skipRender = true; await this.advanceSelection(false); } } // ### _updateLyricFromEditor // The editor is done running, so update the lyric now. async _updateLyricFromEditor() { if (this.editor === null || this.lyric === null) { return; } const txt = this.editor.getText(); this.lyric.setText(txt); this.lyric.skipRender = false; this.editor.stopEditor(); if (!this.lyric.deleted && this.originalText !== txt && this.selection !== null) { await this.view.addOrUpdateLyric(this.selection.selector, this.lyric); } } // ### evKey // Key handler (pass to editor) async evKey(evdata: KeyEvent): Promise<boolean> { if (this.state !== SuiTextEditor.States.RUNNING) { return false; } if (evdata.key === '-' || evdata.key === ' ') { // skip const back = evdata.shiftKey && evdata.key === ' '; if (evdata.key === '-' && this.editor !== null) { await this.editor.evKey(evdata); } this._updateLyricFromEditor(); this._advanceSelection(back); } else if (this.editor !== null) { await this.editor.evKey(evdata); this._hideLyric(); } return true; } get textType(): number { if (this.isRunning && this.editor !== null) { return this.editor.textType; } return SuiInlineText.textTypes.normal; } set textType(type) { if (this.editor) { this.editor.textType = type; } } // ### handleMouseEvent // Mouse event (send to editor) handleMouseEvent(ev: any) { if (this.state !== SuiTextEditor.States.RUNNING || this.editor === null) { return; } this.editor.handleMouseEvent(ev); } } /** * @category SuiRender */ export class SuiChordSession extends SuiLyricSession { editor: SuiLyricEditor | null = null; constructor(params: SuiLyricSessionParams) { super(params); this.parser = SmoLyric.parsers.chord; } // ### evKey // Key handler (pass to editor) async evKey(evdata: KeyEvent): Promise<boolean> { let edited = false; if (this.state !== SuiTextEditor.States.RUNNING) { return false; } if (evdata.code === 'Enter') { this._updateLyricFromEditor(); this._advanceSelection(evdata.shiftKey); edited = true; } else if (this.editor !== null) { edited = await this.editor.evKey(evdata); } this._hideLyric(); return edited; } // ### _setLyricForNote // Get the text from the editor and update the lyric with it. _setLyricForNote() { this.lyric = null; if (this.note === null) { return; } const lar = this.note.getLyricForVerse(this.verse, this.parser); if (lar.length) { this.lyric = lar[0] as SmoLyric; } if (!this.lyric) { const scoreFont = this.score.fonts.find((fn) => fn.name === 'chords'); const fontInfo = JSON.parse(JSON.stringify(scoreFont)); const ldef = SmoLyric.defaults; ldef.text = ''; ldef.verse = this.verse; ldef.parser = this.parser; ldef.fontInfo = fontInfo; this.lyric = new SmoLyric(ldef); this.note.addLyric(this.lyric); } this.text = this.lyric.text; } // ### _startSessionForNote // Start the lyric editor for a note (current selected note) _startSessionForNote() { if (this.lyric === null) { return; } if (this.selection === null || this.note === null || this.note.logicalBox === null) { return; } let startX = this.note.logicalBox.x; let startY = this.selection.measure.svg.logicalBox.y; if (this.lyric.logicalBox !== null) { startX = this.lyric.logicalBox.x; startY = this.lyric.logicalBox.y + this.lyric.logicalBox.height; } this.selection.measure.svg.logicalBox.y + this.selection.measure.svg.logicalBox.height - 70; const context = this.renderer.pageMap.getRenderer({ x: startX, y: startY }); if (context) { this.editor = new SuiChordEditor({ context, lyric: this.lyric, x: startX, y: startY, scroller: this.scroller, text: this.lyric.getText(), pageMap: this.renderer.pageMap }); this.state = SuiTextEditor.States.RUNNING; if (this.editor !== null && this.editor.svgText !== null) { const delta = (-1) * this.editor.svgText.maxFontHeight(1.0) * (this.lyric.verse + 1); this.editor.svgText.offsetStartY(delta); } this.cursorPromise = this.editor.startCursorPromise(); this._hideLyric(); } } }