diff --git a/SQLLinter/Infrastructure/Diagram/FragmentDiagramBuilder.cs b/SQLLinter/Infrastructure/Diagram/FragmentDiagramBuilder.cs deleted file mode 100644 index 0337687..0000000 --- a/SQLLinter/Infrastructure/Diagram/FragmentDiagramBuilder.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text; -using Microsoft.SqlServer.TransactSql.ScriptDom; -using System; -using System.Linq; -using System.Collections.Generic; - -namespace SQLLinter.Infrastructure.Diagram; - -public static class FragmentDiagramBuilder -{ - public static string RenderMermaid(TSqlFragment fragment) - { - if (fragment == null) return string.Empty; - var diagram = BpmnBuilder.Build(fragment); - return MermaidRenderer.RenderMarkdown(diagram); - } - - public static string RenderHtmlSvg(TSqlFragment fragment) - { - if (fragment == null) return string.Empty; - var diagram = BpmnBuilder.Build(fragment); - // Use mermaid HTML instead of SVG fallback - return MermaidRenderer.RenderHtml(diagram); - } - - // keep helpers used by earlier code if needed - private static string Escape(string s) - { - if (s == null) return string.Empty; - return System.Net.WebUtility.HtmlEncode(s).Replace("\n", " ").Replace("\r", " "); - } - - private static string Truncate(string s, int len) - { - if (s == null) return string.Empty; - if (s.Length <= len) return s; - return s.Substring(0, len - 3) + "..."; - } - - private static string SanitizeId(string s) - { - if (string.IsNullOrEmpty(s)) return "id"; - var sb = new StringBuilder(); - foreach (var ch in s) - { - if (char.IsLetterOrDigit(ch)) sb.Append(ch); - else sb.Append('_'); - } - return sb.ToString(); - } -} diff --git a/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs b/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs index f243d7a..9c1f667 100644 --- a/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs +++ b/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs @@ -43,6 +43,7 @@ public static class MermaidRenderer { var procId = SanitizeId(proc.Id); sb.AppendLine($" subgraph {procId} [\"{Escape(proc.Name)}\"]"); + sb.AppendLine($" direction TB"); var startNodes = proc.Nodes.Where(n => n.Type == BpmnNodeType.Start).ToList(); var taskNodes = proc.Nodes.Where(n => n.Type == BpmnNodeType.Task || n.Type == BpmnNodeType.Gateway).ToList(); @@ -116,17 +117,6 @@ public static class MermaidRenderer return sb.ToString(); } - public static string RenderHtml(BpmnDiagram diagram) - { - var content = ToMermaidContent(diagram); - var sb = new StringBuilder(); - - // Put raw mermaid text inside .mermaid container so mermaid.js can render it - sb.AppendLine("
\n" + content + "\n
"); - - return sb.ToString(); - } - private static string Escape(string s) { if (s == null) return string.Empty; diff --git a/SQLLinter/Infrastructure/Reporters/HtmlReportFormatter.cs b/SQLLinter/Infrastructure/Reporters/HtmlReportFormatter.cs index 4525ed4..1347be9 100644 --- a/SQLLinter/Infrastructure/Reporters/HtmlReportFormatter.cs +++ b/SQLLinter/Infrastructure/Reporters/HtmlReportFormatter.cs @@ -1,5 +1,6 @@ using SQLLinter.Common; using SQLLinter.Infrastructure.Diagram; +using System.Reflection; using System.Text; namespace SQLLinter.Infrastructure.Reporters; @@ -7,31 +8,64 @@ namespace SQLLinter.Infrastructure.Reporters; public class HtmlReportFormatter : IReportFormatter { public string Format(List violations) + => Format(violations, null); + + public string Format(List violations, BpmnDiagram? diagram) { - if (violations.Count == 0) + var sb = new StringBuilder(); + + GenerateBeginningHtml(sb); + + if (violations.Count == 0 && diagram == null) { - return "

Нет нарушений

"; + sb.AppendLine("
"); + sb.AppendLine("
"); + sb.AppendLine("
"); + sb.AppendLine("

Проверка завершена

"); + sb.AppendLine("

Нарушений правил SQL не обнаружено.

"); + sb.AppendLine("
"); + sb.AppendLine("
"); + + GenerateEndingHtml(sb, false); + return sb.ToString(); } var groupedByFile = violations .GroupBy(v => v.FileName) - .OrderBy(g => g.Key); - - var sb = new StringBuilder(); - GenerateBeginningHtml(sb); + .OrderBy(g => g.Key) + .ToList(); int fileIndex = 0; + + // --- Отчёты по файлам --- foreach (var fileGroup in groupedByFile) { - string divId = $"file_{fileIndex}"; - sb.AppendLine($"
"); - sb.AppendLine($"

Файл: {fileGroup.Key}

"); + string fileName = Path.GetFileName(fileGroup.Key); - var groupedBySeverity = fileGroup + sb.AppendLine($""" +
+ """); + + // Заголовок файла - добавляем ПЕРЕД секциями с ошибками + sb.AppendLine($""" +
+
+ + + + + {fileName} +
+
+ """); + + // Группируем по severity и выводим таблицы + var severityGroups = fileGroup .GroupBy(v => v.Severity) - .OrderByDescending(g => g.Key); + .OrderByDescending(g => g.Key) + .ToList(); - foreach (var severityGroup in groupedBySeverity) + foreach (var severityGroup in severityGroups) { string severityClass = severityGroup.Key switch { @@ -41,202 +75,210 @@ public class HtmlReportFormatter : IReportFormatter _ => "" }; - sb.AppendLine($"
"); - sb.AppendLine($"

{severityGroup.Key}

"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - - int rowIndex = 1; - foreach (var v in severityGroup - .OrderBy(x => x.Line) - .ThenBy(x => x.Column)) + string severityTitle = severityGroup.Key switch { - sb.AppendLine($""); - rowIndex++; - } - - sb.AppendLine(""); - sb.AppendLine("
#СтрокаКолонкаПравилоОписание
{rowIndex}{v.Line}{v.Column}{v.RuleName}{v.Text}
"); - sb.AppendLine("
"); - } - - sb.AppendLine("
"); - fileIndex++; - } - - // Табы снизу - sb.AppendLine("
"); - fileIndex = 0; - foreach (var fileGroup in groupedByFile) - { - sb.AppendLine($"
{fileGroup.Key}
"); - fileIndex++; - } - sb.AppendLine("
"); - - GenerateEndingHtml(sb); - return sb.ToString(); - } - - public string Format(List violations, BpmnDiagram diagram) - { - if (violations.Count == 0) - { - return "

Нет нарушений

"; - } - - var groupedByFile = violations - .GroupBy(v => v.FileName) - .OrderBy(g => g.Key); - - var sb = new StringBuilder(); - GenerateBeginningHtml(sb); - - int fileIndex = 0; - foreach (var fileGroup in groupedByFile) - { - string divId = $"file_{fileIndex}"; - sb.AppendLine($"
"); - sb.AppendLine($"

Файл: {fileGroup.Key}

"); - - var groupedBySeverity = fileGroup - .GroupBy(v => v.Severity) - .OrderByDescending(g => g.Key); - - foreach (var severityGroup in groupedBySeverity) - { - string severityClass = severityGroup.Key switch - { - RuleViolationSeverity.Critical => "critical", - RuleViolationSeverity.Warning => "warning", - RuleViolationSeverity.Info => "info", + RuleViolationSeverity.Critical => "Critical", + RuleViolationSeverity.Warning => "Warning", + RuleViolationSeverity.Info => "Info", _ => "" }; - sb.AppendLine($"
"); - sb.AppendLine($"

{severityGroup.Key}

"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); + sb.AppendLine($""" +
+
+
+

{severityTitle}

+ {severityGroup.Count()} +
+
+
+
#СтрокаКолонкаПравилоОписание
+ + + + + + + + + + + """); - int rowIndex = 1; + int row = 1; foreach (var v in severityGroup - .OrderBy(x => x.Line) - .ThenBy(x => x.Column)) + .OrderBy(x => x.Line) + .ThenBy(x => x.Column)) { - sb.AppendLine($""); - rowIndex++; + sb.AppendLine( + $""" + + + + + + + + """); + row++; } - sb.AppendLine(""); - sb.AppendLine("
#СтрокаКолонкаПравилоОписание
{rowIndex}{v.Line}{v.Column}{v.RuleName}{v.Text}
{row}{v.Line}{v.Column}{v.RuleName}{EscapeHtml(v.Text)}
"); - sb.AppendLine("
"); + sb.AppendLine($""" + + +
+
+ """); } sb.AppendLine(""); fileIndex++; } - //mermaid диаграмма - sb.AppendLine($"
"); - sb.AppendLine($"

Диаграмма

"); - - var mermaid = MermaidRenderer.RenderHtml(diagram); - sb.AppendLine(mermaid); - sb.AppendLine("
"); - - // Табы снизу - sb.AppendLine("
"); - fileIndex = 0; - foreach (var fileGroup in groupedByFile) + // --- Вкладка с диаграммой --- + bool hasDiagram = diagram != null; + if (hasDiagram) { - sb.AppendLine($"
{fileGroup.Key}
"); - fileIndex++; + sb.AppendLine($""" +
+
+ +
+ + +
+
+ +
+
+
+
+ + +
+ """); } - sb.AppendLine($"
Диграмма
"); - sb.AppendLine("
"); + // --- Табы --- + if (violations.Count > 0 || hasDiagram) + { + sb.AppendLine(""" +
+
+ """); - GenerateEndingHtml(sb); + // Табы для файлов + for (int i = 0; i < groupedByFile.Count; i++) + { + var fileGroup = groupedByFile[i]; + string fileName = Path.GetFileName(fileGroup.Key); + string activeClass = i == 0 ? " active" : ""; + string tabBadge = fileGroup.Count().ToString(); + + sb.AppendLine($""" +
+ {fileName} + {tabBadge} +
+ """); + } + + // Таб для диаграммы + if (hasDiagram) + { + sb.AppendLine(""" +
+ Диаграмма +
+ """); + } + + sb.AppendLine(""" +
+
+ """); + } + + GenerateEndingHtml(sb, hasDiagram); return sb.ToString(); } private void GenerateBeginningHtml(StringBuilder sb) { - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine("Отчёт по SQL‑проверкам"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - //sb.AppendLine("

Отчёт по SQL‑проверкам

"); + // Загружаем CSS из ресурсов и добавляем стили для заголовка файла + sb.AppendLine(LoadResource("HtmlFormatter.css")); + + sb.AppendLine(""" + + + +
+ """); } - private void GenerateEndingHtml(StringBuilder sb) + private void GenerateEndingHtml(StringBuilder sb, bool hasDiagram) { + sb.AppendLine(""" +
+ """); - // JS для переключения - sb.AppendLine(""); + sb.AppendLine(""" + + + + """); } -} + + private static string LoadResource(string endsWith) + { + var assembly = Assembly.GetExecutingAssembly(); + + var name = assembly.GetManifestResourceNames() + .First(n => n.EndsWith(endsWith, StringComparison.OrdinalIgnoreCase)); + + using var stream = assembly.GetManifestResourceStream(name); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + private static string EscapeHtml(string text) + { + return System.Net.WebUtility.HtmlEncode(text); + } +} \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.css b/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.css new file mode 100644 index 0000000..c6dc1a1 --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.css @@ -0,0 +1,1210 @@ +:root { + /* Colors - Light Theme */ + --color-primary: #0078d4; + --color-primary-dark: #106ebe; + --color-primary-darker: #005a9e; + --color-primary-light: #c7e0f4; + --color-primary-lighter: #deecf9; + --color-text-primary: #323130; + --color-text-secondary: #605e5c; + --color-text-tertiary: #a19f9d; + --color-text-disabled: #c8c6c4; + --color-background: #ffffff; + --color-background-alt: #faf9f8; + --color-background-neutral: #f3f2f1; + --color-background-neutral-dark: #edebe9; + --color-border: #edebe9; + --color-border-strong: #d2d0ce; + --color-border-input: #8a8886; + --color-critical: #d13438; + --color-critical-bg: #fde7e9; + --color-warning: #ffaa44; + --color-warning-bg: #fff4ce; + --color-success: #107c10; + --color-success-bg: #dff6dd; + --color-info: #0078d4; + --color-info-bg: #c7e0f4; + --color-mermaid-node: #f0f8ff; + --color-mermaid-node-border: #0078d4; + --color-mermaid-cluster: #f3f2f1; + --color-mermaid-edge: #8a8886; + --color-mermaid-text: #323130; + /* Shadows (облегчённые) */ + --shadow-2: 0 1px 2px rgba(0, 0, 0, 0.08); + --shadow-4: 0 2px 4px rgba(0, 0, 0, 0.12); + --shadow-8: 0 4px 8px rgba(0, 0, 0, 0.14); + --shadow-16: 0 8px 16px rgba(0, 0, 0, 0.16); + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 20px; + --spacing-xxl: 24px; + --spacing-xxxl: 32px; + /* Border Radius */ + --border-radius-small: 2px; + --border-radius-medium: 4px; + --border-radius-large: 6px; + --border-radius-xlarge: 8px; + /* Typography */ + --font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; + --font-size-xs: 12px; + --font-size-sm: 13px; + --font-size-md: 14px; + --font-size-lg: 16px; + --font-size-xl: 18px; + --font-size-xxl: 20px; + --font-size-xxxl: 24px; + /* Transitions */ + --transition-fast: 0.1s cubic-bezier(0.4, 0, 0.2, 1); + --transition-medium: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.3s cubic-bezier(0.4, 0, 0.2, 1); + /* Z-index */ + --z-index-base: 1; + --z-index-dropdown: 100; + --z-index-sticky: 200; + --z-index-fixed: 300; + --z-index-modal: 400; + --z-index-popover: 500; + --z-index-tooltip: 600; +} + +/* Dark Theme Variables */ +@media (prefers-color-scheme: dark) { + :root { + --color-primary: #2899f5; + --color-primary-dark: #3aa0f3; + --color-primary-darker: #6cb8f6; + --color-primary-light: #0f2b4d; + --color-primary-lighter: #1a365d; + --color-text-primary: #f3f2f1; + --color-text-secondary: #a19f9d; + --color-text-tertiary: #797775; + --color-text-disabled: #605e5c; + --color-background: #1e1e1e; + --color-background-alt: #2d2d2d; + --color-background-neutral: #3c3c3c; + --color-background-neutral-dark: #424242; + --color-border: #424242; + --color-border-strong: #616161; + --color-border-input: #797775; + --color-critical: #d13438; + --color-critical-bg: #4c191b; + --color-warning: #ffaa44; + --color-warning-bg: #4c3b1a; + --color-success: #107c10; + --color-success-bg: #1c3b1c; + --color-info: #2899f5; + --color-info-bg: #0f2b4d; + --color-mermaid-node: #2d2d2d; + --color-mermaid-node-border: #2899f5; + --color-mermaid-cluster: #201f1e; + --color-mermaid-edge: #797775; + --color-mermaid-text: #f3f2f1; + } +} + +/* --------------------------------------------------------- + RESET & BASE STYLES +--------------------------------------------------------- */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 100%; + -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scroll-behavior: smooth; +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-md); + line-height: 1.5; + color: var(--color-text-primary); + background-color: var(--color-background-neutral); + margin: 0; + padding: 0; + overflow-x: hidden; +} + +/* --------------------------------------------------------- + TYPOGRAPHY +--------------------------------------------------------- */ +h3 { + font-size: var(--font-size-xl); + font-weight: 600; + line-height: 1.3; + margin-bottom: var(--spacing-md); + color: var(--color-text-primary); +} + +/* --------------------------------------------------------- + FILE REPORTS & LAYOUT +--------------------------------------------------------- */ + +main#main-content { + min-height: 100vh; +} + +/* Основной контейнер отчёта по файлу */ +.file-report { + display: none; + padding-bottom: 120px; + animation: fadeIn var(--transition-slow); +} + + .file-report.active { + display: block; + } + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Заголовок файла */ +.file-title-container { + padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + background-color: var(--color-background); + position: sticky; + top: 0; + z-index: var(--z-index-sticky); + box-shadow: var(--shadow-2); +} + +.file-title { + display: flex; + align-items: center; + gap: var(--spacing-md); + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--color-text-primary); +} + + .file-title svg { + flex-shrink: 0; + opacity: 0.8; + } + +.file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Состояние без нарушений */ +.no-violations { + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + padding: var(--spacing-xl); +} + +.no-violations-content { + text-align: center; + max-width: 400px; +} + +.no-violations-icon { + font-size: 48px; + margin-bottom: var(--spacing-md); + color: var(--color-success); +} + +.no-violations-title { + font-size: 20px; + font-weight: 600; + margin-bottom: var(--spacing-sm); + color: var(--color-text-primary); +} + +.no-violations-description { + color: var(--color-text-secondary); + line-height: 1.6; +} + +/* --------------------------------------------------------- + SEVERITY SECTIONS +--------------------------------------------------------- */ +.severity-section { + margin: var(--spacing-xl) var(--spacing-xxxl); + padding: var(--spacing-xl); + background-color: var(--color-background); + border-radius: var(--border-radius-large); + box-shadow: var(--shadow-4); + border-top: 4px solid transparent; + transition: transform var(--transition-medium), box-shadow var(--transition-medium); +} + + .severity-section:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-8); + } + + .severity-section.critical { + border-top-color: var(--color-critical); + } + + .severity-section.warning { + border-top-color: var(--color-warning); + } + + .severity-section.info { + border-top-color: var(--color-info); + } + +.severity-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-xl); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--color-border); +} + +.severity-title { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.severity-count { + background-color: var(--color-background-neutral); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: 600; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--border-radius-medium); + min-width: 24px; + text-align: center; + border: 1px solid var(--color-border); +} + +.severity-section.critical .severity-count { + background-color: var(--color-critical-bg); + color: var(--color-critical); + border-color: var(--color-critical); +} + +.severity-section.warning .severity-count { + background-color: var(--color-warning-bg); + color: var(--color-warning); + border-color: var(--color-warning); +} + +.severity-section.info .severity-count { + background-color: var(--color-info-bg); + color: var(--color-info); + border-color: var(--color-info); +} + +/* --------------------------------------------------------- + TABLES +--------------------------------------------------------- */ +.table-container { + overflow-x: auto; + border-radius: var(--border-radius-medium); + border: 1px solid var(--color-border); + background-color: var(--color-background); +} + + .table-container table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: var(--font-size-sm); + min-width: 100%; + } + + .table-container thead { + background-color: var(--color-background-alt); + position: sticky; + top: 0; + z-index: var(--z-index-base); + } + +th { + font-weight: 600; + text-align: left; + color: var(--color-text-primary); + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 2px solid var(--color-border); + white-space: nowrap; + position: sticky; + top: 0; + z-index: 10; + background-color: var(--color-background-alt); +} + +td { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-primary); + vertical-align: top; +} + +tbody tr { + transition: background-color var(--transition-fast); +} + + tbody tr:last-child td { + border-bottom: none; + } + +/* Table cell specific styles */ +td.line, +td.column { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + text-align: center; + width: 60px; + font-feature-settings: "tnum"; + font-variant-numeric: tabular-nums; +} + +td.index { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); + text-align: center; + width: 40px; + font-feature-settings: "tnum"; + font-variant-numeric: tabular-nums; +} + +td.rule { + font-weight: 600; + color: var(--color-text-primary); + min-width: 200px; +} + +td.description { + color: var(--color-text-primary); + line-height: 1.4; +} + +/* Подсветка строк в зависимости от severity */ +.severity-section.critical tbody tr { + border-left: 3px solid var(--color-critical); +} + + .severity-section.critical tbody tr:hover { + background-color: rgba(209, 52, 56, 0.06); + } + +.severity-section.warning tbody tr { + border-left: 3px solid var(--color-warning); +} + + .severity-section.warning tbody tr:hover { + background-color: rgba(255, 170, 68, 0.06); + } + +.severity-section.info tbody tr { + border-left: 3px solid var(--color-info); +} + + .severity-section.info tbody tr:hover { + background-color: rgba(0, 120, 212, 0.06); + } + +/* --------------------------------------------------------- + TABS NAVIGATION +--------------------------------------------------------- */ +.tabs-container { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: var(--color-background); + border-top: 1px solid var(--color-border); + box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.08); + z-index: var(--z-index-fixed); + padding: var(--spacing-sm) var(--spacing-xxxl); +} + +.tabs { + display: flex; + gap: var(--spacing-xs); + overflow-x: auto; + scrollbar-width: thin; + padding: var(--spacing-xs) 0; +} + +.tab { + position: relative; + padding: var(--spacing-md) var(--spacing-lg); + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--color-text-secondary); + font-family: var(--font-family); + font-size: var(--font-size-md); + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: color var(--transition-medium), background-color var(--transition-medium), border-color var(--transition-medium), transform var(--transition-medium); + border-radius: var(--border-radius-medium) var(--border-radius-medium) 0 0; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1002; +} + + .tab:hover { + background-color: var(--color-background-neutral); + color: var(--color-text-primary); + } + + .tab.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); + background-color: var(--color-background-neutral); + } + +.tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 var(--spacing-xs); + margin-left: var(--spacing-xs); + font-size: var(--font-size-xs); + font-weight: 600; + background-color: var(--color-background-neutral-dark); + color: var(--color-text-secondary); + border-radius: 10px; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.tab.active .tab-badge { + background-color: var(--color-primary); + color: #ffffff; +} + +/* --------------------------------------------------------- + DIAGRAM VIEWER +--------------------------------------------------------- */ +#mermaid.file-report { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + background-color: var(--color-background); + z-index: var(--z-index-modal); + display: none; + padding: 0 !important; + margin: 0 !important; +} + + #mermaid.file-report.active { + display: block; + } + +/* Для диаграммы убираем заголовок */ +#mermaid .file-title-container { + display: none; +} + +/* Diagram Toolbar */ +.diagram-toolbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 52px; + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-xxxl); + background-color: var(--color-background); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow-4); + z-index: var(--z-index-fixed); +} + +.toolbar-search { + flex: 1; + max-width: 400px; + position: relative; +} + + .toolbar-search input { + width: 100%; + padding: var(--spacing-md) var(--spacing-lg); + padding-right: 40px; + border: 1px solid var(--color-border-input); + border-radius: var(--border-radius-medium); + font-family: var(--font-family); + font-size: var(--font-size-md); + background-color: var(--color-background); + color: var(--color-text-primary); + transition: border-color var(--transition-medium), box-shadow var(--transition-medium), background-color var(--transition-medium); + } + + .toolbar-search input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary-light); + } + + .toolbar-search input::placeholder { + color: var(--color-text-tertiary); + } + +.search-clear { + position: absolute; + right: var(--spacing-md); + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--color-text-tertiary); + cursor: pointer; + padding: var(--spacing-xs); + font-size: var(--font-size-sm); + opacity: 0.7; + transition: opacity var(--transition-fast), background-color var(--transition-fast); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + + .search-clear:hover { + opacity: 1; + background-color: var(--color-background-neutral); + } + +.toolbar-actions { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-left: auto; +} + +.toolbar-button { + padding: var(--spacing-md) var(--spacing-lg); + border: none; + border-radius: var(--border-radius-medium); + background-color: var(--color-primary); + color: #ffffff; + font-family: var(--font-family); + font-size: var(--font-size-md); + font-weight: 600; + cursor: pointer; + transition: background-color var(--transition-medium), box-shadow var(--transition-medium), transform var(--transition-medium); + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + min-height: 40px; +} + + .toolbar-button:hover { + background-color: var(--color-primary-dark); + box-shadow: var(--shadow-4); + } + + .toolbar-button:active { + background-color: var(--color-primary-darker); + transform: translateY(1px); + } + + .toolbar-button.secondary { + background-color: transparent; + color: var(--color-text-primary); + border: 1px solid var(--color-border); + } + + .toolbar-button.secondary:hover { + background-color: var(--color-background-neutral); + border-color: var(--color-border-strong); + } + +/* Diagram Container */ +#diagramContainer { + position: absolute; + top: 52px; + left: 0; + width: 100vw; + height: calc(100vh - 112px); /* 52px тулбар + ~60px табы */ + overflow: hidden; + background-color: var(--color-background); +} + +#diagramSvgContainer { + width: 100%; + height: 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + + #diagramSvgContainer svg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: grab; + overflow: visible; + display: block; + transition: transform 0.3s ease-out; /* Плавный переход при изменении масштаба */ + } + + #diagramSvgContainer svg:active { + cursor: grabbing; + } + + /* Индикатор загрузки для диаграммы */ + #diagramSvgContainer::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + border: 3px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + z-index: 100; + opacity: 0; + transition: opacity 0.3s; + } + + #diagramSvgContainer.loading::before { + opacity: 1; + } + +@keyframes spin { + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +/* Minimap */ +#minimap { + position: absolute; + right: var(--spacing-xxl); + bottom: 120px; + width: 200px; + height: 140px; + border: 1px solid var(--color-border-strong); + background-color: var(--color-background); + /* убран backdrop-filter для производительности */ + border-radius: var(--border-radius-large); + box-shadow: var(--shadow-8); + z-index: var(--z-index-popover); + overflow: hidden; +} + +@media (prefers-color-scheme: dark) { + #minimap { + background-color: var(--color-background-alt); + border-color: var(--color-border-input); + } +} + +.minimap-viewport { + position: absolute; + border: 2px solid var(--color-primary); + background-color: rgba(0, 120, 212, 0.15); + pointer-events: none; + transition: all 0.1s ease; + box-sizing: border-box; +} + +@media (prefers-color-scheme: dark) { + .minimap-viewport { + border-color: var(--color-primary); + background-color: rgba(40, 153, 245, 0.2); + } +} + +/* Упрощаем отображение элементов в миникарте */ +#minimap svg { + width: 100%; + height: 100%; +} + +#minimap .node rect { + stroke-width: 1px !important; + rx: 2px !important; + ry: 2px !important; +} + +#minimap .edgePath path { + stroke-width: 1px !important; +} + +#minimap text { + font-size: 4px !important; +} + +/* Search Results Info */ +.search-results-info { + position: absolute; + top: 70px; + left: var(--spacing-xxl); + background: var(--color-background); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--border-radius-large); + box-shadow: var(--shadow-8); + border: 1px solid var(--color-border); + font-size: var(--font-size-sm); + z-index: var(--z-index-popover); + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +/* --------------------------------------------------------- + MERMAID DIAGRAM STYLING +--------------------------------------------------------- */ + +#diagramSvgContainer svg { + font-family: var(--font-family); + font-size: var(--font-size-md); +} + + #diagramSvgContainer svg .label { + font-family: var(--font-family); + } + +/* Nodes */ +svg .node rect { + fill: var(--color-mermaid-node); + stroke: var(--color-mermaid-node-border); + stroke-width: 2px; + rx: 6px; + ry: 6px; +} + +svg .node circle, +svg .node ellipse, +svg .node polygon { + fill: var(--color-primary-lighter); + stroke: var(--color-mermaid-node-border); + stroke-width: 2px; +} + +svg .node text { + fill: var(--color-mermaid-text); + font-family: var(--font-family); + font-size: var(--font-size-md); + font-weight: 500; +} + +/* Clusters */ +svg .cluster rect { + fill: rgba(243, 242, 241, 0.9); + stroke: var(--color-text-tertiary); + stroke-width: 1.5px; + stroke-dasharray: 5, 5; + rx: 8px; + ry: 8px; +} + +svg .cluster text { + fill: var(--color-text-secondary); + font-size: var(--font-size-lg); + font-weight: 600; +} + +/* Arrow and Line Styling */ +svg marker path { + fill: var(--color-mermaid-edge); +} + +svg .arrowheadPath { + fill: var(--color-mermaid-edge) !important; +} + +svg .edgePath path { + stroke: var(--color-mermaid-edge); + stroke-width: 2px; + fill: none; +} + +svg .edgeLabel text { + fill: var(--color-mermaid-text); + font-size: var(--font-size-sm); +} + +svg .edgeLabel rect { + fill: var(--color-background); + stroke: var(--color-border); + stroke-width: 1px; + rx: 4px; + ry: 4px; +} + +/* Text Labels */ +svg .label text { + font-size: var(--font-size-sm); + font-weight: 400; +} + +/* Dark Theme Mermaid Styles */ +@media (prefers-color-scheme: dark) { + svg .node rect { + fill: var(--color-mermaid-node); + stroke: var(--color-mermaid-node-border); + } + + svg .node circle, + svg .node ellipse, + svg .node polygon { + fill: var(--color-background-neutral); + stroke: var(--color-mermaid-node-border); + } + + svg .node text { + fill: var(--color-mermaid-text); + font-weight: 400; + } + + svg .cluster rect { + fill: rgba(32, 31, 30, 0.9); + stroke: var(--color-text-tertiary); + stroke-dasharray: 5, 5; + } + + svg .cluster text { + fill: var(--color-text-tertiary); + font-weight: 500; + } + + svg .edgePath path { + stroke: var(--color-mermaid-edge); + } + + svg .edgeLabel text { + fill: var(--color-mermaid-text); + } + + svg .edgeLabel rect { + fill: var(--color-background-alt); + stroke: var(--color-border-strong); + } + + svg .label text { + fill: var(--color-text-tertiary); + } + + #diagramSvgContainer svg, + #diagramContainer { + background-color: var(--color-background); + } +} + +/* Node Type Specific Styles */ +svg .node.condition rect { + fill: var(--color-warning-bg); + stroke: var(--color-warning); +} + +svg .node.process rect { + fill: var(--color-info-bg); + stroke: var(--color-info); +} + +svg .node.start rect, +svg .node.end rect { + fill: var(--color-primary-lighter); + stroke: var(--color-primary); +} + +svg .node.import rect { + fill: var(--color-success-bg); + stroke: var(--color-success); +} + +svg .node.exec rect { + fill: #f0f0ff; + stroke: #4b4bd8; +} + +@media (prefers-color-scheme: dark) { + svg .node.start rect, + svg .node.end rect { + fill: var(--color-primary-light); + stroke: var(--color-primary); + } + + svg .node.exec rect { + fill: #2d2a4d; + stroke: #4b4bd8; + } +} + +/* Node Highlighting (упрощённо, без тяжёлых фильтров) */ +.node-search-match rect { + stroke: #ffc107 !important; + stroke-width: 3px !important; +} + +.node-highlight rect { + stroke: #ff5722 !important; + stroke-width: 3px !important; +} + +.node-search-active rect { + stroke: #4caf50 !important; + stroke-width: 3px !important; +} + +/* Усиление видимости линий */ +svg .edgePath, +svg .edgePath path, +svg .flowchart-link { + stroke: var(--color-mermaid-edge) !important; + stroke-width: 2.4px !important; + fill: none !important; + opacity: 1 !important; + vector-effect: non-scaling-stroke !important; +} + + svg .edgePath:hover .path, + svg .edgePath:hover path { + stroke-width: 3px !important; + } + +/* --------------------------------------------------------- + TOAST NOTIFICATIONS +--------------------------------------------------------- */ +.toast { + position: fixed; + top: 80px; + right: var(--spacing-xxl); + background: var(--color-background); + color: var(--color-text-primary); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--border-radius-large); + box-shadow: var(--shadow-8); + border: 1px solid var(--color-border); + z-index: var(--z-index-tooltip); + display: flex; + align-items: center; + gap: var(--spacing-md); + max-width: 400px; + border-left: 4px solid var(--color-primary); +} + +/* --------------------------------------------------------- + SCROLLBAR STYLING +--------------------------------------------------------- */ +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-track { + background: var(--color-background-neutral); + border-radius: var(--border-radius-medium); +} + +::-webkit-scrollbar-thumb { + background: var(--color-border-strong); + border-radius: var(--border-radius-medium); + border: 3px solid var(--color-background-neutral); +} + + ::-webkit-scrollbar-thumb:hover { + background: var(--color-text-tertiary); + } + +::-webkit-scrollbar-corner { + background: var(--color-background-neutral); +} + +/* For Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-border-strong) var(--color-background-neutral); +} + +/* --------------------------------------------------------- + RESPONSIVE ADJUSTMENTS +--------------------------------------------------------- */ +@media (max-width: 768px) { + .severity-section { + margin: var(--spacing-lg); + padding: var(--spacing-lg); + } + + .tabs-container { + padding: var(--spacing-sm) var(--spacing-lg); + } + + .diagram-toolbar { + padding: var(--spacing-sm) var(--spacing-lg); + } + + .toolbar-actions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .toolbar-button { + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-sm); + } + + #minimap { + right: var(--spacing-lg); + bottom: 100px; + width: 150px; + height: 105px; + } + + .search-results-info { + left: var(--spacing-lg); + right: var(--spacing-lg); + max-width: none; + } + + .toast { + right: var(--spacing-lg); + left: var(--spacing-lg); + max-width: none; + } + + .file-title-container { + padding: var(--spacing-lg); + } + + .file-title { + font-size: var(--font-size-lg); + } + + #diagramContainer { + height: calc(100vh - 102px); + } +} + +@media (max-width: 480px) { + :root { + --spacing-xxxl: 24px; + --spacing-xxl: 20px; + --spacing-xl: 16px; + --spacing-lg: 12px; + --spacing-md: 8px; + --spacing-sm: 6px; + --spacing-xs: 4px; + } + + .severity-header { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-md); + } + + .toolbar-search { + max-width: none; + } + + .toolbar-actions { + width: 100%; + justify-content: space-between; + } + + .tabs { + gap: 2px; + } + + .tab { + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-sm); + } +} + +/* --------------------------------------------------------- + PRINT STYLES +--------------------------------------------------------- */ +@media print { + .tabs-container, + .diagram-toolbar, + #minimap, + .search-results-info, + .toast { + display: none !important; + } + + body { + background-color: #ffffff; + color: #000000; + } + + .file-report { + padding-bottom: 0; + display: block !important; + } + + .severity-section { + break-inside: avoid; + box-shadow: none; + border: 1px solid #ddd; + } + + table { + break-inside: avoid; + } +} + +/* --------------------------------------------------------- + ACCESSIBILITY +--------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--border-radius-small); +} + +::selection { + background-color: var(--color-primary); + color: #ffffff; +} + +::-moz-selection { + background-color: var(--color-primary); + color: #ffffff; +} + +/* Убираем blue highlight на mobile tap */ +* { + -webkit-tap-highlight-color: transparent; +} diff --git a/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js b/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js new file mode 100644 index 0000000..33753b3 --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js @@ -0,0 +1,1317 @@ + +import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs"; + +/* --------------------------------------------------------- + TABS MANAGEMENT +--------------------------------------------------------- */ +function initTabs(onTabActivated) { + const tabs = document.querySelectorAll(".tab"); + const reports = document.querySelectorAll(".file-report"); + + tabs.forEach(tab => { + tab.addEventListener("click", () => { + const id = tab.dataset.target; + + // Add ripple effect + const ripple = document.createElement('span'); + const rect = tab.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + ripple.style.cssText = ` + position: absolute; + border-radius: 50%; + background: rgba(0, 120, 212, 0.2); + transform: scale(0); + animation: ripple 0.6s linear; + width: ${size}px; + height: ${size}px; + left: ${x - size / 2}px; + top: ${y - size / 2}px; + `; + + tab.style.position = 'relative'; + tab.style.overflow = 'hidden'; + tab.appendChild(ripple); + + setTimeout(() => { + if (ripple.parentElement === tab) { + ripple.remove(); + } + }, 600); + + tabs.forEach(t => t.classList.remove("active")); + reports.forEach(r => r.classList.remove("active")); + + tab.classList.add("active"); + const report = document.getElementById(id); + if (report) { + report.classList.add("active"); + + // Scroll to top when switching tabs + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + if (onTabActivated) onTabActivated(id); + }); + }); +} + +// Исправление для табов на странице диаграммы +document.addEventListener('DOMContentLoaded', function () { + const tabsContainer = document.querySelector('.tabs-container'); + if (tabsContainer) { + tabsContainer.style.zIndex = '1001'; + tabsContainer.style.position = 'fixed'; + tabsContainer.style.bottom = '0'; + tabsContainer.style.left = '0'; + tabsContainer.style.right = '0'; + } + + // Гарантируем, что табы всегда поверх всего + const tabs = document.querySelectorAll('.tab'); + tabs.forEach(tab => { + tab.style.zIndex = '1002'; + }); +}); + +/* --------------------------------------------------------- + DIAGRAM VIEWER CLASS +--------------------------------------------------------- */ +class DiagramViewer { + constructor() { + this.container = document.getElementById("diagramSvgContainer"); + this.minimap = document.getElementById("minimap"); + this.searchInput = document.getElementById("diagramSearch"); + this.resetBtn = document.getElementById("resetViewBtn"); + + this.svg = null; + this.viewportGroup = null; + this.nodes = []; + + this.scale = 1; + this.tx = 0; + this.ty = 0; + this.minScale = 0.1; + this.maxScale = 6; + + this.originalViewBox = null; + this.minimapSvg = null; + this.minimapViewport = null; + + this.needsRender = false; + this.isPanning = false; + this.lastSearchTerm = ''; + this.searchResults = []; + this.currentSearchIndex = -1; + + this.init(); + } + + init() { + // Add CSS for ripple animation + if (!document.querySelector('#ripple-styles')) { + const style = document.createElement('style'); + style.id = 'ripple-styles'; + style.textContent = ` + @keyframes ripple { + to { + transform: scale(4); + opacity: 0; + } + } + + @keyframes fadeIn { + from {opacity: 0; transform: translateY(-10px); } + to {opacity: 1; transform: translateY(0); } + } + `; + document.head.appendChild(style); + } + } + + async render() { + this.container.classList.add('loading'); + + + try { + + const mermaidSource = document.getElementById("mermaidSource").textContent.trim(); + const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + mermaid.initialize({ + startOnLoad: false, + theme: isDark ? "dark" : "default", + themeVariables: isDark ? { + fontFamily: "'Segoe UI', system-ui, -apple-system, sans-serif", + primaryColor: "#2d2d2d", + primaryBorderColor: "#2899f5", + primaryTextColor: "#f3f2f1", + lineColor: "#797775", + lineWidth: 2.5, + secondaryColor: "#3c3c3c", + tertiaryColor: "#484644", + background: "#1e1e1e", + clusterBkg: "#201f1e", + clusterBorder: "#605e5c", + edgeLabelBackground: "#252423", + nodeBkg: "#2d2d2d", + nodeBorder: "#2899f5", + nodeTextColor: "#f3f2f1", + mainBkg: "#1e1e1e", + textColor: "#f3f2f1", + arrowheadColor: "#797775", + fontSize: "14px" + } : { + fontFamily: "'Segoe UI', system-ui, -apple-system, sans-serif", + primaryColor: "#f0f8ff", + primaryBorderColor: "#0078d4", + primaryTextColor: "#323130", + lineColor: "#8a8886", + lineWidth: 2.5, + secondaryColor: "#deecf9", + tertiaryColor: "#c7e0f4", + background: "#ffffff", + clusterBkg: "#f3f2f1", + clusterBorder: "#a19f9d", + edgeLabelBackground: "#ffffff", + nodeBkg: "#f0f8ff", + nodeBorder: "#0078d4", + nodeTextColor: "#323130", + mainBkg: "#ffffff", + textColor: "#323130", + arrowheadColor: "#8a8886", + fontSize: "14px" + }, + flowchart: { + useMaxWidth: false, + htmlLabels: true, + curve: "basis" + } + }); + + try { + const { svg } = await mermaid.render("diagram", mermaidSource); + + // Убираем только встроенные стили Mermaid, оставляя структуру + const cleanedSvg = svg.replace(/]*>[\s\S]*?<\/style>/gi, ""); + + this.container.innerHTML = cleanedSvg; + this.svg = this.container.querySelector("svg"); + + if (this.svg) { + // Добавляем небольшую задержку для гарантированной отрисовки + await new Promise(resolve => setTimeout(resolve, 50)); + + // Добавляем классы для разных типов узлов + this._addNodeClasses(); + this._afterSvgLoaded(); + this._initSearch(); + this._attachEventListeners(); + this._setupResizeObserver(); + this.container.classList.remove('loading'); + } + } catch (error) { + console.error('Mermaid rendering error:', error); + this._showError(error); + } + } catch (error) { + this.container.classList.remove('loading'); + this._showError(error); + } + } + + recalculateBounds() { + this._calculateRealBounds(); + this._updateMinimapViewport(); + } + + _showError(error) { + this.container.innerHTML = ` +
+
+
📊
+

+ Не удалось загрузить диаграмму +

+

+ ${error.message} +

+ +
+
+ `; + } + + _addNodeClasses() { + // Добавляем классы к узлам на основе текста + const nodes = this.svg.querySelectorAll('.node'); + nodes.forEach(node => { + const textElement = node.querySelector('text'); + if (textElement) { + const text = textElement.textContent.toLowerCase(); + + if (text.includes('if') || text.includes('condition') || text.includes('условие')) { + node.classList.add('condition'); + } else if (text.includes('start') || text.includes('end') || + text.includes('начало') || text.includes('конец')) { + node.classList.add('start'); + } else if (text.includes('exec') || text.includes('execute') || + text.includes('выполнить') || text.includes('выполнение')) { + node.classList.add('exec'); + } else if (text.includes('select') || text.includes('insert') || + text.includes('update') || text.includes('delete') || + text.includes('выбрать') || text.includes('вставить') || + text.includes('обновить') || text.includes('удалить')) { + node.classList.add('process'); + } else if (text.includes('import') || text.includes('export') || + text.includes('импорт') || text.includes('экспорт')) { + node.classList.add('import'); + } + } + }); + } + + _setupResizeObserver() { + const resizeObserver = new ResizeObserver(() => { + if (this.svg) { + this._updateMinimapViewport(); + } + }); + + resizeObserver.observe(this.container); + } + + _attachEventListeners() { + // Add double click to reset zoom + this.svg.addEventListener('dblclick', (e) => { + e.preventDefault(); + this.resetView(); + }); + + // Add keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.target === this.searchInput || e.target.matches('input, textarea')) return; + + switch (e.key) { + case '+': + case '=': + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + this.zoomIn(); + } + break; + case '-': + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + this.zoomOut(); + } + break; + case '0': + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + this.resetView(); + } + break; + case 'Escape': + this.clearSearch(); + break; + case 'F3': + case 'Enter': + if (e.shiftKey) { + e.preventDefault(); + this.prevSearchResult(); + } else if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + this.nextSearchResult(); + } + break; + } + }); + + // Reset button + if (this.resetBtn) { + this.resetBtn.addEventListener('click', () => { + this.resetView(); + this._showToast('Вид диаграммы сброшен', 'info'); + }); + } + } + + _initSearch() { + if (this.searchInput) { + // Clear button + const clearBtn = document.createElement('button'); + clearBtn.innerHTML = '✕'; + clearBtn.title = 'Очистить поиск'; + clearBtn.style.cssText = ` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + font-size: 12px; + opacity: 0.7; + transition: opacity 0.2s; + border-radius: 50%; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + `; + + clearBtn.addEventListener('mouseover', () => { + clearBtn.style.opacity = '1'; + clearBtn.style.backgroundColor = 'var(--surface-neutral)'; + }); + + clearBtn.addEventListener('mouseout', () => { + clearBtn.style.opacity = '0.7'; + clearBtn.style.backgroundColor = 'transparent'; + }); + + clearBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.clearSearch(); + this.searchInput.value = ''; + this.searchInput.focus(); + this._showToast('Поиск очищен', 'info'); + }); + + const searchContainer = this.searchInput.parentElement; + searchContainer.style.position = 'relative'; + searchContainer.appendChild(clearBtn); + + this.searchInput.addEventListener('keyup', (e) => { + if (e.key === 'Enter') { + const term = this.searchInput.value.trim(); + if (term) { + this.searchNodes(term); + } else { + this.clearSearch(); + } + } else if (e.key === 'Escape') { + this.clearSearch(); + this.searchInput.value = ''; + } + }); + + // Show clear button only when there's text + this.searchInput.addEventListener('input', () => { + clearBtn.style.display = this.searchInput.value ? 'flex' : 'none'; + }); + + clearBtn.style.display = 'none'; + } + } + + searchNodes(term) { + if (!this.svg || !term) { + this.clearSearch(); + return; + } + + // Clear previous highlights + this.clearSearch(); + + this.lastSearchTerm = term.toLowerCase(); + const nodes = this.svg.querySelectorAll('.node'); + this.searchResults = []; + + nodes.forEach((node) => { + const textElement = node.querySelector('text'); + if (textElement && textElement.textContent.toLowerCase().includes(this.lastSearchTerm)) { + this.searchResults.push(node); + } + }); + + if (this.searchResults.length > 0) { + this.currentSearchIndex = 0; + this._highlightCurrentSearchResult(); + this._showSearchResultsInfo(); + + // Center view on first result + this._centerOnNode(this.searchResults[0]); + } else { + this._showToast('Ничего не найдено', 'warning'); + } + } + + _highlightCurrentSearchResult() { + // Clear all highlights first + this.searchResults.forEach((node, index) => { + node.classList.remove('node-search-match', 'node-highlight', 'node-search-active'); + + if (index === this.currentSearchIndex) { + node.classList.add('node-search-active', 'node-highlight'); + } else { + node.classList.add('node-search-match'); + } + }); + } + + nextSearchResult() { + if (this.searchResults.length === 0) return; + + this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length; + this._highlightCurrentSearchResult(); + this._centerOnNode(this.searchResults[this.currentSearchIndex]); + this._updateSearchResultsInfo(); + } + + prevSearchResult() { + if (this.searchResults.length === 0) return; + + this.currentSearchIndex = (this.currentSearchIndex - 1 + this.searchResults.length) % this.searchResults.length; + this._highlightCurrentSearchResult(); + this._centerOnNode(this.searchResults[this.currentSearchIndex]); + this._updateSearchResultsInfo(); + } + + _centerOnNode(node) { + if (!node || !this.svg) return; + + const bbox = node.getBBox(); + const container = document.getElementById("diagramContainer"); + + // Calculate center of node in SVG coordinates + const centerX = bbox.x + bbox.width / 2; + const centerY = bbox.y + bbox.height / 2; + + // Calculate transform to center this point + this.tx = container.clientWidth / 2 - centerX * this.scale; + this.ty = container.clientHeight / 2 - centerY * this.scale; + + this._scheduleRender(); + + // Add a little bounce effect + node.style.transform = 'scale(1.05)'; + setTimeout(() => { + node.style.transform = ''; + }, 300); + } + + _showSearchResultsInfo() { + // Remove existing info if any + this._removeSearchResultsInfo(); + + const info = document.createElement('div'); + info.className = 'search-results-info'; + info.style.cssText = ` + position: absolute; + top: 60px; + left: 16px; + background: var(--surface-default); + padding: var(--spacing-s) var(--spacing-m); + border-radius: var(--border-radius-medium); + box-shadow: var(--shadow-16); + border: 1px solid var(--border-default); + font-size: 13px; + z-index: 1000; + animation: fadeIn 0.3s ease; + display: flex; + align-items: center; + gap: var(--spacing-m); + `; + + info.innerHTML = ` + + Найдено: ${this.searchResults.length} узлов + + ${this.searchResults.length > 1 ? ` +
+ + +
+ + ${this.currentSearchIndex + 1} из ${this.searchResults.length} + + ` : ''} + + `; + + this.container.appendChild(info); + } + + _updateSearchResultsInfo() { + const info = this.container.querySelector('.search-results-info'); + if (info && this.searchResults.length > 1) { + const counter = info.querySelector('span:last-of-type'); + if (counter) { + counter.textContent = `${this.currentSearchIndex + 1} из ${this.searchResults.length}`; + } + } + } + + _removeSearchResultsInfo() { + const info = this.container.querySelector('.search-results-info'); + if (info) { + info.remove(); + } + } + + clearSearch() { + if (this.svg) { + const nodes = this.svg.querySelectorAll('.node'); + nodes.forEach(node => { + node.classList.remove('node-search-match', 'node-highlight', 'node-search-active'); + node.style.transform = ''; + }); + } + + this.searchResults = []; + this.currentSearchIndex = -1; + this._removeSearchResultsInfo(); + } + + _showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 80px; + right: 20px; + background: var(--surface-default); + color: var(--text-primary); + padding: var(--spacing-m) var(--spacing-l); + border-radius: var(--border-radius-medium); + box-shadow: var(--shadow-16); + border: 1px solid var(--border-default); + z-index: 9999; + animation: fadeIn 0.3s ease; + font-size: 14px; + border-left: 4px solid ${type === 'error' ? '#d13438' : + type === 'warning' ? '#ffaa44' : + type === 'success' ? '#107c10' : + 'var(--primary-color)' + }; + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateY(-10px)'; + setTimeout(() => { + if (toast.parentElement) { + toast.remove(); + } + }, 300); + }, 3000); + } + + zoomIn() { + const zoomFactor = 1.2; + const newScale = Math.min(this.maxScale, this.scale * zoomFactor); + this.scale = newScale; + this._scheduleRender(); + this._showToast(`Масштаб: ${Math.round(this.scale * 100)}%`, 'info'); + } + + zoomOut() { + const zoomFactor = 0.8; + const newScale = Math.max(this.minScale, this.scale * zoomFactor); + this.scale = newScale; + this._scheduleRender(); + this._showToast(`Масштаб: ${Math.round(this.scale * 100)}%`, 'info'); + } + + _afterSvgLoaded() { + const vb = this.svg.viewBox.baseVal; + this.originalViewBox = { + x: vb.x, + y: vb.y, + width: vb.width, + height: vb.height + }; + + this._wrapContent(); + this._initZoomPan(); + + // Используем requestAnimationFrame для получения реальных границ после отрисовки + requestAnimationFrame(() => { + this._calculateRealBounds(); + this._initMinimap(); + this.resetView(); + this._scheduleRender(); + }); + } + + _calculateRealBounds() { + if (!this.viewportGroup) return; + + try { + // Пробуем получить реальные границы содержимого + let bbox; + + // Метод 1: Пробуем получить bbox всей группы + try { + bbox = this.viewportGroup.getBBox(); + } catch (e) { + bbox = null; + } + + // Метод 2: Если getBBox не работает или возвращает 0, используем viewBox + if (!bbox || bbox.width === 0 || bbox.height === 0 || + bbox.x === Infinity || bbox.y === Infinity) { + console.warn("getBBox failed, using originalViewBox"); + this.diagramBounds = { ...this.originalViewBox }; + return; + } + + // Метод 3: Если getBBox работает, но границы слишком малы, проверяем ручной расчет + if (bbox.width < 10 || bbox.height < 10) { + // Пробуем ручной расчет через содержимое + const elements = this.viewportGroup.querySelectorAll('*[x], *[y], *[cx], *[cy]'); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let hasValidCoords = false; + + elements.forEach(el => { + let x = parseFloat(el.getAttribute('x') || el.getAttribute('cx') || '0'); + let y = parseFloat(el.getAttribute('y') || el.getAttribute('cy') || '0'); + let width = parseFloat(el.getAttribute('width') || '0'); + let height = parseFloat(el.getAttribute('height') || '0'); + let r = parseFloat(el.getAttribute('r') || '0'); + + if (!isNaN(x) && !isNaN(y)) { + if (el.tagName === 'circle') { + x = x - r; + y = y - r; + width = height = r * 2; + } else if (el.tagName === 'ellipse') { + const rx = parseFloat(el.getAttribute('rx') || '0'); + const ry = parseFloat(el.getAttribute('ry') || '0'); + x = x - rx; + y = y - ry; + width = rx * 2; + height = ry * 2; + } + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + width); + maxY = Math.max(maxY, y + height); + hasValidCoords = true; + } + }); + + if (hasValidCoords && minX !== Infinity) { + bbox = { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + }; + } + } + + // Добавляем отступ в 5% от размера диаграммы + const padding = Math.max(bbox.width, bbox.height) * 0.05; + this.diagramBounds = { + x: bbox.x - padding, + y: bbox.y - padding, + width: bbox.width + padding * 2, + height: bbox.height + padding * 2 + }; + + console.log("Diagram bounds calculated:", this.diagramBounds); + + } catch (error) { + console.error("Error calculating diagram bounds:", error); + this.diagramBounds = { ...this.originalViewBox }; + } + } + + _wrapContent() { + const children = Array.from(this.svg.children); + this.viewportGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); + this.viewportGroup.setAttribute("id", "viewport-group"); + + children.forEach(child => { + if (child.tagName.toLowerCase() !== "defs") { + this.viewportGroup.appendChild(child.cloneNode(true)); + child.remove(); + } + }); + + this.svg.appendChild(this.viewportGroup); + } + + _initZoomPan() { + let lastX = 0; + let lastY = 0; + let isMouseDown = false; + let lastTouchDistance = 0; + + // Перевод dx/dy из экранных пикселей в SVG-координаты + const screenDeltaToSvgDelta = (dx, dy) => { + const pt1 = this.svg.createSVGPoint(); + const pt2 = this.svg.createSVGPoint(); + pt1.x = 0; + pt1.y = 0; + pt2.x = dx; + pt2.y = dy; + + const ctm = this.svg.getScreenCTM(); + if (!ctm) return { dxSvg: dx, dySvg: dy }; + + const inv = ctm.inverse(); + const w1 = pt1.matrixTransform(inv); + const w2 = pt2.matrixTransform(inv); + + return { + dxSvg: w2.x - w1.x, + dySvg: w2.y - w1.y + }; + }; + + /* --------------------------------------------------------- + PAN (mouse) + --------------------------------------------------------- */ + this.svg.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + + isMouseDown = true; + this.isPanning = true; + + lastX = e.clientX; + lastY = e.clientY; + + this.svg.style.cursor = "grabbing"; + }); + + window.addEventListener("mousemove", (e) => { + if (!isMouseDown) return; + + const dxScreen = e.clientX - lastX; + const dyScreen = e.clientY - lastY; + + const { dxSvg, dySvg } = screenDeltaToSvgDelta(dxScreen, dyScreen); + + this.tx += dxSvg; + this.ty += dySvg; + + lastX = e.clientX; + lastY = e.clientY; + + this._scheduleRender(); + }); + + window.addEventListener("mouseup", () => { + isMouseDown = false; + this.isPanning = false; + this.svg.style.cursor = "grab"; + }); + + /* --------------------------------------------------------- + PAN + PINCH (touch) + --------------------------------------------------------- */ + this.svg.addEventListener("touchstart", (e) => { + if (e.touches.length === 1) { + const t = e.touches[0]; + lastX = t.clientX; + lastY = t.clientY; + this.isPanning = true; + } else if (e.touches.length === 2) { + this.isPanning = false; + lastTouchDistance = this._getTouchDistance(e); + } + }, { passive: false }); + + this.svg.addEventListener("touchmove", (e) => { + if (e.touches.length === 1 && this.isPanning) { + e.preventDefault(); + const t = e.touches[0]; + + const dxScreen = t.clientX - lastX; + const dyScreen = t.clientY - lastY; + + const { dxSvg, dySvg } = screenDeltaToSvgDelta(dxScreen, dyScreen); + + this.tx += dxSvg; + this.ty += dySvg; + + lastX = t.clientX; + lastY = t.clientY; + + this._scheduleRender(); + } else if (e.touches.length === 2) { + e.preventDefault(); + + const newDist = this._getTouchDistance(e); + const zoomFactor = newDist / lastTouchDistance; + lastTouchDistance = newDist; + + const rect = this.svg.getBoundingClientRect(); + const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left; + const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top; + + const worldX = (midX - this.tx) / this.scale; + const worldY = (midY - this.ty) / this.scale; + + const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * zoomFactor)); + + this.tx = midX - worldX * newScale; + this.ty = midY - worldY * newScale; + this.scale = newScale; + + this._scheduleRender(); + } + }, { passive: false }); + + this.svg.addEventListener("touchend", () => { + this.isPanning = false; + }); + + /* --------------------------------------------------------- + ZOOM (wheel) — zoom-to-cursor + --------------------------------------------------------- */ + this.svg.addEventListener("wheel", (e) => { + e.preventDefault(); + + const rect = this.svg.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * zoomFactor)); + + const worldX = (mouseX - this.tx) / this.scale; + const worldY = (mouseY - this.ty) / this.scale; + + this.tx = mouseX - worldX * newScale; + this.ty = mouseY - worldY * newScale; + this.scale = newScale; + + this._scheduleRender(); + }, { passive: false }); + } + + _getTouchDistance(e) { + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + _getWorldRect() { + const container = document.getElementById("diagramContainer"); + const ctm = this.viewportGroup.getCTM(); + if (!ctm) return { x: 0, y: 0, width: 100, height: 100 }; + + const inv = ctm.inverse(); + + const p1 = this.svg.createSVGPoint(); + p1.x = 0; + p1.y = 0; + const tl = p1.matrixTransform(inv); + + const p2 = this.svg.createSVGPoint(); + p2.x = container.clientWidth; + p2.y = container.clientHeight; + const br = p2.matrixTransform(inv); + + return { + x: tl.x, + y: tl.y, + width: br.x - tl.x, + height: br.y - tl.y + }; + } + + _applyTransform() { + if (!this.viewportGroup) return; + + this.viewportGroup.setAttribute( + "transform", + `translate(${this.tx}, ${this.ty}) scale(${this.scale})` + ); + + + // ОБЯЗАТЕЛЬНО обновляем миникарту после трансформации + this._updateMinimapViewport(); + } + + + _scheduleRender() { + if (this.needsRender) return; + this.needsRender = true; + + requestAnimationFrame(() => { + this.needsRender = false; + this._applyTransform(); + }); + } + + _initMinimap() { + if (!this.minimap || !this.svg) return; + + this.minimap.innerHTML = ""; + + // Клонируем SVG для миникарты + this.minimapSvg = this.svg.cloneNode(true); + + // Убираем трансформации с клонированных элементов + const cloneViewport = this.minimapSvg.querySelector("#viewport-group"); + if (cloneViewport) { + cloneViewport.removeAttribute("transform"); + } + + // Используем diagramBounds для viewBox миникарты + const diagram = this.diagramBounds || this.originalViewBox; + + // Устанавливаем viewBox для миникарты + this.minimapSvg.setAttribute( + "viewBox", + `${diagram.x} ${diagram.y} ${diagram.width} ${diagram.height}` + ); + + this.minimapSvg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + this.minimapSvg.style.width = "100%"; + this.minimapSvg.style.height = "100%"; + this.minimapSvg.style.position = "absolute"; + this.minimapSvg.style.opacity = "0.7"; + + // Убираем интерактивность + this.minimapSvg.querySelectorAll('*').forEach(el => { + el.style.pointerEvents = 'none'; + }); + + this.minimap.appendChild(this.minimapSvg); + + // Создаем viewport элемент + this.minimapViewport = document.createElement("div"); + this.minimapViewport.className = "minimap-viewport"; + this.minimapViewport.style.cssText = ` + position: absolute; + border: 2px solid var(--color-primary); + background-color: rgba(0, 120, 212, 0.15); + pointer-events: none; + transition: all 0.1s ease; + box-sizing: border-box; + z-index: 10; + `; + + this.minimap.appendChild(this.minimapViewport); + + // Обработчик клика по миникарте + this.minimap.addEventListener("click", (e) => { + if (!this.minimapSvg) return; + + const rect = this.minimap.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Процентное положение в миникарте + const percentX = x / rect.width; + const percentY = y / rect.height; + + const diagram = this.diagramBounds || this.originalViewBox; + + // Мировые координаты в диаграмме + const worldX = diagram.x + diagram.width * percentX; + const worldY = diagram.y + diagram.height * percentY; + + const container = document.getElementById("diagramContainer"); + if (!container) return; + + // Центрируем view на точке клика + this.tx = container.clientWidth / 2 - worldX * this.scale; + this.ty = container.clientHeight / 2 - worldY * this.scale; + + this._scheduleRender(); + }); + } + + _updateMinimapViewport() { + if (!this.minimapViewport || !this.minimapSvg || !this.minimap || !this.svg) { + return; + } + + try { + // Получаем размеры миникарты + const minimapRect = this.minimap.getBoundingClientRect(); + if (minimapRect.width === 0 || minimapRect.height === 0) return; + + // Получаем текущую трансформацию viewportGroup + const ctm = this.viewportGroup ? this.viewportGroup.getCTM() : null; + if (!ctm) return; + + // Получаем размеры контейнера диаграммы + const container = document.getElementById("diagramContainer"); + if (!container) return; + const containerRect = container.getBoundingClientRect(); + + // Рассчитываем видимую область в мировых координатах + const inv = ctm.inverse(); + + // Левый верхний угол контейнера (0,0 в координатах SVG viewport) + const topLeftScreen = this.svg.createSVGPoint(); + topLeftScreen.x = 0; + topLeftScreen.y = 0; + const topLeftWorld = topLeftScreen.matrixTransform(inv); + + // Правый нижний угол контейнера + const bottomRightScreen = this.svg.createSVGPoint(); + bottomRightScreen.x = containerRect.width; + bottomRightScreen.y = containerRect.height; + const bottomRightWorld = bottomRightScreen.matrixTransform(inv); + + // Определяем границы видимой области + const viewportWorld = { + left: Math.min(topLeftWorld.x, bottomRightWorld.x), + top: Math.min(topLeftWorld.y, bottomRightWorld.y), + right: Math.max(topLeftWorld.x, bottomRightWorld.x), + bottom: Math.max(topLeftWorld.y, bottomRightWorld.y) + }; + + const viewportWidth = viewportWorld.right - viewportWorld.left; + const viewportHeight = viewportWorld.bottom - viewportWorld.top; + + // Получаем границы всей диаграммы (из diagramBounds) + const diagram = this.diagramBounds || this.originalViewBox; + + // Рассчитываем масштаб для миникарты + const scaleX = minimapRect.width / diagram.width; + const scaleY = minimapRect.height / diagram.height; + + // Преобразуем мировые координаты в координаты миникарты + const viewportLeft = (viewportWorld.left - diagram.x) * scaleX; + const viewportTop = (viewportWorld.top - diagram.y) * scaleY; + const viewportWidthPx = viewportWidth * scaleX; + const viewportHeightPx = viewportHeight * scaleY; + + // Применяем стили к viewport'у миникарты + this.minimapViewport.style.left = `${viewportLeft}px`; + this.minimapViewport.style.top = `${viewportTop}px`; + this.minimapViewport.style.width = `${viewportWidthPx}px`; + this.minimapViewport.style.height = `${viewportHeightPx}px`; + + // Для отладки (раскомментируйте если нужно) + // console.log("Minimap viewport:", { + // diagram, + // viewportWorld, + // viewportLeft, viewportTop, viewportWidthPx, viewportHeightPx, + // scaleX, scaleY, + // containerRect: { width: containerRect.width, height: containerRect.height } + // }); + + } catch (error) { + console.error("Error in _updateMinimapViewport:", error); + } + } + async resetView() { + const container = document.getElementById("diagramContainer"); + if (!container || !this.viewportGroup) return; + + const cw = container.clientWidth; + const ch = container.clientHeight; + + if (cw === 0 || ch === 0) { + setTimeout(() => this.resetView(), 50); + return; + } + + // 1. Временно ставим scale = 1, чтобы измерить реальный размер диаграммы + this.scale = 1; + this.tx = 0; + this.ty = 0; + this._applyTransform(); + + // Даем браузеру дорендерить (важно!) + await new Promise(r => requestAnimationFrame(r)); + + // 2. Получаем реальные пиксельные размеры диаграммы + const rect = this.viewportGroup.getBoundingClientRect(); + const diagramPxWidth = rect.width; + const diagramPxHeight = rect.height; + + // 3. Вычисляем zoom-to-fit в пикселях + const scaleX = cw / diagramPxWidth; + const scaleY = ch / diagramPxHeight; + const zoomToFit = Math.min(scaleX, scaleY) * 0.9; + + // 4. Устанавливаем новый пользовательский zoom + this.scale = Math.max(Math.min(this.maxScale, zoomToFit), this.minScale); + + // 5. Центрируем диаграмму + const dx = Math.max(0, (cw - diagramPxWidth * this.scale) / 2); + const dy = Math.max(0, (ch - diagramPxHeight * this.scale) / 2); + + // 6. Преобразуем в юниты + const diagram = this.diagramBounds; + + this.tx = diagramPxWidth / diagram.width * dx; + this.ty = diagramPxHeight / diagram.height * dy; + + this._applyTransform(); + } + + + refresh() { + this._scheduleRender(); + } +} + +/* --------------------------------------------------------- + EXPORT FUNCTION +--------------------------------------------------------- */ +function exportPng() { + const svgElement = document.querySelector('#diagramSvgContainer svg'); + if (!svgElement) { + alert('Диаграмма не найдена для экспорта'); + return; + } + + try { + const svgData = new XMLSerializer().serializeToString(svgElement); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + + // Get actual dimensions + const bbox = svgElement.getBBox(); + canvas.width = bbox.width * 2; + canvas.height = bbox.height * 2; + + const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(svgBlob); + + img.onload = function () { + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + const pngUrl = canvas.toDataURL('image/png'); + const downloadLink = document.createElement('a'); + downloadLink.href = pngUrl; + downloadLink.download = `diagram_${new Date().toISOString().slice(0, 10)}.png`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + + URL.revokeObjectURL(url); + + // Show success toast + if (window.viewer && viewer._showToast) { + viewer._showToast('Диаграмма экспортирована как PNG', 'success'); + } + }; + + img.onerror = function () { + alert('Ошибка при экспорте изображения'); + URL.revokeObjectURL(url); + }; + + img.src = url; + } catch (error) { + console.error('Export error:', error); + alert('Ошибка при экспорте: ' + error.message); + } +} + +/* --------------------------------------------------------- + HANDLE SEARCH KEY PRESS +--------------------------------------------------------- */ +function handleSearchKeyPress(event) { + if (event.key === 'Enter') { + event.preventDefault(); + if (window.viewer && viewer.searchInput) { + const term = viewer.searchInput.value.trim(); + if (term) { + viewer.searchNodes(term); + } else { + viewer.clearSearch(); + } + } + } +} + +/* --------------------------------------------------------- + THEME CHANGE HANDLER +--------------------------------------------------------- */ +function setupThemeChangeHandler() { + const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + darkModeMediaQuery.addEventListener('change', (e) => { + if (window.viewer && document.getElementById('mermaid').classList.contains('active')) { + viewer.render().catch(console.error); + } + }); +} + +/* --------------------------------------------------------- + MAIN INITIALIZATION +--------------------------------------------------------- */ +let viewer = null; + +document.addEventListener("DOMContentLoaded", () => { + initTabs((id) => { + if (id === "mermaid") { + if (!viewer) { + viewer = new DiagramViewer(); + viewer.render().catch(console.error); + } else { + viewer.refresh(); + } + } + }); + + // Auto-click first tab + document.querySelector(".tab.active").click(); + + // Setup theme change handler + setupThemeChangeHandler(); + + // Make viewer globally available for button callbacks + window.viewer = viewer; + window.exportPng = exportPng; + window.handleSearchKeyPress = handleSearchKeyPress; +}); \ No newline at end of file diff --git a/SQLLinter/SQLLinter.csproj b/SQLLinter/SQLLinter.csproj index 6be11c1..41adf56 100644 --- a/SQLLinter/SQLLinter.csproj +++ b/SQLLinter/SQLLinter.csproj @@ -18,6 +18,15 @@ MIT + + + + + + + + +