From 78cec2be8c814c8c3f8eccc9cc4fcbd5644f5daf Mon Sep 17 00:00:00 2001 From: David Evans Date: Sun, 29 Apr 2018 15:34:53 +0100 Subject: [PATCH] Add support for working with online rendering service when available --- bin/handlers/render.js | 2 +- index.html | 1 + lib/sequence-diagram.js | 1 + scripts/core/documents/VirtualDocument.mjs | 1 + styles/editor.css | 85 ++++++- web/interface/Interface.mjs | 247 ++++++++++++++++++++- weblib/editor.js | 247 ++++++++++++++++++++- weblib/editor.min.js | 2 +- 8 files changed, 577 insertions(+), 9 deletions(-) diff --git a/bin/handlers/render.js b/bin/handlers/render.js index 4407701..aea5b4a 100755 --- a/bin/handlers/render.js +++ b/bin/handlers/render.js @@ -39,7 +39,7 @@ function readEncoded(str, encoding) { .from(decodeURIComponent(str), 'base64') .toString('utf8'); case 'uri': - return str.split('/').map((ln) => decodeURIComponent(ln)).join('\n'); + return str.split('/').map(decodeURIComponent).join('\n'); default: throw new HttpError(400, 'Unknown encoding'); } diff --git a/index.html b/index.html index cfce4a6..35956ef 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,7 @@ https://cdnjs.cloudflare.com 'sha256-s7UPtBgvov5WNF9C1DlTZDpqwLgEmfiWha5a5p/Zn7E=' ; + connect-src 'self'; font-src 'self' data:; img-src 'self' blob:; form-action 'none'; diff --git a/lib/sequence-diagram.js b/lib/sequence-diagram.js index 9cbfbc7..c1edfb8 100644 --- a/lib/sequence-diagram.js +++ b/lib/sequence-diagram.js @@ -10065,6 +10065,7 @@ this.parentNode = null; this.childNodes = []; this.attributes = new Map(); + this.style = {}; this.listeners = new Map(); } diff --git a/scripts/core/documents/VirtualDocument.mjs b/scripts/core/documents/VirtualDocument.mjs index 3571a27..7b63a4a 100644 --- a/scripts/core/documents/VirtualDocument.mjs +++ b/scripts/core/documents/VirtualDocument.mjs @@ -58,6 +58,7 @@ class ElementNode { this.parentNode = null; this.childNodes = []; this.attributes = new Map(); + this.style = {}; this.listeners = new Map(); } diff --git a/styles/editor.css b/styles/editor.css index debd242..d13dd98 100644 --- a/styles/editor.css +++ b/styles/editor.css @@ -265,7 +265,6 @@ html, body { .options { position: absolute; background: #FFFFFF; - overflow: hidden; user-select: none; z-index: 30; } @@ -284,6 +283,8 @@ html, body { border-top-left-radius: 5px; border-top: 1px solid #EEEEEE; border-left: 1px solid #EEEEEE; + transition: 0.2s ease; + transition-property: box-shadow; } .options a { @@ -305,3 +306,85 @@ html, body { background: #EEEEEE; color: #6666CC; } + +.urlbuilder { + border-top: 1px solid #EEEEEE; + overflow: auto; + box-sizing: border-box; + transition: 0.2s ease; + transition-property: height, width, padding; + font-size: 0.8em; + text-align: center; + position: relative; +} + +.urlbuilder .message { + color: #666666; + font-size: 1.5em; + padding-top: 30px; +} + +.urlbuilder .config { + padding-top: 10px; +} + +.urlbuilder input[type=number] { + width: 60px; + text-align: right; +} + +.urlbuilder .or { + display: block; + margin: 10px 0; + color: #333333; + font-size: 1.2em; +} + +.urlbuilder .output { + display: block; + padding: 6px; + height: 30px; + border: 1px solid #999999; + border-right: none; + font-size: 1em; + position: absolute; + bottom: 10px; + left: 10px; + width: calc(100% - 50px); + box-sizing: border-box; +} + +.urlbuilder .copy { + display: block; + width: 30px; + height: 30px; + line-height: 28px; + padding-top: 1px; + border: 1px solid #999999; + background: #FFFFFF; + font-size: 1em; + position: absolute; + bottom: 10px; + right: 10px; + box-sizing: border-box; +} + +.urlbuilder .copy:active { + background: #EEEEEE; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); + padding-top: 2px; +} + +.urlbuilder .copied { + display: none; + height: 30px; + border: 1px solid #999999; + font-size: 1em; + position: absolute; + bottom: 10px; + left: 10px; + width: calc(100% - 20px); + line-height: 28px; + background: #99EE99; + box-sizing: border-box; +} diff --git a/web/interface/Interface.mjs b/web/interface/Interface.mjs index ee3478f..403166b 100644 --- a/web/interface/Interface.mjs +++ b/web/interface/Interface.mjs @@ -1,3 +1,5 @@ +/* eslint-disable max-lines */ + import DOMWrapper from '../../scripts/core/DOMWrapper.mjs'; const DELAY_AGENTCHANGE = 500; @@ -36,6 +38,54 @@ function simplifyPreview(code) { .replace(/[{}]/g, ''); } +function toCappedFixed(v, cap) { + const s = v.toString(); + const p = s.indexOf('.'); + if(p === -1 || s.length - p - 1 <= cap) { + return s; + } + return v.toFixed(cap); +} + +function fetchResource(path) { + if(typeof fetch === 'undefined') { + return Promise.reject(new Error()); + } + return fetch(path) + .then((response) => { + if(!response.ok) { + throw new Error(response.statusText); + } + return response; + }); +} + +/* eslint-disable complexity */ +function makeURL(code, {height, width, zoom}) { + /* eslint-enable complexity */ + const uri = code + .split('\n') + .map(encodeURIComponent) + .filter((ln) => ln !== '') + .join('/'); + + let opts = ''; + if(!Number.isNaN(width) || !Number.isNaN(height)) { + if(!Number.isNaN(width)) { + opts += 'w' + toCappedFixed(Math.max(width, 0), 4); + } + if(!Number.isNaN(height)) { + opts += 'h' + toCappedFixed(Math.max(height, 0), 4); + } + opts += '/'; + } else if(!Number.isNaN(zoom) && zoom !== 1) { + opts += 'z' + toCappedFixed(Math.max(zoom, 0), 4); + opts += '/'; + } + + return opts + uri + '.svg'; +} + function makeSplit(require, nodes, options) { // Load on demand for progressive enhancement // (failure to load external module will not block functionality) @@ -142,6 +192,7 @@ export default class Interface { this._downloadSVGClick = this._downloadSVGClick.bind(this); this._downloadPNGClick = this._downloadPNGClick.bind(this); this._downloadPNGFocus = this._downloadPNGFocus.bind(this); + this._downloadURLClick = this._downloadURLClick.bind(this); this._showDropStyle = this._showDropStyle.bind(this); this._hideDropStyle = this._hideDropStyle.bind(this); @@ -189,12 +240,169 @@ export default class Interface { ); this.code.focus(); } + this._hideURLBuilder(); }) .on('dblclick', (element) => { this.diagram.toggleCollapsed(element.ln); + this._hideURLBuilder(); }); } + buildURLBuilder() { + const copied = this.dom.el('div').setClass('copied') + .add('Copied to Clipboard'); + this.urlOutput = this.dom.el('input').setClass('output') + .attr('readonly', 'readonly') + .on('focus', () => { + this.urlOutput.select(0, this.urlOutput.element.value.length); + }); + + const copy = this.dom.el('button').setClass('copy') + .add('\uD83D\uDCCB') + .attr('title', 'Copy to clipboard') + .on('click', () => { + this.urlOutput + .focus() + .select(0, this.urlOutput.element.value.length) + .element.ownerDocument.execCommand('copy'); + copy.focus(); + copied.styles({ + 'display': 'block', + 'opacity': 1, + 'transition': 'none', + }); + setTimeout(() => copied.styles({ + 'opacity': 0, + 'transition': 'opacity 0.5s linear', + }), 1000); + setTimeout(() => copied.styles({'display': 'none'}), 1500); + }); + + this.urlWidth = this.dom.el('input').attrs({ + 'min': 0, + 'placeholder': 'auto', + 'step': 'any', + 'type': 'number', + }).on('input', () => { + this.urlZoom.val('1'); + this._refreshURL(); + }); + + this.urlHeight = this.dom.el('input').attrs({ + 'min': 0, + 'placeholder': 'auto', + 'step': 'any', + 'type': 'number', + }).on('input', () => { + this.urlZoom.val('1'); + this._refreshURL(); + }); + + this.urlZoom = this.dom.el('input').attrs({ + 'min': 0, + 'step': 'any', + 'type': 'number', + 'value': 1, + }).on('input', () => { + this.urlWidth.val(''); + this.urlHeight.val(''); + this._refreshURL(); + }); + + const urlOpts = this.dom.el('div').setClass('config').add( + this.dom.el('label').add('width ', this.urlWidth), + ', ', + this.dom.el('label').add('height ', this.urlHeight), + this.dom.el('span').setClass('or').add('or'), + this.dom.el('label').add('zoom ', this.urlZoom), + this.urlOutput, + copy, + copied + ); + + this.urlBuilder = this.dom.el('div').setClass('urlbuilder') + .styles({'display': 'none'}) + .add( + this.dom.el('div').setClass('message') + .add('Loading\u2026') + ); + + this.renderService = ''; + const relativePath = 'render/'; + fetchResource(relativePath) + .then((response) => response.text()) + .then((content) => { + let path = content.trim(); + if(!path || path.startsWith(' { + this.urlBuilder.empty().add( + this.dom.el('div').setClass('message') + .add('No online rendering service available.') + ); + }); + + return this.urlBuilder; + } + + _refreshURL() { + this.urlOutput.val(this.renderService + makeURL(this.value(), { + height: Number.parseFloat(this.urlHeight.element.value), + width: Number.parseFloat(this.urlWidth.element.value), + zoom: Number.parseFloat(this.urlZoom.element.value || '1'), + })); + } + + _showURLBuilder() { + if(this.builderVisible) { + return; + } + this.builderVisible = true; + this.urlBuilder.styles({ + 'display': 'block', + 'height': '0px', + 'padding': '0px', + 'width': this.optsHold.element.clientWidth + 'px', + }); + clearTimeout(this.builderTm); + this.builderTm = setTimeout(() => { + this.urlBuilder.styles({ + 'height': '150px', + 'padding': '10px', + 'width': '400px', + }); + this.optsHold.styles({ + 'box-shadow': '10px 10px 25px 12px rgba(0,0,0,0.3)', + }); + }, 0); + + this._refreshURL(); + } + + _hideURLBuilder() { + if(!this.builderVisible) { + return; + } + this.builderVisible = false; + this.urlBuilder.styles({ + 'height': '0px', + 'padding': '0px', + 'width': '0px', + }); + this.optsHold.styles({ + 'box-shadow': 'none', + }); + clearTimeout(this.builderTm); + this.builderTm = setTimeout(() => { + this.urlBuilder.styles({'display': 'none'}); + }, 200); + } + buildOptionsDownloads() { this.downloadPNG = this.dom.el('a') .text('Download PNG') @@ -213,8 +421,19 @@ export default class Interface { }) .on('click', this._downloadSVGClick); - return this.dom.el('div').setClass('options downloads') - .add(this.downloadPNG, this.downloadSVG); + this.downloadURL = this.dom.el('a') + .text('URL') + .attrs({'href': '#'}) + .on('click', this._downloadURLClick); + + this.optsHold = this.dom.el('div').setClass('options downloads').add( + this.downloadPNG, + this.downloadSVG, + this.downloadURL, + this.buildURLBuilder() + ); + + return this.optsHold; } buildLibrary(container) { @@ -250,7 +469,8 @@ export default class Interface { buildViewPane() { this.viewPaneInner = this.dom.el('div').setClass('pane-view-inner') - .add(this.diagram.dom()); + .add(this.diagram.dom()) + .on('click', () => this._hideURLBuilder()); this.errorMsg = this.dom.el('div').setClass('msg-error'); @@ -336,6 +556,14 @@ export default class Interface { snapOffset: 70, }); + if(typeof window !== 'undefined') { + window.addEventListener('keydown', (e) => { + if(e.keyCode === 27) { + this._hideURLBuilder(); + } + }); + } + // Delay first update 1 frame to ensure render target is ready // (prevents initial incorrect font calculations for custom fonts) setTimeout(this.update.bind(this), 0); @@ -463,6 +691,7 @@ export default class Interface { } update(immediate = true) { + this._hideURLBuilder(); const src = this.value(); this.saveCode(src); let sequence = null; @@ -529,12 +758,24 @@ export default class Interface { } else if(this.updatePNGLink()) { e.preventDefault(); } + this._hideURLBuilder(); } _downloadSVGClick() { this.forceRender(); const url = this.diagram.getSVGSynchronous(); this.downloadSVG.attr('href', url); + this._hideURLBuilder(); + } + + _downloadURLClick(e) { + e.preventDefault(); + + if(this.builderVisible) { + this._hideURLBuilder(); + } else { + this._showURLBuilder(); + } } _enhanceEditor() { diff --git a/weblib/editor.js b/weblib/editor.js index 6ca7bbe..d7a28b3 100644 --- a/weblib/editor.js +++ b/weblib/editor.js @@ -504,6 +504,8 @@ } } + /* eslint-disable max-lines */ + const DELAY_AGENTCHANGE = 500; const DELAY_STAGECHANGE = 250; const PNG_RESOLUTION = 4; @@ -540,6 +542,54 @@ .replace(/[{}]/g, ''); } + function toCappedFixed(v, cap) { + const s = v.toString(); + const p = s.indexOf('.'); + if(p === -1 || s.length - p - 1 <= cap) { + return s; + } + return v.toFixed(cap); + } + + function fetchResource(path) { + if(typeof fetch === 'undefined') { + return Promise.reject(new Error()); + } + return fetch(path) + .then((response) => { + if(!response.ok) { + throw new Error(response.statusText); + } + return response; + }); + } + + /* eslint-disable complexity */ + function makeURL(code, {height, width, zoom}) { + /* eslint-enable complexity */ + const uri = code + .split('\n') + .map(encodeURIComponent) + .filter((ln) => ln !== '') + .join('/'); + + let opts = ''; + if(!Number.isNaN(width) || !Number.isNaN(height)) { + if(!Number.isNaN(width)) { + opts += 'w' + toCappedFixed(Math.max(width, 0), 4); + } + if(!Number.isNaN(height)) { + opts += 'h' + toCappedFixed(Math.max(height, 0), 4); + } + opts += '/'; + } else if(!Number.isNaN(zoom) && zoom !== 1) { + opts += 'z' + toCappedFixed(Math.max(zoom, 0), 4); + opts += '/'; + } + + return opts + uri + '.svg'; + } + function makeSplit(require, nodes, options) { // Load on demand for progressive enhancement // (failure to load external module will not block functionality) @@ -646,6 +696,7 @@ this._downloadSVGClick = this._downloadSVGClick.bind(this); this._downloadPNGClick = this._downloadPNGClick.bind(this); this._downloadPNGFocus = this._downloadPNGFocus.bind(this); + this._downloadURLClick = this._downloadURLClick.bind(this); this._showDropStyle = this._showDropStyle.bind(this); this._hideDropStyle = this._hideDropStyle.bind(this); @@ -693,12 +744,169 @@ ); this.code.focus(); } + this._hideURLBuilder(); }) .on('dblclick', (element) => { this.diagram.toggleCollapsed(element.ln); + this._hideURLBuilder(); }); } + buildURLBuilder() { + const copied = this.dom.el('div').setClass('copied') + .add('Copied to Clipboard'); + this.urlOutput = this.dom.el('input').setClass('output') + .attr('readonly', 'readonly') + .on('focus', () => { + this.urlOutput.select(0, this.urlOutput.element.value.length); + }); + + const copy = this.dom.el('button').setClass('copy') + .add('\uD83D\uDCCB') + .attr('title', 'Copy to clipboard') + .on('click', () => { + this.urlOutput + .focus() + .select(0, this.urlOutput.element.value.length) + .element.ownerDocument.execCommand('copy'); + copy.focus(); + copied.styles({ + 'display': 'block', + 'opacity': 1, + 'transition': 'none', + }); + setTimeout(() => copied.styles({ + 'opacity': 0, + 'transition': 'opacity 0.5s linear', + }), 1000); + setTimeout(() => copied.styles({'display': 'none'}), 1500); + }); + + this.urlWidth = this.dom.el('input').attrs({ + 'min': 0, + 'placeholder': 'auto', + 'step': 'any', + 'type': 'number', + }).on('input', () => { + this.urlZoom.val('1'); + this._refreshURL(); + }); + + this.urlHeight = this.dom.el('input').attrs({ + 'min': 0, + 'placeholder': 'auto', + 'step': 'any', + 'type': 'number', + }).on('input', () => { + this.urlZoom.val('1'); + this._refreshURL(); + }); + + this.urlZoom = this.dom.el('input').attrs({ + 'min': 0, + 'step': 'any', + 'type': 'number', + 'value': 1, + }).on('input', () => { + this.urlWidth.val(''); + this.urlHeight.val(''); + this._refreshURL(); + }); + + const urlOpts = this.dom.el('div').setClass('config').add( + this.dom.el('label').add('width ', this.urlWidth), + ', ', + this.dom.el('label').add('height ', this.urlHeight), + this.dom.el('span').setClass('or').add('or'), + this.dom.el('label').add('zoom ', this.urlZoom), + this.urlOutput, + copy, + copied + ); + + this.urlBuilder = this.dom.el('div').setClass('urlbuilder') + .styles({'display': 'none'}) + .add( + this.dom.el('div').setClass('message') + .add('Loading\u2026') + ); + + this.renderService = ''; + const relativePath = 'render/'; + fetchResource(relativePath) + .then((response) => response.text()) + .then((content) => { + let path = content.trim(); + if(!path || path.startsWith(' { + this.urlBuilder.empty().add( + this.dom.el('div').setClass('message') + .add('No online rendering service available.') + ); + }); + + return this.urlBuilder; + } + + _refreshURL() { + this.urlOutput.val(this.renderService + makeURL(this.value(), { + height: Number.parseFloat(this.urlHeight.element.value), + width: Number.parseFloat(this.urlWidth.element.value), + zoom: Number.parseFloat(this.urlZoom.element.value || '1'), + })); + } + + _showURLBuilder() { + if(this.builderVisible) { + return; + } + this.builderVisible = true; + this.urlBuilder.styles({ + 'display': 'block', + 'height': '0px', + 'padding': '0px', + 'width': this.optsHold.element.clientWidth + 'px', + }); + clearTimeout(this.builderTm); + this.builderTm = setTimeout(() => { + this.urlBuilder.styles({ + 'height': '150px', + 'padding': '10px', + 'width': '400px', + }); + this.optsHold.styles({ + 'box-shadow': '10px 10px 25px 12px rgba(0,0,0,0.3)', + }); + }, 0); + + this._refreshURL(); + } + + _hideURLBuilder() { + if(!this.builderVisible) { + return; + } + this.builderVisible = false; + this.urlBuilder.styles({ + 'height': '0px', + 'padding': '0px', + 'width': '0px', + }); + this.optsHold.styles({ + 'box-shadow': 'none', + }); + clearTimeout(this.builderTm); + this.builderTm = setTimeout(() => { + this.urlBuilder.styles({'display': 'none'}); + }, 200); + } + buildOptionsDownloads() { this.downloadPNG = this.dom.el('a') .text('Download PNG') @@ -717,8 +925,19 @@ }) .on('click', this._downloadSVGClick); - return this.dom.el('div').setClass('options downloads') - .add(this.downloadPNG, this.downloadSVG); + this.downloadURL = this.dom.el('a') + .text('URL') + .attrs({'href': '#'}) + .on('click', this._downloadURLClick); + + this.optsHold = this.dom.el('div').setClass('options downloads').add( + this.downloadPNG, + this.downloadSVG, + this.downloadURL, + this.buildURLBuilder() + ); + + return this.optsHold; } buildLibrary(container) { @@ -754,7 +973,8 @@ buildViewPane() { this.viewPaneInner = this.dom.el('div').setClass('pane-view-inner') - .add(this.diagram.dom()); + .add(this.diagram.dom()) + .on('click', () => this._hideURLBuilder()); this.errorMsg = this.dom.el('div').setClass('msg-error'); @@ -840,6 +1060,14 @@ snapOffset: 70, }); + if(typeof window !== 'undefined') { + window.addEventListener('keydown', (e) => { + if(e.keyCode === 27) { + this._hideURLBuilder(); + } + }); + } + // Delay first update 1 frame to ensure render target is ready // (prevents initial incorrect font calculations for custom fonts) setTimeout(this.update.bind(this), 0); @@ -967,6 +1195,7 @@ } update(immediate = true) { + this._hideURLBuilder(); const src = this.value(); this.saveCode(src); let sequence = null; @@ -1033,12 +1262,24 @@ } else if(this.updatePNGLink()) { e.preventDefault(); } + this._hideURLBuilder(); } _downloadSVGClick() { this.forceRender(); const url = this.diagram.getSVGSynchronous(); this.downloadSVG.attr('href', url); + this._hideURLBuilder(); + } + + _downloadURLClick(e) { + e.preventDefault(); + + if(this.builderVisible) { + this._hideURLBuilder(); + } else { + this._showURLBuilder(); + } } _enhanceEditor() { diff --git a/weblib/editor.min.js b/weblib/editor.min.js index 995c394..8dc405d 100644 --- a/weblib/editor.min.js +++ b/weblib/editor.min.js @@ -1 +1 @@ -!function(){"use strict";function e(e){return null===e?null:e.element?e.element:e}function t(e){return e.length>0&&"\n"!==e.charAt(e.length-1)?e+"\n":e}function n(e,t){let n=0,i=0;for(;;){const s=e.indexOf("\n",n)+1;if(t{const i=t[0].parentNode,s=i.addEventListener,r=i.removeEventListener;i.addEventListener=((e,t)=>{"mousemove"===e||"touchmove"===e?window.addEventListener(e,t,{passive:!0}):s.call(i,e,t)}),i.removeEventListener=((e,t)=>{"mousemove"===e||"touchmove"===e?window.removeEventListener(e,t):r.call(i,e,t)});let o=null;const a=Object.assign({cursor:"vertical"===n.direction?"row-resize":"col-resize",direction:"vertical",gutterSize:0,onDragEnd:()=>{document.body.style.cursor=o,o=null},onDragStart:()=>{o=document.body.style.cursor,document.body.style.cursor=a.cursor}},n);return new e(t,a)})}var s=[{code:"{Agent1} -> {Agent2}: {Message}",title:"Simple arrow (synchronous)"},{code:"{Agent1} --\x3e {Agent2}: {Message}",title:"Arrow with dotted line (response)"},{code:"{Agent1} ->> {Agent2}: {Message}",title:"Open arrow (asynchronous)"},{code:"{Agent1} -x {Agent2}: {Message}",title:"Lost message"},{code:"{Agent1} ~> {Agent2}: {Message}",title:"Wavy line"},{code:"{Agent1} -> {Agent1}: {Message}",title:"Self-connection"},{code:"{Agent1} -> ...{id}\n...{id} -> {Agent2}: {Message}",preview:"begin A, B\nA -> ...x\n...x -> B: Message",title:"Asynchronous message"},{code:"* -> {Agent1}: {Message}",title:"Found message"},{code:"{Agent1} -> +{Agent2}: {Request}\n{Agent1} <-- -{Agent2}: {Response}",title:"Request/response pair"},{code:"{Agent1} -> *{Agent2}: {Request}\n{Agent1} <-- !{Agent2}: {Response}",title:"Inline agent creation / destruction"},{code:"{Agent1} -> {Agent2}: {Request}\n{Agent1} <-- {Agent2}: {Response}\nend {Agent2}",preview:"begin A\n::\nA -> B: Request\nA <-- B: Response\nend B",title:"Agent creation / destruction"},{code:'autolabel "[]