From f783750c0d30a8690a150933984cff403975d0a4 Mon Sep 17 00:00:00 2001 From: David Evans Date: Sun, 5 Nov 2017 00:30:48 +0000 Subject: [PATCH] Extract image export functionality, add convenience page for generating README images --- readme_images.htm | 37 +++++++++ scripts/interface/Exporter.js | 76 ++++++++++++++++++ scripts/interface/Interface.js | 92 +++++++--------------- scripts/interface/Interface_spec.js | 8 +- scripts/main.js | 3 + scripts/readme_images.js | 115 ++++++++++++++++++++++++++++ styles/readme_images.css | 60 +++++++++++++++ 7 files changed, 324 insertions(+), 67 deletions(-) create mode 100644 readme_images.htm create mode 100644 scripts/interface/Exporter.js create mode 100644 scripts/readme_images.js create mode 100644 styles/readme_images.css diff --git a/readme_images.htm b/readme_images.htm new file mode 100644 index 0000000..421275b --- /dev/null +++ b/readme_images.htm @@ -0,0 +1,37 @@ + + + + + +Sequence Diagram + + + + + + + + + + + + + + + diff --git a/scripts/interface/Exporter.js b/scripts/interface/Exporter.js new file mode 100644 index 0000000..a70d0a3 --- /dev/null +++ b/scripts/interface/Exporter.js @@ -0,0 +1,76 @@ +define(() => { + 'use strict'; + + return class Exporter { + constructor() { + this.latestSVG = null; + this.canvas = null; + this.context = null; + this.indexPNG = 0; + this.latestPNGIndex = 0; + this.latestPNG = null; + } + + getSVGContent(renderer) { + return renderer.svg().outerHTML; + } + + getSVGBlob(renderer) { + return new Blob( + [this.getSVGContent(renderer)], + {type: 'image/svg+xml'} + ); + } + + getSVGURL(renderer) { + const blob = this.getSVGBlob(renderer); + if(this.latestSVG) { + URL.revokeObjectURL(this.latestSVG); + } + this.latestSVG = URL.createObjectURL(blob); + return this.latestSVG; + } + + getPNGBlob(renderer, resolution, callback) { + if(!this.canvas) { + window.devicePixelRatio = 1; + this.canvas = document.createElement('canvas'); + this.context = this.canvas.getContext('2d'); + } + + const width = renderer.width * resolution; + const height = renderer.height * resolution; + const img = new Image(width, height); + + img.addEventListener('load', () => { + this.canvas.width = width; + this.canvas.height = height; + this.context.drawImage(img, 0, 0); + this.canvas.toBlob(callback, 'image/png'); + }, {once: true}); + + img.src = this.getSVGURL(renderer); + } + + getPNGURL(renderer, resolution, callback) { + ++ this.indexPNG; + const index = this.indexPNG; + + this.getPNGBlob(renderer, resolution, (blob) => { + const url = URL.createObjectURL(blob); + const isLatest = index >= this.latestPNGIndex; + if(isLatest) { + if(this.latestPNG) { + URL.revokeObjectURL(this.latestPNG); + } + this.latestPNG = url; + this.latestPNGIndex = index; + callback(url, true); + } else { + callback(url, false); + URL.revokeObjectURL(url); + } + }); + } + }; +}); diff --git a/scripts/interface/Interface.js b/scripts/interface/Interface.js index 6a757e2..18e39df 100644 --- a/scripts/interface/Interface.js +++ b/scripts/interface/Interface.js @@ -33,16 +33,14 @@ define([ parser, generator, renderer, + exporter, defaultCode = '', localStorage = '', }) { - window.devicePixelRatio = 1; - this.canvas = makeNode('canvas'); - this.context = this.canvas.getContext('2d'); - this.parser = parser; this.generator = generator; this.renderer = renderer; + this.exporter = exporter; this.defaultCode = defaultCode; this.localStorage = localStorage; this.minScale = 1.5; @@ -50,11 +48,8 @@ define([ this.debounced = null; this.latestSeq = null; this.renderedSeq = null; - this.canvasDirty = true; - this.svgDirty = true; - this.latestPNG = null; - this.latestSVG = null; - this.updatingPNG = null; + this.pngDirty = true; + this.updatingPNG = false; this._downloadSVGClick = this._downloadSVGClick.bind(this); this._downloadPNGClick = this._downloadPNGClick.bind(this); @@ -168,8 +163,7 @@ define([ redraw(sequence) { clearTimeout(this.debounced); this.debounced = null; - this.canvasDirty = true; - this.svgDirty = true; + this.pngDirty = true; this.renderedSeq = sequence; this.renderer.render(sequence); this.updateMinSize(this.renderer.width, this.renderer.height); @@ -250,76 +244,42 @@ define([ } } - getSVGData() { - this.forceRender(); - if(!this.svgDirty) { - return this.latestSVG; - } - this.svgDirty = false; - const src = this.renderer.svg().outerHTML; - const blob = new Blob([src], {type: 'image/svg+xml'}); - if(this.latestSVG) { - URL.revokeObjectURL(this.latestSVG); - } - this.latestSVG = URL.createObjectURL(blob); - return this.latestSVG; - } - - getPNGData(callback) { - this.forceRender(); - if(!this.canvasDirty) { - // TODO: this could cause issues if getPNGData is called - // while another update is ongoing. Needs a more robust fix - callback(this.latestPNG); - return; - } - this.canvasDirty = false; - const width = this.renderer.width * PNG_RESOLUTION; - const height = this.renderer.height * PNG_RESOLUTION; - this.canvas.width = width; - this.canvas.height = height; - const img = new Image(width, height); - img.addEventListener('load', () => { - this.context.drawImage(img, 0, 0); - this.canvas.toBlob((blob) => { - if(this.latestPNG) { - URL.revokeObjectURL(this.latestPNG); - } - this.latestPNG = URL.createObjectURL(blob); - callback(this.latestPNG); - }, 'image/png'); - }, {once: true}); - img.src = this.getSVGData(); - } - updatePNGLink() { - const nonce = this.updatingPNG = {}; - this.getPNGData((data) => { - if(this.updatingPNG === nonce) { - this.downloadPNG.setAttribute('href', data); - this.updatingPNG = null; + this.forceRender(); + if(this.updatingPNG || !this.pngDirty) { + return false; + } + this.pngDirty = false; + this.updatingPNG = true; + this.exporter.getPNGURL( + this.renderer, + PNG_RESOLUTION, + (url, latest) => { + if(latest) { + this.downloadPNG.setAttribute('href', url); + this.updatingPNG = false; + } } - }); + ); + return true; } _downloadPNGFocus() { - this.forceRender(); - if(this.canvasDirty) { - this.updatePNGLink(); - } + this.updatePNGLink(); } _downloadPNGClick(e) { if(this.updatingPNG) { e.preventDefault(); - } else if(this.canvasDirty) { + } else if(this.updatePNGLink()) { e.preventDefault(); - this.updatePNGLink(); } } _downloadSVGClick() { - this.downloadSVG.setAttribute('href', this.getSVGData()); + this.forceRender(); + const url = this.exporter.getSVGURL(this.renderer); + this.downloadSVG.setAttribute('href', url); } }; }); diff --git a/scripts/interface/Interface_spec.js b/scripts/interface/Interface_spec.js index 02f2865..909a125 100644 --- a/scripts/interface/Interface_spec.js +++ b/scripts/interface/Interface_spec.js @@ -4,6 +4,7 @@ defineDescribe('Interface', ['./Interface'], (Interface) => { let parser = null; let generator = null; let renderer = null; + let exporter = null; let container = null; let ui = null; @@ -26,10 +27,13 @@ defineDescribe('Interface', ['./Interface'], (Interface) => { renderer = jasmine.createSpyObj('renderer', ['render', 'svg']); renderer.svg.and.returnValue(document.createElement('svg')); container = jasmine.createSpyObj('container', ['appendChild']); + exporter = jasmine.createSpyObj('exporter', ['getSVGURL']); + ui = new Interface({ parser, generator, renderer, + exporter, defaultCode: 'my default code', }); }); @@ -49,10 +53,12 @@ defineDescribe('Interface', ['./Interface'], (Interface) => { describe('download SVG', () => { it('triggers a download of the current image in SVG format', () => { + exporter.getSVGURL.and.returnValue('mySVGURL'); ui.build(container); + expect(ui.downloadSVG.getAttribute('href')).toEqual('#'); ui.downloadSVG.dispatchEvent(new Event('click')); - expect(ui.downloadSVG.getAttribute('href')).not.toEqual('#'); + expect(ui.downloadSVG.getAttribute('href')).toEqual('mySVGURL'); }); }); }); diff --git a/scripts/main.js b/scripts/main.js index 0e03fab..a39a991 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -6,12 +6,14 @@ /* jshint -W072 */ // Allow several required modules requirejs([ 'interface/Interface', + 'interface/Exporter', 'sequence/Parser', 'sequence/Generator', 'sequence/Renderer', 'sequence/themes/Basic', ], ( Interface, + Exporter, Parser, Generator, Renderer, @@ -40,6 +42,7 @@ parser: new Parser(), generator: new Generator(), renderer: new Renderer(new Theme()), + exporter: new Exporter(), localStorage: 'src', }); ui.build(document.body); diff --git a/scripts/readme_images.js b/scripts/readme_images.js new file mode 100644 index 0000000..43379a8 --- /dev/null +++ b/scripts/readme_images.js @@ -0,0 +1,115 @@ +((() => { + 'use strict'; + + requirejs.config(window.getRequirejsCDN()); + + function makeText(text = '') { + return document.createTextNode(text); + } + + function makeNode(type, attrs = {}) { + const o = document.createElement(type); + for(let k in attrs) { + if(attrs.hasOwnProperty(k)) { + o.setAttribute(k, attrs[k]); + } + } + return o; + } + + const SAMPLE_REGEX = new RegExp( + /]*>[\s]*```([^]+?)```/g + ); + + function findSamples(content) { + SAMPLE_REGEX.lastIndex = 0; + const results = []; + while(true) { + const match = SAMPLE_REGEX.exec(content); + if(!match) { + break; + } + results.push({ + file: match[1], + code: match[2], + }); + } + return results; + } + + const PNG_RESOLUTION = 4; + + /* jshint -W072 */ // Allow several required modules + requirejs([ + 'sequence/Parser', + 'sequence/Generator', + 'sequence/Renderer', + 'sequence/themes/Basic', + 'interface/Exporter', + ], ( + Parser, + Generator, + Renderer, + Theme, + Exporter + ) => { + const parser = new Parser(); + const generator = new Generator(); + const theme = new Theme(); + + const status = makeNode('div', {'class': 'status'}); + const statusText = makeText('Loading\u2026'); + status.appendChild(statusText); + document.body.appendChild(status); + + function renderSample({file, code}) { + const renderer = new Renderer(theme); + const exporter = new Exporter(); + + const hold = makeNode('div', {'class': 'hold'}); + + hold.appendChild(renderer.svg()); + + const raster = makeNode('img', { + 'src': '', + 'class': 'raster', + 'title': 'new', + }); + hold.appendChild(raster); + + hold.appendChild(makeNode('img', { + 'src': 'screenshots/' + file, + 'class': 'original', + 'title': 'original', + })); + + const downloadPNG = makeNode('a', {'href': '#', 'download': file}); + downloadPNG.appendChild(makeText('Download PNG')); + hold.appendChild(downloadPNG); + + document.body.appendChild(hold); + + const parsed = parser.parse(code); + const sequence = generator.generate(parsed); + renderer.render(sequence); + exporter.getPNGURL(renderer, PNG_RESOLUTION, (url) => { + raster.setAttribute('src', url); + downloadPNG.setAttribute('href', url); + }); + } + + (fetch('README.md') + .then((response) => response.text()) + .then(findSamples) + .then((samples) => { + samples.forEach(renderSample); + }) + .then(() => { + document.body.removeChild(status); + }) + .catch((e) => { + statusText.nodeValue = 'Error: ' + e; + }) + ); + }); +})()); diff --git a/styles/readme_images.css b/styles/readme_images.css new file mode 100644 index 0000000..0eda0ff --- /dev/null +++ b/styles/readme_images.css @@ -0,0 +1,60 @@ +html, body { + margin: 0; + padding: 0; +} + +body { + margin-bottom: 50px; + background: #EEEEEE; +} + +.status { + text-align: center; + font-size: 2em; + margin: 10px; +} + +.hold { + display: block; + width: 920px; + background: #FFFFFF; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); + padding: 5px; + margin: 10px auto; + border-radius: 3px; + overflow: hidden; +} + +.hold > svg { + width: 300px; + height: auto; + margin-right: 10px; +} + +.hold > .raster { + width: 300px; + margin-right: 10px; +} + +.hold > .original { + width: 300px; + background: #F8F8F8; +} + +.hold a { + display: block; + text-align: center; + font-family: sans-serif; + padding: 15px; + margin: 5px -5px -5px; +} + +.hold a:link, .hold a:visited { + color: #666699; + text-decoration: none; + cursor: pointer; +} +.hold a:active, .hold a:hover { + background: #EEEEEE; + color: #6666CC; +}