From 40160f772d318e6896990b442e4dced94741e38b Mon Sep 17 00:00:00 2001 From: Holger Schmidt Date: Wed, 6 Nov 2024 19:47:36 +0100 Subject: [PATCH] added mermaid class diagrammer contributed from https://github.com/h0lg/netAmermaid - find earlier git history there --- ICSharpCode.ILSpyX/ICSharpCode.ILSpyX.csproj | 15 + .../MermaidDiagrammer/AssemblyInfo.cs | 44 + .../MermaidDiagrammer/ClassDiagrammer.cs | 114 ++ .../ClassDiagrammerFactory.cs | 98 ++ .../Extensions/StringExtensions.cs | 55 + .../Extensions/TypeExtensions.cs | 61 + .../MermaidDiagrammer/Factory.BuildTypes.cs | 118 ++ .../MermaidDiagrammer/Factory.FlatMembers.cs | 88 ++ .../Factory.Relationships.cs | 124 ++ .../MermaidDiagrammer/Factory.TypeIds.cs | 84 ++ .../MermaidDiagrammer/Factory.TypeNames.cs | 74 ++ .../GenerateHtmlDiagrammer.cs | 45 + .../MermaidDiagrammer/Generator.Run.cs | 147 +++ .../XmlDocumentationFormatter.cs | 91 ++ .../MermaidDiagrammer/html/.eslintrc.js | 16 + .../MermaidDiagrammer/html/.gitignore | 4 + .../MermaidDiagrammer/html/.vscode/tasks.json | 51 + .../MermaidDiagrammer/html/gulpfile.js | 66 + .../MermaidDiagrammer/html/package.json | 7 + .../MermaidDiagrammer/html/script.js | 1135 +++++++++++++++++ .../MermaidDiagrammer/html/styles.css | 453 +++++++ .../MermaidDiagrammer/html/styles.less | 586 +++++++++ .../MermaidDiagrammer/html/template.html | 194 +++ 23 files changed, 3670 insertions(+) create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/AssemblyInfo.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/ClassDiagrammer.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/ClassDiagrammerFactory.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/Extensions/StringExtensions.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/Extensions/TypeExtensions.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.BuildTypes.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.FlatMembers.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.Relationships.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.TypeIds.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.TypeNames.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/GenerateHtmlDiagrammer.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/Generator.Run.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/XmlDocumentationFormatter.cs create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/.eslintrc.js create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/.gitignore create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/.vscode/tasks.json create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/gulpfile.js create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/package.json create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/script.js create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/styles.css create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/styles.less create mode 100644 ICSharpCode.ILSpyX/MermaidDiagrammer/html/template.html diff --git a/ICSharpCode.ILSpyX/ICSharpCode.ILSpyX.csproj b/ICSharpCode.ILSpyX/ICSharpCode.ILSpyX.csproj index 6ee7581b9..3e83e0a0c 100644 --- a/ICSharpCode.ILSpyX/ICSharpCode.ILSpyX.csproj +++ b/ICSharpCode.ILSpyX/ICSharpCode.ILSpyX.csproj @@ -65,6 +65,21 @@ + + + + + + + + + + + + + + + diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/AssemblyInfo.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/AssemblyInfo.cs new file mode 100644 index 000000000..58680250b --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/AssemblyInfo.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Diagnostics; +using System.Reflection; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + internal static class AssemblyInfo + { + internal static readonly string Location; + internal static readonly string? Version; + + static AssemblyInfo() + { + Assembly assembly = Assembly.GetExecutingAssembly(); + Location = assembly.Location; + var version = assembly.GetName().Version?.ToString(); + Version = version == null ? null : version.Remove(version.LastIndexOf('.')); + } + + internal static string? GetProductVersion() + { + try + { return FileVersionInfo.GetVersionInfo(Location).ProductVersion ?? Version; } + catch { return Version; } + } + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/ClassDiagrammer.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/ClassDiagrammer.cs new file mode 100644 index 000000000..bcf3072d6 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/ClassDiagrammer.cs @@ -0,0 +1,114 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; + +using ICSharpCode.Decompiler.TypeSystem; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + /// Contains type info and metadata for generating a HTML class diagrammer from a source assembly. + /// Serialized into JSON by . + public sealed class ClassDiagrammer + { + internal const string NewLine = "\n"; + + internal string SourceAssemblyName { get; set; } = null!; + internal string SourceAssemblyVersion { get; set; } = null!; + + /// Types selectable in the diagrammer, grouped by their + /// to facilitate a structured type selection. + internal Dictionary TypesByNamespace { get; set; } = null!; + + /// Types not included in the , + /// but referenced by s that are. + /// Contains display names (values; similar to ) + /// by their referenced IDs (keys; similar to ). + internal Dictionary OutsideReferences { get; set; } = null!; + + /// Types excluded from the ; + /// used to support . + internal string[] Excluded { get; set; } = null!; + + /// A -like structure with collections + /// of property relations to one or many other s. + public abstract class Relationships + { + /// Relations to zero or one other instances of s included in the , + /// with the display member names as keys and the related as values. + /// This is because member names must be unique within the owning , + /// while the related may be the same for multiple properties. + public Dictionary? HasOne { get; set; } + + /// Relations to zero to infinite other instances of s included in the , + /// with the display member names as keys and the related as values. + /// This is because member names must be unique within the owning , + /// while the related may be the same for multiple properties. + public Dictionary? HasMany { get; set; } + } + + /// The mermaid class diagram definition, inheritance and relationships metadata + /// and XML documentation for a from the source assembly. + public sealed class Type : Relationships + { + /// Uniquely identifies the in the scope of the source assembly + /// as well as any HTML diagrammer generated from it. + /// Should match \w+ to be safe to use as select option value and + /// part of the DOM id of the SVG node rendered for this type. + /// May be the type name itself. + internal string Id { get; set; } = null!; + + /// The human-readable label for the type, if different from . + /// Not guaranteed to be unique in the scope of the . + public string? Name { get; set; } + + /// Contains the definition of the type and its own (not inherited) flat members + /// in mermaid class diagram syntax, see https://mermaid.js.org/syntax/classDiagram.html . + public string Body { get; set; } = null!; + + /// The base type directly implemented by this type, with the as key + /// and the (optional) differing display name as value of the single entry + /// - or null if the base type is . + /// Yes, Christopher Lambert, there can only be one. For now. + /// But using the same interface as for is convenient + /// and who knows - at some point the .Net bus may roll up with multi-inheritance. + /// Then this'll look visionary! + public Dictionary? BaseType { get; set; } + + /// Interfaces directly implemented by this type, with their as keys + /// and their (optional) differing display names as values. + public Dictionary? Interfaces { get; set; } + + /// Contains inherited members by the of their + /// for the consumer to choose which of them to display in an inheritance scenario. + public IDictionary? Inherited { get; set; } + + /// Contains the XML documentation comments for this type + /// (using a key) and its members, if available. + public IDictionary? XmlDocs { get; set; } + + /// Members inherited from an ancestor type specified by the Key of . + public class InheritedMembers : Relationships + { + /// The simple, non-complex members inherited from another + /// in mermaid class diagram syntax. + public string? FlatMembers { get; set; } + } + } + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/ClassDiagrammerFactory.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/ClassDiagrammerFactory.cs new file mode 100644 index 000000000..6834160b7 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/ClassDiagrammerFactory.cs @@ -0,0 +1,98 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.TypeSystem; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + using CD = ClassDiagrammer; + + /* See class diagram syntax + * reference (may be outdated!) https://mermaid.js.org/syntax/classDiagram.html + * lexical definition https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/diagrams/class/parser/classDiagram.jison */ + + /// Produces mermaid class diagram syntax for a filtered list of types from a specified .Net assembly. + public partial class ClassDiagrammerFactory + { + private readonly XmlDocumentationFormatter? xmlDocs; + private readonly DecompilerSettings decompilerSettings; + + private ITypeDefinition[]? selectedTypes; + private Dictionary? uniqueIds; + private Dictionary? labels; + private Dictionary? outsideReferences; + + public ClassDiagrammerFactory(XmlDocumentationFormatter? xmlDocs) + { + this.xmlDocs = xmlDocs; + + decompilerSettings = new DecompilerSettings(Decompiler.CSharp.LanguageVersion.Latest) { + AutomaticProperties = true // for IsHidden to return true for backing fields + }; + } + + public CD BuildModel(string assemblyPath, string? include, string? exclude) + { + CSharpDecompiler decompiler = new(assemblyPath, decompilerSettings); + MetadataModule mainModule = decompiler.TypeSystem.MainModule; + IEnumerable allTypes = mainModule.TypeDefinitions; + + selectedTypes = FilterTypes(allTypes, + include == null ? null : new(include, RegexOptions.Compiled), + exclude == null ? null : new(exclude, RegexOptions.Compiled)).ToArray(); + + // generate dictionary to read names from later + uniqueIds = GenerateUniqueIds(selectedTypes); + labels = []; + outsideReferences = []; + + Dictionary typesByNamespace = selectedTypes.GroupBy(t => t.Namespace).OrderBy(g => g.Key).ToDictionary(g => g.Key, + ns => ns.OrderBy(t => t.FullName).Select(type => type.Kind == TypeKind.Enum ? BuildEnum(type) : BuildType(type)).ToArray()); + + string[] excluded = allTypes.Except(selectedTypes).Select(t => t.ReflectionName).ToArray(); + + return new CD { + SourceAssemblyName = mainModule.AssemblyName, + SourceAssemblyVersion = mainModule.AssemblyVersion.ToString(), + TypesByNamespace = typesByNamespace, + OutsideReferences = outsideReferences, + Excluded = excluded + }; + } + + /// The default strategy for pre-filtering the available in the HTML diagrammer. + /// Applies as well as + /// matching by and not by . + /// The types to effectively include in the HTML diagrammer. + protected virtual IEnumerable FilterTypes(IEnumerable typeDefinitions, Regex? include, Regex? exclude) + => typeDefinitions.Where(type => IsIncludedByDefault(type) + && (include?.IsMatch(type.ReflectionName) != false) // applying optional whitelist filter + && (exclude?.IsMatch(type.ReflectionName) != true)); // applying optional blacklist filter + + /// The strategy for deciding whether a should be included + /// in the HTML diagrammer by default. Excludes compiler-generated and their nested types. + protected virtual bool IsIncludedByDefault(ITypeDefinition type) + => !type.IsCompilerGeneratedOrIsInCompilerGeneratedClass(); + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/Extensions/StringExtensions.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/Extensions/StringExtensions.cs new file mode 100644 index 000000000..49b9302e1 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/Extensions/StringExtensions.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions +{ + internal static class StringExtensions + { + /// Replaces all consecutive horizontal white space characters in + /// with while leaving line breaks intact. + internal static string NormalizeHorizontalWhiteSpace(this string input, string normalizeTo = " ") + => Regex.Replace(input, @"[ \t]+", normalizeTo); + + /// Replaces all occurrences of in + /// with . + internal static string ReplaceAll(this string input, IEnumerable oldValues, string? newValue) + => oldValues.Aggregate(input, (aggregate, oldValue) => aggregate.Replace(oldValue, newValue)); + + /// Joins the specified to a single one + /// using the specified as a delimiter. + /// Whether to pad the start and end of the string with the as well. + internal static string Join(this IEnumerable? strings, string separator, bool pad = false) + { + if (strings == null) + return string.Empty; + + var joined = string.Join(separator, strings); + return pad ? string.Concat(separator, joined, separator) : joined; + } + + /// Formats all items in using the supplied strategy + /// and returns a string collection - even if the incoming is null. + internal static IEnumerable FormatAll(this IEnumerable? collection, Func format) + => collection?.Select(format) ?? []; + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/Extensions/TypeExtensions.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/Extensions/TypeExtensions.cs new file mode 100644 index 000000000..6432c408a --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/Extensions/TypeExtensions.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +using ICSharpCode.Decompiler.TypeSystem; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions +{ + internal static class TypeExtensions + { + internal static bool IsObject(this IType t) => t.IsKnownType(KnownTypeCode.Object); + internal static bool IsInterface(this IType t) => t.Kind == TypeKind.Interface; + + internal static bool TryGetNullableType(this IType type, [MaybeNullWhen(false)] out IType typeArg) + { + bool isNullable = type.IsKnownType(KnownTypeCode.NullableOfT); + typeArg = isNullable ? type.TypeArguments.Single() : null; + return isNullable; + } + } + + internal static class MemberInfoExtensions + { + /// Groups the into a dictionary + /// with keys. + internal static Dictionary GroupByDeclaringType(this IEnumerable members) where T : IMember + => members.GroupByDeclaringType(m => m); + + /// Groups the into a dictionary + /// with keys using . + internal static Dictionary GroupByDeclaringType(this IEnumerable objectsWithMembers, Func getMember) + => objectsWithMembers.GroupBy(m => getMember(m).DeclaringType).ToDictionary(g => g.Key, g => g.ToArray()); + } + + internal static class DictionaryExtensions + { + /// Returns the s value for the specified + /// if available and otherwise the default for . + internal static Tout? GetValue(this IDictionary dictionary, T key) + => dictionary.TryGetValue(key, out Tout? value) ? value : default; + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.BuildTypes.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.BuildTypes.cs new file mode 100644 index 000000000..79f4bb86e --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.BuildTypes.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Linq; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + using CD = ClassDiagrammer; + + partial class ClassDiagrammerFactory + { + private CD.Type BuildEnum(ITypeDefinition type) + { + IField[] fields = type.GetFields(f => f.IsConst && f.IsStatic && f.Accessibility == Accessibility.Public).ToArray(); + Dictionary? docs = xmlDocs?.GetXmlDocs(type, fields); + string name = GetName(type), typeId = GetId(type); + + var body = fields.Select(f => f.Name).Prepend("<>") + .Join(CD.NewLine + " ", pad: true).TrimEnd(' '); + + return new CD.Type { + Id = typeId, + Name = name == typeId ? null : name, + Body = $"class {typeId} {{{body}}}", + XmlDocs = docs + }; + } + + private CD.Type BuildType(ITypeDefinition type) + { + string typeId = GetId(type); + IMethod[] methods = GetMethods(type).ToArray(); + IProperty[] properties = type.GetProperties().ToArray(); + IProperty[] hasOneRelations = GetHasOneRelations(properties); + (IProperty property, IType elementType)[] hasManyRelations = GetManyRelations(properties); + + var propertyNames = properties.Select(p => p.Name).ToArray(); + IField[] fields = GetFields(type, properties); + + #region split members up by declaring type + // enables the diagrammer to exclude inherited members from derived types if they are already rendered in a base type + Dictionary flatPropertiesByType = properties.Except(hasOneRelations) + .Except(hasManyRelations.Select(r => r.property)).GroupByDeclaringType(); + + Dictionary hasOneRelationsByType = hasOneRelations.GroupByDeclaringType(); + Dictionary hasManyRelationsByType = hasManyRelations.GroupByDeclaringType(r => r.property); + Dictionary fieldsByType = fields.GroupByDeclaringType(); + Dictionary methodsByType = methods.GroupByDeclaringType(); + #endregion + + #region build diagram definitions for the type itself and members declared by it + string members = flatPropertiesByType.GetValue(type).FormatAll(FormatFlatProperty) + .Concat(methodsByType.GetValue(type).FormatAll(FormatMethod)) + .Concat(fieldsByType.GetValue(type).FormatAll(FormatField)) + .Join(CD.NewLine + " ", pad: true); + + // see https://mermaid.js.org/syntax/classDiagram.html#annotations-on-classes + string? annotation = type.IsInterface() ? "Interface" : type.IsAbstract ? type.IsSealed ? "Service" : "Abstract" : null; + + string body = annotation == null ? members.TrimEnd(' ') : members + $"<<{annotation}>>" + CD.NewLine; + #endregion + + Dictionary? docs = xmlDocs?.GetXmlDocs(type, fields, properties, methods); + + #region build diagram definitions for inherited members by declaring type + string explicitTypePrefix = typeId + " : "; + + // get ancestor types this one is inheriting members from + Dictionary inheritedMembersByType = type.GetNonInterfaceBaseTypes().Where(t => t != type && !t.IsObject()) + // and group inherited members by declaring type + .ToDictionary(GetId, t => { + IEnumerable flatMembers = flatPropertiesByType.GetValue(t).FormatAll(p => explicitTypePrefix + FormatFlatProperty(p)) + .Concat(methodsByType.GetValue(t).FormatAll(m => explicitTypePrefix + FormatMethod(m))) + .Concat(fieldsByType.GetValue(t).FormatAll(f => explicitTypePrefix + FormatField(f))); + + return new CD.Type.InheritedMembers { + FlatMembers = flatMembers.Any() ? flatMembers.Join(CD.NewLine) : null, + HasOne = MapHasOneRelations(hasOneRelationsByType, t), + HasMany = MapHasManyRelations(hasManyRelationsByType, t) + }; + }); + #endregion + + string typeName = GetName(type); + + return new CD.Type { + Id = typeId, + Name = typeName == typeId ? null : typeName, + Body = $"class {typeId} {{{body}}}", + HasOne = MapHasOneRelations(hasOneRelationsByType, type), + HasMany = MapHasManyRelations(hasManyRelationsByType, type), + BaseType = GetBaseType(type), + Interfaces = GetInterfaces(type), + Inherited = inheritedMembersByType, + XmlDocs = docs + }; + } + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.FlatMembers.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.FlatMembers.cs new file mode 100644 index 000000000..398b6c96c --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.FlatMembers.cs @@ -0,0 +1,88 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + partial class ClassDiagrammerFactory + { + /// Wraps a method configurable via + /// that can be used to determine whether a member should be hidden. + private bool IsHidden(IEntity entity) => CSharpDecompiler.MemberIsHidden(entity.ParentModule!.MetadataFile, entity.MetadataToken, decompilerSettings); + + private IField[] GetFields(ITypeDefinition type, IProperty[] properties) + // only display fields that are not backing properties of the same name and type + => type.GetFields(f => !IsHidden(f) // removes compiler-generated backing fields + /* tries to remove remaining manual backing fields by matching type and name */ + && !properties.Any(p => f.ReturnType.Equals(p.ReturnType) + && Regex.IsMatch(f.Name, "_?" + p.Name, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.NonBacktracking))).ToArray(); + + private static IEnumerable GetMethods(ITypeDefinition type) => type.GetMethods(m => + !m.IsOperator && !m.IsCompilerGenerated() + && (m.DeclaringType == type // include methods if self-declared + /* but exclude methods declared by object and their overrides, if inherited */ + || (!m.DeclaringType.IsObject() + && (!m.IsOverride || !InheritanceHelper.GetBaseMember(m).DeclaringType.IsObject())))); + + private string FormatMethod(IMethod method) + { + string parameters = method.Parameters.Select(p => $"{GetName(p.Type)} {p.Name}").Join(", "); + string? modifier = method.IsAbstract ? "*" : method.IsStatic ? "$" : default; + string name = method.Name; + + if (method.IsExplicitInterfaceImplementation) + { + IMember member = method.ExplicitlyImplementedInterfaceMembers.Single(); + name = GetName(member.DeclaringType) + '.' + member.Name; + } + + string? typeArguments = method.TypeArguments.Count == 0 ? null : $"❰{method.TypeArguments.Select(GetName).Join(", ")}❱"; + return $"{GetAccessibility(method.Accessibility)}{name}{typeArguments}({parameters}){modifier} {GetName(method.ReturnType)}"; + } + + private string FormatFlatProperty(IProperty property) + { + char? visibility = GetAccessibility(property.Accessibility); + string? modifier = property.IsAbstract ? "*" : property.IsStatic ? "$" : default; + return $"{visibility}{GetName(property.ReturnType)} {property.Name}{modifier}"; + } + + private string FormatField(IField field) + { + string? modifier = field.IsAbstract ? "*" : field.IsStatic ? "$" : default; + return $"{GetAccessibility(field.Accessibility)}{GetName(field.ReturnType)} {field.Name}{modifier}"; + } + + // see https://stackoverflow.com/a/16024302 for accessibility modifier flags + private static char? GetAccessibility(Accessibility access) => access switch { + Accessibility.Private => '-', + Accessibility.ProtectedAndInternal or Accessibility.Internal => '~', + Accessibility.Protected or Accessibility.ProtectedOrInternal => '#', + Accessibility.Public => '+', + _ => default, + }; + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.Relationships.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.Relationships.cs new file mode 100644 index 000000000..14d772598 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.Relationships.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Linq; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + using CD = ClassDiagrammer; + + partial class ClassDiagrammerFactory + { + private IProperty[] GetHasOneRelations(IProperty[] properties) => properties.Where(property => { + IType type = property.ReturnType; + + if (type.TryGetNullableType(out var typeArg)) + type = typeArg; + + return selectedTypes!.Contains(type); + }).ToArray(); + + private (IProperty property, IType elementType)[] GetManyRelations(IProperty[] properties) + => properties.Select(property => { + IType elementType = property.ReturnType.GetElementTypeFromIEnumerable(property.Compilation, true, out bool? isGeneric); + + if (isGeneric == false && elementType.IsObject()) + { + IProperty[] indexers = property.ReturnType.GetProperties( + p => p.IsIndexer && !p.ReturnType.IsObject(), + GetMemberOptions.IgnoreInheritedMembers).ToArray(); // TODO mayb order by declaring type instead of filtering + + if (indexers.Length > 0) + elementType = indexers[0].ReturnType; + } + + return isGeneric == true && selectedTypes!.Contains(elementType) ? (property, elementType) : default; + }).Where(pair => pair != default).ToArray(); + + /// Returns the relevant direct super type the inherits from + /// in a format matching . + private Dictionary? GetBaseType(IType type) + { + IType? relevantBaseType = type.DirectBaseTypes.SingleOrDefault(t => !t.IsInterface() && !t.IsObject()); + return relevantBaseType == null ? default : new[] { BuildRelationship(relevantBaseType) }.ToDictionary(r => r.to, r => r.label); + } + + /// Returns the direct interfaces implemented by + /// in a format matching . + private Dictionary? GetInterfaces(ITypeDefinition type) + { + var interfaces = type.DirectBaseTypes.Where(t => t.IsInterface()).ToArray(); + + return interfaces.Length == 0 ? null + : interfaces.Select(i => BuildRelationship(i)).GroupBy(r => r.to) + .ToDictionary(g => g.Key, g => g.Select(r => r.label).ToArray()); + } + + /// Returns the one-to-one relations from to other s + /// in a format matching . + private Dictionary? MapHasOneRelations(Dictionary hasOneRelationsByType, IType type) + => hasOneRelationsByType.GetValue(type)?.Select(p => { + IType type = p.ReturnType; + string label = p.Name; + + if (p.IsIndexer) + label += $"[{p.Parameters.Single().Type.Name} {p.Parameters.Single().Name}]"; + + if (type.TryGetNullableType(out var typeArg)) + { + type = typeArg; + label += " ?"; + } + + return BuildRelationship(type, label); + }).ToDictionary(r => r.label!, r => r.to); + + /// Returns the one-to-many relations from to other s + /// in a format matching . + private Dictionary? MapHasManyRelations(Dictionary hasManyRelationsByType, IType type) + => hasManyRelationsByType.GetValue(type)?.Select(relation => { + (IProperty property, IType elementType) = relation; + return BuildRelationship(elementType, property.Name); + }).ToDictionary(r => r.label!, r => r.to); + + /// Builds references to super types and (one/many) relations, + /// recording outside references on the way and applying labels if required. + /// The type to reference. + /// Used only for property one/many relations. + private (string to, string? label) BuildRelationship(IType type, string? propertyName = null) + { + (string id, IType? openGeneric) = GetIdAndOpenGeneric(type); + AddOutsideReference(id, openGeneric ?? type); + + // label the relation with the property name if provided or the closed generic type for super types + string? label = propertyName ?? (openGeneric == null ? null : GetName(type)); + + return (to: id, label); + } + + private void AddOutsideReference(string typeId, IType type) + { + if (!selectedTypes!.Contains(type) && outsideReferences?.ContainsKey(typeId) == false) + outsideReferences.Add(typeId, type.Namespace + '.' + GetName(type)); + } + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.TypeIds.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.TypeIds.cs new file mode 100644 index 000000000..cb8226c33 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.TypeIds.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Linq; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + using CD = ClassDiagrammer; + + public partial class ClassDiagrammerFactory + { + /// Generates a dictionary of unique and short, but human readable identifiers for + /// to be able to safely reference them in any combination. + private static Dictionary GenerateUniqueIds(IEnumerable types) + { + Dictionary uniqueIds = []; + var groups = types.GroupBy(t => t.Name); + + // simplified handling for the majority of unique types + foreach (var group in groups.Where(g => g.Count() == 1)) + uniqueIds[group.First()] = SanitizeTypeName(group.Key); + + // number non-unique types + foreach (var group in groups.Where(g => g.Count() > 1)) + { + var counter = 0; + + foreach (var type in group) + uniqueIds[type] = type.Name + ++counter; + } + + return uniqueIds; + } + + private string GetId(IType type) => GetIdAndOpenGeneric(type).id; + + /// For a non- or open generic , returns a unique identifier and null. + /// For a closed generic , returns the open generic type and the unique identifier of it. + /// That helps connecting closed generic references (e.g. Store<int>) to their corresponding + /// open generic (e.g. Store<T>) like in . + private (string id, IType? openGeneric) GetIdAndOpenGeneric(IType type) + { + // get open generic type if type is a closed generic (i.e. has type args none of which are parameters) + var openGeneric = type is ParameterizedType generic && !generic.TypeArguments.Any(a => a is ITypeParameter) + ? generic.GenericType : null; + + type = openGeneric ?? type; // reference open instead of closed generic type + + if (uniqueIds!.TryGetValue(type, out var uniqueId)) + return (uniqueId, openGeneric); // types included by FilterTypes + + // types excluded by FilterTypes + string? typeParams = type.TypeParameterCount == 0 ? null : ("_" + type.TypeParameters.Select(GetId).Join("_")); + + var id = SanitizeTypeName(type.FullName.Replace('.', '_')) + + typeParams; // to achieve uniqueness for types with same FullName (i.e. generic overloads) + + uniqueIds![type] = id; // update dictionary to avoid re-generation + return (id, openGeneric); + } + + private static string SanitizeTypeName(string typeName) + => typeName.Replace('<', '_').Replace('>', '_'); // for module of executable + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.TypeNames.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.TypeNames.cs new file mode 100644 index 000000000..a8997df6d --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/Factory.TypeNames.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + public partial class ClassDiagrammerFactory + { + /// Returns a cached display name for . + private string GetName(IType type) + { + if (labels!.TryGetValue(type, out string? value)) + return value; // return cached value + + return labels[type] = GenerateName(type); // generate and cache new value + } + + /// Generates a display name for . + private string GenerateName(IType type) + { + // non-generic types + if (type.TypeParameterCount < 1) + { + if (type is ArrayType array) + return GetName(array.ElementType) + "[]"; + + if (type is ByReferenceType byReference) + return "&" + GetName(byReference.ElementType); + + ITypeDefinition? typeDefinition = type.GetDefinition(); + + if (typeDefinition == null) + return type.Name; + + if (typeDefinition.KnownTypeCode == KnownTypeCode.None) + { + if (type.DeclaringType == null) + return type.Name.Replace('<', '❰').Replace('>', '❱'); // for module of executable + else + return type.DeclaringType.Name + '+' + type.Name; // nested types + } + + return KnownTypeReference.GetCSharpNameByTypeCode(typeDefinition.KnownTypeCode) ?? type.Name; + } + + // nullable types + if (type.TryGetNullableType(out var nullableType)) + return GetName(nullableType) + "?"; + + // other generic types + string typeArguments = type.TypeArguments.Select(GetName).Join(", "); + return type.Name + $"❰{typeArguments}❱"; + } + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/GenerateHtmlDiagrammer.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/GenerateHtmlDiagrammer.cs new file mode 100644 index 000000000..c25892ae7 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/GenerateHtmlDiagrammer.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + /// The command for creating an HTML5 diagramming app with an API optimized for binding command line parameters. + /// To use it outside of that context, set its properties and call . + public partial class GenerateHtmlDiagrammer + { + internal const string RepoUrl = "https://github.com/h0lg/netAmermaid"; + + public required string Assembly { get; set; } + public string? OutputFolder { get; set; } + + public string? Include { get; set; } + public string? Exclude { get; set; } + public bool JsonOnly { get; set; } + public bool ReportExludedTypes { get; set; } + public string? XmlDocs { get; set; } + + /// Namespaces to strip from . + /// Implemented as a list of exact replacements instead of a single, more powerful RegEx because replacement in + /// + /// happens on the unstructured string where matching and replacing the namespaces of referenced types, members and method parameters + /// using RegExes would add a lot of complicated RegEx-heavy code for a rather unimportant feature. + public IEnumerable? StrippedNamespaces { get; set; } + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/Generator.Run.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/Generator.Run.cs new file mode 100644 index 000000000..c01f07b73 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/Generator.Run.cs @@ -0,0 +1,147 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +using ICSharpCode.Decompiler.Documentation; +using ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + partial class GenerateHtmlDiagrammer + { + public void Run() + { + var assemblyPath = GetPath(Assembly); + XmlDocumentationFormatter? xmlDocs = CreateXmlDocsFormatter(assemblyPath); + ClassDiagrammer model = BuildModel(assemblyPath, xmlDocs); + GenerateOutput(assemblyPath, model); + } + + protected virtual XmlDocumentationFormatter? CreateXmlDocsFormatter(string assemblyPath) + { + var xmlDocsPath = XmlDocs == null ? Path.ChangeExtension(assemblyPath, ".xml") : GetPath(XmlDocs); + XmlDocumentationFormatter? xmlDocs = null; + + if (File.Exists(xmlDocsPath)) + xmlDocs = new XmlDocumentationFormatter(new XmlDocumentationProvider(xmlDocsPath), StrippedNamespaces?.ToArray()); + else + Console.WriteLine("No XML documentation file found. Continuing without."); + + return xmlDocs; + } + + protected virtual ClassDiagrammer BuildModel(string assemblyPath, XmlDocumentationFormatter? xmlDocs) + => new ClassDiagrammerFactory(xmlDocs).BuildModel(assemblyPath, Include, Exclude); + + private string SerializeModel(ClassDiagrammer diagrammer) + { + object jsonModel = new { + diagrammer.OutsideReferences, + + /* convert collections to dictionaries for easier access in ES using + * for (let [key, value] of Object.entries(dictionary)) */ + TypesByNamespace = diagrammer.TypesByNamespace.ToDictionary(ns => ns.Key, + ns => ns.Value.ToDictionary(t => t.Id, t => t)) + }; + + // wrap model including the data required for doing the template replacement in a JS build task + if (JsonOnly) + { + jsonModel = new { + diagrammer.SourceAssemblyName, + diagrammer.SourceAssemblyVersion, + BuilderVersion = AssemblyInfo.Version, + RepoUrl, + // pre-serialize to a string so that we don't have to re-serialize it in the JS build task + Model = Serialize(jsonModel) + }; + } + + return Serialize(jsonModel); + } + + private static JsonSerializerOptions serializerOptions = new() { + WriteIndented = true, + // avoid outputting null properties unnecessarily + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static string Serialize(object json) => JsonSerializer.Serialize(json, serializerOptions); + + private void GenerateOutput(string assemblyPath, ClassDiagrammer model) + { + var htmlSourcePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "html"); + string modelJson = SerializeModel(model); + + var outputFolder = OutputFolder ?? + /* If no out folder is specified and export mode is JsonOnly, + * default to the HTML diagrammer source folder - that's where it's most likely used. + * Otherwise default to a "netAmermaid" folder next to the input assembly. */ + (JsonOnly ? htmlSourcePath : Path.Combine(Path.GetDirectoryName(assemblyPath) ?? string.Empty, "netAmermaid")); + + if (!Directory.Exists(outputFolder)) + Directory.CreateDirectory(outputFolder); + + if (JsonOnly) + { + File.WriteAllText(Path.Combine(outputFolder, "model.json"), modelJson); + Console.WriteLine("Successfully generated model.json for HTML diagrammer."); + } + else + { + var htmlTemplate = File.ReadAllText(Path.Combine(htmlSourcePath, "template.html")); + + var html = htmlTemplate + .Replace("{{SourceAssemblyName}}", model.SourceAssemblyName) + .Replace("{{SourceAssemblyVersion}}", model.SourceAssemblyVersion) + .Replace("{{BuilderVersion}}", AssemblyInfo.Version) + .Replace("{{RepoUrl}}", RepoUrl) + .Replace("{{Model}}", modelJson); + + File.WriteAllText(Path.Combine(outputFolder, "class-diagrammer.html"), html); + + // copy required resources to output folder while flattening paths if required + foreach (var path in new[] { "styles.css", "netAmermaid.ico", "script.js" }) + File.Copy(Path.Combine(htmlSourcePath, path), Path.Combine(outputFolder, Path.GetFileName(path)), overwrite: true); + + Console.WriteLine("Successfully generated HTML diagrammer."); + } + + if (ReportExludedTypes) + { + string excludedTypes = model.Excluded.Join(Environment.NewLine); + File.WriteAllText(Path.Combine(outputFolder, "excluded types.txt"), excludedTypes); + } + } + + private protected virtual string GetPath(string pathOrUri) + { + // convert file:// style argument, see https://stackoverflow.com/a/38245329 + if (!Uri.TryCreate(pathOrUri, UriKind.RelativeOrAbsolute, out Uri? uri)) + throw new ArgumentException("'{0}' is not a valid URI", pathOrUri); + + // support absolute paths as well as file:// URIs and interpret relative path as relative to the current directory + return uri.IsAbsoluteUri ? uri.AbsolutePath : pathOrUri; + } + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/XmlDocumentationFormatter.cs b/ICSharpCode.ILSpyX/MermaidDiagrammer/XmlDocumentationFormatter.cs new file mode 100644 index 000000000..6b5aef568 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/XmlDocumentationFormatter.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2024 Holger Schmidt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +using ICSharpCode.Decompiler.Documentation; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.MermaidDiagrammer.Extensions; + +namespace ICSharpCode.ILSpyX.MermaidDiagrammer +{ + /// Wraps the to prettify XML documentation comments. + /// Make sure to enable XML documentation output, see + /// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/xmldoc/#create-xml-documentation-output . + public class XmlDocumentationFormatter + { + /// Matches XML indent. + protected const string linePadding = @"^[ \t]+|[ \t]+$"; + + /// Matches reference tags including "see href", "see cref" and "paramref name" + /// with the cref value being prefixed by symbol-specific letter and a colon + /// including the quotes around the attribute value and the closing slash of the tag containing the attribute. + protected const string referenceAttributes = @"(see\s.ref=""(.:)?)|(paramref\sname="")|(""\s/)"; + + private readonly IDocumentationProvider docs; + private readonly Regex noiseAndPadding; + + public XmlDocumentationFormatter(IDocumentationProvider docs, string[]? strippedNamespaces) + { + this.docs = docs; + List regexes = new() { linePadding, referenceAttributes }; + + if (strippedNamespaces?.Length > 0) + regexes.AddRange(strippedNamespaces.Select(ns => $"({ns.Replace(".", "\\.")}\\.)")); + + noiseAndPadding = new Regex(regexes.Join("|"), RegexOptions.Multiline); // builds an OR | combined regex + } + + internal Dictionary? GetXmlDocs(ITypeDefinition type, params IMember[][] memberCollections) + { + Dictionary? docs = new(); + AddXmlDocEntry(docs, type); + + foreach (IMember[] members in memberCollections) + { + foreach (IMember member in members) + AddXmlDocEntry(docs, member); + } + + return docs?.Keys.Count != 0 ? docs : default; + } + + protected virtual string? GetDoco(IEntity entity) + { + string? comment = docs.GetDocumentation(entity)? + .ReplaceAll(["", ""], null) + .ReplaceAll(["", ""], ClassDiagrammer.NewLine).Trim() // to format + .Replace('<', '[').Replace('>', ']'); // to prevent ugly escaped output + + return comment == null ? null : noiseAndPadding.Replace(comment, string.Empty).NormalizeHorizontalWhiteSpace(); + } + + private void AddXmlDocEntry(Dictionary docs, IEntity entity) + { + string? doc = GetDoco(entity); + + if (string.IsNullOrEmpty(doc)) + return; + + string key = entity is IMember member ? member.Name : string.Empty; + docs[key] = doc; + } + } +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.eslintrc.js b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.eslintrc.js new file mode 100644 index 000000000..3ef10a646 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.eslintrc.js @@ -0,0 +1,16 @@ +module.exports = { + 'env': { + 'commonjs': true, + 'es6': true, + 'browser': true + }, + 'extends': 'eslint:recommended', + 'parserOptions': { + 'sourceType': 'module', + 'ecmaVersion': 'latest' + }, + 'rules': { + 'indent': ['error', 4, { 'SwitchCase': 1 }], + 'semi': ['error', 'always'] + } +}; \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.gitignore b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.gitignore new file mode 100644 index 000000000..1922e8652 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/class-diagrammer.html +/model.json +/package-lock.json diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.vscode/tasks.json b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.vscode/tasks.json new file mode 100644 index 000000000..361bb32e5 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/.vscode/tasks.json @@ -0,0 +1,51 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Generate model.json", + "detail": "for editing the template, script or styles in inner dev loop of the HTML diagrammer", + "group": "build", + "type": "shell", + "command": [ + "if (Test-Path '../bin/Release/net8.0/netAmermaid.exe') {", + " ../bin/Release/net8.0/netAmermaid.exe -a ../bin/Release/net8.0/netAmermaid.dll -n NetAmermaid System -j -o .", + "} else {", + " Write-Host 'netAmermaid.exe Release build not found. Please build it first.';", + " exit 1", + "}" + ], + "problemMatcher": [] + }, + { + "label": "Transpile .less", + "detail": "into .css files", + "group": "build", + "type": "gulp", + "task": "transpileLess", + "problemMatcher": [ + "$lessc" + ] + }, + { + "label": "Generate HTML diagrammer", + "detail": "from the template.html and a model.json", + "group": "build", + "type": "gulp", + "task": "generateHtmlDiagrammer", + "problemMatcher": [ + "$gulp-tsc" + ] + }, + { + "label": "Auto-rebuild on change", + "detail": "run build tasks automatically when source files change", + "type": "gulp", + "task": "autoRebuildOnChange", + "problemMatcher": [ + "$gulp-tsc" + ] + } + ] +} \ No newline at end of file diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/html/gulpfile.js b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/gulpfile.js new file mode 100644 index 000000000..0fd2112d4 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/gulpfile.js @@ -0,0 +1,66 @@ +const gulp = require('gulp'); +const less = require('gulp-less'); +const fs = require('fs'); + +function transpileLess (done) { + gulp + .src('styles.less') // source file(s) to process + .pipe(less()) // pass them through the LESS compiler + .pipe(gulp.dest(f => f.base)); // Use the base directory of the source file for output + + done(); // signal task completion +} + +function generateHtmlDiagrammer (done) { + // Read and parse model.json + fs.readFile('model.json', 'utf8', function (err, data) { + if (err) { + console.error('Error reading model.json:', err); + done(err); + return; + } + + const model = JSON.parse(data); // Parse the JSON data + + // Read template.html + fs.readFile('template.html', 'utf8', function (err, templateContent) { + if (err) { + console.error('Error reading template.html:', err); + done(err); + return; + } + + // Replace placeholders in template with values from model + let outputContent = templateContent; + + for (const [key, value] of Object.entries(model)) { + const placeholder = `{{${key}}}`; // Create the placeholder + outputContent = outputContent.replace(new RegExp(placeholder, 'g'), value); // Replace all occurrences + } + + // Save the replaced content + fs.writeFile('class-diagrammer.html', outputContent, 'utf8', function (err) { + if (err) { + console.error('Error writing class-diagrammer.html:', err); + done(err); + return; + } + + console.log('class-diagrammer.html generated successfully.'); + done(); // Signal completion + }); + }); + }); +} + +exports.transpileLess = transpileLess; +exports.generateHtmlDiagrammer = generateHtmlDiagrammer; + +/* Run individual build tasks first, then start watching for changes + see https://code.visualstudio.com/Docs/languages/CSS#_automating-sassless-compilation */ +exports.autoRebuildOnChange = gulp.series(transpileLess, generateHtmlDiagrammer, function (done) { + // Watch for changes in source files and rerun the corresponding build task + gulp.watch('styles.less', gulp.series(transpileLess)); + gulp.watch(['template.html', 'model.json'], gulp.series(generateHtmlDiagrammer)); + done(); // signal task completion +}); diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/html/package.json b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/package.json new file mode 100644 index 000000000..b5d722e21 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/package.json @@ -0,0 +1,7 @@ +{ + "devDependencies": { + "eslint": "^8.57.1", + "gulp": "^4.0.2", + "gulp-less": "^5.0.0" + } +} diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/html/script.js b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/script.js new file mode 100644 index 000000000..824d5d221 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/script.js @@ -0,0 +1,1135 @@ +/*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); + } + }, + + // 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.ILSpyX/MermaidDiagrammer/html/styles.css b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/styles.css new file mode 100644 index 000000000..b3c9a0595 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/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; +} +#netAmermaid { + position: absolute; + bottom: 2em; + right: 2em; + align-items: end; +} +#netAmermaid #toaster { + margin-right: 2.8em; +} +#netAmermaid #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; +} +#netAmermaid #toaster span.leaving { + animation: 1s ease-in-out fadeOut; +} +#netAmermaid .build-info { + align-items: end; + height: 2.3em; + border-radius: 7px; + background-color: rgba(0, 0, 0, calc(3/16 * 3)); + color: whitesmoke; +} +#netAmermaid .build-info > * { + height: 100%; +} +#netAmermaid .build-info #build-info { + text-align: right; +} +#netAmermaid .build-info #build-info > * { + padding: 0 0.5em; +} +#netAmermaid .build-info #build-info a { + color: whitesmoke; +} +#netAmermaid .build-info #build-info a:not(.project) { + text-decoration: none; +} +#netAmermaid .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, + #netAmermaid, + 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; +} diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/html/styles.less b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/styles.less new file mode 100644 index 000000000..584aa3ec7 --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/styles.less @@ -0,0 +1,586 @@ +@darkBlue: #117; + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +.clickable() { + cursor: pointer; +} + +.useBrightText() { + color: whitesmoke; +} + +.colorLabelWithDocs() { + color: darkgoldenrod; +} + +.darkenBg(@times: 1) { + background-color: rgba(0,0,0, calc(3/16 * @times)); +} + +.brightenBg(@times: 1) { + background-color: rgba(255,255,255, calc(1/16 * @times)); +} + +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: @darkBlue; + .clickable; + + &.icon { + font-size: 1em; + background-color: transparent; + } + + &:disabled { + opacity: .5; + } +} + +[type=checkbox], [type=radio] { + .clickable; + + & ~ label { + .clickable; + } +} + +fieldset { + border-radius: 5px; +} + +select { + border: none; + border-radius: 3px; + .darkenBg; + .useBrightText; + + option:checked { + .darkenBg; + color: darkorange; + } +} + +.flx:not([hidden]) { + display: flex; + + &.col { + flex-direction: column; + } + + &.spaced { + justify-content: space-between; + } + + &.gap { + gap: .5em; + } + + &.aligned { + align-items: center; + } + + .grow { + flex-grow: 1; + } +} + +.collapse { + &.vertical { + max-height: 0; + overflow: hidden; + transition: max-height ease-in-out .5s; + + &.open { + max-height: 100vh; + } + } + + &.horizontal { + max-width: 0; + padding: 0; + margin: 0; + transition: all ease-in-out .5s; + overflow: hidden; + + &.open { + padding: revert; + max-width: 100vw; + } + } +} + +.toggle, [data-toggles] { + .clickable; +} + +.container { + position: absolute; + inset: 0; + margin: 0; +} + +.scndry { + font-size: smaller; +} + +.mano-a-borsa { + transform: rotate(95deg); + .clickable; + + &:after { + content: '🤏'; + } +} + +.trawl-net { + transform: rotate(180deg) translateY(-2px); + display: inline-block; + + &:after { + content: '🥅'; + } +} + +.torch { + display: inline-block; + + &: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 .5s; + overflow: hidden; + .darkenBg; + .useBrightText; + + &.open { + max-width: 15em; + overflow: auto; + } + + &.resizing { + transition: none; + } + + > * { + margin: .3em .3em 0; + + &:last-child { + margin-bottom: .3em; + } + } + + #pre-filter-types { + min-width: 3em; + } + + [data-toggles="#info"] { + .torch { + transform: rotate(-90deg); + transition: transform .5s; + } + + &[aria-expanded=true] { + .torch { + transform: rotate(-255deg); + } + } + } + + #info { + overflow: auto; + .brightenBg(2); + + a.toggle { + .useBrightText; + + img { + height: 1em; + } + } + } + + #type-select { + overflow: auto; + } + + #inheritance { + padding: .1em .75em .2em; + } + + #direction { + [type=radio] { + display: none; + + &:checked + label { + .brightenBg(4); + } + } + + label { + flex-grow: 1; + text-align: center; + margin: -1em 0 -.7em; + padding-top: .2em; + + &:first-of-type { + margin-left: -.8em; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + &:last-of-type { + margin-right: -.8em; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + } + } + + #actions { + margin-top: 1em; + justify-content: space-between; + + #render { + font-weight: bold; + } + } + + #exportOptions { + overflow: auto; + .brightenBg(2); + + #save { + margin-right: .5em; + } + + #dimensions fieldset { + padding: .5em; + + .scale-size { + margin-left: .5em; + + #scale-size { + width: 2.5em; + margin: 0 .2em; + } + } + } + } +} + +#filter-toggle { + padding: 0; + border-radius: 0; + background-color: @darkBlue; + .useBrightText; +} + +#output { + overflow: auto; + + > svg { + cursor: grab; + + &:active { + cursor: grabbing; + } + } + + .edgeLabels { + .edgeTerminals .edgeLabel { + .useBrightText; + } + + .edgeLabel { + border-radius: 3px; + + .edgeLabel[title] { + .colorLabelWithDocs; + } + } + } + + path.relation { + stroke: whitesmoke; + } + + g.nodes { + > g { + .clickable; + + > rect { + rx: 5px; + ry: 5px; + } + } + + g.label .nodeLabel[title] { + .colorLabelWithDocs; + } + } +} + +#netAmermaid { + position: absolute; + bottom: 2em; + right: 2em; + align-items: end; + @logoWidth: 2.3em; + + #toaster { + margin-right: @logoWidth + .5em; + + span { + animation: .5s ease-in fadeIn; + border-radius: .5em; + padding: .5em; + .darkenBg(2); + .useBrightText; + + &.leaving { + animation: 1s ease-in-out fadeOut; + } + } + } + + .build-info { + align-items: end; + height: @logoWidth; + border-radius: 7px; + .darkenBg(3); + .useBrightText; + + > * { + height: 100%; + } + + #build-info { + text-align: right; + + > * { + padding: 0 .5em; + } + + a { + .useBrightText; + + &:not(.project) { + text-decoration: none; + } + + span { + display: inline-block; + } + } + } + } +} + +#pressed-keys { + position: fixed; + left: 50%; + transform: translateX(-50%); + font-size: 3em; + bottom: 1em; + opacity: 1; + border-radius: .5em; + padding: .5em; + .darkenBg(2); + .useBrightText; + + &.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 .1em yellow; + + &.down { + background-color: #ff08; + } +} + +/* hide stuff in print view */ +@media print { + #filter, #filter-toggle, #netAmermaid, 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; + + li { + position: absolute; + display: block; + list-style: none; + width: 20px; + height: 20px; + background: rgba(255, 255, 255, .2); + animation: rotateUp 25s linear infinite; + bottom: -150px; + + &:nth-child(1) { + left: 25%; + width: 80px; + height: 80px; + animation-delay: 0s; + } + + &:nth-child(2) { + left: 10%; + width: 20px; + height: 20px; + animation-delay: 2s; + animation-duration: 12s; + } + + &:nth-child(3) { + left: 70%; + width: 20px; + height: 20px; + animation-delay: 4s; + } + + &:nth-child(4) { + left: 40%; + width: 60px; + height: 60px; + animation-delay: 0s; + animation-duration: 18s; + } + + &:nth-child(5) { + left: 65%; + width: 20px; + height: 20px; + animation-delay: 0s; + } + + &:nth-child(6) { + left: 75%; + width: 110px; + height: 110px; + animation-delay: 3s; + } + + &:nth-child(7) { + left: 35%; + width: 150px; + height: 150px; + animation-delay: 7s; + } + + &:nth-child(8) { + left: 50%; + width: 25px; + height: 25px; + animation-delay: 15s; + animation-duration: 45s; + } + + &:nth-child(9) { + left: 20%; + width: 15px; + height: 15px; + animation-delay: 2s; + animation-duration: 35s; + } + + &:nth-child(10) { + left: 85%; + width: 150px; + height: 150px; + animation-delay: 0s; + animation-duration: 11s; + } + } +} diff --git a/ICSharpCode.ILSpyX/MermaidDiagrammer/html/template.html b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/template.html new file mode 100644 index 000000000..fbfe799af --- /dev/null +++ b/ICSharpCode.ILSpyX/MermaidDiagrammer/html/template.html @@ -0,0 +1,194 @@ + + + + + {{SourceAssemblyName}} class diagrammer - netAmermaid + + + + + + + +
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+ +
+
+
+ + + +
+ +
+

+ 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 {{SourceAssemblyName}} v{{SourceAssemblyVersion}} and mermaid.js from CDN + 📥 + + + using netAmermaid v{{BuilderVersion}} + 📜 + 💬 + + 🌩️ + +
+ +
+
+ + + + + + + + +