diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index a940cc6..db643df 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -90,6 +90,10 @@ --section-toolbar-left: calc(12px + var(--tb-size, 25px) + var(--tb-gap, 10px)); --section-toolbar-bottom: 123px; --tb-size: 25px; + --tb-min-size: 25px; + --tb-max-size: 56px; + --tb-lens-count: 7; + --tb-autohide: 0; --tb-gap: 10px; --tb-radius: 7px; --tb-pad-y: 6px; @@ -2998,17 +3002,41 @@ header { .bottom-toolbar { position: fixed; left: 12px; - bottom: 12px; + top: 50%; display: flex; flex-direction: column-reverse; + align-items: flex-start; gap: var(--tb-gap, 8px); + width: var(--tb-min-size, var(--tb-size, 25px)); padding: var(--tb-pad-y, 8px) 0; + transform: translateY(-50%); + transform-origin: left center; z-index: 15; + opacity: 1; + transition: transform 220ms ease, opacity 180ms ease; + will-change: transform, opacity; +} + +.bottom-toolbar.toolbar-dock-active { + width: var(--tb-max-size, var(--tb-min-size, 56px)); +} + +.bottom-toolbar.toolbar-autohide { + pointer-events: none; + opacity: 0; + transform: translate(calc(-1 * (var(--tb-max-size, 56px) + 24px)), -50%); +} + +.bottom-toolbar.toolbar-autohide.is-visible { + pointer-events: auto; + opacity: 1; + transform: translateY(-50%); } .tb-btn { - width: var(--tb-size, 52px); - height: var(--tb-size, 52px); + width: var(--tb-min-size, var(--tb-size, 52px)); + height: var(--tb-min-size, var(--tb-size, 52px)); + flex: 0 0 auto; border-radius: var(--tb-radius, 12px); background: var(--toolbar-bg); border: 1px solid var(--toolbar-border); @@ -3020,13 +3048,16 @@ header { justify-content: center; padding: var(--tb-icon-pad, 10px); line-height: 1; - transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease; + transform: translateY(var(--tb-shift-y, 0px)) scale(var(--tb-scale, 1)); + transform-origin: left center; + transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease, color 120ms ease; + will-change: transform; } .tb-btn svg { display: block; - width: var(--tb-svg-size, 24px); - height: var(--tb-svg-size, 24px); + width: var(--tb-icon-size, 24px); + height: var(--tb-icon-size, 24px); fill: none; stroke: currentColor; stroke-width: 1.8; @@ -3035,7 +3066,6 @@ header { } .tb-btn:hover { - transform: translateY(-1px); border-color: var(--toolbar-outline, rgba(76, 224, 210, 0.7)); box-shadow: 0 10px 20px color-mix(in srgb, var(--toolbar-outline, rgba(76, 224, 210, 0.7)) 30%, transparent); } @@ -3049,7 +3079,6 @@ header { .tb-btn:disabled { cursor: default; opacity: 0.45; - transform: none; box-shadow: none; } diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index c667507..397016c 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -2,13 +2,13 @@ const SDK_VERSION = "2.6.78"; // соответствует package.json const SDK_URL = "./lib/dc-camera-context.es.js?v=3"; import { createLogo } from "./src/ui/logo.js"; -import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js"; +import { applyToolbarVars } from "./src/ui/toolbar.js?v=2"; import { createTemplatesMenu } from "./src/ui/templatesMenu.js?v=3"; -import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=11"; +import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=12"; import { createMeasurementModal } from "./src/ui/measurementModal.js"; import { createGlassSelect } from "./src/ui/glassSelect.js"; import { createSliderControl } from "./src/ui/controls/slider.js"; -import "./theme.js"; +import "./theme.js?v=2"; import { TransformGizmo } from "./transformGizmo.js"; import { createObjectModal } from "./src/ui/objectModal.js"; @@ -2124,6 +2124,238 @@ const clampNumber = (value, min, max, fallback) => { return Math.max(min, Math.min(max, num)); }; +let toolbarDockHideTimer = null; +let toolbarDockAnimationFrame = null; +let toolbarDockLastTs = 0; +const toolbarDockState = new WeakMap(); +const TOOLBAR_DOCK_BASE_SCALE = 2.25; +const TOOLBAR_DOCK_BASE_DISTANCE = 110; +const TOOLBAR_DOCK_BASE_NUDGE = 40; +const TOOLBAR_DOCK_NUDGE_TIGHTNESS = 0.82; +const TOOLBAR_DOCK_SPRING = { + mass: 0.1, + stiffness: 170, + damping: 12, +}; +const TOOLBAR_DOCK_MAX_STEP = 1 / 240; + +const getVisibleToolbarButtons = () => { + if (!bottomToolbar) return []; + return Array.from(bottomToolbar.querySelectorAll(".tb-btn")).filter((btn) => { + if (btn.hidden || btn.classList.contains("hidden")) return false; + return getComputedStyle(btn).display !== "none"; + }); +}; + +const readToolbarDockSettings = () => { + const minSize = clampNumber(parseFloat(cssValue("--tb-min-size", "25")), 18, 88, 25); + const maxSize = Math.max(minSize, clampNumber(parseFloat(cssValue("--tb-max-size", "56")), 18, 96, 56)); + const rawLens = Math.round(clampNumber(parseFloat(cssValue("--tb-lens-count", "7")), 1, 15, 7)); + const lensCount = rawLens % 2 === 0 ? rawLens + 1 : rawLens; + return { + minSize, + maxSize, + lensCount, + autoHide: cssValue("--tb-autohide", "0") === "1", + }; +}; + +const getToolbarDockState = (btn) => { + let state = toolbarDockState.get(btn); + if (!state) { + state = { + scale: 1, + targetScale: 1, + scaleVelocity: 0, + shiftY: 0, + targetShiftY: 0, + shiftVelocity: 0, + }; + toolbarDockState.set(btn, state); + } + return state; +}; + +const stepToolbarSpring = (value, velocity, target, dt) => { + let nextValue = value; + let nextVelocity = velocity; + const steps = Math.max(1, Math.ceil(dt / TOOLBAR_DOCK_MAX_STEP)); + const step = dt / steps; + for (let i = 0; i < steps; i += 1) { + const displacement = nextValue - target; + const springForce = -TOOLBAR_DOCK_SPRING.stiffness * displacement; + const dampingForce = -TOOLBAR_DOCK_SPRING.damping * nextVelocity; + const acceleration = (springForce + dampingForce) / TOOLBAR_DOCK_SPRING.mass; + nextVelocity += acceleration * step; + nextValue += nextVelocity * step; + } + return [nextValue, nextVelocity]; +}; + +const renderToolbarDockFrame = (timestamp = performance.now()) => { + const buttons = getVisibleToolbarButtons(); + if (!buttons.length) { + toolbarDockAnimationFrame = null; + return; + } + const dt = toolbarDockLastTs ? Math.min(0.032, Math.max(0.001, (timestamp - toolbarDockLastTs) / 1000)) : 0.016; + toolbarDockLastTs = timestamp; + let active = false; + buttons.forEach((btn) => { + const state = getToolbarDockState(btn); + [state.scale, state.scaleVelocity] = stepToolbarSpring(state.scale, state.scaleVelocity, state.targetScale, dt); + [state.shiftY, state.shiftVelocity] = stepToolbarSpring(state.shiftY, state.shiftVelocity, state.targetShiftY, dt); + if ( + Math.abs(state.scale - state.targetScale) < 0.001 && + Math.abs(state.scaleVelocity) < 0.001 && + Math.abs(state.shiftY - state.targetShiftY) < 0.05 && + Math.abs(state.shiftVelocity) < 0.05 + ) { + state.scale = state.targetScale; + state.scaleVelocity = 0; + state.shiftY = state.targetShiftY; + state.shiftVelocity = 0; + } else { + active = true; + } + btn.style.setProperty("--tb-scale", state.scale.toFixed(4)); + btn.style.setProperty("--tb-shift-y", `${state.shiftY.toFixed(2)}px`); + }); + if (active) { + toolbarDockAnimationFrame = window.requestAnimationFrame(renderToolbarDockFrame); + } else { + toolbarDockAnimationFrame = null; + toolbarDockLastTs = 0; + } +}; + +const startToolbarDockAnimation = () => { + if (toolbarDockAnimationFrame) return; + toolbarDockLastTs = 0; + toolbarDockAnimationFrame = window.requestAnimationFrame(renderToolbarDockFrame); +}; + +const setToolbarButtonTarget = (btn, size, shiftY, settings) => { + const state = getToolbarDockState(btn); + state.targetScale = settings.minSize > 0 ? size / settings.minSize : 1; + state.targetShiftY = shiftY; + startToolbarDockAnimation(); +}; + +const resetToolbarDockMagnification = () => { + const settings = readToolbarDockSettings(); + bottomToolbar?.classList.remove("toolbar-dock-active"); + getVisibleToolbarButtons().forEach((btn) => setToolbarButtonTarget(btn, settings.minSize, 0, settings)); + if (sectionModeActive) { + positionSectionToolbar(); + } +}; + +const updateToolbarDockMagnification = (clientY) => { + const buttons = getVisibleToolbarButtons(); + if (!buttons.length) return; + const settings = readToolbarDockSettings(); + const toolbarRect = bottomToolbar.getBoundingClientRect(); + const entries = buttons.map((btn) => { + return { + btn, + centerY: toolbarRect.top + btn.offsetTop + btn.offsetHeight / 2, + }; + }).sort((a, b) => a.centerY - b.centerY); + const gap = parseFloat(cssValue("--tb-gap", "10")) || 10; + const step = settings.minSize + gap; + const lensRadius = Math.max( + settings.minSize, + Math.floor((settings.lensCount - 1) / 2) * step + gap / 2 + ); + const maxScale = settings.minSize > 0 ? settings.maxSize / settings.minSize : 1; + const scaledNudge = TOOLBAR_DOCK_BASE_NUDGE * + (lensRadius / TOOLBAR_DOCK_BASE_DISTANCE) * + Math.max(0, maxScale - 1) / (TOOLBAR_DOCK_BASE_SCALE - 1) * + TOOLBAR_DOCK_NUDGE_TIGHTNESS; + entries.forEach((entry) => { + const distance = clientY - entry.centerY; + const absDistance = Math.abs(distance); + const scale = absDistance >= lensRadius + ? 1 + : 1 + (maxScale - 1) * (1 - absDistance / lensRadius); + const size = settings.minSize * scale; + const shiftY = absDistance > lensRadius + ? Math.sign(distance || 1) * -1 * scaledNudge + : (-distance / lensRadius) * scaledNudge * scale; + setToolbarButtonTarget(entry.btn, size, shiftY, settings); + }); + if (sectionModeActive) { + positionSectionToolbar(); + } +}; + +const requestToolbarDockMagnification = (clientY) => { + updateToolbarDockMagnification(clientY); +}; + +const showToolbarDock = () => { + if (!bottomToolbar) return; + window.clearTimeout(toolbarDockHideTimer); + toolbarDockHideTimer = null; + bottomToolbar.classList.add("is-visible"); +}; + +const scheduleToolbarDockHide = () => { + if (!bottomToolbar) return; + const settings = readToolbarDockSettings(); + if (!settings.autoHide) return; + if (toolbarDockHideTimer) return; + toolbarDockHideTimer = window.setTimeout(() => { + toolbarDockHideTimer = null; + bottomToolbar.classList.remove("is-visible"); + resetToolbarDockMagnification(); + }, 5000); +}; + +const updateToolbarDockMode = () => { + if (!bottomToolbar) return; + const settings = readToolbarDockSettings(); + bottomToolbar.classList.toggle("toolbar-autohide", settings.autoHide); + bottomToolbar.classList.toggle("is-visible", !settings.autoHide); + if (!settings.autoHide) { + window.clearTimeout(toolbarDockHideTimer); + toolbarDockHideTimer = null; + } + resetToolbarDockMagnification(); +}; + +const setupToolbarDock = () => { + if (!bottomToolbar) return; + bottomToolbar.addEventListener("pointermove", (event) => { + showToolbarDock(); + bottomToolbar.classList.add("toolbar-dock-active"); + requestToolbarDockMagnification(event.clientY); + }); + bottomToolbar.addEventListener("pointerenter", () => { + showToolbarDock(); + bottomToolbar.classList.add("toolbar-dock-active"); + }); + bottomToolbar.addEventListener("pointerleave", () => { + resetToolbarDockMagnification(); + scheduleToolbarDockHide(); + }); + window.addEventListener("pointermove", (event) => { + const settings = readToolbarDockSettings(); + if (!settings.autoHide || isGuestMode()) return; + if (event.clientX <= Math.max(32, settings.maxSize * 0.75)) { + showToolbarDock(); + } else if (!bottomToolbar.matches(":hover")) { + scheduleToolbarDockHide(); + } + }, { passive: true }); + window.addEventListener("resize", () => { + resetToolbarDockMagnification(); + updateToolbarDockMode(); + }); + updateToolbarDockMode(); +}; + const hexToRgb01 = (hex) => { if (!hex) return [1, 1, 1]; const clean = hex.trim(); @@ -4097,7 +4329,8 @@ const buildProjectSnapshot = async (name) => { const applyDesignState = (design) => { if (!design || !settingsMenu?.applyState) return; settingsMenu.applyState(design); - applyToolbarVars(); + applyToolbarVars(settingsMenu?.getState?.()); + updateToolbarDockMode(); applyHighlightTheme(); applyMeasureTheme(); if (sdkRef) { @@ -4481,7 +4714,8 @@ const initViewer = async () => { rebuildNavCube(sdk); resetTreeView(sdk); - applyToolbarVars(); + applyToolbarVars(settingsMenu?.getState?.()); + updateToolbarDockMode(); applyHighlightTheme(); updatePanelsVisibility(); ensureCameraControlActive(); @@ -5330,6 +5564,10 @@ const loadProjectSnapshot = async (project) => { if (key && key.startsWith("commentTarget")) { renderCommentTargets(); } + if (key && key.startsWith("toolbar")) { + applyToolbarVars(settingsMenu?.getState?.()); + updateToolbarDockMode(); + } focusCanvasAndControls(); }, onShow: () => { @@ -5343,6 +5581,8 @@ const loadProjectSnapshot = async (project) => { } }); // ensure persisted settings are applied to cube/highlight on first load + applyToolbarVars(settingsMenu?.getState?.()); + updateToolbarDockMode(); rebuildNavCube(sdk); applyHighlightTheme(); applyMeasureTheme(); @@ -5944,6 +6184,7 @@ const loadProjectSnapshot = async (project) => { // нижняя тулбар if (bottomToolbar) { + setupToolbarDock(); bottomToolbar.addEventListener("click", (e) => { const btn = e.target.closest(".tb-btn"); if (!btn) return; diff --git a/frontend/index.html b/frontend/index.html index 358318d..ddced96 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ - + + diff --git a/frontend/src/ui/settingsMenu.js b/frontend/src/ui/settingsMenu.js index 0ca4e5e..6b8849a 100644 --- a/frontend/src/ui/settingsMenu.js +++ b/frontend/src/ui/settingsMenu.js @@ -24,6 +24,10 @@ const defaultSettings = { toolbarBgActive: "#c4ff67", toolbarBorder: "#292929", toolbarOutline: "#292929", + toolbarMinSize: 25, + toolbarMaxSize: 56, + toolbarLensCount: 7, + toolbarAutoHide: false, modalBg: "#1c1c1c", modalBgAlpha: 1, modalFocus: "#292929", @@ -213,6 +217,17 @@ const applySettings = (s) => { root.style.setProperty("--toolbar-bg-active-contrast", readableTextColor(s.toolbarBgActive)); root.style.setProperty("--toolbar-border", s.toolbarBorder); root.style.setProperty("--toolbar-outline", s.toolbarOutline); + const toolbarMinRaw = Number(s.toolbarMinSize ?? defaultSettings.toolbarMinSize); + const toolbarMaxRaw = Number(s.toolbarMaxSize ?? defaultSettings.toolbarMaxSize); + const toolbarLensRaw = Number(s.toolbarLensCount ?? defaultSettings.toolbarLensCount); + const toolbarMinSize = clamp(Number.isFinite(toolbarMinRaw) ? toolbarMinRaw : defaultSettings.toolbarMinSize, 18, 88); + const toolbarMaxSize = Math.max(toolbarMinSize, clamp(Number.isFinite(toolbarMaxRaw) ? toolbarMaxRaw : defaultSettings.toolbarMaxSize, 18, 96)); + const toolbarLensCount = Math.max(1, Math.min(15, Math.round(Number.isFinite(toolbarLensRaw) ? toolbarLensRaw : defaultSettings.toolbarLensCount))); + root.style.setProperty("--tb-size", `${toolbarMinSize}px`); + root.style.setProperty("--tb-min-size", `${toolbarMinSize}px`); + root.style.setProperty("--tb-max-size", `${toolbarMaxSize}px`); + root.style.setProperty("--tb-lens-count", `${toolbarLensCount % 2 === 0 ? toolbarLensCount + 1 : toolbarLensCount}`); + root.style.setProperty("--tb-autohide", s.toolbarAutoHide ? "1" : "0"); const bgAlphaPct = `${(s.modalBgAlpha ?? 1) * 100}%`; const focusAlphaPct = `${(s.modalFocusAlpha ?? 1) * 100}%`; root.style.setProperty("--modal-bg", s.modalBg); @@ -442,6 +457,24 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh return input; }; + const addBoolean = (parent, label, key) => { + const row = document.createElement("label"); + row.className = "checkbox-row"; + const text = document.createElement("span"); + text.className = "checkbox-row__label"; + text.textContent = label; + const chk = document.createElement("input"); + chk.type = "checkbox"; + chk.checked = !!state[key]; + chk.addEventListener("change", () => { + setVal(key, chk.checked); + }); + row.appendChild(text); + row.appendChild(chk); + parent.appendChild(row); + return chk; + }; + const font = section("Фон"); addColor(font, "Фон внешний", "bgOuter"); addColor(font, "Фон холста верх", "canvasTop"); @@ -484,6 +517,49 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh addColor(toolbar, "Актив", "toolbarBgActive"); addColor(toolbar, "Бордер", "toolbarBorder"); addColor(toolbar, "Outline", "toolbarOutline"); + addSettingsSlider({ + parent: toolbar, + label: "Минимальный размер", + value: Math.max(18, Math.min(42, Number(state.toolbarMinSize ?? defaultSettings.toolbarMinSize))), + min: 18, + max: 42, + step: 1, + formatValue: (val) => `${Math.round(val)}px`, + onChange: (val) => { + const next = Math.max(18, Math.min(42, Math.round(Number(val)))); + setVal("toolbarMinSize", next); + if (Number(state.toolbarMaxSize) < next) { + setVal("toolbarMaxSize", next); + } + }, + }); + addSettingsSlider({ + parent: toolbar, + label: "Максимальный размер", + value: Math.max(28, Math.min(88, Number(state.toolbarMaxSize ?? defaultSettings.toolbarMaxSize))), + min: 28, + max: 88, + step: 1, + formatValue: (val) => `${Math.round(val)}px`, + onChange: (val) => { + const minSize = Math.max(18, Math.min(42, Number(state.toolbarMinSize ?? defaultSettings.toolbarMinSize))); + setVal("toolbarMaxSize", Math.max(minSize, Math.min(88, Math.round(Number(val))))); + }, + }); + addSettingsSlider({ + parent: toolbar, + label: "Линза", + value: Math.max(1, Math.min(13, Number(state.toolbarLensCount ?? defaultSettings.toolbarLensCount))), + min: 1, + max: 13, + step: 2, + formatValue: (val) => `${Math.round(val)} иконок`, + onChange: (val) => { + const next = Math.max(1, Math.min(13, Math.round(Number(val)))); + setVal("toolbarLensCount", next % 2 === 0 ? next + 1 : next); + }, + }); + addBoolean(toolbar, "Автоскрытие", "toolbarAutoHide"); body.appendChild(toolbar); const navCube = section("NavCube"); diff --git a/frontend/src/ui/toolbar.js b/frontend/src/ui/toolbar.js index 5ec619e..6abe865 100644 --- a/frontend/src/ui/toolbar.js +++ b/frontend/src/ui/toolbar.js @@ -1,4 +1,8 @@ const toolbarConfig = { + minSize: 25, + maxSize: 56, + lensCount: 7, + autoHide: false, size: 25, gap: 10, radius: 7, @@ -7,9 +11,22 @@ const toolbarConfig = { iconSize: 13, }; -const applyToolbarVars = () => { +const numericSetting = (settings, key, fallback) => { + const value = Number(settings?.[key]); + return Number.isFinite(value) ? value : fallback; +}; + +const applyToolbarVars = (settings = {}) => { const root = document.documentElement; - root.style.setProperty("--tb-size", `${toolbarConfig.size}px`); + const minSize = numericSetting(settings, "toolbarMinSize", toolbarConfig.minSize); + const maxSize = Math.max(minSize, numericSetting(settings, "toolbarMaxSize", toolbarConfig.maxSize)); + const lensCount = Math.max(1, Math.round(numericSetting(settings, "toolbarLensCount", toolbarConfig.lensCount))); + const autoHide = settings?.toolbarAutoHide ?? toolbarConfig.autoHide; + root.style.setProperty("--tb-size", `${minSize}px`); + root.style.setProperty("--tb-min-size", `${minSize}px`); + root.style.setProperty("--tb-max-size", `${maxSize}px`); + root.style.setProperty("--tb-lens-count", `${lensCount}`); + root.style.setProperty("--tb-autohide", autoHide ? "1" : "0"); root.style.setProperty("--tb-gap", `${toolbarConfig.gap}px`); root.style.setProperty("--tb-radius", `${toolbarConfig.radius}px`); root.style.setProperty("--tb-pad-y", `${toolbarConfig.paddingY}px`); diff --git a/frontend/theme.js b/frontend/theme.js index 0d44adf..b67a9df 100644 --- a/frontend/theme.js +++ b/frontend/theme.js @@ -17,6 +17,10 @@ const PROJECT_THEME = { toolbarBgActive: "#c4ff67", toolbarBorder: "#292929", toolbarOutline: "#292929", + toolbarMinSize: 25, + toolbarMaxSize: 56, + toolbarLensCount: 7, + toolbarAutoHide: false, modalBg: "#1c1c1c", modalBgAlpha: 1, modalFocus: "#292929",