dockview-core
Version:
Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript
355 lines (354 loc) • 14.7 kB
JavaScript
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`;
}
}
}