license import { css } from "aphrodite" ; import DOMPurify from "dompurify" ; import * as monacoEditor from "monaco-editor" ; import React from "react" ; import { debounce } from "lodash" ; import { decodeArrayBuffer } from "./base64Binary" ; import { MODE_BOTH , MODE_VIEW , ViewMode } from "./Header" ; import Logs from "./Logs" ; import RPCClient , { RenderResponse } from "./RPCClient" ; import { APP_STYLE } from "./styles" ; /**
 * How long the code must not be edited for a preview to render.
 */ const DEBOUNCE_REFERSH_TIMEOUT = 1000 ; /**
 * HTML with a #root element under which the svgs will be inserted.
 *
 * Note: Chrome doesn't render font-face in inline CSS.
 */ const BODY_IFRAME_TEMPLATE = `
<link rel="stylesheet" type="text/css" href="/preview.css">
<div id="root"></div>
` ; interface Props { /**
     * The source to render.
     *
     * When this changes, the preview will update (but maybe not right away -- see
     * DEBOUNCE_REFRESH_TIMEOUT).
     */ code : string ; /**
     * The logs to render in the Logs button.
     *
     * Although the logs are generated here, it's not stored here to -- that way the parent
     * stays the single source of truth for the logs.
     */ logs : string | null ; /**
     * Whether the preview is full-page, half-page, or hidden.
     */ mode : ViewMode ; /**
     * Client to use to request the SVG and logs.
     */ rpc : RPCClient ; /**
     * Called whenever a preview is rendered. The parent should in turn re-render,
     * settings the logs prop on this component and <Editor />.
     */ onLogsObtained ( logs , version ) : void ; onMidiObtained ( midi ) : void ; /**
     * Called whenever a note is clicked. The parent should let the <Editor /> know
     * so it can focus where the note is defined.
     */ onSelectionChanged ( selection ) : void ; } interface State { error : string | null ; pendingPreviews : number ; previewAlreadyDirty : boolean ; } /**
 * Makes the logs easier to read by hiding details not relevant to the user.
 */ function cleanLogs ( logs ) { return ( logs . replace ( /\/tmp\/lyp\/wrappers\/hacklily.ly:([0-9]*):([0-9]*)/g , "/tmp/hacklily.ly:2:1" ) . replace ( /`\/tmp\/lyp\/wrappers\/hacklily.ly'/g , "`hacklily-wrapper.ly'" ) . replace ( /^Lyp.*\n/gm , "" ) . replace ( /^GNU LilyPond Server.*[\n\s]*/gm , "" ) . replace ( /^>\s*\n?/gm , "" ) ) ; } /**
 * Renders an SVG preview of the song in an iframe.
 *
 * The SVG is retreived from the backend server via the RPCClient.
 *
 * On update, it sends back the logs to the parent so that it can be used to highlight
 * errors in the editor. See onLogsObtained.
 *
 * When you click a note, it fires onSelectionChanged so that the editor can highlight
 * where the note is defined.
 */ export default class Preview extends React . PureComponent < Props , State > { state : State = { error : null , pendingPreviews : 0 , previewAlreadyDirty : false , } ; private iframeLoaded : boolean = false ; private previousMIDIData : string | null = null ; private sheetMusicView : HTMLIFrameElement | null = null ; componentDidMount ( ) { this . fetchNewPreview ( ) ; } componentDidUpdate ( prevProps ) { if ( prevProps . code !== this . props . code ) { this . fetchNewPreview ( ) ; } if ( prevProps . mode !== this . props . mode ) { this . handleModeDidChange ( ) ; } } render ( ) { const { mode , logs } = this . props ; const { pendingPreviews , error } = this . state ; const previewMaskStyle = css ( APP_STYLE . pendingPreviewMask , mode === MODE_VIEW && APP_STYLE . previewPendingMaskModeView ) ; return ( < div style = { { backgroundColor : "white" } } > < iframe className = { css ( APP_STYLE . sheetMusicView ) } frameBorder = "0" height = "100%" ref = { this . handleSetSheetMusicView } width = { mode === MODE_BOTH ? "50%" : mode === MODE_VIEW ? "100%" : "0" } /> { pendingPreviews > 0 && < div className = { previewMaskStyle } />} { error && < div className = { css ( APP_STYLE . errorMask ) } > { error } < /div>} < Logs logs = { logs } loading = { ! logs || logs . length === 0 || pendingPreviews > 0 } /> < /div>); } private fetchNewPreview = debounce ( async ( ) => { if ( this . state . pendingPreviews ) { this . setState ( { previewAlreadyDirty : true , } ) ; return ; } this . setState ( { pendingPreviews : this . state . pendingPreviews + 1 , } ) ; if ( ! this . iframeLoaded ) { this . handleIFrameLoaded ( ) ; } const { code , rpc } = this . props ; let version = "stable" ; try { if ( ! rpc ) { throw new Error ( "rpc must be set!" ) ; } const maybeVersion = /\\version\s*"(\d+)\.?(\d+)?\.?(\d+)?/gm . exec ( code ) ; const versionSlices = maybeVersion ? maybeVersion . slice ( 1 ) . map ( ( v ) => parseInt ( v , 10 ) ) : [ ] ; const isUnstable = versionSlices [ 0 ] === 2 && versionSlices [ 1 ] > 22 ; version = isUnstable ? "unstable" : "stable" ; const response = await rpc . call ( "render" , { backend : "svg" , src : code , version , } ) ; const files = response . result . files ; const dirtyLogs = response . result . logs ; const midi = response . result . midi || null ; const logs = cleanLogs ( dirtyLogs ) ; if ( ! this . sheetMusicView ) { throw new Error ( "Could not get frame's window" ) ; } if ( ! this . sheetMusicView . contentWindow ) { throw new Error ( "Could not get frame's window" ) ; } const root = this . sheetMusicView . contentWindow . document . getElementById ( "root" ) ; if ( ! root ) { throw new Error ( "Could not get sheet music view root!" ) ; } root . style . minHeight = files . length === 0 || files [ 0 ] . trim ( ) === "" ? `${ root . scrollHeight }px` : "0" ; root . innerHTML = DOMPurify . sanitize ( files . join ( "" ) , { ALLOW_UNKNOWN_PROTOCOLS : true , } as object ) ; this . props . onLogsObtained ( logs , version ) ; if ( midi !== this . previousMIDIData ) { if ( midi ) { this . props . onMidiObtained ( decodeArrayBuffer ( midi . replace ( /\s/g , "" ) ) ) ; } else { this . props . onMidiObtained ( null ) ; } } this . previousMIDIData = midi ; const svgs = Array . from ( root . getElementsByTagName ( "svg" ) ) ; for ( const svg of svgs ) { svg . removeAttribute ( "width" ) ; svg . removeAttribute ( "height" ) ; } this . setState ( { pendingPreviews : this . state . pendingPreviews - 1 , } ) ; if ( this . state . previewAlreadyDirty ) { this . setState ( { error : null , previewAlreadyDirty : false , } ) ; this . fetchNewPreview ( ) ; } return ; } catch ( err ) { const errMsg = ( err && err . error && typeof err . error . message === "string" && err . error . message ) || "unknown error" ; this . props . onLogsObtained ( errMsg , version ) ; this . setState ( { error : errMsg , pendingPreviews : this . state . pendingPreviews - 1 , previewAlreadyDirty : true , } ) ; } } , DEBOUNCE_REFERSH_TIMEOUT ) ; private handleIFrameLoaded = ( ) : void => { if ( this . sheetMusicView && this . sheetMusicView . contentWindow ) { const body = this . sheetMusicView . contentWindow . document . body ; body . innerHTML = BODY_IFRAME_TEMPLATE ; body . addEventListener ( "click" , ( ev ) => { ev . preventDefault ( ) ; let target = ev . target as HTMLElement | null ; while ( target && target . localName !== "a" ) { target = target . parentElement ; } if ( target && target . localName === "a" ) { const link = target . getAttribute ( "xlink:href" ) ; if ( ! link ) { return ; } const pathAndLocation = link . split ( ".ly:" ) ; if ( pathAndLocation . length === 2 ) { const [ line , startColumn , endColumn ] = pathAndLocation [ 1 ] . split ( ":" ) ; this . props . onSelectionChanged ( { positionColumn : parseInt ( endColumn , 10 ) + 1 , positionLineNumber : parseInt ( line , 10 ) - 1 , selectionStartColumn : parseInt ( startColumn , 10 ) + 1 , selectionStartLineNumber : parseInt ( line , 10 ) - 1 , } ) ; } } } ) ; } this . handleModeDidChange ( ) ; this . iframeLoaded = true ; } ; private handleModeDidChange = ( ) : void => { if ( this . sheetMusicView && this . sheetMusicView . contentWindow ) { const body = this . sheetMusicView . contentWindow . document . body ; body . classList . remove ( "modeBoth" , "modeView" , "modeEdit" ) ; switch ( this . props . mode ) { case MODE_BOTH : body . classList . add ( "modeBoth" ) ; break ; case MODE_VIEW : body . classList . add ( "modeView" ) ; break ; default : body . classList . add ( "modeEdit" ) ; break ; } } } ; private handleSetSheetMusicView = ( sheetMusicView ) : void => { this . sheetMusicView = sheetMusicView ; } ; }