Support open-in-editor URLs [#59]

This commit is contained in:
David Evans 2020-01-20 19:23:17 +00:00
parent bdd8122a9f
commit ed1110d74f
8 changed files with 290 additions and 49 deletions

View File

@ -370,6 +370,10 @@
});
}
getRawHash() {
return getHash();
}
maxSlots() {
// Capacity of localStorage is limited
// So avoid allowing too many documents
@ -1176,20 +1180,34 @@
}
class URLExporter {
constructor(base = '') {
this.base = base;
constructor(renderBase = '', editBase = '') {
this.renderBase = renderBase;
this.editBase = editBase;
}
setBase(base) {
this.base = base;
setRenderBase(renderBase) {
this.renderBase = renderBase;
}
_convertCode(code) {
return code
setEditBase(editBase) {
this.editBase = editBase;
}
_convertCode(code, keepBlankLines = false) {
let lines = code
.split('\n')
.map(encodeURIComponent)
.filter((ln) => ln !== '')
.join('/');
.map(encodeURIComponent);
if(keepBlankLines) {
// Always trim trailing blank lines
while(lines.length > 0 && lines[lines.length - 1] === '') {
-- lines.length;
}
} else {
lines = lines.filter((ln) => ln !== '');
}
return lines.join('/');
}
_convertWidthHeight(width, height) {
@ -1220,14 +1238,22 @@
return '';
}
getURL(code, size = {}) {
getRenderURL(code, size = {}) {
return (
this.base +
this.renderBase +
this._convertSize(size) +
this._convertCode(code) +
'.svg'
);
}
getEditURL(code) {
return (
this.editBase +
'#edit:' +
this._convertCode(code, true)
);
}
}
class VoidStorage {
@ -1277,6 +1303,14 @@
});
}
function setDocumentTitle(diagramTitle) {
let title = diagramTitle.trim();
if(title.length > 20) {
title = title.substr(0, 18).trim() + '\u2026';
}
document.title = (title ? `${title} \u2014 ` : '') + 'Sequence Diagram';
}
class Interface {
constructor({
sequenceDiagram,
@ -1313,6 +1347,7 @@
this.diagram
.on('render', () => {
this.updateMinSize(this.diagram.getSize());
setDocumentTitle(this.diagram.getTitle());
this.pngDirty = true;
})
.on('mouseover', (element) => this.code.markLineHover(element.ln))
@ -1365,6 +1400,32 @@
setTimeout(() => copied.styles({'display': 'none'}), 1500);
});
const updateMode = () => {
this.renderOpts.styles({
'display': this.modeEdit.element.checked ? 'none' : 'block',
});
this._refreshURL();
};
this.modeRender = this.dom.el('input').attrs({
'checked': 'checked',
'name': 'export-mode',
'type': 'radio',
'value': 'render',
}).on('change', updateMode);
this.modeEdit = this.dom.el('input').attrs({
'name': 'export-mode',
'type': 'radio',
'value': 'edit',
}).on('change', updateMode);
this.modeMarkdown = this.dom.el('input').attrs({
'name': 'export-mode',
'type': 'radio',
'value': 'markdown',
}).on('change', updateMode);
this.urlWidth = this.dom.el('input').attrs({
'min': 0,
'placeholder': 'auto',
@ -1396,12 +1457,21 @@
this._refreshURL();
});
const urlOpts = this.dom.el('div').setClass('config').add(
this.renderOpts = this.dom.el('div').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.dom.el('label').add('zoom ', this.urlZoom)
);
const urlOpts = this.dom.el('div').setClass('config').add(
this.dom.el('div').setClass('export-mode').add(
this.dom.el('label').add(this.modeRender, 'View'),
this.dom.el('label').add(this.modeEdit, 'Edit'),
this.dom.el('label').add(this.modeMarkdown, 'Markdown')
),
this.renderOpts,
this.urlOutput,
copy,
copied
@ -1414,7 +1484,10 @@
.add('Loading\u2026')
);
const ownURL = (typeof window === 'undefined') ? 'http://localhost' : window.location.href;
this.renderService = new URLExporter();
this.renderService.setEditBase(new URL('.', ownURL).href);
const relativePath = 'render/';
fetchResource(relativePath)
.then((response) => response.text())
@ -1423,9 +1496,7 @@
if(!path || path.startsWith('<')) {
path = relativePath;
}
this.renderService.setBase(
new URL(path, window.location.href).href
);
this.renderService.setRenderBase(new URL(path, ownURL).href);
urlBuilder.empty().add(urlOpts);
this._refreshURL();
})
@ -1440,11 +1511,27 @@
}
_refreshURL() {
this.urlOutput.val(this.renderService.getURL(this.code.value(), {
const code = this.code.value();
const viewOpts = {
height: Number.parseFloat(this.urlHeight.element.value),
width: Number.parseFloat(this.urlWidth.element.value),
zoom: Number.parseFloat(this.urlZoom.element.value || '1'),
}));
};
let url = '';
if(this.modeMarkdown.element.checked) {
const edit = this.renderService.getEditURL(code);
const view = this.renderService.getRenderURL(code, viewOpts);
const title = this.diagram.getTitle()
.replace(/[^a-zA-Z0-9 \-_'"]/g, '')
.trim();
url = `[![${title}](${view})](${edit})`;
} else if(this.modeEdit.element.checked) {
url = this.renderService.getEditURL(code);
} else {
url = this.renderService.getRenderURL(code, viewOpts);
}
this.urlOutput.val(url);
}
_showURLBuilder() {
@ -2214,6 +2301,32 @@
}
}
function loadHashDocument(hashNav, slotStorage) {
const editPrefix = 'edit:';
const hash = hashNav.getRawHash();
if(!hash.startsWith(editPrefix)) {
return;
}
let doc = hash
.substr(editPrefix.length)
.split('/')
.map(decodeURIComponent)
.join('\n');
if(!doc) {
return;
}
if(!doc.endsWith('\n')) {
doc += '\n';
}
const newSlot = slotStorage.nextAvailableSlot();
slotStorage.set(newSlot, doc);
hashNav.setSlot(newSlot);
}
window.addEventListener('load', () => {
const loader = window.document.getElementById('loader');
const [nav] = loader.getElementsByTagName('nav');
@ -2235,6 +2348,7 @@
// If the slot is changed by the user, reload to force a document load
window.location.reload();
});
loadHashDocument(hashNav, slotStorage);
loader.parentNode.removeChild(loader);

File diff suppressed because one or more lines are too long

View File

@ -37,6 +37,32 @@ function migrateOldDocument(slotStorage) {
}
}
function loadHashDocument(hashNav, slotStorage) {
const editPrefix = 'edit:';
const hash = hashNav.getRawHash();
if(!hash.startsWith(editPrefix)) {
return;
}
let doc = hash
.substr(editPrefix.length)
.split('/')
.map(decodeURIComponent)
.join('\n');
if(!doc) {
return;
}
if(!doc.endsWith('\n')) {
doc += '\n';
}
const newSlot = slotStorage.nextAvailableSlot();
slotStorage.set(newSlot, doc);
hashNav.setSlot(newSlot);
}
window.addEventListener('load', () => {
const loader = window.document.getElementById('loader');
const [nav] = loader.getElementsByTagName('nav');
@ -58,6 +84,7 @@ window.addEventListener('load', () => {
// If the slot is changed by the user, reload to force a document load
window.location.reload();
});
loadHashDocument(hashNav, slotStorage);
loader.parentNode.removeChild(loader);

View File

@ -39,6 +39,14 @@ function fetchResource(path) {
});
}
function setDocumentTitle(diagramTitle) {
let title = diagramTitle.trim();
if(title.length > 20) {
title = title.substr(0, 18).trim() + '\u2026';
}
document.title = (title ? `${title} \u2014 ` : '') + 'Sequence Diagram';
}
export default class Interface {
constructor({
sequenceDiagram,
@ -75,6 +83,7 @@ export default class Interface {
this.diagram
.on('render', () => {
this.updateMinSize(this.diagram.getSize());
setDocumentTitle(this.diagram.getTitle());
this.pngDirty = true;
})
.on('mouseover', (element) => this.code.markLineHover(element.ln))
@ -127,6 +136,32 @@ export default class Interface {
setTimeout(() => copied.styles({'display': 'none'}), 1500);
});
const updateMode = () => {
this.renderOpts.styles({
'display': this.modeEdit.element.checked ? 'none' : 'block',
});
this._refreshURL();
};
this.modeRender = this.dom.el('input').attrs({
'checked': 'checked',
'name': 'export-mode',
'type': 'radio',
'value': 'render',
}).on('change', updateMode);
this.modeEdit = this.dom.el('input').attrs({
'name': 'export-mode',
'type': 'radio',
'value': 'edit',
}).on('change', updateMode);
this.modeMarkdown = this.dom.el('input').attrs({
'name': 'export-mode',
'type': 'radio',
'value': 'markdown',
}).on('change', updateMode);
this.urlWidth = this.dom.el('input').attrs({
'min': 0,
'placeholder': 'auto',
@ -158,12 +193,21 @@ export default class Interface {
this._refreshURL();
});
const urlOpts = this.dom.el('div').setClass('config').add(
this.renderOpts = this.dom.el('div').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.dom.el('label').add('zoom ', this.urlZoom)
);
const urlOpts = this.dom.el('div').setClass('config').add(
this.dom.el('div').setClass('export-mode').add(
this.dom.el('label').add(this.modeRender, 'View'),
this.dom.el('label').add(this.modeEdit, 'Edit'),
this.dom.el('label').add(this.modeMarkdown, 'Markdown')
),
this.renderOpts,
this.urlOutput,
copy,
copied
@ -176,7 +220,10 @@ export default class Interface {
.add('Loading\u2026')
);
const ownURL = (typeof window === 'undefined') ? 'http://localhost' : window.location.href;
this.renderService = new URLExporter();
this.renderService.setEditBase(new URL('.', ownURL).href);
const relativePath = 'render/';
fetchResource(relativePath)
.then((response) => response.text())
@ -185,9 +232,7 @@ export default class Interface {
if(!path || path.startsWith('<')) {
path = relativePath;
}
this.renderService.setBase(
new URL(path, window.location.href).href
);
this.renderService.setRenderBase(new URL(path, ownURL).href);
urlBuilder.empty().add(urlOpts);
this._refreshURL();
})
@ -202,11 +247,27 @@ export default class Interface {
}
_refreshURL() {
this.urlOutput.val(this.renderService.getURL(this.code.value(), {
const code = this.code.value();
const viewOpts = {
height: Number.parseFloat(this.urlHeight.element.value),
width: Number.parseFloat(this.urlWidth.element.value),
zoom: Number.parseFloat(this.urlZoom.element.value || '1'),
}));
};
let url = '';
if(this.modeMarkdown.element.checked) {
const edit = this.renderService.getEditURL(code);
const view = this.renderService.getRenderURL(code, viewOpts);
const title = this.diagram.getTitle()
.replace(/[^a-zA-Z0-9 \-_'"]/g, '')
.trim();
url = `[![${title}](${view})](${edit})`;
} else if(this.modeEdit.element.checked) {
url = this.renderService.getEditURL(code);
} else {
url = this.renderService.getRenderURL(code, viewOpts);
}
this.urlOutput.val(url);
}
_showURLBuilder() {

View File

@ -12,20 +12,34 @@ function valid(v = null) {
}
export default class URLExporter {
constructor(base = '') {
this.base = base;
constructor(renderBase = '', editBase = '') {
this.renderBase = renderBase;
this.editBase = editBase;
}
setBase(base) {
this.base = base;
setRenderBase(renderBase) {
this.renderBase = renderBase;
}
_convertCode(code) {
return code
setEditBase(editBase) {
this.editBase = editBase;
}
_convertCode(code, keepBlankLines = false) {
let lines = code
.split('\n')
.map(encodeURIComponent)
.filter((ln) => ln !== '')
.join('/');
.map(encodeURIComponent);
if(keepBlankLines) {
// Always trim trailing blank lines
while(lines.length > 0 && lines[lines.length - 1] === '') {
-- lines.length;
}
} else {
lines = lines.filter((ln) => ln !== '');
}
return lines.join('/');
}
_convertWidthHeight(width, height) {
@ -56,12 +70,20 @@ export default class URLExporter {
return '';
}
getURL(code, size = {}) {
getRenderURL(code, size = {}) {
return (
this.base +
this.renderBase +
this._convertSize(size) +
this._convertCode(code) +
'.svg'
);
}
getEditURL(code) {
return (
this.editBase +
'#edit:' +
this._convertCode(code, true)
);
}
}

View File

@ -3,70 +3,74 @@ import URLExporter from './URLExporter.mjs';
describe('URLExporter', () => {
it('converts code into a URL-safe format', () => {
const exporter = new URLExporter();
const url = exporter.getURL('A\nB');
const url = exporter.getRenderURL('A\nB');
expect(url).toEqual('A/B.svg');
});
it('escapes special characters', () => {
const exporter = new URLExporter();
const url = exporter.getURL('a/b%c"d');
const url = exporter.getRenderURL('a/b%c"d');
expect(url).toEqual('a%2Fb%25c%22d.svg');
});
it('adds a base url if specified', () => {
const exporter = new URLExporter('abc/');
const url = exporter.getURL('d');
const url = exporter.getRenderURL('d');
expect(url).toEqual('abc/d.svg');
});
it('adds width/height information if specified', () => {
const exporter = new URLExporter();
const url = exporter.getURL('a', {height: 20, width: 10});
const url = exporter.getRenderURL('a', {height: 20, width: 10});
expect(url).toEqual('w10h20/a.svg');
});
it('adds zoom information if specified', () => {
const exporter = new URLExporter();
const url = exporter.getURL('a', {zoom: 2});
const url = exporter.getRenderURL('a', {zoom: 2});
expect(url).toEqual('z2/a.svg');
});
it('ignores zoom of 1', () => {
const exporter = new URLExporter();
const url = exporter.getURL('a', {zoom: 1});
const url = exporter.getRenderURL('a', {zoom: 1});
expect(url).toEqual('a.svg');
});
it('ignores values of not-a-number', () => {
const exporter = new URLExporter();
const url = exporter.getURL('a', {zoom: Number.NaN});
const url = exporter.getRenderURL('a', {zoom: Number.NaN});
expect(url).toEqual('a.svg');
});
it('adds just width if specified', () => {
const exporter = new URLExporter();
const url = exporter.getURL('a', {width: 10});
const url = exporter.getRenderURL('a', {width: 10});
expect(url).toEqual('w10/a.svg');
});
it('adds just height if specified', () => {
const exporter = new URLExporter();
const url = exporter.getURL('a', {height: 10});
const url = exporter.getRenderURL('a', {height: 10});
expect(url).toEqual('h10/a.svg');
});
it('prefers width/height over zoom', () => {
const exporter = new URLExporter();
const url = exporter.getURL('a', {height: 20, width: 10, zoom: 2});
const url = exporter.getRenderURL('a', {
height: 20,
width: 10,
zoom: 2,
});
expect(url).toEqual('w10h20/a.svg');
});

View File

@ -16,6 +16,10 @@ export default class HashSlotNav {
});
}
getRawHash() {
return getHash();
}
maxSlots() {
// Capacity of localStorage is limited
// So avoid allowing too many documents

View File

@ -315,7 +315,16 @@ html, body {
}
.urlbuilder .config {
padding-top: 10px;
padding-top: 0;
}
.urlbuilder .export-mode input {
margin-right: 5px;
}
.urlbuilder .export-mode label {
display: inline-block;
margin: 0 10px 10px;
}
.urlbuilder input[type=number] {
@ -325,7 +334,7 @@ html, body {
.urlbuilder .or {
display: block;
margin: 10px 0;
margin: 5px 0;
color: #333333;
font-size: 1.2em;
}