/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
js/components/ProjectCompareModal.js
1 425 строк
51 KB
Starolat Sergei
feat: модальное окно сравнения двух версий проекта (COMP-034)
24 июл 2026, 08:22
24 июл 2026, 08:22
2d6eeee
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ProjectCompareModal — Модальное окно сравнения двух версий проекта (COMP-034) * @version 1.0.0 * @see specs/widgets/ProjectCompareModal.md * * Сравнивает два .dddb-файла одного проекта по распределению ресурсов на работы: * трудозатраты (labor) и физобъёмы (material), в режимах «текущий график» / «целевой план». * Diff-логика — ProjectCompareService (чистая, без DOM). * Парсинг второго файла не затрагивает состояние приложения (БЕЗ importDatabaseFromFile). */ import binaryDeserializer from '../services/BinaryDeserializer.js'; import dataService from '../services/DataService.js'; import dataIndexer from '../services/DataIndexer.js'; import { computeProjectDiff } from '../services/ProjectCompareService.js'; import { ToastManager } from './ToastManager.js'; import { GitCompare, X, FolderOpenLucide, RefreshCw, Inbox, AlertTriangle, ArrowDown, Check, } from './ConstantinIcons.js'; /** * @typedef {import('../types.js').ParsedDDDB} ParsedDDDB * @typedef {import('../types.js').CompareSide} CompareSide * @typedef {import('../types.js').CompareScheduleMode} CompareScheduleMode * @typedef {import('../types.js').CompareResourceKind} CompareResourceKind * @typedef {import('../types.js').ProjectCompareResult} ProjectCompareResult * @typedef {import('../types.js').AssignmentDiffRow} AssignmentDiffRow */ /** Максимум строк таблицы без пагинации (раздел I спеки) */ const MAX_RENDERED_ROWS = 2000; /** Допустимые расширения файлов */ const ACCEPTED_EXTENSIONS = ['.dddb', '.dddb.zip', '.dddb.zst', '.dddb.gz', '.zip', '.zst', '.gz']; /** * Создать SVG-элемент из строки ConstantinIcons * @param {string} svgString * @param {string} [className] * @returns {string} */ function icon(svgString, className = '') { const cls = className ? ` class="${className}"` : ''; return svgString.replace('<svg', `<svg${cls}`); } /** * Проверить расширение файла * @param {string} fileName * @returns {boolean} */ function isAcceptedFile(fileName) { const lower = fileName.toLowerCase(); return ACCEPTED_EXTENSIONS.some((ext) => lower.endsWith(ext)); } /** * Форматировать число units * @param {number|null} value * @returns {string} */ function formatUnits(value) { if (value === null || value === undefined) return '—'; return Number(value.toFixed(2)).toLocaleString('ru-RU'); } /** * Форматировать delta со знаком * @param {number} delta * @returns {string} */ function formatDelta(delta) { const sign = delta > 0 ? '+' : ''; return `${sign}${Number(delta.toFixed(2)).toLocaleString('ru-RU')}`; } /** * @element project-compare-modal * * @fires compare-side-loaded - Файл стороны успешно распарсен (bubbles) * @fires compare-computed - Сравнение пересчитано * @fires compare-error - Ошибка загрузки/парсинга (bubbles) */ class ProjectCompareModal extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); /** @type {CompareSide|null} */ this.sideA = null; /** @type {CompareSide|null} */ this.sideB = null; /** @type {CompareScheduleMode} */ this.scheduleMode = 'current'; /** @type {CompareResourceKind} */ this.resourceKind = 'labor'; /** @type {boolean} Фильтр «только изменённые» (по умолчанию включён) */ this._changedOnly = true; /** @type {ProjectCompareResult|null} */ this._result = null; /** @type {boolean} */ this._isParsing = false; /** @type {{A: string|null, B: string|null}} Ошибки загрузки сторон */ this._sideErrors = { A: null, B: null }; /** @type {boolean} Свёрнут ли блок добавленных/удалённых работ */ this._activitiesCollapsed = false; /** @type {'A'|'B'} Сторона, для которой открыт файловый диалог */ this._pendingSide = 'B'; /** @type {HTMLElement|null} */ this._triggerElement = null; this._handleKeyDown = this._handleKeyDown.bind(this); this._handleDialogClick = this._handleDialogClick.bind(this); this._handleFileChange = this._handleFileChange.bind(this); this._handleChangedOnlyToggle = this._handleChangedOnlyToggle.bind(this); } static get observedAttributes() { return ['open']; } /** @returns {boolean} */ get isOpen() { return this.hasAttribute('open'); } connectedCallback() { this._renderBase(); this._setupEventListeners(); this._syncTheme(); this._themeObserver = new MutationObserver(() => this._syncTheme()); this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'], }); this._themeChangedHandler = () => this._syncTheme(); document.addEventListener('theme-changed', this._themeChangedHandler); } disconnectedCallback() { document.removeEventListener('keydown', this._handleKeyDown); if (this._themeObserver) this._themeObserver.disconnect(); if (this._themeChangedHandler) { document.removeEventListener('theme-changed', this._themeChangedHandler); } } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'open') { if (newValue !== null) { this._showDialog(); } else { this._hideDialog(); } } } // ==================== Public API ==================== /** * Открыть модалку. Если есть активная БД — принять её как Проект 1. * @public */ open() { if (this.isOpen) return; this._triggerElement = /** @type {HTMLElement|null} */ (document.activeElement); // Автоподстановка активной БД в Проект 1 if (!this.sideA || this.sideA.origin === 'active') { const activeSide = this._resolveActiveDatabase(); if (activeSide) { this.sideA = activeSide; this._sideErrors.A = null; } else if (this.sideA && this.sideA.origin === 'active') { this.sideA = null; } } this.setAttribute('open', ''); this._renderAll(); this._announce('Сравнение версий проекта открыто.'); } /** * Закрыть модалку и освободить временные данные Проекта 2. * @public */ close() { this.removeAttribute('open'); // Освобождаем ParsedDDDB Проекта 2 (раздел I: память) this.sideB = null; this._sideErrors.B = null; this._result = null; if (this._triggerElement) { this._triggerElement.focus(); this._triggerElement = null; } } /** * Загрузить .dddb* файл в сторону 'A' или 'B', распарсить и пересчитать сравнение. * Не изменяет состояние приложения (БЕЗ importDatabaseFromFile / setActiveDatabase). * @public * @param {'A'|'B'} side * @param {File} file * @returns {Promise<void>} */ async loadSide(side, file) { if (!file) return; if (!isAcceptedFile(file.name)) { this._setSideError(side, `Неподдерживаемый формат файла «${file.name}». Ожидается .dddb, .dddb.zip, .dddb.zst или .dddb.gz`); return; } this._isParsing = true; this._sideErrors[side] = null; this._renderAll(); try { const data = await binaryDeserializer.deserializeFromFile(file); /** @type {CompareSide} */ const compareSide = { origin: 'file', name: file.name, data, loadedAt: new Date().toISOString(), }; if (side === 'A') { this.sideA = compareSide; } else { this.sideB = compareSide; } const activityCount = Array.isArray(data.activities) ? data.activities.length : 0; this.dispatchEvent(new CustomEvent('compare-side-loaded', { detail: { side, name: file.name, activityCount }, bubbles: true, composed: true, })); this._announce(`Проект ${side === 'A' ? '1' : '2'} «${file.name}» загружен. Работ: ${activityCount}.`); } catch (err) { console.error(`[ProjectCompareModal] Failed to parse side ${side}:`, err); this._setSideError(side, `Ошибка парсинга файла «${file.name}»: ${err instanceof Error ? err.message : String(err)}`); } finally { this._isParsing = false; this._recompute(); this._renderAll(); } } /** * Переключить режим сравнения current/target, пересчитать diff. * @public * @param {CompareScheduleMode} mode */ setScheduleMode(mode) { if (mode !== 'current' && mode !== 'target') return; if (this.scheduleMode === mode) return; this.scheduleMode = mode; this._recompute(); this._renderAll(); } /** * Переключить разрез labor/material, пересчитать diff. * @public * @param {CompareResourceKind} kind */ setResourceKind(kind) { if (kind !== 'labor' && kind !== 'material') return; if (this.resourceKind === kind) return; this.resourceKind = kind; this._recompute(); this._renderAll(); } // ==================== Data ==================== /** * Извлечь ParsedDDDB-подобную структуру из активной БД через DataIndexer. * Индексы активной БД содержат те же сущности (activities/resources/assignments), * поэтому конвертируем Map-индексы в массивы ParsedDDDB. * @private * @returns {CompareSide|null} */ _resolveActiveDatabase() { try { const dbId = dataService.getActiveDatabaseId?.(); if (!dbId) return null; const indexes = dataIndexer.getIndexes(dbId); if (!indexes) return null; const data = /** @type {ParsedDDDB} */ (/** @type {unknown} */ ({ header: { magic: 'DDDB', version: 0, flags: 0, createdAt: 0, tableCount: 0 }, projects: Array.from(indexes.projectsById.values()), wbs: Array.from(indexes.wbsById.values()), codeTypes: Array.from(indexes.codeTypeById.values()), codeValues: Array.from(indexes.codeValueById.values()), activities: Array.from(indexes.activitiesById.values()), codeAssignments: [], resources: Array.from(indexes.resourcesById.values()), resourceAssignments: Array.from(indexes.assignmentsById.values()), spread: indexes.spread, name: indexes.name, })); return { origin: 'active', name: indexes.name || dbId, data, loadedAt: null, }; } catch (err) { console.warn('[ProjectCompareModal] Failed to resolve active database:', err); return null; } } /** * Пересчитать diff, если обе стороны загружены. * @private */ _recompute() { this._result = null; if (!this.sideA || !this.sideB) return; try { const start = performance.now(); this._result = computeProjectDiff( this.sideA.data, this.sideB.data, this.scheduleMode, this.resourceKind, ); const durationMs = Math.round(performance.now() - start); console.debug(`[ProjectCompareModal] Diff computed in ${durationMs} ms (${this._result.rows.length} rows)`); this.dispatchEvent(new CustomEvent('compare-computed', { detail: { summary: this._result.summary }, bubbles: false, composed: true, })); } catch (err) { console.error('[ProjectCompareModal] Failed to compute diff:', err); const message = err instanceof Error ? err.message : String(err); ToastManager.getInstance().error(`Ошибка сравнения: ${message}`); this.dispatchEvent(new CustomEvent('compare-error', { detail: { message }, bubbles: true, composed: true, })); } } /** * Установить ошибку стороны: toast + inline + событие. * @private * @param {'A'|'B'} side * @param {string} message */ _setSideError(side, message) { this._sideErrors[side] = message; ToastManager.getInstance().error(message); this.dispatchEvent(new CustomEvent('compare-error', { detail: { message, side }, bubbles: true, composed: true, })); this._renderAll(); } // ==================== Rendering ==================== /** * Базовый рендер структуры модалки * @private */ _renderBase() { const style = document.createElement('style'); style.textContent = this._getStyles(); const dialog = document.createElement('dialog'); dialog.className = 'project-compare-backdrop'; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'pcm-title'); dialog.innerHTML = ` <div class="pcm-container"> <header class="pcm-header"> <h2 id="pcm-title" tabindex="-1"> ${icon(GitCompare, 'pcm-title-icon')} Сравнение версий проекта </h2> <button class="pcm-close" aria-label="Закрыть"> ${icon(X, 'pcm-close-icon')} </button> </header> <main class="pcm-body"> <div class="pcm-sides"></div> <div class="pcm-controls"> <div class="pcm-control-group" role="radiogroup" aria-label="Режим сравнения"> <span class="pcm-control-label">Режим:</span> <button class="compact-button pcm-mode-btn" data-mode="current" aria-pressed="true">Текущий график</button> <button class="compact-button pcm-mode-btn" data-mode="target" aria-pressed="false">Целевой план</button> </div> <div class="pcm-control-group" role="radiogroup" aria-label="Разрез ресурсов"> <span class="pcm-control-label">Разрез:</span> <button class="compact-button pcm-kind-btn" data-kind="labor" aria-pressed="true">Трудозатраты</button> <button class="compact-button pcm-kind-btn" data-kind="material" aria-pressed="false">Физобъёмы</button> </div> <label class="pcm-changed-filter"> <input type="checkbox" class="pcm-changed-only" checked> Только изменённые </label> </div> <div class="pcm-warning" hidden></div> <div class="pcm-parsing" hidden> <span class="pcm-spinner"></span> Парсинг файла… </div> <div class="pcm-empty" hidden> ${icon(Inbox, 'pcm-empty-icon')} <p>Загрузите оба проекта для сравнения.</p> </div> <div class="pcm-no-mode-data" hidden> ${icon(Inbox, 'pcm-empty-icon')} <p class="pcm-no-mode-text"></p> </div> <div class="pcm-result" hidden> <div class="pcm-activities"></div> <div class="pcm-table-wrap"> <table class="pcm-table"> <thead> <tr> <th scope="col">Работа</th> <th scope="col">Ресурс</th> <th scope="col" class="pcm-num">П1, количество</th> <th scope="col" class="pcm-num">П2, количество</th> <th scope="col" class="pcm-num">Δ</th> <th scope="col" class="pcm-num">Δ %</th> </tr> </thead> <tbody class="pcm-tbody"></tbody> </table> </div> <div class="pcm-rows-note" hidden></div> </div> </main> <footer class="pcm-footer"> <span class="project-compare-summary" aria-live="polite"></span> </footer> <div class="pcm-announcer" aria-live="polite" aria-atomic="true"></div> </div> <input type="file" class="pcm-file-input" accept=".dddb,.dddb.zip,.dddb.zst,.dddb.gz,.zip,.zst,.gz" style="display:none;"> `; this.shadowRoot?.appendChild(style); this.shadowRoot?.appendChild(dialog); } /** * Полный перерендер содержимого * @private */ _renderAll() { this._renderSides(); this._renderControls(); this._renderResult(); } /** * Отрисовать карточки сторон * @private */ _renderSides() { const container = this.shadowRoot?.querySelector('.pcm-sides'); if (!container) return; const renderSide = (/** @type {'A'|'B'} */ side, /** @type {CompareSide|null} */ data, /** @type {string} */ label) => { const error = this._sideErrors[side]; const ariaLabel = side === 'A' ? 'Загрузить первый проект' : 'Загрузить второй проект'; const badge = data ? (data.origin === 'active' ? '<span class="pcm-side-badge pcm-side-badge--active">активная БД</span>' : `<span class="pcm-side-badge">${icon(Check, 'pcm-check-icon')}</span>`) : ''; const nameHtml = data ? `<span class="pcm-side-name" title="${this._escapeHtml(data.name)}">${this._escapeHtml(data.name)}</span>` : `<span class="pcm-side-placeholder">Файл не загружен</span>`; const errorHtml = error ? `<div class="pcm-side-error">${icon(AlertTriangle, 'pcm-error-icon')} ${this._escapeHtml(error)} <button class="compact-button pcm-retry-btn" data-side="${side}">${icon(RefreshCw)} Повторить</button> </div>` : ''; return ` <div class="project-compare-side ${error ? 'project-compare-side--error' : ''}"> <div class="pcm-side-header">${label}</div> <div class="pcm-side-body"> ${nameHtml} ${badge} <button class="compact-button pcm-load-btn" data-side="${side}" aria-label="${ariaLabel}"> ${icon(FolderOpenLucide)} ${data ? 'Заменить…' : 'Загрузить файл…'} </button> </div> ${errorHtml} </div> `; }; container.innerHTML = renderSide('A', this.sideA, 'Проект 1 (базовый)') + renderSide('B', this.sideB, 'Проект 2 (сравниваемый)'); } /** * Синхронизировать состояние переключателей * @private */ _renderControls() { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; dialog.querySelectorAll('.pcm-mode-btn').forEach((btn) => { const active = btn.getAttribute('data-mode') === this.scheduleMode; btn.setAttribute('aria-pressed', String(active)); btn.classList.toggle('pcm-toggle--active', active); }); dialog.querySelectorAll('.pcm-kind-btn').forEach((btn) => { const active = btn.getAttribute('data-kind') === this.resourceKind; btn.setAttribute('aria-pressed', String(active)); btn.classList.toggle('pcm-toggle--active', active); }); const checkbox = /** @type {HTMLInputElement|null} */ (dialog.querySelector('.pcm-changed-only')); if (checkbox) checkbox.checked = this._changedOnly; } /** * Отрисовать результат сравнения * @private */ _renderResult() { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const parsingEl = /** @type {HTMLElement|null} */ (dialog.querySelector('.pcm-parsing')); const emptyEl = /** @type {HTMLElement|null} */ (dialog.querySelector('.pcm-empty')); const noModeEl = /** @type {HTMLElement|null} */ (dialog.querySelector('.pcm-no-mode-data')); const resultEl = /** @type {HTMLElement|null} */ (dialog.querySelector('.pcm-result')); const warningEl = /** @type {HTMLElement|null} */ (dialog.querySelector('.pcm-warning')); const summaryEl = /** @type {HTMLElement|null} */ (dialog.querySelector('.project-compare-summary')); if (parsingEl) parsingEl.hidden = !this._isParsing; const bothLoaded = !!this.sideA && !!this.sideB; if (emptyEl) emptyEl.hidden = this._isParsing || bothLoaded; // Empty state режима: в одном из файлов нет работ выбранного режима let noModeText = ''; if (bothLoaded && !this._isParsing) { const modeLabel = this.scheduleMode === 'target' ? 'целевого плана' : 'текущего графика'; const countFor = (/** @type {CompareSide} */ side) => (Array.isArray(side.data.activities) ? side.data.activities : []) .filter((a) => a.scheduleType === this.scheduleMode).length; const emptySides = [this.sideA, this.sideB] .filter((s) => s && countFor(s) === 0) .map((s) => /** @type {CompareSide} */ (s).name); if (emptySides.length > 0) { noModeText = `В файле ${emptySides.map((n) => `«${n}»`).join(' и ')} нет работ ${modeLabel}. Переключите режим.`; } } if (noModeEl) { noModeEl.hidden = !noModeText; const textEl = noModeEl.querySelector('.pcm-no-mode-text'); if (textEl) textEl.textContent = noModeText; } const showResult = bothLoaded && !this._isParsing && !noModeText && !!this._result; if (resultEl) resultEl.hidden = !showResult; // Warning: нет общих activityCode let showWarning = false; if (showResult && this._result) { const s = this._result.summary; const commonRows = this._result.rows.length - s.assignmentsAdded - s.assignmentsRemoved; const hasCommonActivity = this._result.rows.some((r) => r.changeType === 'same' || r.changeType === 'changed'); if (!hasCommonActivity && commonRows <= 0 && (s.activitiesAdded > 0 || s.activitiesRemoved > 0)) { showWarning = true; } } if (warningEl) { warningEl.hidden = !showWarning; if (showWarning) { warningEl.textContent = 'Нет общих работ: файлы, похоже, относятся к разным проектам. Показан полный diff добавленных/удалённых назначений.'; } } if (showResult && this._result) { this._renderActivityDiff(); this._renderTable(); } // Сводка в футере if (summaryEl) { if (this._result && bothLoaded && !noModeText) { const s = this._result.summary; const unitLabel = this.resourceKind === 'labor' ? 'ч' : 'ед.'; summaryEl.textContent = `Итоги: работ +${s.activitiesAdded} / −${s.activitiesRemoved} · изменено ${s.activitiesChanged} · ` + `назначений +${s.assignmentsAdded} / −${s.assignmentsRemoved} / Δ ${s.assignmentsChanged} · ` + `ΣΔ = ${formatDelta(s.totalDelta)} ${unitLabel}`; } else { summaryEl.textContent = ''; } } } /** * Отрисовать сворачиваемый блок добавленных/удалённых работ * @private */ _renderActivityDiff() { const container = this.shadowRoot?.querySelector('.pcm-activities'); if (!container || !this._result) return; const rows = this._result.activityDiffRows; const added = rows.filter((r) => r.changeType === 'added'); const removed = rows.filter((r) => r.changeType === 'removed'); if (rows.length === 0) { container.innerHTML = `<div class="pcm-activities-empty">Состав работ не изменился</div>`; return; } const collapsed = this._activitiesCollapsed; const listItem = (/** @type {import('../types.js').ActivityDiffRow} */ r, /** @type {string} */ sign, /** @type {string} */ cls) => `<li class="pcm-activity-item ${cls}">${sign} ${this._escapeHtml(r.activityCode)} ${this._escapeHtml(r.activityName)}</li>`; container.innerHTML = ` <button class="pcm-activities-toggle" aria-expanded="${String(!collapsed)}"> ${icon(ArrowDown, `pcm-chevron ${collapsed ? 'pcm-chevron--collapsed' : ''}`)} Работы: +${added.length} добавлено / −${removed.length} удалено </button> <div class="pcm-activities-list" ${collapsed ? 'hidden' : ''}> ${added.length ? `<ul class="pcm-activity-ul">${added.map((r) => listItem(r, '+', 'pcm-activity-item--added')).join('')}</ul>` : ''} ${removed.length ? `<ul class="pcm-activity-ul">${removed.map((r) => listItem(r, '−', 'pcm-activity-item--removed')).join('')}</ul>` : ''} </div> `; } /** * Отрисовать таблицу diff назначений * @private */ _renderTable() { const tbody = this.shadowRoot?.querySelector('.pcm-tbody'); const noteEl = /** @type {HTMLElement|null} */ (this.shadowRoot?.querySelector('.pcm-rows-note')); if (!tbody || !this._result) return; let rows = this._result.rows; if (this._changedOnly) { rows = rows.filter((r) => r.changeType !== 'same'); } if (rows.length === 0) { tbody.innerHTML = `<tr><td colspan="6" class="pcm-table-empty">Изменений нет</td></tr>`; if (noteEl) noteEl.hidden = true; return; } const truncated = rows.length > MAX_RENDERED_ROWS; const visible = truncated ? rows.slice(0, MAX_RENDERED_ROWS) : rows; const rowHtml = (/** @type {AssignmentDiffRow} */ r) => { const deltaCls = r.delta > 0 ? 'project-compare-delta-pos' : (r.delta < 0 ? 'project-compare-delta-neg' : ''); const typeLabel = { added: ' (+)', removed: ' (−)', changed: '', same: '', }[r.changeType]; return ` <tr class="pcm-row pcm-row--${r.changeType}"> <td title="${this._escapeHtml(r.activityName)}">${this._escapeHtml(r.activityCode)} ${this._escapeHtml(this._truncate(r.activityName, 30))}${typeLabel}</td> <td>${this._escapeHtml(r.resourceName)}</td> <td class="pcm-num">${formatUnits(r.unitsA)}</td> <td class="pcm-num">${formatUnits(r.unitsB)}</td> <td class="pcm-num ${deltaCls}">${r.changeType === 'same' ? '0' : formatDelta(r.delta)}</td> <td class="pcm-num ${deltaCls}">${r.deltaPercent === null ? '—' : `${formatDelta(Math.round(r.deltaPercent))}%`}</td> </tr> `; }; tbody.innerHTML = visible.map(rowHtml).join(''); if (noteEl) { noteEl.hidden = !truncated; if (truncated) { noteEl.textContent = `Показаны первые ${MAX_RENDERED_ROWS} из ${rows.length} строк. Уточните фильтр.`; } } } // ==================== Event Handling ==================== /** * Навесить обработчики * @private */ _setupEventListeners() { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; dialog.addEventListener('click', this._handleDialogClick); const fileInput = dialog.querySelector('.pcm-file-input'); fileInput?.addEventListener('change', this._handleFileChange); const changedOnly = dialog.querySelector('.pcm-changed-only'); changedOnly?.addEventListener('change', this._handleChangedOnlyToggle); document.addEventListener('keydown', this._handleKeyDown); } /** * Делегированный клик внутри dialog * @private * @param {MouseEvent} e */ _handleDialogClick(e) { const target = /** @type {HTMLElement} */ (e.target); // Backdrop: клик по самому dialog (за пределами контейнера) if (target === e.currentTarget) { this.close(); return; } const closeBtn = target.closest('.pcm-close'); if (closeBtn) { this.close(); return; } const loadBtn = target.closest('.pcm-load-btn, .pcm-retry-btn'); if (loadBtn) { const side = /** @type {'A'|'B'} */ (loadBtn.getAttribute('data-side') || 'B'); this._pendingSide = side; const fileInput = /** @type {HTMLInputElement|null} */ (this.shadowRoot?.querySelector('.pcm-file-input')); if (fileInput) { fileInput.value = ''; fileInput.click(); } return; } const modeBtn = target.closest('.pcm-mode-btn'); if (modeBtn) { this.setScheduleMode(/** @type {CompareScheduleMode} */ (modeBtn.getAttribute('data-mode'))); return; } const kindBtn = target.closest('.pcm-kind-btn'); if (kindBtn) { this.setResourceKind(/** @type {CompareResourceKind} */ (kindBtn.getAttribute('data-kind'))); return; } const toggle = target.closest('.pcm-activities-toggle'); if (toggle) { this._activitiesCollapsed = !this._activitiesCollapsed; this._renderActivityDiff(); return; } } /** * Переключение фильтра «только изменённые» * @private * @param {Event} e */ _handleChangedOnlyToggle(e) { this._changedOnly = /** @type {HTMLInputElement} */ (e.target).checked; this._renderTable(); } /** * Выбор файла для стороны * @private * @param {Event} e */ async _handleFileChange(e) { const input = /** @type {HTMLInputElement} */ (e.target); const file = input.files && input.files[0]; if (!file) return; await this.loadSide(this._pendingSide, file); } /** * Клавиатура: Esc закрывает, Tab-ловушка * @private * @param {KeyboardEvent} e */ _handleKeyDown(e) { if (!this.isOpen) return; if (e.key === 'Escape') { e.preventDefault(); this.close(); return; } if (e.key === 'Tab') { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const focusable = dialog.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; const activeEl = document.activeElement; const isInside = activeEl ? dialog.contains(activeEl) : false; if (e.shiftKey && (!isInside || activeEl === first)) { e.preventDefault(); /** @type {HTMLElement} */ (last).focus(); } else if (!e.shiftKey && (!isInside || activeEl === last)) { e.preventDefault(); /** @type {HTMLElement} */ (first).focus(); } } } /** * Показать dialog * @private */ _showDialog() { const dialog = this.shadowRoot?.querySelector('dialog'); if (dialog && !dialog.open) { dialog.showModal(); // Фокус: первая пустая кнопка загрузки, иначе кнопка закрытия const focusTarget = /** @type {HTMLElement|null} */ ( (!this.sideA && dialog.querySelector('.pcm-load-btn[data-side="A"]')) || (this.sideA && !this.sideB && dialog.querySelector('.pcm-load-btn[data-side="B"]')) || dialog.querySelector('.pcm-close') ); focusTarget?.focus(); } } /** * Скрыть dialog * @private */ _hideDialog() { const dialog = this.shadowRoot?.querySelector('dialog'); if (dialog && dialog.open) dialog.close(); } /** * Синхронизировать тему * @private */ _syncTheme() { const theme = document.documentElement.getAttribute('data-theme'); if (theme) this.setAttribute('data-theme', theme); } /** * Анонс для скринридера * @private * @param {string} text */ _announce(text) { const announcer = this.shadowRoot?.querySelector('.pcm-announcer'); if (announcer) { announcer.textContent = ''; void /** @type {HTMLElement} */ (announcer).offsetHeight; announcer.textContent = text; } } /** * Экранировать HTML * @private * @param {string} text * @returns {string} */ _escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Обрезать строку * @private * @param {string} text * @param {number} max * @returns {string} */ _truncate(text, max) { if (!text) return ''; return text.length > max ? `${text.slice(0, max)}…` : text; } // ==================== Styles ==================== /** * CSS стили компонента (Consta Compact) * @private * @returns {string} */ _getStyles() { return ` :host { display: contents; } [hidden] { display: none !important; } .project-compare-backdrop { padding: 0; border: none; background: transparent; max-width: none; max-height: none; width: auto; height: auto; overflow: visible; } .project-compare-backdrop::backdrop { background: rgba(0, 0, 0, 0.5); } .project-compare-backdrop[open] { display: flex; align-items: center; justify-content: center; } .pcm-container { width: 860px; max-width: 92vw; max-height: 85vh; display: flex; flex-direction: column; background: var(--surface-bg, #fff); border-radius: var(--border-radius, 4px); box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.2)); overflow: hidden; font-family: 'Inter', sans-serif; color: var(--text-primary, #333); } /* Header */ .pcm-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs, 8px) 12px; min-height: 32px; border-bottom: 1px solid var(--border-color, #e0e0e0); flex-shrink: 0; } .pcm-header h2 { font-size: var(--font-size-xs, 10px); text-transform: uppercase; letter-spacing: 0.05em; margin: 0; display: flex; align-items: center; gap: var(--space-2xs, 4px); color: var(--text-primary, #333); font-weight: 600; min-height: 24px; } .pcm-title-icon { width: 16px; height: 16px; flex-shrink: 0; } .pcm-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; background: transparent; border: none; cursor: pointer; color: var(--text-secondary, #666); border-radius: var(--border-radius, 4px); } .pcm-close:hover { background: rgba(0, 0, 0, 0.04); color: var(--text-primary, #333); } .pcm-close-icon { width: 16px; height: 16px; } /* Body */ .pcm-body { flex: 1; overflow-y: auto; padding: var(--space-xs, 8px); display: flex; flex-direction: column; gap: var(--space-xs, 8px); min-height: 0; } .pcm-body::-webkit-scrollbar { width: 6px; } .pcm-body::-webkit-scrollbar-track { background: transparent; } .pcm-body::-webkit-scrollbar-thumb { background: var(--border-color, #e0e0e0); border-radius: 3px; } /* Sides */ .pcm-sides, .project-compare-sides { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-xs, 8px); } .project-compare-side { background: var(--surface-bg, #fff); border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius, 4px); padding: var(--space-xs, 8px); display: flex; flex-direction: column; gap: var(--space-2xs, 4px); min-width: 0; } .project-compare-side--error { border-color: var(--color-typo-alert, #d32f2f); } .pcm-side-header { font-size: var(--font-size-xs, 10px); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary, #666); font-weight: 600; } .pcm-side-body { display: flex; align-items: flex-start; gap: var(--space-2xs, 4px); min-height: 24px; } .pcm-side-name { font-size: 11px; font-weight: 500; overflow-wrap: anywhere; word-break: break-word; white-space: normal; flex: 1; min-width: 0; } .pcm-side-placeholder { font-size: 11px; color: var(--text-secondary, #666); flex: 1; } .pcm-side-badge { display: inline-flex; align-items: center; font-size: var(--font-size-xs, 10px); color: var(--accent-color, #0078d2); flex-shrink: 0; } .pcm-check-icon { width: 14px; height: 14px; color: var(--color-typo-success, #22c55e); } .pcm-side-error { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2xs, 4px); font-size: var(--font-size-xs, 10px); color: var(--color-typo-alert, #d32f2f); } .pcm-error-icon { width: 14px; height: 14px; flex-shrink: 0; } /* Controls */ .project-compare-controls, .pcm-controls { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-xs, 8px); } .pcm-control-group { display: flex; align-items: center; gap: var(--space-2xs, 4px); } .pcm-control-label { font-size: var(--font-size-xs, 10px); color: var(--text-secondary, #666); } .compact-button { display: inline-flex; align-items: center; gap: var(--space-2xs, 4px); height: 24px; padding: 0 var(--space-xs, 8px); font-size: var(--font-size-xs, 10px); font-family: inherit; background: var(--surface-bg, #fff); color: var(--text-primary, #333); border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius, 4px); cursor: pointer; white-space: nowrap; } .compact-button:hover { border-color: var(--accent-color, #0078d2); } .compact-button svg { width: 14px; height: 14px; } .compact-button.pcm-toggle--active, .compact-button[aria-pressed="true"] { background: var(--accent-color, #0078d2); border-color: var(--accent-color, #0078d2); color: #fff; } .pcm-changed-filter { display: inline-flex; align-items: center; gap: var(--space-2xs, 4px); font-size: var(--font-size-xs, 10px); color: var(--text-primary, #333); cursor: pointer; margin-left: auto; } /* Warning */ .pcm-warning { padding: var(--space-xs, 8px); font-size: var(--font-size-xs, 10px); color: var(--color-typo-warning, #b45309); background: rgba(245, 158, 11, 0.08); border: 1px solid var(--color-typo-warning, #b45309); border-radius: var(--border-radius, 4px); } /* Parsing */ .pcm-parsing { display: flex; align-items: center; gap: var(--space-xs, 8px); padding: var(--space-xs, 8px); font-size: 11px; color: var(--text-secondary, #666); } .pcm-spinner { width: 14px; height: 14px; border: 2px solid var(--border-color, #e0e0e0); border-top-color: var(--accent-color, #0078d2); border-radius: 50%; animation: pcm-spin 0.8s linear infinite; } @keyframes pcm-spin { to { transform: rotate(360deg); } } /* Empty */ .pcm-empty, .pcm-no-mode-data { display: flex; flex-direction: column; align-items: center; gap: var(--space-xs, 8px); padding: 24px var(--space-xs, 8px); color: var(--text-secondary, #666); font-size: 11px; text-align: center; } .pcm-empty-icon { width: 32px; height: 32px; opacity: 0.4; } .pcm-empty p, .pcm-no-mode-data p { margin: 0; } /* Result */ .pcm-result { display: flex; flex-direction: column; gap: var(--space-xs, 8px); min-height: 0; } .pcm-activities-empty { font-size: var(--font-size-xs, 10px); color: var(--text-secondary, #666); } .pcm-activities-toggle { display: flex; align-items: center; gap: var(--space-2xs, 4px); width: 100%; padding: 0; background: transparent; border: none; cursor: pointer; font-family: inherit; font-size: var(--font-size-xs, 10px); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; color: var(--text-primary, #333); min-height: 24px; } .pcm-chevron { width: 14px; height: 14px; transition: transform 0.2s; } .pcm-chevron--collapsed { transform: rotate(-90deg); } .pcm-activities-list { padding-left: var(--space-xs, 8px); max-height: 18vh; overflow-y: auto; border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius, 4px); padding: var(--space-2xs, 4px) var(--space-xs, 8px); } .pcm-activity-ul { margin: 0; padding: 0; list-style: none; } .pcm-activity-item { font-size: 11px; line-height: 20px; } .pcm-activity-item--added { color: var(--color-typo-success, #22c55e); } .pcm-activity-item--removed { color: var(--color-typo-alert, #d32f2f); } /* Table */ .pcm-table-wrap { overflow-y: auto; max-height: 50vh; border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius, 4px); } .pcm-table { width: 100%; border-collapse: collapse; font-size: 11px; } .pcm-table th { position: sticky; top: 0; background: var(--surface-bg, #fff); font-size: var(--font-size-xs, 10px); text-transform: uppercase; letter-spacing: 0.05em; text-align: left; color: var(--text-secondary, #666); padding: 0 var(--space-xs, 8px); height: 24px; border-bottom: 1px solid var(--border-color, #e0e0e0); z-index: 1; } .pcm-table td { padding: 0 var(--space-xs, 8px); height: 20px; line-height: 20px; border-bottom: 1px solid var(--border-color, #e0e0e0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; } .pcm-num { text-align: right; font-variant-numeric: tabular-nums; } .project-compare-delta-pos { color: var(--accent-color, #0078d2); } .project-compare-delta-neg { color: var(--color-typo-warning, #e69500); } .pcm-row--added td:first-child { border-left: 2px solid var(--accent-color, #0078d2); } .pcm-row--removed td:first-child { border-left: 2px solid var(--color-typo-warning, #e69500); } .pcm-table-empty { text-align: center; color: var(--text-secondary, #666); } .pcm-rows-note { font-size: var(--font-size-xs, 10px); color: var(--text-secondary, #666); } /* Footer */ .pcm-footer { padding: var(--space-xs, 8px) 12px; border-top: 1px solid var(--border-color, #e0e0e0); flex-shrink: 0; } .project-compare-summary { font-size: var(--font-size-xs, 10px); color: var(--text-secondary, #666); } .pcm-announcer { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } `; } } customElements.define('project-compare-modal', ProjectCompareModal); export default ProjectCompareModal;