/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
js/components/LeftSidebar.js
616 строк
20 KB
Starolat Sergei
fix: выделение текста в виджетах при перетаскивании за заголовок
15 июл 2026, 09:26
15 июл 2026, 09:26
0f40aca
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview LeftSidebar - Левая боковая панель с управлением макетами * @version 1.3.0 * * Содержит модуль управления макетами. * Использует CSS классы DockableLayoutManager для dock/undock. * * Изменения в v1.3.0: * - Убран виджет «Физические объёмы» (material-volume-panel) из левой панели * * Изменения в v1.2.0: * - Добавлен Preset Quick-Switch модуль * * Изменения в v1.1.0: * - Удалён устаревший Settings Module (дублирует AppSettingsModal COMP-008) * * Изменения в v1.0.2: * - Иконки заменены на Consta Icons (Save, Collapse, Exit, Check, Trash) * * Изменения в v1.0.1: * - Добавлен Public API: toggle(), loadLayout(name), saveLayout(name) * - Добавлено управление макетами через localStorage * - Добавлены события layout-change */ import { Save, ArrowDown, Layout, Check, Trash, Draggable, Download, Upload, RotateCcw, } from "./ConstantinIcons.js"; import { LayoutStateService } from "../services/LayoutStateService.js"; import { LayoutPresetService } from "../services/LayoutPresetService.js"; import { UserSettingsService } from "../services/UserSettingsService.js"; import config from "../config.js"; import { updateModuleHeights } from "../ui.js"; /** * @typedef {Object} Layout * @property {string} name - Название макета * @property {Object} config - Конфигурация макета * @property {string} createdAt - Дата создания */ class LeftSidebar extends HTMLElement { constructor() { super(); /** @type {Layout[]} */ this.layouts = []; /** @type {string|null} */ this.currentLayout = null; this._loadLayoutsFromStorage(); this._render(); } connectedCallback() { this._setupEventListeners(); requestAnimationFrame(() => { const container = this.querySelector(".module-container"); if (container) updateModuleHeights(container); }); } disconnectedCallback() { const saveBtn = this.querySelector("#save-layout"); if (saveBtn) { saveBtn.replaceWith(saveBtn.cloneNode(true)); } const exportBtn = this.querySelector("#export-layout"); if (exportBtn) { exportBtn.replaceWith(exportBtn.cloneNode(true)); } const importBtn = this.querySelector("#import-layout"); if (importBtn) { importBtn.replaceWith(importBtn.cloneNode(true)); } const resetBtn = this.querySelector("#reset-layout"); if (resetBtn) { resetBtn.replaceWith(resetBtn.cloneNode(true)); } this.querySelectorAll(".delete-layout").forEach((btn) => { btn.replaceWith(btn.cloneNode(true)); }); this.querySelectorAll(".load-layout").forEach((btn) => { btn.replaceWith(btn.cloneNode(true)); }); } /** * Рендер компонента * @private */ _render() { this.innerHTML = ` <div class="sidebar-content module-container"> <wbs-filter id="wbs-filter-module" class="module module-elastic module-dockable"></wbs-filter> <div class="resizer-x"></div> <activity-chain-panel id="activity-chain-panel" class="module module-elastic module-dockable"></activity-chain-panel> <div class="resizer-x"></div> <section id="layouts-module" class="module module-dockable"> <div class="module-header"> <h3><span class="draggable-icon" style="display: inline-flex; width: 20px; height: 20px; vertical-align: middle; margin-right: 2px; color: var(--text-tertiary);">${Draggable}</span>Макеты</h3> <div class="module-actions"> <button id="export-layout" class="icon-button" title="Экспорт макета">${Download}</button> <button id="import-layout" class="icon-button" title="Импорт макета">${Upload}</button> <button id="reset-layout" class="icon-button" title="Сбросить к заводским">${RotateCcw}</button> <button id="save-layout" class="icon-button" title="Сохранить макет">${Save}</button> <button class="collapse-btn icon-button" title="Свернуть/Развернуть"><span class="arrow-icon">${ArrowDown}</span></button> <button class="dock-toggle-btn icon-button" data-target="layouts-module" title="Открепить/Закрепить">${Layout}</button> </div> </div> <ul id="layouts-list" style="list-style: none; padding: 0; margin: 0;"> ${this._renderLayoutsList()} </ul> </section> <div class="resizer-x"></div> <section id="presets-module" class="module module-dockable"> <div class="module-header"> <h3><span class="draggable-icon" style="display: inline-flex; width: 20px; height: 20px; vertical-align: middle; margin-right: 2px; color: var(--text-tertiary);">${Draggable}</span>Пресеты</h3> <div class="module-actions"> <button class="collapse-btn icon-button" title="Свернуть/Развернуть"><span class="arrow-icon">${ArrowDown}</span></button> <button class="dock-toggle-btn icon-button" data-target="presets-module" title="Открепить/Закрепить">${Layout}</button> </div> </div> <div style="padding: 8px 12px;"> <select id="preset-select" style="width: 100%; height: 28px; font-size: 11px; background: var(--surface-bg); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px;"> <option value="">— Выберите пресет —</option> </select> </div> </section> </div> `; } /** * Рендер списка макетов * @private * @returns {string} */ _renderLayoutsList() { if (this.layouts.length === 0) { return '<li class="text-muted text-sm" style="padding: 8px 16px;">Нет сохраненных макетов</li>'; } return this.layouts .map( (layout) => ` <li style="display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--border-color);"> <button class="load-layout" data-name="${layout.name}" style="background: none; border: none; cursor: pointer; text-align: left; flex: 1; color: var(--text-color);"> ${layout.name} ${layout.name === this.currentLayout ? `<span style="display: inline-flex; width: 14px; height: 14px; margin-left: 4px; vertical-align: middle;">${Check}</span>` : ""} </button> <button class="delete-layout icon-button danger" data-name="${layout.name}" title="Удалить макет"> ${Trash} </button> </li> `, ) .join(""); } /** * Настройка обработчиков событий * @private */ _setupEventListeners() { const exportBtn = this.querySelector("#export-layout"); if (exportBtn) { exportBtn.addEventListener("click", () => { try { const lss = LayoutStateService.getInstance(); const json = lss.exportToJSON(); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `deepdive-layout-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); } catch (e) { console.error("[LeftSidebar] Export failed:", e); alert("Ошибка экспорта: " + e.message); } }); } const importBtn = this.querySelector("#import-layout"); if (importBtn) { importBtn.addEventListener("click", () => { const input = document.createElement("input"); input.type = "file"; input.accept = ".json,application/json"; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); const lss = LayoutStateService.getInstance(); await lss.importFromJSON(text); alert("Макет импортирован успешно"); } catch (err) { console.error("[LeftSidebar] Import failed:", err); alert("Ошибка импорта: " + err.message); } }; input.click(); }); } const resetBtn = this.querySelector("#reset-layout"); if (resetBtn) { resetBtn.addEventListener("click", () => { if ( confirm( "Сбросить макет к заводским настройкам? Все пользовательские изменения будут потеряны.", ) ) { LayoutStateService.getInstance().resetToFactoryDefault(); } }); } const saveBtn = this.querySelector("#save-layout"); if (saveBtn) { saveBtn.addEventListener("click", async () => { const name = prompt("Введите название макета:"); if (name && name.trim()) { try { await this.saveLayout(name.trim()); } catch (e) { console.error("[LeftSidebar] Failed to save layout:", e); } } }); } this.querySelectorAll(".delete-layout").forEach((btn) => { btn.addEventListener("click", async (e) => { const name = e.currentTarget.dataset.name; if (confirm(`Удалить макет "${name}"?`)) { try { await this.deleteLayout(name); } catch (err) { console.error("[LeftSidebar] Failed to delete layout:", err); } } }); }); this.querySelectorAll(".load-layout").forEach((btn) => { btn.addEventListener("click", (e) => { const name = e.currentTarget.dataset.name; this.loadLayout(name); }); }); // Preset quick-switch dropdown (Phase 5) this._loadPresetOptions(); const presetSelect = this.querySelector("#preset-select"); if (presetSelect) { presetSelect.addEventListener("change", (e) => { const presetId = e.target.value; if (!presetId) return; try { const lps = LayoutPresetService.getInstance(); lps.setActivePreset(presetId); } catch (err) { console.warn("[LeftSidebar] Failed to activate preset:", err); } }); } // Refresh preset list when presets change document.addEventListener("preset-saved", () => this._loadPresetOptions()); document.addEventListener("preset-deleted", () => this._loadPresetOptions(), ); document.addEventListener("preset-loaded", () => this._loadPresetOptions()); } /** * Populate preset quick-switch dropdown. * Skips initialization of LayoutPresetService if it is not ready yet; * AppController will initialize it in the proper order. * @private */ async _loadPresetOptions() { const select = this.querySelector("#preset-select"); if (!select) return; try { const lps = LayoutPresetService.getInstance(); // Avoid triggering premature LayoutPresetService.init() before // AppController has had a chance to set storage options. if (!lps._initialized) return; const presets = lps.listPresets ? await lps.listPresets() : []; const activePreset = lps.getActivePreset ? lps.getActivePreset() : null; const html = [ '<option value="">— Выберите пресет —</option>', ...(Array.isArray(presets) ? presets.map((p) => { const isActive = activePreset && activePreset.presetId === p.presetId; return `<option value="${p.presetId}" ${isActive ? "selected" : ""}>${p.name}${p.isBuiltIn ? " (встроенный)" : ""}</option>`; }) : []), ].join(""); select.innerHTML = html; } catch (err) { console.warn("[LeftSidebar] Failed to load presets:", err); } } /** * Переключить видимость сайдбара * @param {boolean} [force] - Принудительное значение * @returns {boolean} Текущее состояние видимости */ toggle(force) { const isVisible = this.style.display !== "none"; const newState = force !== undefined ? force : !isVisible; this.style.display = newState ? "" : "none"; // Обновляем grid layout const dashboard = document.getElementById("dashboard"); if (dashboard) { if (newState) { dashboard.style.gridTemplateColumns = "250px 3px 1fr 3px 250px"; } else { dashboard.style.gridTemplateColumns = "0px 0px 1fr 3px 250px"; } } this.dispatchEvent( new CustomEvent("layout-change", { detail: { action: "toggle", visible: newState }, bubbles: true, }), ); return newState; } /** * Собрать конфигурацию текущего макета (legacy named layouts) * @private * @returns {Object} */ _collectLayoutConfig() { const modules = document.querySelectorAll(".module"); const moduleConfigs = []; modules.forEach((module) => { const rect = module.getBoundingClientRect(); moduleConfigs.push({ id: module.id, isDockedOut: module.classList.contains("docked-out"), position: { x: parseFloat(module.dataset.xOffset) || 0, y: parseFloat(module.dataset.yOffset) || 0, }, size: { width: rect.width, height: rect.height, }, isCollapsed: module.classList.contains("collapsed"), }); }); return { modules: moduleConfigs, sidebarVisible: this.style.display !== "none", timestamp: new Date().toISOString(), }; } /** * Применить конфигурацию макета (legacy named layouts) * @private * @param {Object} config */ _applyLayoutConfig(config) { if (!config || !config.modules) return; config.modules.forEach((moduleConfig) => { const module = document.getElementById(moduleConfig.id); if (!module) return; // Восстанавливаем состояние dock/undock if ( moduleConfig.isDockedOut && !module.classList.contains("docked-out") ) { module.classList.add("docked-out"); module.style.position = "fixed"; module.style.zIndex = "1000"; } else if ( !moduleConfig.isDockedOut && module.classList.contains("docked-out") ) { module.classList.remove("docked-out"); module.style.position = ""; module.style.zIndex = ""; } // Восстанавливаем позицию if (moduleConfig.position) { module.dataset.xOffset = moduleConfig.position.x; module.dataset.yOffset = moduleConfig.position.y; module.style.transform = `translate3d(${moduleConfig.position.x}px, ${moduleConfig.position.y}px, 0)`; } // Восстанавливаем размер if (moduleConfig.size) { module.style.width = moduleConfig.size.width + "px"; module.style.height = moduleConfig.size.height + "px"; } }); } /** * Сохранить текущий макет * @param {string} name - Название макета * @returns {Promise<boolean>} Успешно ли сохранено */ async saveLayout(name) { try { /** @type {Layout} */ const layout = { name: name, config: this._collectLayoutConfig(), createdAt: new Date().toISOString(), }; // Обновляем или добавляем макет const existingIndex = this.layouts.findIndex((l) => l.name === name); if (existingIndex >= 0) { this.layouts[existingIndex] = layout; } else { this.layouts.push(layout); } await this._saveLayoutsToStorage(); this.currentLayout = name; this._updateLayoutsList(); this.dispatchEvent( new CustomEvent("layout-change", { detail: { action: "save", layout: layout }, bubbles: true, }), ); return true; } catch (error) { console.error("[LeftSidebar] Failed to save layout:", error); return false; } } /** * Загрузить макет * @param {string} name - Название макета * @returns {boolean} Успешно ли загружено */ loadLayout(name) { const layout = this.layouts.find((l) => l.name === name); if (!layout) { console.warn(`[LeftSidebar] Layout "${name}" not found`); return false; } try { this._applyLayoutConfig(layout.config); // Also sync with LayoutStateService so the current workspace snapshot is updated try { LayoutStateService.getInstance().saveLayout(); } catch (e) { console.warn( "[LeftSidebar] Failed to sync layout to LayoutStateService:", e, ); } this.currentLayout = name; this._updateLayoutsList(); this.dispatchEvent( new CustomEvent("layout-change", { detail: { action: "load", layout: layout }, bubbles: true, }), ); return true; } catch (error) { console.error("[LeftSidebar] Failed to load layout:", error); return false; } } /** * Удалить макет * @param {string} name - Название макета * @returns {Promise<boolean>} Успешно ли удалено */ async deleteLayout(name) { const index = this.layouts.findIndex((l) => l.name === name); if (index < 0) return false; this.layouts.splice(index, 1); if (this.currentLayout === name) { this.currentLayout = null; } await this._saveLayoutsToStorage(); this._updateLayoutsList(); this.dispatchEvent( new CustomEvent("layout-change", { detail: { action: "delete", name: name }, bubbles: true, }), ); return true; } /** * Получить список макетов * @returns {Layout[]} */ getLayouts() { return [...this.layouts]; } /** * Обновить отображение списка макетов * @private */ _updateLayoutsList() { const list = this.querySelector("#layouts-list"); if (list) { list.innerHTML = this._renderLayoutsList(); this._setupEventListeners(); } } /** * Использовать ли UserSettingsService для хранения макетов. * @private * @returns {boolean} */ _useUserSettings() { return ( !!config.FEATURE_USER_SETTINGS_SERVICE && UserSettingsService.getInstance().isReady() ); } /** * Загрузить макеты из актуального хранилища * @private */ _loadLayoutsFromStorage() { if (this._useUserSettings()) { const layouts = UserSettingsService.getInstance().get( "sidebarLayouts", null, ); this.layouts = Array.isArray(layouts) ? layouts : []; return; } try { const stored = localStorage.getItem("deepdive-layouts"); if (stored) { this.layouts = JSON.parse(stored); } } catch (error) { console.error( "[LeftSidebar] Failed to load layouts from storage:", error, ); this.layouts = []; } } /** * Сохранить макеты в актуальное хранилище * @private * @returns {Promise<void>} */ async _saveLayoutsToStorage() { if (this._useUserSettings()) { await UserSettingsService.getInstance().set( "sidebarLayouts", this.layouts, ); return; } localStorage.setItem("deepdive-layouts", JSON.stringify(this.layouts)); } } customElements.define("left-sidebar", LeftSidebar); export { LeftSidebar };