UNPKG

dockview-core

Version:

Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript

355 lines (354 loc) 14.7 kB
import { resolveTabGroupAccent, } from '../../tabGroupAccent'; /** * Shared positioning logic for tab group indicators. * Subclasses implement `applyShape` to control the visual output. */ class BaseTabGroupIndicator { get underlines() { return this._underlines; } constructor(_ctx) { this._ctx = _ctx; this._underlines = new Map(); this._rafId = null; } positionUnderlines() { requestAnimationFrame(() => { this._positionUnderlinesSync(); }); } /** * Continuously reposition underlines every frame for the duration * of a tab transition (~200ms), so the underline tracks tab sizes. */ trackUnderlines() { if (this._rafId !== null) { cancelAnimationFrame(this._rafId); } const start = performance.now(); const duration = 250; // slightly longer than transition to ensure we catch the end const tick = () => { this._positionUnderlinesSync(); if (performance.now() - start < duration) { this._rafId = requestAnimationFrame(tick); } else { this._rafId = null; } }; this._rafId = requestAnimationFrame(tick); } syncUnderlineElements(activeGroupIds) { // Ensure underline elements exist for active groups for (const groupId of activeGroupIds) { if (!this._underlines.has(groupId)) { const underline = document.createElement('div'); underline.className = 'dv-tab-group-underline'; this._ctx.tabsList.appendChild(underline); this._underlines.set(groupId, underline); } } // Remove underlines for dissolved groups for (const [groupId, el] of this._underlines) { if (!activeGroupIds.has(groupId)) { el.remove(); this._underlines.delete(groupId); } } } getUnderline(groupId) { return this._underlines.get(groupId); } dispose() { if (this._rafId !== null) { cancelAnimationFrame(this._rafId); this._rafId = null; } for (const [, el] of this._underlines) { el.remove(); } this._underlines.clear(); } _positionUnderlinesSync() { const containerRect = this._ctx.tabsList.getBoundingClientRect(); const tabGroups = this._ctx.getTabGroups(); const isVertical = this._ctx.getDirection() === 'vertical'; const containerCrossSize = isVertical ? containerRect.width : containerRect.height; const activePanelId = this._ctx.getActivePanelId(); const tabMap = this._ctx.getTabMap(); for (const tg of tabGroups) { const underline = this._underlines.get(tg.id); if (!underline) { continue; } const panelIds = tg.panelIds; if (panelIds.length === 0) { underline.style.display = 'none'; continue; } underline.style.display = ''; const chipEl = this._ctx.getChipElement(tg.id); // In vertical mode, compute top/bottom edges; in horizontal, left/right. let startEdge; if (chipEl) { const chipRect = chipEl.getBoundingClientRect(); const chipStyle = getComputedStyle(chipEl); const leadingMargin = isVertical ? Number.parseFloat(chipStyle.marginTop) || 0 : Number.parseFloat(chipStyle.marginLeft) || 0; startEdge = isVertical ? chipRect.top - containerRect.top - leadingMargin : chipRect.left - containerRect.left - leadingMargin; } else { const firstPanelId = panelIds[0]; const firstTabEntry = tabMap.get(firstPanelId); if (firstTabEntry) { const firstRect = firstTabEntry.value.element.getBoundingClientRect(); startEdge = isVertical ? firstRect.top - containerRect.top : firstRect.left - containerRect.left; } else { startEdge = 0; } } // Measure the actual last tab position (follows CSS transitions in real-time) const lastPanelId = panelIds[panelIds.length - 1]; const lastTabEntry = tabMap.get(lastPanelId); if (!lastTabEntry) { if (isVertical) { underline.style.top = `${startEdge}px`; underline.style.height = '0px'; underline.style.left = ''; underline.style.width = ''; } else { underline.style.left = `${startEdge}px`; underline.style.width = '0px'; underline.style.top = ''; underline.style.height = ''; } continue; } const lastTabRect = lastTabEntry.value.element.getBoundingClientRect(); let endEdge = isVertical ? lastTabRect.bottom - containerRect.top : lastTabRect.right - containerRect.left; let span = endEdge - startEdge; // During collapse or expand: converge both edges toward chip center const isAnimating = tg.collapsed || tg.panelIds.some((pid) => { const te = tabMap.get(pid); return (te && te.value.element.classList.contains('dv-tab--group-expanding')); }); if (isAnimating && chipEl) { const chipRect = chipEl.getBoundingClientRect(); const chipCenter = isVertical ? chipRect.top + chipRect.height / 2 - containerRect.top : chipRect.left + chipRect.width / 2 - containerRect.left; // Sum of current visible tab sizes (shrinking or growing) let currentTabSize = 0; let fullTabSize = 0; for (const pid of tg.panelIds) { const te = tabMap.get(pid); if (!te) continue; const el = te.value.element; if (isVertical) { currentTabSize += el.getBoundingClientRect().height; fullTabSize += el.scrollHeight; } else { currentTabSize += el.getBoundingClientRect().width; fullTabSize += el.scrollWidth; } } // progress: 0 when tabs at 0 size, 1 when fully open const progress = fullTabSize > 0 ? Math.min(1, currentTabSize / fullTabSize) : 0; // Interpolate start and end edges toward chip center startEdge = chipCenter + (startEdge - chipCenter) * progress; endEdge = chipCenter + (endEdge - chipCenter) * progress; span = Math.max(0, endEdge - startEdge); } if (isVertical) { underline.style.top = `${startEdge}px`; underline.style.height = `${Math.max(0, span)}px`; // Clear horizontal properties underline.style.left = ''; underline.style.width = ''; } else { underline.style.left = `${startEdge}px`; underline.style.width = `${Math.max(0, span)}px`; // Clear vertical properties underline.style.top = ''; underline.style.height = ''; } this.applyShape(underline, tg, startEdge, span, containerCrossSize, activePanelId, containerRect, isVertical); } } } /** * Chrome-style wrap-around indicator using SVG paths. */ export class WrapTabGroupIndicator extends BaseTabGroupIndicator { _applyStraightLine(svg, path, underline, t, mainSize, isVertical) { if (isVertical) { svg.setAttribute('width', String(t)); svg.setAttribute('height', String(mainSize)); underline.style.width = `${t}px`; underline.style.height = `${mainSize}px`; path.setAttribute('d', `M ${t / 2},0 L ${t / 2},${mainSize}`); } else { svg.setAttribute('width', String(mainSize)); svg.setAttribute('height', String(t)); underline.style.width = `${mainSize}px`; underline.style.height = `${t}px`; path.setAttribute('d', `M 0,${t / 2} L ${mainSize},${t / 2}`); } } /** * Chrome-style wrap-around underline: a stroked SVG path that runs * along the bottom (or left edge in vertical mode), curving up and * over the active tab with rounded corners. * * The SVG and path elements are created once per underline and reused; * only the `d`, `stroke`, and viewport attributes are updated each frame. */ applyShape(underline, tg, groupStart, groupSpan, containerCrossSize, activePanelId, containerRect, isVertical) { const t = 2; // line thickness in px const crossSize = containerCrossSize; const mainSize = groupSpan; const color = resolveTabGroupAccent(tg.color, this._ctx.getColorPalette()); if (mainSize <= 0 || crossSize <= 0 || color === undefined) { underline.style.display = 'none'; return; } underline.style.display = ''; // Find the active tab within this group let activeTabEntry; if (activePanelId && tg.panelIds.includes(activePanelId)) { activeTabEntry = this._ctx.getTabMap().get(activePanelId); } // Ensure SVG + path child exists (created once, reused) let svg = underline.firstElementChild; let path; if (!svg || svg.tagName !== 'svg') { underline.replaceChildren(); svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.style.display = 'block'; path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('fill', 'none'); svg.appendChild(path); underline.appendChild(svg); } else { path = svg.firstElementChild; } path.setAttribute('stroke', color); path.setAttribute('stroke-width', String(t)); if (!activeTabEntry) { this._applyStraightLine(svg, path, underline, t, mainSize, isVertical); return; } const activeRect = activeTabEntry.value.element.getBoundingClientRect(); // Compute active tab start/end relative to the group start let aStart; let aEnd; if (isVertical) { aStart = Math.max(0, activeRect.top - containerRect.top - groupStart); aEnd = Math.min(mainSize, activeRect.bottom - containerRect.top - groupStart); } else { aStart = Math.max(0, activeRect.left - containerRect.left - groupStart); aEnd = Math.min(mainSize, activeRect.right - containerRect.left - groupStart); } if (aEnd <= aStart) { this._applyStraightLine(svg, path, underline, t, mainSize, isVertical); return; } const r = 6; // corner radius const half = t / 2; if (isVertical) { const svgW = crossSize; const svgH = mainSize; svg.setAttribute('width', String(svgW)); svg.setAttribute('height', String(svgH)); underline.style.width = `${svgW}px`; underline.style.height = `${svgH}px`; const xLeft = half; const xRight = svgW - half; const d = [ `M ${xLeft},0`, `L ${xLeft},${aStart - r}`, `Q ${xLeft},${aStart} ${xLeft + r},${aStart}`, `L ${xRight - r},${aStart}`, `Q ${xRight},${aStart} ${xRight},${aStart + r}`, `L ${xRight},${aEnd - r}`, `Q ${xRight},${aEnd} ${xRight - r},${aEnd}`, `L ${xLeft + r},${aEnd}`, `Q ${xLeft},${aEnd} ${xLeft},${aEnd + r}`, `L ${xLeft},${svgH}`, ].join(' '); path.setAttribute('d', d); } else { const svgW = mainSize; const svgH = crossSize; svg.setAttribute('width', String(svgW)); svg.setAttribute('height', String(svgH)); underline.style.width = `${svgW}px`; underline.style.height = `${svgH}px`; const yBot = svgH - half; const yTop = half; const d = [ `M 0,${yBot}`, `L ${aStart - r},${yBot}`, `Q ${aStart},${yBot} ${aStart},${yBot - r}`, `L ${aStart},${yTop + r}`, `Q ${aStart},${yTop} ${aStart + r},${yTop}`, `L ${aEnd - r},${yTop}`, `Q ${aEnd},${yTop} ${aEnd},${yTop + r}`, `L ${aEnd},${yBot - r}`, `Q ${aEnd},${yBot} ${aEnd + r},${yBot}`, `L ${svgW},${yBot}`, ].join(' '); path.setAttribute('d', d); } } } /** * Flat continuous bar indicator — no wrap-around, just a colored line * spanning the full tab group width. */ export class NoneTabGroupIndicator extends BaseTabGroupIndicator { applyShape(underline, tg, _startEdge, span, _containerCrossSize, _activePanelId, _containerRect, isVertical) { const t = 2; // line thickness in px const color = resolveTabGroupAccent(tg.color, this._ctx.getColorPalette()); if (span <= 0 || color === undefined) { underline.style.display = 'none'; return; } underline.style.display = ''; // Clear any SVG content left over from a mode switch if (underline.firstElementChild) { underline.replaceChildren(); } underline.style.backgroundColor = color; if (isVertical) { underline.style.width = `${t}px`; underline.style.height = `${span}px`; } else { underline.style.width = `${span}px`; underline.style.height = `${t}px`; } } }