From 4412306ef61ac254480098ee093e5d6d42c8a3fa Mon Sep 17 00:00:00 2001 From: Norwin Roosen Date: Wed, 20 Feb 2019 16:00:31 +0100 Subject: [PATCH] persist & restore last sketch Leaving the blockly page saves the current state of the blocks via the postMessage protocol. This protocol is now properly encapsulated with a promise based API. --- src/assets/blockly.html | 33 +++++++-- src/pages/blockly/blockly.html | 2 +- src/pages/blockly/blockly.ts | 63 +++++++++-------- src/pages/blockly/blockly_protocol.ts | 97 +++++++++++++++++++++++++++ src/pages/settings/settings.html | 2 +- src/providers/storage/storage.ts | 15 ++--- 6 files changed, 161 insertions(+), 51 deletions(-) create mode 100644 src/pages/blockly/blockly_protocol.ts diff --git a/src/assets/blockly.html b/src/assets/blockly.html index b00e511..c098611 100644 --- a/src/assets/blockly.html +++ b/src/assets/blockly.html @@ -320,24 +320,43 @@ Ardublockly.init({ blocklyPath: './blockly' }); SenseboxExtension.init(); - window.addEventListener('message', function load(event) { - switch (event.data) { + /** + * message handler for the postMessage protocol with the iframe parent (ionic BlocklyPage). + */ + window.addEventListener('message', function msgHandler (event) { + switch (event.data.type) { case 'getSketch': var code = Ardublockly.generateArduino(); - event.source.postMessage({ - type: 'sketch', - data: code, - }, event.origin) + event.source.postMessage({ type: 'sketch', data: code }, event.origin); + break; + + case 'getXml': + var xml = Ardublockly.generateXml(); + event.source.postMessage({ type: 'xml', data: xml }, event.origin); + break; + + case 'setXml': + var xml = Ardublockly.replaceBlocksfromXml(event.data.data); break; + case 'toggleView': document.getElementById('blocklyview').classList.toggle('hidden'); document.querySelector('.blocklyToolboxDiv').classList.toggle('hidden'); document.getElementById('codeview').classList.toggle('hidden'); break; + default: - console.warn(`postMessage type ${event.data} not implemented`) + event.source.postMessage({ + type: 'log', + data: `postMessage type ${event.data.type} not implemented`, + }, event.origin); } }); + + // notify parent that we are ready to receive commands. + // @FIXME: this is not exactly correct, as the default sketch is still loading after this point! + parent.postMessage({ type: 'ready' }, '*') + }); diff --git a/src/pages/blockly/blockly.html b/src/pages/blockly/blockly.html index 7552b60..2202635 100644 --- a/src/pages/blockly/blockly.html +++ b/src/pages/blockly/blockly.html @@ -32,7 +32,7 @@ mini color="light" [title]="'BLOCKLY.BTN_CODE' | translate" - (click)="toggleView();" + (click)="blockly.toggleView()" > diff --git a/src/pages/blockly/blockly.ts b/src/pages/blockly/blockly.ts index 2b3be10..01200ef 100644 --- a/src/pages/blockly/blockly.ts +++ b/src/pages/blockly/blockly.ts @@ -1,73 +1,70 @@ -import { Component, ViewChild, ElementRef } from '@angular/core'; +import { Component, ViewChild, ElementRef, OnInit } from '@angular/core'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; -import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { IonicPage, NavController, NavParams, Platform } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { OtaWizardPage } from '../ota-wizard/ota-wizard'; import { LoggingProvider } from '../../providers/logging/logging'; +import { StorageProvider, LASTSKETCH } from '../../providers/storage/storage'; +import { BlocklyMessageProtocol } from './blockly_protocol'; @IonicPage() @Component({ selector: 'page-blockly', templateUrl: 'blockly.html', }) -export class BlocklyPage { +export class BlocklyPage implements OnInit { @ViewChild('blocklyFrame') blocklyFrame: ElementRef blocklyUrl: SafeResourceUrl + blockly: BlocklyMessageProtocol - private messageHandler: (ev: IframePostMessageEvent) => void private log: LoggingProvider constructor( public navCtrl: NavController, public navParams: NavParams, private sanitizer: DomSanitizer, + private platform: Platform, + private storage: StorageProvider, logger: LoggingProvider, translate: TranslateService, ) { this.log = logger.createChild('BlocklyPage') this.blocklyUrl = this.buildBlocklyUrl(translate.currentLang) + } - // need to assign it here to keep the function reference for unsubscribing again - // and to maintain the this scope properly - this.messageHandler = (ev: IframePostMessageEvent) => { - const { type, data } = ev.data; - switch (type) { - case 'sketch': - this.log.debug('sketch received, launching ota wizard page', { sketch: data }) - this.navCtrl.push(OtaWizardPage, { sketch: data }) - break - default: - } - } + async ngOnInit () { + // blocklyFrame is available from here on + this.blockly = new BlocklyMessageProtocol(this.blocklyFrame, this.log) + await this.blockly.ready + // load the last sketch + const xml = this.storage.get(LASTSKETCH) + if (xml) this.blockly.setXml(xml) - window.addEventListener('message', this.messageHandler) + // save state when exiting (on mobile & browsers separately) + this.platform.pause.subscribe(() => this.saveBlocklyState()) + window.addEventListener('beforeunload', () => this.saveBlocklyState()) } - ionViewWillUnload () { - window.removeEventListener('message', this.messageHandler) + async ionViewCanLeave () { + // hold closing the page until we saved the current blockly state + await this.saveBlocklyState() + return true } - launchOtaWizard () { - this.log.debug('clicked launch ota') - this.blocklyFrame.nativeElement.contentWindow.postMessage('getSketch', '*') + async saveBlocklyState () { + const xml = await this.blockly.getXml() + this.storage.set(LASTSKETCH, xml) } - toggleView () { - this.log.debug('clicked toggle view') - this.blocklyFrame.nativeElement.contentWindow.postMessage('toggleView', '*') + async launchOtaWizard () { + const sketch = await this.blockly.getSketch() + this.navCtrl.push(OtaWizardPage, { sketch }) } - buildBlocklyUrl (lang: string): SafeResourceUrl { + private buildBlocklyUrl (lang: string): SafeResourceUrl { if (!lang) this.log.error('building url with empty language!') const url = `./assets/blockly.html?lang=${lang}` return this.sanitizer.bypassSecurityTrustResourceUrl(url) } } - -interface IframePostMessageEvent extends MessageEvent { - data: { - type: 'sketch', - data: any, - } -} diff --git a/src/pages/blockly/blockly_protocol.ts b/src/pages/blockly/blockly_protocol.ts new file mode 100644 index 0000000..a3a947c --- /dev/null +++ b/src/pages/blockly/blockly_protocol.ts @@ -0,0 +1,97 @@ +import { ElementRef } from '@angular/core'; +import { LoggingProvider } from '../../providers/logging/logging'; + +/** + * this file defines & implements the message passing protocol to communicate + * with an iframe that has src/assets/blockly.html loaded. + * The underlying postMessage protocol is wrapped into a promise API. + */ + +export class BlocklyMessageProtocol { + ready: Promise + + constructor (private blocklyFrame: ElementRef, private log: LoggingProvider) { + window.addEventListener('message', (ev: IframePostMessageEvent) => { + if (ev.data.type === 'log') + this.log.warn('log entry from blockly:', ev.data) + else + this.log.debug(`received ${ev.data.type} message from blockly`, { message: ev.data }) + }) + + this.ready = new Promise(resolve => { + window.addEventListener('message', (ev: IframePostMessageEvent) => { + // @HACK @FIXME: timeout is required, as blockly resolves some async functions + // after firing the `ready` event.. + if (ev.data.type === 'ready') setTimeout(resolve, 300) + }) + }) + } + + private reqResPatterns: BlocklyReqPatterns = { + 'getSketch': 'sketch', + 'getXml': 'xml', + 'setXml': null, + 'toggleView': null, + } + + toggleView() { this.sendRequest({ type: 'toggleView' }) } + setXml(data: string) { this.sendRequest({ type: 'setXml', data }) } + + getXml() { return this.sendRequest({ type: 'getXml' }) } + getSketch() { return this.sendRequest({ type: 'getSketch' }) } + + private async sendRequest(req: BlocklyRequest): Promise { + await this.ready + + if ( + !this.blocklyFrame || + !this.blocklyFrame.nativeElement && + !this.blocklyFrame.nativeElement.contentWindow + ) { + throw Error('cannot access blockly frame') + } + + const expectResponse = this.reqResPatterns[req.type] + this.log.debug(`sending ${req.type} message to blockly, expecting response: ${expectResponse}`, { message: req }) + + if (!expectResponse) + return this.blocklyFrame.nativeElement.contentWindow.postMessage(req, '*') + + // create promise waiting for the response event + const resPromise = new Promise((resolve, reject) => { + const resHandler = ({ data: res }: IframePostMessageEvent) => { + if (expectResponse !== res.type) return + window.removeEventListener('message', resHandler) + if (expectResponse === 'error') reject(res.data) + else resolve(res.data) + } + // TODO: promise reject after timeout? + window.addEventListener('message', resHandler) + }) + + // send message after registering the subscribe handler! + this.blocklyFrame.nativeElement.contentWindow.postMessage(req, '*') + return resPromise + } +} + +interface IframePostMessageEvent extends MessageEvent { + data: BlocklyResponse +} + +type BlocklyReqPatterns = { + [k in BlocklyRequest['type']]: BlocklyResponse['type'] +} + +type BlocklyRequest = + { type: 'getSketch' } | + { type: 'getXml' } | + { type: 'setXml', data: string } | + { type: 'toggleView' } + +type BlocklyResponse = + { type: 'log', data: any } | + { type: 'error', data: any } | + { type: 'ready', data: undefined } | + { type: 'sketch', data: string } | + { type: 'xml', data: string } diff --git a/src/pages/settings/settings.html b/src/pages/settings/settings.html index 51688c5..64dd406 100644 --- a/src/pages/settings/settings.html +++ b/src/pages/settings/settings.html @@ -8,7 +8,7 @@ - + diff --git a/src/providers/storage/storage.ts b/src/providers/storage/storage.ts index a59e495..8af4ad5 100644 --- a/src/providers/storage/storage.ts +++ b/src/providers/storage/storage.ts @@ -1,30 +1,27 @@ import { Injectable } from '@angular/core'; export const SETTINGS = 'appsettings' +export const LASTSKETCH = 'lastsketch' @Injectable() export class StorageProvider { private cache: Map = new Map() constructor () { - this.registerKey(SETTINGS, { - logOptin: false, - }) + this.registerKey(SETTINGS, { logOptin: false }) + this.registerKey(LASTSKETCH, '') } registerKey (key, defaultValue) { const stored = localStorage.getItem(key) - if (!stored) { - localStorage.setItem(key, JSON.stringify(defaultValue)) - this.cache[key] = defaultValue + if (stored === null) { + this.set(key, defaultValue) } else { this.cache[key] = JSON.parse(stored) } } - get (key) { - return this.cache[key] - } + get (key) { return this.cache[key] } set (key, value) { localStorage.setItem(key, JSON.stringify(value))