diff --git a/ICSharpCode.Decompiler/diagrammer/ILSpy.ico b/ICSharpCode.Decompiler/diagrammer/ILSpy.ico new file mode 100644 index 0000000000..900abec819 Binary files /dev/null and b/ICSharpCode.Decompiler/diagrammer/ILSpy.ico differ diff --git a/ICSharpCode.Decompiler/diagrammer/index.html b/ICSharpCode.Decompiler/diagrammer/index.html new file mode 100644 index 0000000000..f84f856e0b --- /dev/null +++ b/ICSharpCode.Decompiler/diagrammer/index.html @@ -0,0 +1,2521 @@ + + + + + ICSharpCode.Decompiler class diagrammer - ILSpy + + + + + + + + + +
+
+
+ + + +
+ +
+

+ The type picker is โœœ focused when you open the app. + You can just โŒจ๏ธ key in the first letter/s of the type + you want to start your diagram with and hit [Enter] to render it. +

+

+ After rendering you can ๐Ÿ‘† tap types on the diagram + to update your selection and redraw. + This allows you to explore the domain along relations. +

+

+ Don't forget that you can hold [Shift] to โ†• range-select + and [Ctrl] to ยฑ add to or subtract from your selection. +

+

+ Note that the diagram has a ๐ŸŸˆ layout direction - + i.e. it depends on how you โ‡… sort selected types using [Alt + Arrow Up|Down]. +

+

+ Changing the type selection or rendering options + updates the URL in the location bar. That means you can +

    +
  • ๐Ÿ”– bookmark or ๐Ÿ“ฃ share the URL to your diagram with whoever has access to this diagrammer,
  • +
  • access ๐Ÿ•” earlier diagrams recorded in your ๐Ÿงพ browser history and
  • +
  • โ‡ฅ restore your type selection to the picker from the URL using โŸณ Refresh [F5] if you lose it.
  • +
+

+

Looking for help with something else?

+

+ Stop and spot the tooltips. ๐ŸŒท They'll give you more info where necessary. + Get a hint for elements with helping tooltips using [Alt + i]. +

+

Alternatively, find helpful links to the docs and discussions in the + build info โžช

+

If you find this helpful and want to share your ๐Ÿ“บ screen and ๐ŸŽ“ wisdom on how it works + with a ๐Ÿฆ— newcomer, try toggling presentation mode using [Ctrl + i].

+
+ + + +
+ show inherited + + + + + + + + + + + + + +
+ +
+ layout direction + + + + + + + + +
+ +
+ + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + + + + + +
+ +
+
+ png dimensions + +
+ + + + +
+ +
+ + + + +
+ + + +
+
+
+
+
+
+ + + +
+
+ +
+
+
+
+ built from ICSharpCode.Decompiler v9.0.0.7867 and mermaid.js from CDN + ๐Ÿ“ฅ + + + using ICSharpCode.ILSpyX v9.0.0.7867-mermaid-diagrammer-preview3+b278c6ae33c4eaaf69efda798ab01e908b005b62 + ๐Ÿ“œ + ๐Ÿ’ฌ + + ๐ŸŒฉ๏ธ + +
+ +
+
+ + + + + + + + + diff --git a/ICSharpCode.Decompiler/diagrammer/script.js b/ICSharpCode.Decompiler/diagrammer/script.js new file mode 100644 index 0000000000..f26c851b32 --- /dev/null +++ b/ICSharpCode.Decompiler/diagrammer/script.js @@ -0,0 +1,1137 @@ +/*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(`(? 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('
', '
') + .replaceAll(/]*)>/g, (m, 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(); +})(); diff --git a/ICSharpCode.Decompiler/diagrammer/styles.css b/ICSharpCode.Decompiler/diagrammer/styles.css new file mode 100644 index 0000000000..23b929be15 --- /dev/null +++ b/ICSharpCode.Decompiler/diagrammer/styles.css @@ -0,0 +1,453 @@ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +body { + font-family: system-ui, sans-serif; + background: #4e54c8; + background-image: linear-gradient(to left, #8f94fb, #4e54c8); +} +input[type=text] { + border-radius: 3px; +} +button { + border-radius: 3px; + background-color: #aad; + border: none; + color: #117; + cursor: pointer; +} +button.icon { + font-size: 1em; + background-color: transparent; +} +button:disabled { + opacity: 0.5; +} +[type=checkbox], +[type=radio] { + cursor: pointer; +} +[type=checkbox] ~ label, +[type=radio] ~ label { + cursor: pointer; +} +fieldset { + border-radius: 5px; +} +select { + border: none; + border-radius: 3px; + background-color: rgba(0, 0, 0, calc(3/16 * 1)); + color: whitesmoke; +} +select option:checked { + background-color: rgba(0, 0, 0, calc(3/16 * 1)); + color: darkorange; +} +.flx:not([hidden]) { + display: flex; +} +.flx:not([hidden]).col { + flex-direction: column; +} +.flx:not([hidden]).spaced { + justify-content: space-between; +} +.flx:not([hidden]).gap { + gap: 0.5em; +} +.flx:not([hidden]).aligned { + align-items: center; +} +.flx:not([hidden]) .grow { + flex-grow: 1; +} +.collapse.vertical { + max-height: 0; + overflow: hidden; + transition: max-height ease-in-out 0.5s; +} +.collapse.vertical.open { + max-height: 100vh; +} +.collapse.horizontal { + max-width: 0; + padding: 0; + margin: 0; + transition: all ease-in-out 0.5s; + overflow: hidden; +} +.collapse.horizontal.open { + padding: revert; + max-width: 100vw; +} +.toggle, +[data-toggles] { + cursor: pointer; +} +.container { + position: absolute; + inset: 0; + margin: 0; +} +.scndry { + font-size: smaller; +} +.mano-a-borsa { + transform: rotate(95deg); + cursor: pointer; +} +.mano-a-borsa:after { + content: '๐Ÿค'; +} +.trawl-net { + transform: rotate(180deg) translateY(-2px); + display: inline-block; +} +.trawl-net:after { + content: '๐Ÿฅ…'; +} +.torch { + display: inline-block; +} +.torch:after { + content: '๐Ÿ”ฆ'; +} +.pulsing { + animation: whiteBoxShadowPulse 2s 3; +} +@keyframes whiteBoxShadowPulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + } + 5% { + box-shadow: 0 0 0 15px rgba(255, 255, 255, 0.5); + } + 50% { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1); + } + 90% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + } +} +#content { + height: 100%; + position: relative; +} +#filter { + max-width: 0; + transition: max-width ease-in-out 0.5s; + overflow: hidden; + background-color: rgba(0, 0, 0, calc(3/16 * 1)); + color: whitesmoke; +} +#filter.open { + max-width: 15em; + overflow: auto; +} +#filter.resizing { + transition: none; +} +#filter > * { + margin: 0.3em 0.3em 0; +} +#filter > *:last-child { + margin-bottom: 0.3em; +} +#filter #pre-filter-types { + min-width: 3em; +} +#filter [data-toggles="#info"] .torch { + transform: rotate(-90deg); + transition: transform 0.5s; +} +#filter [data-toggles="#info"][aria-expanded=true] .torch { + transform: rotate(-255deg); +} +#filter #info { + overflow: auto; + background-color: rgba(255, 255, 255, calc(1/16 * 2)); +} +#filter #info a.toggle { + color: whitesmoke; +} +#filter #info a.toggle img { + height: 1em; +} +#filter #type-select { + overflow: auto; +} +#filter #inheritance { + padding: 0.1em 0.75em 0.2em; +} +#filter #direction [type=radio] { + display: none; +} +#filter #direction [type=radio]:checked + label { + background-color: rgba(255, 255, 255, calc(1/16 * 4)); +} +#filter #direction label { + flex-grow: 1; + text-align: center; + margin: -1em 0 -0.7em; + padding-top: 0.2em; +} +#filter #direction label:first-of-type { + margin-left: -0.8em; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} +#filter #direction label:last-of-type { + margin-right: -0.8em; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} +#filter #actions { + margin-top: 1em; + justify-content: space-between; +} +#filter #actions #render { + font-weight: bold; +} +#filter #exportOptions { + overflow: auto; + background-color: rgba(255, 255, 255, calc(1/16 * 2)); +} +#filter #exportOptions #save { + margin-right: 0.5em; +} +#filter #exportOptions #dimensions fieldset { + padding: 0.5em; +} +#filter #exportOptions #dimensions fieldset .scale-size { + margin-left: 0.5em; +} +#filter #exportOptions #dimensions fieldset .scale-size #scale-size { + width: 2.5em; + margin: 0 0.2em; +} +#filter-toggle { + padding: 0; + border-radius: 0; + background-color: #117; + color: whitesmoke; +} +#output { + overflow: auto; +} +#output > svg { + cursor: grab; +} +#output > svg:active { + cursor: grabbing; +} +#output .edgeLabels .edgeTerminals .edgeLabel { + color: whitesmoke; +} +#output .edgeLabels .edgeLabel { + border-radius: 3px; +} +#output .edgeLabels .edgeLabel .edgeLabel[title] { + color: darkgoldenrod; +} +#output path.relation { + stroke: whitesmoke; +} +#output g.nodes > g { + cursor: pointer; +} +#output g.nodes > g > rect { + rx: 5px; + ry: 5px; +} +#output g.nodes g.label .nodeLabel[title] { + color: darkgoldenrod; +} +#about { + position: absolute; + bottom: 2em; + right: 2em; + align-items: end; +} +#about #toaster { + margin-right: 2.8em; +} +#about #toaster span { + animation: 0.5s ease-in fadeIn; + border-radius: 0.5em; + padding: 0.5em; + background-color: rgba(0, 0, 0, calc(3/16 * 2)); + color: whitesmoke; +} +#about #toaster span.leaving { + animation: 1s ease-in-out fadeOut; +} +#about .build-info { + align-items: end; + height: 2.3em; + border-radius: 7px; + background-color: rgba(0, 0, 0, calc(3/16 * 3)); + color: whitesmoke; +} +#about .build-info > * { + height: 100%; +} +#about .build-info #build-info { + text-align: right; +} +#about .build-info #build-info > * { + padding: 0 0.5em; +} +#about .build-info #build-info a { + color: whitesmoke; +} +#about .build-info #build-info a:not(.project) { + text-decoration: none; +} +#about .build-info #build-info a span { + display: inline-block; +} +#pressed-keys { + position: fixed; + left: 50%; + transform: translateX(-50%); + font-size: 3em; + bottom: 1em; + opacity: 1; + border-radius: 0.5em; + padding: 0.5em; + background-color: rgba(0, 0, 0, calc(3/16 * 2)); + color: whitesmoke; +} +#pressed-keys.hidden { + transition: opacity 0.5s ease-in-out; + opacity: 0; +} +#mouse { + position: fixed; + transform: translateX(-50%) translateY(-50%); + height: 2em; + width: 2em; + pointer-events: none; + z-index: 9999; + border-radius: 1em; + border: solid 0.1em yellow; +} +#mouse.down { + background-color: #ff08; +} +/* hide stuff in print view */ +@media print { + #filter, + #filter-toggle, + #about, + img, + .bubbles { + display: none; + } +} +/* ANIMATED BACKGROUND, from https://codepen.io/alvarotrigo/pen/GRvYNax + found in https://alvarotrigo.com/blog/animated-backgrounds-css/ */ +@keyframes rotateUp { + 0% { + transform: translateY(0) rotate(0deg); + opacity: 1; + border-radius: 100%; + } + 100% { + transform: translateY(-150vh) rotate(720deg); + opacity: 0; + border-radius: 0; + } +} +.bubbles { + overflow: hidden; +} +.bubbles li { + position: absolute; + display: block; + list-style: none; + width: 20px; + height: 20px; + background: rgba(255, 255, 255, 0.2); + animation: rotateUp 25s linear infinite; + bottom: -150px; +} +.bubbles li:nth-child(1) { + left: 25%; + width: 80px; + height: 80px; + animation-delay: 0s; +} +.bubbles li:nth-child(2) { + left: 10%; + width: 20px; + height: 20px; + animation-delay: 2s; + animation-duration: 12s; +} +.bubbles li:nth-child(3) { + left: 70%; + width: 20px; + height: 20px; + animation-delay: 4s; +} +.bubbles li:nth-child(4) { + left: 40%; + width: 60px; + height: 60px; + animation-delay: 0s; + animation-duration: 18s; +} +.bubbles li:nth-child(5) { + left: 65%; + width: 20px; + height: 20px; + animation-delay: 0s; +} +.bubbles li:nth-child(6) { + left: 75%; + width: 110px; + height: 110px; + animation-delay: 3s; +} +.bubbles li:nth-child(7) { + left: 35%; + width: 150px; + height: 150px; + animation-delay: 7s; +} +.bubbles li:nth-child(8) { + left: 50%; + width: 25px; + height: 25px; + animation-delay: 15s; + animation-duration: 45s; +} +.bubbles li:nth-child(9) { + left: 20%; + width: 15px; + height: 15px; + animation-delay: 2s; + animation-duration: 35s; +} +.bubbles li:nth-child(10) { + left: 85%; + width: 150px; + height: 150px; + animation-delay: 0s; + animation-duration: 11s; +}