.NET Decompiler with support for PDB generation, ReadyToRun, Metadata (&more) - cross-platform!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1137 lines
52 KiB

/*globals mermaid:false*/
(async () => {
const getById = id => document.getElementById(id),
triggerChangeOn = element => { element.dispatchEvent(new Event('change')); },
hasProperty = (obj, name) => Object.prototype.hasOwnProperty.call(obj, name);
const checkable = (() => {
const checked = ':checked',
inputsByName = name => `input[name=${name}]`,
getInput = (name, filter, context) => (context || document).querySelector(inputsByName(name) + filter),
getInputs = (name, context) => (context || document).querySelectorAll(inputsByName(name));
return {
getValue: (name, context) => getInput(name, checked, context).value,
onChange: (name, handle, context) => {
for (let input of getInputs(name, context)) input.onchange = handle;
},
setChecked: (name, value, triggerChange, context) => {
const input = getInput(name, `[value="${value}"]`, context);
input.checked = true;
if (triggerChange !== false) triggerChangeOn(input);
}
};
})();
const collapse = (() => {
const open = 'open',
isOpen = element => element.classList.contains(open),
/** Toggles the open class on the collapse.
* @param {HTMLElement} element The collapse to toggle.
* @param {boolean} force The state to force. */
toggle = (element, force) => element.classList.toggle(open, force);
return {
toggle,
open: element => {
if (isOpen(element)) return false; // return whether collapse was opened by this process
return toggle(element, true);
},
initToggles: () => {
for (let trigger of [...document.querySelectorAll('.toggle[href],[data-toggles]')]) {
trigger.addEventListener('click', event => {
event.preventDefault(); // to avoid pop-state event
const trigger = event.currentTarget;
trigger.ariaExpanded = !(trigger.ariaExpanded === 'true');
toggle(document.querySelector(trigger.attributes.href?.value || trigger.dataset.toggles));
});
}
}
};
})();
const notify = (() => {
const toaster = getById('toaster');
return message => {
const toast = document.createElement('span');
toast.innerText = message;
toaster.appendChild(toast); // fades in the message
setTimeout(() => {
toast.classList.add('leaving'); // fades out the message
// ...and removes it. Note this timeout has to match the animation duration for '.leaving' in the .less file.
setTimeout(() => { toast.remove(); }, 1000);
}, 5000);
};
})();
const output = (function () {
const output = getById('output'),
hasSVG = () => output.childElementCount > 0,
getSVG = () => hasSVG() ? output.children[0] : null,
updateSvgViewBox = (svg, viewBox) => {
if (svg.originalViewBox === undefined) {
const vb = svg.viewBox.baseVal;
svg.originalViewBox = { x: vb.x, y: vb.y, width: vb.width, height: vb.height, };
}
svg.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
};
// enable zooming SVG using Ctrl + mouse wheel
const zoomFactor = 0.1, panFactor = 2023; // to go with the Zeitgeist
output.addEventListener('wheel', event => {
if (!event.ctrlKey || !hasSVG()) return;
event.preventDefault();
const svg = getSVG(),
delta = event.deltaY < 0 ? 1 : -1,
zoomDelta = 1 + zoomFactor * delta,
viewBox = svg.viewBox.baseVal;
viewBox.width *= zoomDelta;
viewBox.height *= zoomDelta;
updateSvgViewBox(svg, viewBox);
});
// enable panning SVG by grabbing and dragging
let isPanning = false, panStartX = 0, panStartY = 0;
output.addEventListener('mousedown', event => {
isPanning = true;
panStartX = event.clientX;
panStartY = event.clientY;
});
output.addEventListener('mouseup', () => { isPanning = false; });
output.addEventListener('mousemove', event => {
if (!isPanning || !hasSVG()) return;
event.preventDefault();
const svg = getSVG(),
viewBox = svg.viewBox.baseVal,
dx = event.clientX - panStartX,
dy = event.clientY - panStartY;
viewBox.x -= dx * panFactor / viewBox.width;
viewBox.y -= dy * panFactor / viewBox.height;
panStartX = event.clientX;
panStartY = event.clientY;
updateSvgViewBox(svg, viewBox);
});
return {
getDiagramTitle: () => output.dataset.title,
setSVG: svg => { output.innerHTML = svg; },
getSVG,
resetZoomAndPan: () => {
const svg = getSVG();
if (svg !== null) updateSvgViewBox(svg, svg.originalViewBox);
}
};
})();
const mermaidExtensions = (() => {
const logLevel = (() => {
/* int indexes as well as string values can identify a valid log level;
see log levels and logger definition at https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/logger.ts .
Note the names correspond to console output methods https://developer.mozilla.org/en-US/docs/Web/API/console .*/
const names = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'],
maxIndex = names.length - 1,
getIndex = level => {
const index = Number.isInteger(level) ? level : names.indexOf(level);
return index < 0 ? maxIndex : Math.min(index, maxIndex); // normalize, but return maxIndex (i.e. lowest level) by default
};
let requested; // the log level index of the in-coming config or the default
return {
/** Sets the desired log level.
* @param {string|int} level The name or index of the desired log level. */
setRequested: level => { requested = getIndex(level); },
/** Returns all names above (not including) the given level.
* @param {int} level The excluded lower boundary log level index (not name).
* @returns an array. */
above: level => names.slice(level + 1),
/** Indicates whether the log level is configured to be enabled.
* @param {string|int} level The log level to test.
* @returns a boolean. */
isEnabled: level => requested <= getIndex(level)
};
})();
/** Calculates the shortest distance in pixels between a point
* represented by 'top' and 'left' and the closest side of an axis-aligned rectangle.
* Returns 0 if the point is inside or on the edge of the rectangle.
* Inspired by https://gamedev.stackexchange.com/a/50722 .
* @param {int} top The distance of the point from the top of the viewport.
* @param {int} left The distance of the point from the left of the viewport.
* @param {DOMRect} rect The bounding box to get the distance to.
* @returns {int} The distance of the outside point or 0. */
function getDistanceToRect(top, left, rect) {
const dx = Math.max(rect.left, Math.min(left, rect.right)),
dy = Math.max(rect.top, Math.min(top, rect.bottom));
return Math.sqrt((left - dx) * (left - dx) + (top - dy) * (top - dy));
}
/** Calculates the distance between two non-overlapping axis-aligned rectangles.
* Returns 0 if the rectangles touch or overlap.
* @param {DOMRect} a The first bounding box.
* @param {DOMRect} b The second bounding box.
* @returns {int} The distance between the two bounding boxes or 0 if they touch or overlap. */
function getDistance(a, b) {
/** Gets coordinate pairs for the corners of a rectangle r.
* @param {DOMRect} r the rectangle.
* @returns {Array}} */
const getCorners = r => [[r.top, r.left], [r.top, r.right], [r.bottom, r.left], [r.bottom, r.right]],
/** Gets the distances of the corners of rectA to rectB. */
getCornerDistances = (rectA, rectB) => getCorners(rectA).map(c => getDistanceToRect(c[0], c[1], rectB)),
aRect = a.getBoundingClientRect(),
bRect = b.getBoundingClientRect(),
cornerDistances = getCornerDistances(aRect, bRect).concat(getCornerDistances(bRect, aRect));
return Math.min(...cornerDistances);
}
function interceptConsole(interceptorsByLevel) {
const originals = {};
for (let [level, interceptor] of Object.entries(interceptorsByLevel)) {
if (typeof console[level] !== 'function') continue;
originals[level] = console[level];
console[level] = function () { interceptor.call(this, originals[level], arguments); };
}
return () => { // call to detach interceptors
for (let [level, original] of Object.entries(originals))
console[level] = original;
};
}
let renderedEdges = [], // contains info about the arrows between types on the diagram once rendered
lastRenderedDiagram;
function getRelationLabels(svg, typeId) {
const edgeLabels = [...svg.querySelectorAll('.edgeLabels span.edgeLabel span')],
extension = 'extension';
return renderedEdges.filter(e => e.v === typeId // type name needs to match
&& e.value.arrowTypeStart !== extension && e.value.arrowTypeEnd !== extension) // exclude inheritance arrows
.map(edge => {
const labelHtml = edge.value.label,
// filter edge labels with matching HTML
labels = edgeLabels.filter(l => l.outerHTML === labelHtml);
if (labels.length === 1) return labels[0]; // return the only matching label
else if (labels.length < 1) console.error(
"Tried to find a relation label for the following edge (by its value.label) but couldn't.", edge);
else { // there are multiple edge labels with the same HTML (i.e. matching relation name)
// find the path that is rendered for the edge
const path = svg.querySelector('.edgePaths>path.relation#' + edge.value.id),
labelsByDistance = labels.sort((a, b) => getDistance(path, a) - getDistance(path, b));
console.warn('Found multiple relation labels matching the following edge (by its value.label). Returning the closest/first.',
edge, labelsByDistance);
return labelsByDistance[0]; // and return the matching label closest to it
}
});
}
return {
init: config => {
/* Override console.info to intercept a message posted by mermaid including information about the edges
(represented by arrows between types in the rendered diagram) to access the relationship info
parsed from the diagram descriptions of selected types.
This works around the mermaid API currently not providing access to this information
and it being hard to reconstruct from the rendered SVG alone.
Why do we need that info? Knowing about the relationships between types, we can find the label
corresponding to a relation and attach XML documentation information to it, if available.
See how getRelationLabels is used. */
const requiredLevel = 2, // to enable intercepting info message
interceptors = {
info: function (overridden, args) {
// intercept message containing rendered edges
if (args[2] === 'Graph in recursive render: XXX') renderedEdges = args[3].edges;
// only forward to overridden method if this log level was originally enabled
if (logLevel.isEnabled(requiredLevel)) overridden.call(this, ...args);
}
};
logLevel.setRequested(config.logLevel); // remember original log level
// lower configured log level if required to guarantee above interceptor gets called
if (!logLevel.isEnabled(requiredLevel)) config.logLevel = requiredLevel;
// suppress console output for higher log levels accidentally activated by lowering to required level
for (let level of logLevel.above(requiredLevel))
if (!logLevel.isEnabled(level)) interceptors[level] = () => { };
const detachInterceptors = interceptConsole(interceptors); // attaches console interceptors
mermaid.initialize(config); // init the mermaid sub-system with interceptors in place
detachInterceptors(); // to avoid intercepting messages outside of that context we're not interested in
},
/** Processes the type selection into mermaid diagram syntax (and the corresponding XML documentation data, if available).
* @param {object} typeDetails An object with the IDs of types to display in detail (i.e. with members) for keys
* and objects with the data structure of ClassDiagrammer.Type (excluding the Id) for values.
* @param {function} getTypeLabel A strategy for getting the type label for a type ID.
* @param {string} direction The layout direction of the resulting diagram.
* @param {object} showInherited A regular expression matching things to exclude from the diagram definition.
* @returns {object} An object like { diagram, detailedTypes, xmlDocs } with 'diagram' being the mermaid diagram syntax,
* 'xmlDocs' the corresponding XML documentation to be injected into the rendered diagram in the 'postProcess' step and
* 'detailedTypes' being a flat list of IDs of types that will be rendered in detail (including their members and relations). */
processTypes: (typeDetails, getTypeLabel, direction, showInherited) => {
const detailedTypes = Object.keys(typeDetails), // types that will be rendered including their members and relations
xmlDocs = {}, // to be appended with docs of selected types below
getAncestorTypes = typeDetails => Object.keys(typeDetails.Inherited),
isRendered = type => detailedTypes.includes(type),
mayNeedLabelling = new Set(),
cleanUpDiagramMmd = mmd => mmd.replace(/(\r?\n){3,}/g, '\n\n'), // squash more than two consecutive line breaks down into two
// renders base type and interfaces depending on settings and selected types
renderSuperType = (supertTypeId, link, typeId, name, displayAll) => {
/* display relation arrow if either the user chose to display this kind of super type
or the super type is selected to be rendered anyway and we might as well for completeness */
if (displayAll || isRendered(supertTypeId)) {
const label = name ? ' : ' + name : '';
diagram += `${supertTypeId} <|${link} ${typeId}${label}\n`;
mayNeedLabelling.add(supertTypeId);
}
},
/* TODO watch https://github.com/mermaid-js/mermaid/issues/6034 for a solution to render multiple self-references,
which is currently broken. E.g. for LightJson.JsonValue (compare console log) */
// renders HasOne and HasMany relations
renderRelations = (typeId, relations, many) => {
if (relations) // expecting object; only process if not null or undefined
for (let [label, relatedId] of Object.entries(relations)) {
const nullable = label.endsWith(' ?');
const cardinality = many ? '"*" ' : nullable ? '"?" ' : '';
if (nullable) label = label.substring(0, label.length - 2); // nullability is expressed via cardinality
diagram += `${typeId} --> ${cardinality}${relatedId} : ${label}\n`;
mayNeedLabelling.add(relatedId);
}
},
renderInheritedMembers = (typeId, details) => {
const ancestorTypes = getAncestorTypes(details);
// only include inherited members in sub classes if they aren't already rendered in a super class
for (let [ancestorType, members] of Object.entries(details.Inherited)) {
if (isRendered(ancestorType)) continue; // inherited members will be rendered in base type
let ancestorsOfDetailedAncestors = ancestorTypes.filter(t => detailedTypes.includes(t)) // get detailed ancestor types
.map(type => getAncestorTypes(typeDetails[type])) // select their ancestor types
.reduce((union, ancestors) => union.concat(ancestors), []); // squash them into a one-dimensional array (ignoring duplicates)
// skip displaying inherited members already displayed by detailed ancestor types
if (ancestorsOfDetailedAncestors.includes(ancestorType)) continue;
diagram += members.FlatMembers + '\n';
renderRelations(typeId, members.HasOne);
renderRelations(typeId, members.HasMany, true);
}
};
// init diagram code with header and layout direction to be appended to below
let diagram = 'classDiagram' + '\n'
+ 'direction ' + direction + '\n\n';
// process selected types
for (let [typeId, details] of Object.entries(typeDetails)) {
mayNeedLabelling.add(typeId);
diagram += details.Body + '\n\n';
if (details.BaseType) // expecting object; only process if not null or undefined
for (let [baseTypeId, label] of Object.entries(details.BaseType))
renderSuperType(baseTypeId, '--', typeId, label, showInherited.types);
if (details.Interfaces) // expecting object; only process if not null or undefined
for (let [ifaceId, labels] of Object.entries(details.Interfaces))
for (let label of labels)
renderSuperType(ifaceId, '..', typeId, label, showInherited.interfaces);
renderRelations(typeId, details.HasOne);
renderRelations(typeId, details.HasMany, true);
xmlDocs[typeId] = details.XmlDocs;
if (showInherited.members && details.Inherited) renderInheritedMembers(typeId, details);
}
for (let typeId of mayNeedLabelling) {
const label = getTypeLabel(typeId);
if (label !== typeId) diagram += `class ${typeId} ["${label}"]\n`;
}
diagram = cleanUpDiagramMmd(diagram);
lastRenderedDiagram = diagram; // store diagram syntax for export
return { diagram, detailedTypes, xmlDocs };
},
getDiagram: () => lastRenderedDiagram,
/** Enhances the SVG rendered by mermaid by injecting xmlDocs if available
* and attaching type click handlers, if available.
* @param {SVGElement} svg The SVG containing the rendered mermaid diagram.
* @param {object} options An object like { xmlDocs, onTypeClick }
* with 'xmlDocs' being the XML docs by type ID
* and 'onTypeClick' being an event listener for the click event
* that gets the event and the typeId as parameters. */
postProcess: (svg, options) => {
// matches 'MyClass2' from generated id attributes in the form of 'classId-MyClass2-0'
const typeIdFromDomId = /(?<=classId-)\w+(?=-\d+)/;
for (let entity of svg.querySelectorAll('g.nodes>g.node').values()) {
const typeId = typeIdFromDomId.exec(entity.id)[0];
// clone to have a modifiable collection without affecting the original
const docs = structuredClone((options.xmlDocs || [])[typeId]);
// splice in XML documentation as label titles if available
if (docs) {
const typeKey = '', nodeLabel = 'span.nodeLabel',
title = entity.querySelector('.label-group'),
relationLabels = getRelationLabels(svg, typeId),
setDocs = (label, member) => {
label.title = docs[member];
delete docs[member];
},
documentOwnLabel = (label, member) => {
setDocs(label, member);
ownLabels = ownLabels.filter(l => l !== label); // remove label
};
let ownLabels = [...entity.querySelectorAll('g.label ' + nodeLabel)];
// document the type label itself
if (hasProperty(docs, typeKey)) documentOwnLabel(title.querySelector(nodeLabel), typeKey);
// loop through documented members longest name first
for (let member of Object.keys(docs).sort((a, b) => b.length - a.length)) {
// matches only whole words in front of method signatures starting with (
const memberName = new RegExp(`(?<!.*\\(.*)\\b${member}\\b`),
matchingLabels = ownLabels.filter(l => memberName.test(l.textContent)),
related = relationLabels.find(l => l.textContent === member);
if (related) matchingLabels.push(related);
if (matchingLabels.length === 0) continue; // members may be rendered in an ancestor type
if (matchingLabels.length > 1) console.warn(
`Expected to find one member or relation label for ${title.textContent}.${member}`
+ ' to attach the XML documentation to but found multiple. Applying the first.', matchingLabels);
documentOwnLabel(matchingLabels[0], member);
}
}
if (typeof options.onTypeClick === 'function') entity.addEventListener('click',
function (event) { options.onTypeClick.call(this, event, typeId); });
}
}
};
})();
const state = (() => {
const typeUrlDelimiter = '-',
originalTitle = document.head.getElementsByTagName('title')[0].textContent;
const restore = async data => {
if (data.d) layoutDirection.set(data.d);
if (data.t) {
inheritanceFilter.setFlagHash(data.i || ''); // if types are set, enable deselecting all options
typeSelector.setSelected(data.t.split(typeUrlDelimiter));
await render(true);
}
};
function updateQueryString(href, params) {
// see https://developer.mozilla.org/en-US/docs/Web/API/URL
const url = new URL(href), search = url.searchParams;
for (const [name, value] of Object.entries(params)) {
//see https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
if (value === null || value === undefined || value === '') search.delete(name);
else if (Array.isArray(value)) {
search.delete(name);
for (let item of value) search.append(name, item);
}
else search.set(name, value);
}
url.search = search.toString();
return url.href;
}
window.onpopstate = async event => { await restore(event.state); };
return {
update: () => {
const types = typeSelector.getSelected(),
t = Object.keys(types).join(typeUrlDelimiter),
d = layoutDirection.get(),
i = inheritanceFilter.getFlagHash(),
data = { t, d, i },
typeNames = Object.values(types).map(t => t.Name);
history.pushState(data, '', updateQueryString(location.href, data));
// record selected types in title so users see which selection they return to when using a history link
document.title = (typeNames.length ? typeNames.join(', ') + ' - ' : '') + originalTitle;
},
restore: async () => {
if (!location.search) return; // assume fresh open and don't try to restore state, preventing inheritance options from being unset
const search = new URLSearchParams(location.search);
await restore({ d: search.get('d'), i: search.get('i'), t: search.get('t') });
}
};
})();
const typeSelector = (() => {
const select = getById('type-select'),
preFilter = getById('pre-filter-types'),
renderBtn = getById('render'),
model = JSON.parse(getById('model').innerHTML),
tags = { optgroup: 'OPTGROUP', option: 'option' },
getNamespace = option => option.parentElement.nodeName === tags.optgroup ? option.parentElement.label : '',
getOption = typeId => select.querySelector(tags.option + `[value='${typeId}']`);
// fill select list
for (let [namespace, types] of Object.entries(model.TypesByNamespace)) {
let optionParent;
if (namespace) {
const group = document.createElement(tags.optgroup);
group.label = namespace;
select.appendChild(group);
optionParent = group;
} else optionParent = select;
for (let typeId of Object.keys(types)) {
const type = types[typeId],
option = document.createElement(tags.option);
option.value = typeId;
if (!type.Name) type.Name = typeId; // set omitted label to complete structure
option.innerText = type.Name;
optionParent.appendChild(option);
}
}
// only enable render button if types are selected
select.onchange = () => { renderBtn.disabled = select.selectedOptions.length < 1; };
preFilter.addEventListener('input', () => {
const regex = preFilter.value ? new RegExp(preFilter.value, 'i') : null;
for (let option of select.options)
option.hidden = regex !== null && !regex.test(option.innerHTML);
// toggle option groups hidden depending on whether they have visible children
for (let group of select.getElementsByTagName(tags.optgroup))
group.hidden = regex !== null && [...group.children].filter(o => !o.hidden).length === 0;
});
return {
focus: () => select.focus(),
focusFilter: () => preFilter.focus(),
setSelected: types => {
for (let option of select.options)
option.selected = types.includes(option.value);
triggerChangeOn(select);
},
toggleOption: typeId => {
const option = getOption(typeId);
if (option !== null) {
option.selected = !option.selected;
triggerChangeOn(select);
}
},
/** Returns the types selected by the user in the form of an object with the type IDs for keys
* and objects with the data structure of ClassDiagrammer.Type (excluding the Id) for values. */
getSelected: () => Object.fromEntries([...select.selectedOptions].map(option => {
const namespace = getNamespace(option), typeId = option.value,
details = model.TypesByNamespace[namespace][typeId];
return [typeId, details];
})),
moveSelection: up => {
// inspired by https://stackoverflow.com/a/25851154
for (let option of select.selectedOptions) {
if (up && option.previousElementSibling) { // move up
option.parentElement.insertBefore(option, option.previousElementSibling);
} else if (!up && option.nextElementSibling) { // move down
// see https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore
option.parentElement.insertBefore(option, option.nextElementSibling.nextElementSibling);
}
}
},
//TODO add method returning namespace to add to title
getLabel: typeId => {
const option = getOption(typeId);
return option ? option.innerText : model.OutsideReferences[typeId];
}
};
})();
const inheritanceFilter = (() => {
const baseType = getById('show-base-types'),
interfaces = getById('show-interfaces'),
members = getById('show-inherited-members'),
getFlags = () => { return { types: baseType.checked, interfaces: interfaces.checked, members: members.checked }; };
// automatically re-render on change
for (let checkbox of [baseType, interfaces, members])
checkbox.onchange = async () => { await render(); };
return {
getFlags,
getFlagHash: () => Object.entries(getFlags())
.filter(([, value]) => value) // only true flags
.map(([key]) => key[0]).join(''), // first character of each flag
setFlagHash: hash => {
baseType.checked = hash.includes('t');
interfaces.checked = hash.includes('i');
members.checked = hash.includes('m');
}
};
})();
const layoutDirection = (() => {
const inputName = 'direction';
// automatically re-render on change
checkable.onChange(inputName, async () => { await render(); });
return {
get: () => checkable.getValue(inputName),
set: (value, event) => {
const hasEvent = event !== undefined;
checkable.setChecked(inputName, value, hasEvent);
if (hasEvent) event.preventDefault();
}
};
})();
const render = async isRestoringState => {
const { diagram, detailedTypes, xmlDocs } = mermaidExtensions.processTypes(
typeSelector.getSelected(), typeSelector.getLabel, layoutDirection.get(), inheritanceFilter.getFlags());
console.info(diagram);
const titledDiagram = diagram + '\naccTitle: ' + output.getDiagramTitle().replaceAll('\n', '#10;') + '\n';
/* Renders response and deconstructs returned object because we're only interested in the svg.
Note that the ID supplied as the first argument must not match any existing element ID
unless you want its contents to be replaced. See https://mermaid.js.org/config/usage.html#api-usage */
const { svg } = await mermaid.render('foo', titledDiagram);
output.setSVG(svg);
mermaidExtensions.postProcess(output.getSVG(), {
xmlDocs,
onTypeClick: async (event, typeId) => {
// toggle selection and re-render on clicking entity
typeSelector.toggleOption(typeId);
await render();
}
});
exportOptions.enable(detailedTypes.length > 0);
if (!isRestoringState) state.update();
};
const filterSidebar = (() => {
const filterForm = getById('filter'),
resizing = 'resizing',
toggleBtn = getById('filter-toggle'),
toggle = () => collapse.toggle(filterForm);
// enable rendering by hitting Enter on filter form
filterForm.onsubmit = async (event) => {
event.preventDefault();
await render();
};
// enable adjusting max sidebar width
(() => {
const filterWidthOverride = getById('filter-width'), // a style tag dedicated to overriding the default filter max-width
minWidth = 210, maxWidth = window.innerWidth / 2; // limit the width of the sidebar
let isDragging = false; // tracks whether the sidebar is being dragged
let pickedUp = 0; // remembers where the dragging started from
let widthBefore = 0; // remembers the width when dragging starts
let change = 0; // remembers the total distance of the drag
toggleBtn.addEventListener('mousedown', (event) => {
isDragging = true;
pickedUp = event.clientX;
widthBefore = filterForm.offsetWidth;
});
document.addEventListener('mousemove', (event) => {
if (!isDragging) return;
const delta = event.clientX - pickedUp,
newWidth = Math.max(minWidth, Math.min(maxWidth, widthBefore + delta));
change = delta;
filterForm.classList.add(resizing);
filterWidthOverride.innerHTML = `#filter.open { max-width: ${newWidth}px; }`;
});
document.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
filterForm.classList.remove(resizing);
});
// enable toggling filter info on click
toggleBtn.addEventListener('click', () => {
if (Math.abs(change) < 5) toggle(); // prevent toggling for small, accidental drags
change = 0; // reset the remembered distance to enable subsequent clicks
});
})();
return {
toggle,
open: () => collapse.open(filterForm)
};
})();
/* Shamelessly copied from https://github.com/mermaid-js/mermaid-live-editor/blob/develop/src/lib/components/Actions.svelte
with only a few modifications after I failed to get the solutions described here working:
https://stackoverflow.com/questions/28226677/save-inline-svg-as-jpeg-png-svg/28226736#28226736
The closest I got was with this example https://canvg.js.org/examples/offscreen , but the shapes would remain empty. */
const exporter = (() => {
const getSVGstring = (svg, width, height) => {
height && svg?.setAttribute('height', `${height}px`);
width && svg?.setAttribute('width', `${width}px`); // Workaround https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage
if (!svg) svg = getSvgEl();
return svg.outerHTML.replaceAll('<br>', '<br/>')
.replaceAll(/<img([^>]*)>/g, (m, g) => `<img ${g} />`);
};
const toBase64 = utf8String => {
const bytes = new TextEncoder().encode(utf8String);
return window.btoa(String.fromCharCode.apply(null, bytes));
};
const getBase64SVG = (svg, width, height) => toBase64(getSVGstring(svg, width, height));
const exportImage = (event, exporter, imagemodeselected, userimagesize) => {
const canvas = document.createElement('canvas');
const svg = document.querySelector('#output svg');
if (!svg) {
throw new Error('svg not found');
}
const box = svg.getBoundingClientRect();
canvas.width = box.width;
canvas.height = box.height;
if (imagemodeselected === 'width') {
const ratio = box.height / box.width;
canvas.width = userimagesize;
canvas.height = userimagesize * ratio;
} else if (imagemodeselected === 'height') {
const ratio = box.width / box.height;
canvas.width = userimagesize * ratio;
canvas.height = userimagesize;
}
const context = canvas.getContext('2d');
if (!context) {
throw new Error('context not found');
}
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
const image = new Image();
image.onload = exporter(context, image);
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
event.stopPropagation();
event.preventDefault();
};
const getSvgEl = () => {
const svgEl = document.querySelector('#output svg').cloneNode(true);
svgEl.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
const fontAwesomeCdnUrl = Array.from(document.head.getElementsByTagName('link'))
.map((l) => l.href)
.find((h) => h.includes('font-awesome'));
if (fontAwesomeCdnUrl == null) {
return svgEl;
}
const styleEl = document.createElement('style');
styleEl.innerText = `@import url("${fontAwesomeCdnUrl}");'`;
svgEl.prepend(styleEl);
return svgEl;
};
const simulateDownload = (download, href) => {
const a = document.createElement('a');
a.download = download;
a.href = href;
a.click();
a.remove();
};
const downloadImage = (context, image) => {
return () => {
const { canvas } = context;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
simulateDownload(
exportOptions.getFileName('png'),
canvas.toDataURL('image/png').replace('image/png', 'image/octet-stream')
);
};
};
const tryWriteToClipboard = blob => {
try {
if (!blob) throw new Error('blob is empty');
void navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
return true;
} catch (error) {
console.error(error);
return false;
}
};
const copyPNG = (context, image) => {
return () => {
const { canvas } = context;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => { tryWriteToClipboard(blob); });
};
};
const tryWriteTextToClipboard = async text => {
try {
if (!text) throw new Error('text is empty');
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.error(error);
return false;
}
};
const copyText = async (event, text) => {
if (await tryWriteTextToClipboard(text)) {
event.stopPropagation();
event.preventDefault();
}
};
return {
isClipboardAvailable: () => hasProperty(window, 'ClipboardItem'),
onCopyPNG: (event, imagemodeselected, userimagesize) => {
exportImage(event, copyPNG, imagemodeselected, userimagesize);
},
onCopySVG: event => { void copyText(event, getSVGstring()); },
onCopyMMD: (event, diagram) => { void copyText(event, diagram); },
onDownloadPNG: (event, imagemodeselected, userimagesize) => {
exportImage(event, downloadImage, imagemodeselected, userimagesize);
},
onDownloadSVG: () => {
simulateDownload(exportOptions.getFileName('svg'), `data:image/svg+xml;base64,${getBase64SVG()}`);
},
onDownloadMMD: diagram => {
simulateDownload(exportOptions.getFileName('mmd'), `data:text/vnd.mermaid;base64,${toBase64(diagram)}`);
}
};
})();
const exportOptions = (() => {
let wereOpened = false; // used to track whether user was able to see save options and may quick-save
const container = getById('exportOptions'),
toggle = getById('exportOptions-toggle'),
saveBtn = getById('save'),
copyBtn = getById('copy'),
saveAs = 'saveAs',
png = 'png',
svg = 'svg',
isDisabled = () => toggle.hidden, // using toggle visibility as indicator
open = () => {
wereOpened = true;
return collapse.open(container);
},
copy = event => {
if (isDisabled()) return; // allow the default for copying text if no types are rendered
if (!exporter.isClipboardAvailable()) notify('The clipboard seems unavailable in this browser :(');
else {
const type = checkable.getValue(saveAs);
try {
if (type === png) {
const [dimension, size] = getDimensions();
exporter.onCopyPNG(event, dimension, size);
}
else if (type === svg) exporter.onCopySVG(event);
else exporter.onCopyMMD(event, mermaidExtensions.getDiagram());
notify(`The diagram ${type.toUpperCase()} is in your clipboard.`);
} catch (e) {
notify(e.toString());
}
}
},
save = event => {
const type = checkable.getValue(saveAs);
if (type === png) {
const [dimension, size] = getDimensions();
exporter.onDownloadPNG(event, dimension, size);
}
else if (type === svg) exporter.onDownloadSVG();
else exporter.onDownloadMMD(mermaidExtensions.getDiagram());
};
const getDimensions = (() => {
const inputName = 'dimension',
scale = 'scale',
dimensions = getById('dimensions'),
scaleInputs = container.querySelectorAll('#scale-controls input');
// enable toggling dimension controls
checkable.onChange(saveAs, event => {
collapse.toggle(dimensions, event.target.value === png);
}, container);
// enable toggling scale controls
checkable.onChange(inputName, event => {
const disabled = event.target.value !== scale;
for (let input of scaleInputs) input.disabled = disabled;
}, container);
return () => {
let dimension = checkable.getValue(inputName);
// return dimension to scale to desired size if not exporting in current size
if (dimension !== 'auto') dimension = checkable.getValue(scale);
return [dimension, getById('scale-size').value];
};
})();
if (exporter.isClipboardAvailable()) copyBtn.onclick = copy;
else copyBtn.hidden = true;
saveBtn.onclick = save;
return {
copy,
getFileName: ext => `${saveBtn.dataset.assembly}-diagram-${new Date().toISOString().replace(/[Z:.]/g, '')}.${ext}`,
enable: enable => {
if (!enable) collapse.toggle(container, false); // make sure the container is closed when disabling
toggle.hidden = !enable;
},
quickSave: event => {
if (isDisabled()) return; // allow the default for saving HTML doc if no types are rendered
if (wereOpened) {
save(event); // allow quick save
return;
}
const filterOpened = filterSidebar.open(),
optionsOpenend = open();
/* Make sure the collapses containing the save options are open and visible when user hits Ctrl + S.
If neither needed opening, trigger saving. I.e. hitting Ctrl + S again should do it. */
if (!filterOpened && !optionsOpenend) save(event);
else event.preventDefault(); // prevent saving HTML page
}
};
})();
// displays pressed keys and highlights mouse cursor for teaching usage and other presentations
const controlDisplay = (function () {
let used = new Set(), enabled = false, wheelTimeout;
const alt = 'Alt',
display = getById('pressed-keys'), // a label displaying the keys being pressed and mouse wheel being scrolled
mouse = getById('mouse'), // a circle tracking the mouse to make following it easier
translateKey = key => key.length === 1 ? key.toUpperCase() : key,
updateDisplay = () => {
display.textContent = [...used].join(' + ');
display.classList.toggle('hidden', used.size === 0);
},
eventHandlers = {
keydown: event => {
if (event.altKey) used.add(alt); // handle separately because Alt key alone doesn't trigger a key event
used.add(translateKey(event.key));
updateDisplay();
},
keyup: event => {
setTimeout(() => {
if (!event.altKey && used.has(alt)) used.delete(alt);
used.delete(translateKey(event.key));
updateDisplay();
}, 500);
},
wheel: event => {
const label = 'wheel ' + (event.deltaY < 0 ? 'up' : 'down'),
wasUsed = used.has(label);
if (wasUsed) {
if (wheelTimeout) clearTimeout(wheelTimeout);
} else {
used.add(label);
updateDisplay();
}
// automatically remove
wheelTimeout = setTimeout(() => {
used.delete(label);
updateDisplay();
wheelTimeout = undefined;
}, 500);
},
mousemove: event => {
mouse.style.top = event.clientY + 'px';
mouse.style.left = event.clientX + 'px';
},
mousedown: () => { mouse.classList.add('down'); },
mouseup: () => { setTimeout(() => { mouse.classList.remove('down'); }, 300); }
};
return {
toggle: () => {
enabled = !enabled;
if (enabled) {
mouse.hidden = false;
for (let [event, handler] of Object.entries(eventHandlers))
document.addEventListener(event, handler);
} else {
mouse.hidden = true;
for (let [event, handler] of Object.entries(eventHandlers))
document.removeEventListener(event, handler);
used.clear();
updateDisplay();
}
}
};
})();
// key bindings
document.onkeydown = async (event) => {
const arrowUp = 'ArrowUp', arrowDown = 'ArrowDown';
// support Cmd key as alternative on Mac, see https://stackoverflow.com/a/5500536
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 'b': filterSidebar.toggle(); return;
case 'k':
event.preventDefault();
filterSidebar.open();
typeSelector.focusFilter();
return;
case 's': exportOptions.quickSave(event); return;
case 'c': exportOptions.copy(event); return;
case 'i':
event.preventDefault();
controlDisplay.toggle();
return;
case 'ArrowLeft': layoutDirection.set('RL', event); return;
case 'ArrowRight': layoutDirection.set('LR', event); return;
case arrowUp: layoutDirection.set('BT', event); return;
case arrowDown: layoutDirection.set('TB', event); return;
case '0': output.resetZoomAndPan(); return;
}
}
if (event.altKey) { // naturally triggered by Mac's option key as well
// enable moving selected types up and down using arrow keys while holding [Alt]
const upOrDown = event.key === arrowUp ? true : event.key === arrowDown ? false : null;
if (upOrDown !== null) {
typeSelector.focus();
typeSelector.moveSelection(upOrDown);
event.preventDefault();
return;
}
// pulse-animate elements with helping title attributes to point them out
if (event.key === 'i') {
event.preventDefault();
const pulsing = 'pulsing';
for (let element of document.querySelectorAll('[title],:has(title)')) {
element.addEventListener('animationend', () => { element.classList.remove(pulsing); }, { once: true });
element.classList.add(pulsing);
}
}
}
};
// rewrite help replacing references to 'Ctrl' with 'Cmd' for Mac users
if (/(Mac)/i.test(navigator.userAgent)) {
const ctrl = /Ctrl/mg,
replace = source => source.replaceAll(ctrl, '⌘');
for (let titled of document.querySelectorAll('[title]'))
if (ctrl.test(titled.title)) titled.title = replace(titled.title);
for (let titled of document.querySelectorAll('[data-title]'))
if (ctrl.test(titled.dataset.title)) titled.dataset.title = replace(titled.dataset.title);
for (let element of getById('info').querySelectorAll('*')) {
const text = element.innerText || element.textContent; // Get the text content of the element
if (ctrl.test(text)) element.innerHTML = replace(text);
}
}
collapse.initToggles();
mermaidExtensions.init({ startOnLoad: false }); // initializes mermaid as well
typeSelector.focus(); // focus type filter initially to enable keyboard input
await state.restore();
})();