diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index aa562a9b4..749865761 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -32,11 +32,11 @@ jobs: - name: Transpile files run: npm run build:ts - - name: Prettier - run: npm run prettier - - name: ESLint run: npm run lint + - name: Prettier + run: npm run prettier + - name: Test run: npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cee402ee..df2badd77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +## [Unreleased] + +- **Table**: The column resizing algorithm has been rewritten. Columns can now push each other. + ## [2.4.0] - 2025-12-26 ### Fixed diff --git a/dev/vscode-table/shift-table-columns.html b/dev/vscode-table/shift-table-columns.html new file mode 100644 index 000000000..998b29384 --- /dev/null +++ b/dev/vscode-table/shift-table-columns.html @@ -0,0 +1,96 @@ + + + + + + VSCode Elements + + + + + + + + +

Basic example

+
+ + + + id + firstname + lastname + email + company + + + + 30b1851f-393a-4462-ba28-133df9951cae + Leonel + Feeney + Jarrod.Beatty@hotmail.com + Adams, Kozey and Dooley + + + a7deaa18-0475-468f-a4e3-046df5ad2878 + Emerson + Collins + Kiarra_Predovic@hotmail.com + Yost LLC + + + 31b666d3-765d-45c9-bbbc-9d2cb64b9ff0 + Damien + Bednar + Clement57@yahoo.com + Welch and Sons + + + 72c76b94-c878-4046-85d6-28f4ebbca913 + Filomena + Dach + Morton_Nienow26@gmail.com + Rosenbaum - Wilkinson + + + + +
+ + diff --git a/eslint.config.mjs b/eslint.config.mjs index a3106bae9..8fcb28387 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,6 +39,7 @@ export default [ rules: { 'no-console': 'error', 'no-unexpected-multiline': 'off', + curly: ['error', 'all'], '@typescript-eslint/indent': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/no-non-null-assertion': 'off', diff --git a/src/vscode-table/ColumnResizeController.ts b/src/vscode-table/ColumnResizeController.ts new file mode 100644 index 000000000..e8d75af38 --- /dev/null +++ b/src/vscode-table/ColumnResizeController.ts @@ -0,0 +1,218 @@ +import {ReactiveController} from 'lit'; +import {type VscodeTable} from './vscode-table.js'; +import { + calculateColumnWidths, + Percent, + percent, + Px, + px, + toPercent, + toPx, +} from './calculations.js'; + +type SplitterElement = HTMLDivElement & { + dataset: DOMStringMap & { + index: string; + }; +}; + +export class ColumnResizeController implements ReactiveController { + private _host: VscodeTable; + private _hostWidth = px(0); + private _hostX = px(0); + private _activeSplitter: SplitterElement | null = null; + private _minColumnWidth = percent(0); + private _columnWidths: Percent[] = []; + private _dragState: { + splitterIndex: number; + pointerId: number; + prevX: Px; + dragOffset: Px; + } | null = null; + private _cachedSplitterPositions: Percent[] | null = null; + + constructor(host: VscodeTable) { + (this._host = host).addController(this); + } + + hostConnected(): void { + this.saveHostDimensions(); + } + + get isDragging(): boolean { + return this._dragState !== null; + } + + get splitterPositions(): Percent[] { + if (this._cachedSplitterPositions) { + return this._cachedSplitterPositions; + } + + const result: Percent[] = []; + + let acc = percent(0); + + for (let i = 0; i < this._columnWidths.length - 1; i++) { + acc = percent(acc + this._columnWidths[i]); + result.push(acc); + } + + this._cachedSplitterPositions = result; + + return result; + } + + getActiveSplitterCalculatedPosition() { + const splitterPositions = this.splitterPositions; + + if (!this._dragState) { + return px(0); + } + + const activeSplitterPos = splitterPositions[this._dragState.splitterIndex]; + const activeSplitterPosPx = this._toPx(activeSplitterPos); + + return activeSplitterPosPx; + } + + get columnWidths() { + return this._columnWidths; + } + + saveHostDimensions() { + const cr = this._host.getBoundingClientRect(); + const {width, x} = cr; + this._hostWidth = px(width); + this._hostX = px(x); + return this; + } + + setActiveSplitter(splitter: HTMLElement) { + this._activeSplitter = splitter as SplitterElement; + return this; + } + + getActiveSplitter() { + return this._activeSplitter; + } + + setMinColumnWidth(width: Percent) { + this._minColumnWidth = width; + return this; + } + + setColumWidths(widths: Percent[]) { + this._columnWidths = widths; + this._cachedSplitterPositions = null; + this._host.requestUpdate(); + return this; + } + + shouldDrag(event: PointerEvent) { + return ( + +(event.currentTarget as SplitterElement).dataset.index === + this._dragState?.splitterIndex + ); + } + + startDrag(event: PointerEvent) { + event.stopPropagation(); + + if (this._dragState) { + return; + } + + this._activeSplitter?.setPointerCapture(event.pointerId); + + const mouseX = event.pageX; + const splitter = event.currentTarget as SplitterElement; + const splitterX = splitter!.getBoundingClientRect().x; + const xOffset = px(mouseX - splitterX); + + this._dragState = { + dragOffset: px(xOffset), + pointerId: event.pointerId, + splitterIndex: +splitter.dataset.index, + prevX: px(mouseX - xOffset), + }; + + this._host.requestUpdate(); + } + + drag(event: PointerEvent) { + event.stopPropagation(); + + if ( + !(event?.currentTarget as SplitterElement)?.hasPointerCapture?.( + event.pointerId + ) + ) { + return; + } + + if (!this._dragState) { + return; + } + + if (event.pointerId !== this._dragState.pointerId) { + return; + } + + if (!this.shouldDrag(event)) { + return; + } + + const mouseX = event.pageX; + const x = px(mouseX - this._dragState.dragOffset); + const deltaPx = px(x - this._dragState.prevX); + const delta = this._toPercent(deltaPx); + this._dragState.prevX = x; + + const splitterPos = this.getActiveSplitterCalculatedPosition(); + + if ( + (deltaPx <= 0 && mouseX > splitterPos + this._hostX) || + (deltaPx > 0 && mouseX < splitterPos + this._hostX) + ) { + return; + } + + this._columnWidths = calculateColumnWidths( + this._columnWidths, + this._dragState.splitterIndex, + delta, + this._minColumnWidth + ); + this._cachedSplitterPositions = null; + + this._host.requestUpdate(); + } + + stopDrag(event: PointerEvent) { + event.stopPropagation(); + + if (!this._dragState) { + return; + } + + const el = event.currentTarget as SplitterElement; + + try { + el.releasePointerCapture(this._dragState.pointerId); + } catch (e) { + // ignore + } + + this._dragState = null; + this._activeSplitter = null; + this._host.requestUpdate(); + } + + private _toPercent(px: Px) { + return toPercent(px, this._hostWidth); + } + + private _toPx(percent: Percent) { + return toPx(percent, this._hostWidth); + } +} diff --git a/src/vscode-table/calculations.test.ts b/src/vscode-table/calculations.test.ts new file mode 100644 index 000000000..2e4a51f23 --- /dev/null +++ b/src/vscode-table/calculations.test.ts @@ -0,0 +1,179 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import {expect} from '@open-wc/testing'; +import { + calculateColumnWidths, + parseSizeAttributeToPercent, + Percent, + percent, +} from './calculations.js'; + +describe('parseSizeAttributeToPercent', () => { + const base = 200; + + // number input + it('should parse valid number input', () => { + expect(parseSizeAttributeToPercent(50, base)).to.equal(25); + expect(parseSizeAttributeToPercent(0, base)).to.equal(0); + expect(parseSizeAttributeToPercent(200, base)).to.equal(100); + expect(parseSizeAttributeToPercent(-50, base)).to.equal(-25); + }); + + it('should return null for invalid number input', () => { + expect(parseSizeAttributeToPercent(NaN, base)).to.be.null; + expect(parseSizeAttributeToPercent(Infinity, base)).to.be.null; + expect(parseSizeAttributeToPercent(-Infinity, base)).to.be.null; + }); + + // string number input + it('should parse valid string number', () => { + expect(parseSizeAttributeToPercent('50', base)).to.equal(25); + expect(parseSizeAttributeToPercent('0', base)).to.equal(0); + expect(parseSizeAttributeToPercent('100.5', base)).to.be.closeTo( + 50.25, + 0.0001 + ); + expect(parseSizeAttributeToPercent('-50', base)).to.equal(-25); + expect(parseSizeAttributeToPercent(' 50 ', base)).to.equal(25); + }); + + it('should return null for invalid string number', () => { + expect(parseSizeAttributeToPercent('abc', base)).to.be.null; + expect(parseSizeAttributeToPercent('50abc', base)).to.be.null; + expect(parseSizeAttributeToPercent('', base)).to.be.null; + expect(parseSizeAttributeToPercent(' ', base)).to.be.null; + expect(parseSizeAttributeToPercent('NaN', base)).to.be.null; + }); + + // px input + it('should parse valid px input', () => { + expect(parseSizeAttributeToPercent('50px', base)).to.equal(25); + expect(parseSizeAttributeToPercent('0px', base)).to.equal(0); + expect(parseSizeAttributeToPercent('100.5px', base)).to.be.closeTo( + 50.25, + 0.0001 + ); + expect(parseSizeAttributeToPercent('-50px', base)).to.equal(-25); + expect(parseSizeAttributeToPercent(' 50px ', base)).to.equal(25); + }); + + it('should return null for invalid px input', () => { + expect(parseSizeAttributeToPercent('50p', base)).to.be.null; + expect(parseSizeAttributeToPercent('px', base)).to.be.null; + expect(parseSizeAttributeToPercent('50px%', base)).to.be.null; + }); + + // percent input + it('should parse valid percent input', () => { + expect(parseSizeAttributeToPercent('25%', base)).to.equal(25); + expect(parseSizeAttributeToPercent('0%', base)).to.equal(0); + expect(parseSizeAttributeToPercent('100%', base)).to.equal(100); + expect(parseSizeAttributeToPercent('50.5%', base)).to.be.closeTo( + 50.5, + 0.0001 + ); + expect(parseSizeAttributeToPercent('-20%', base)).to.equal(-20); + expect(parseSizeAttributeToPercent(' 30% ', base)).to.equal(30); + }); + + it('should return null for invalid percent input', () => { + expect(parseSizeAttributeToPercent('%', base)).to.be.null; + expect(parseSizeAttributeToPercent('20%%', base)).to.be.null; + expect(parseSizeAttributeToPercent('abc%', base)).to.be.null; + expect(parseSizeAttributeToPercent('50%px', base)).to.be.null; + }); + + // invalid base + it('should return null for invalid base', () => { + expect(parseSizeAttributeToPercent('50', 0)).to.be.null; + expect(parseSizeAttributeToPercent('50', NaN)).to.be.null; + expect(parseSizeAttributeToPercent('50', Infinity)).to.be.null; + expect(parseSizeAttributeToPercent(50, 0)).to.be.null; + }); +}); + +describe('calculateColumnWidths', () => { + it('returns unchanged widths when delta is 0', () => { + const widths = [percent(25), percent(25), percent(50)]; + + const result = calculateColumnWidths(widths, 1, percent(0), percent(10)); + + expect(result).to.deep.equal(widths); + }); + + it('returns unchanged widths for invalid splitter index', () => { + const widths = [percent(30), percent(30), percent(40)]; + + expect( + calculateColumnWidths(widths, -1, percent(10), percent(10)) + ).to.deep.equal(widths); + expect( + calculateColumnWidths(widths, 2, percent(10), percent(10)) + ).to.deep.equal(widths); + }); + + it('shrinks right column and grows left column when dragging right (delta > 0)', () => { + const widths = [percent(30), percent(30), percent(40)]; + + const result = calculateColumnWidths(widths, 1, percent(10), percent(10)); + + expect(result).to.deep.equal([percent(30), percent(40), percent(30)]); + }); + + it('shrinks left column and grows right column when dragging left (delta < 0)', () => { + const widths = [percent(30), percent(30), percent(40)]; + + const result = calculateColumnWidths(widths, 1, percent(-10), percent(10)); + + expect(result).to.deep.equal([percent(30), percent(20), percent(50)]); + }); + + it('respects minWidth when shrinking', () => { + const widths = [percent(30), percent(20), percent(50)]; + + const result = calculateColumnWidths(widths, 0, percent(15), percent(20)); + + // right side shrinks, left side grows + expect(result).to.deep.equal([percent(45), percent(20), percent(35)]); + }); + + it('shrinks multiple columns sequentially when needed', () => { + const widths = [percent(40), percent(30), percent(30)]; + + const result = calculateColumnWidths(widths, 0, percent(25), percent(10)); + + expect(result).to.deep.equal([percent(65), percent(10), percent(25)]); + }); + + it('aborts if total available shrink space is insufficient', () => { + const widths = [percent(40), percent(15), percent(45)]; + + const result = calculateColumnWidths(widths, 0, percent(20), percent(10)); + expect(result).to.not.deep.equal(widths); + + const impossible = calculateColumnWidths( + widths, + 0, + percent(50), + percent(10) + ); + expect(impossible).to.deep.equal(widths); + }); + + it('only grows the nearest column on the growing side', () => { + const widths = [percent(20), percent(40), percent(40)]; + + const result = calculateColumnWidths(widths, 1, percent(10), percent(10)); + + expect(result).to.deep.equal([percent(20), percent(50), percent(30)]); + }); + + it('preserves total width sum', () => { + const widths = [percent(25), percent(25), percent(50)]; + + const result = calculateColumnWidths(widths, 0, percent(15), percent(10)); + + const sum = (arr: Percent[]) => arr.reduce((a, b) => a + b, 0); + + expect(sum(result)).to.equal(sum(widths)); + }); +}); diff --git a/src/vscode-table/calculations.ts b/src/vscode-table/calculations.ts new file mode 100644 index 000000000..d47d97a7d --- /dev/null +++ b/src/vscode-table/calculations.ts @@ -0,0 +1,123 @@ +export type Px = number & {readonly __unit: 'px'}; +export type Percent = number & {readonly __unit: '%'}; + +export const px = (value: number): Px => value as Px; +export const percent = (value: number): Percent => value as Percent; + +export const toPercent = (px: Px, container: Px): Percent => + percent((px / container) * 100); + +export const toPx = (p: Percent, container: Px): Px => + px((p / 100) * container); + +export function calculateColumnWidths( + widths: Percent[], + splitterIndex: number, + delta: Percent, + minWidth: Percent +): Percent[] { + const result = [...widths]; + + // No-op for invalid splitter position or zero delta + if (delta === 0 || splitterIndex < 0 || splitterIndex >= widths.length - 1) { + return result; + } + + const absDelta = Math.abs(delta); + let remaining: Percent = percent(absDelta); + + const leftIndices: number[] = []; + const rightIndices: number[] = []; + + // Collect column indices to the left of the splitter (inclusive) + for (let i = splitterIndex; i >= 0; i--) { + leftIndices.push(i); + } + + // Collect column indices to the right of the splitter + for (let i = splitterIndex + 1; i < widths.length; i++) { + rightIndices.push(i); + } + + // One side shrinks, the other grows depending on drag direction + const shrinkingSide = delta > 0 ? rightIndices : leftIndices; + const growingSide = delta > 0 ? leftIndices : rightIndices; + + // Calculate total shrinkable space respecting minWidth + let totalAvailable: Percent = percent(0); + + for (const i of shrinkingSide) { + const available = Math.max(0, result[i] - minWidth); + totalAvailable = percent(totalAvailable + available); + } + + // Abort if the requested delta cannot be fully satisfied + if (totalAvailable < remaining) { + return result; + } + + // Shrink columns sequentially until the delta is fully consumed + for (const i of shrinkingSide) { + if (remaining === 0) { + break; + } + + const available = Math.max(0, result[i] - minWidth); + const take = Math.min(available, remaining); + + result[i] = percent(result[i] - take); + remaining = percent(remaining - take); + } + + // Apply the exact opposite delta to the growing side + let toAdd: Percent = percent(absDelta); + + for (const i of growingSide) { + if (toAdd === 0) { + break; + } + + result[i] = percent(result[i] + toAdd); + toAdd = percent(0); // all growth is applied to the nearest column + } + + return result; +} + +type Parser = { + test: (value: string) => boolean; + parse: (value: string, base: number) => number; +}; + +const parsers: Parser[] = [ + { + test: (v) => /^-?\d+(\.\d+)?%$/.test(v), + parse: (v) => Number(v.slice(0, -1)), + }, + { + test: (v) => /^-?\d+(\.\d+)?px$/.test(v), + parse: (v, base) => (Number(v.slice(0, -2)) / base) * 100, + }, + { + test: (v) => /^-?\d+(\.\d+)?$/.test(v), + parse: (v, base) => (Number(v) / base) * 100, + }, +]; + +export const parseSizeAttributeToPercent = ( + raw: string | number, + base: number +): number | null => { + if (!Number.isFinite(base) || base === 0) { + return null; + } + + if (typeof raw === 'number') { + return Number.isFinite(raw) ? (raw / base) * 100 : null; + } + + const value = raw.trim(); + const parser = parsers.find((p) => p.test(value)); + + return parser ? parser.parse(value, base) : null; +}; diff --git a/src/vscode-table/helpers.test.ts b/src/vscode-table/helpers.test.ts deleted file mode 100644 index ffcee9787..000000000 --- a/src/vscode-table/helpers.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {expect} from '@open-wc/testing'; -import {rawValueToPercentage} from './helpers.js'; - -describe('vscode-table helpers', () => { - it('input type is number', () => { - expect(rawValueToPercentage(50, 200)).to.eq(25); - expect(rawValueToPercentage(10.5, 200)).to.eq(5.25); - }); - - it('input type is string', () => { - expect(rawValueToPercentage('50', 200)).to.eq(25); - expect(rawValueToPercentage('10.5', 200)).to.eq(5.25); - }); - - it('input type is percentage', () => { - expect(rawValueToPercentage('50%', 200)).to.eq(50); - expect(rawValueToPercentage('10.5%', 200)).to.eq(10.5); - }); - - it('input type is pixel', () => { - expect(rawValueToPercentage('50px', 200)).to.eq(25); - expect(rawValueToPercentage('10.5px', 200)).to.eq(5.25); - }); - - it('input type is invalid value', () => { - expect(rawValueToPercentage('-50%', 200)).to.eq(null); - expect(rawValueToPercentage('auto', 200)).to.eq(null); - }); -}); diff --git a/src/vscode-table/helpers.ts b/src/vscode-table/helpers.ts deleted file mode 100644 index c125fe897..000000000 --- a/src/vscode-table/helpers.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const rawValueToPercentage = ( - raw: string | number, - base: number -): number | null => { - if (typeof raw === 'number' && !Number.isNaN(raw)) { - return (raw / base) * 100; - } else if (typeof raw === 'string' && /^[0-9.]+$/.test(raw)) { - const val = Number(raw); - return (val / base) * 100; - } else if (typeof raw === 'string' && /^[0-9.]+%$/.test(raw)) { - return Number(raw.substring(0, raw.length - 1)); - } else if (typeof raw === 'string' && /^[0-9.]+px$/.test(raw)) { - const val = Number(raw.substring(0, raw.length - 2)); - return (val / base) * 100; - } else { - return null; - } -}; diff --git a/src/vscode-table/vscode-table.styles.ts b/src/vscode-table/vscode-table.styles.ts index 19aafe422..5f43752ab 100644 --- a/src/vscode-table/vscode-table.styles.ts +++ b/src/vscode-table/vscode-table.styles.ts @@ -1,6 +1,9 @@ import {css, CSSResultGroup} from 'lit'; import baseStyles from '../includes/default.styles.js'; +export const SPLITTER_HIT_WIDTH = 5; +export const SPLITTER_VISIBLE_WIDTH = 1; + const styles: CSSResultGroup = [ baseStyles, css` @@ -120,10 +123,10 @@ const styles: CSSResultGroup = [ --vscode-editorGroup-border, rgba(255, 255, 255, 0.09) ); - height: 100%; + height: calc(100% - 30px); position: absolute; top: 30px; - width: 1px; + width: ${SPLITTER_VISIBLE_WIDTH}px; } .sash.hover .sash-visible { @@ -132,11 +135,10 @@ const styles: CSSResultGroup = [ } .sash .sash-clickable { - background-color: transparent; height: 100%; - left: -2px; + left: ${0 - (SPLITTER_HIT_WIDTH - SPLITTER_VISIBLE_WIDTH) / 2}px; position: absolute; - width: 5px; + width: ${SPLITTER_HIT_WIDTH}px; } `, ]; diff --git a/src/vscode-table/vscode-table.ts b/src/vscode-table/vscode-table.ts index 2ff07ca14..39a1d3582 100644 --- a/src/vscode-table/vscode-table.ts +++ b/src/vscode-table/vscode-table.ts @@ -1,4 +1,4 @@ -import {html, TemplateResult} from 'lit'; +import {html, PropertyValues, TemplateResult} from 'lit'; import { property, query, @@ -15,10 +15,10 @@ import {VscodeTableBody} from '../vscode-table-body/index.js'; import {VscodeTableCell} from '../vscode-table-cell/index.js'; import {VscodeTableHeader} from '../vscode-table-header/index.js'; import {VscodeTableHeaderCell} from '../vscode-table-header-cell/index.js'; -import {rawValueToPercentage} from './helpers.js'; +import {parseSizeAttributeToPercent} from './calculations.js'; import styles from './vscode-table.styles.js'; - -const COMPONENT_WIDTH_PERCENTAGE = 100; +import {ColumnResizeController} from './ColumnResizeController.js'; +import {percent} from './calculations.js'; /** * @tag vscode-table @@ -124,9 +124,6 @@ export class VscodeTable extends VscElement { @property({type: Boolean, reflect: true, attribute: 'zebra-odd'}) zebraOdd = false; - @query('slot[name="body"]') - private _bodySlot!: HTMLSlotElement; - @query('.header') private _headerElement!: HTMLDivElement; @@ -174,8 +171,6 @@ export class VscodeTable extends VscElement { private _headerResizeObserver?: ResizeObserver; private _bodyResizeObserver?: ResizeObserver; private _activeSashElementIndex = -1; - private _activeSashCursorOffset = 0; - private _componentX = 0; private _componentH = 0; private _componentW = 0; /** @@ -188,11 +183,11 @@ export class VscodeTable extends VscElement { * It shouldn't be used directly, check the "_getCellsOfFirstRow" function. */ private _cellsOfFirstRow: VscodeTableCell[] = []; - private _cellsToResize!: VscodeTableCell[]; - private _headerCellsToResize!: VscodeTableHeaderCell[]; private _prevHeaderHeight = 0; private _prevComponentHeight = 0; + private _columnResizeController = new ColumnResizeController(this); + override connectedCallback(): void { super.connectedCallback(); @@ -207,12 +202,13 @@ export class VscodeTable extends VscElement { this._bodyResizeObserver?.disconnect(); } - private _px2Percent(px: number) { - return (px / this._componentW) * 100; - } - - private _percent2Px(percent: number) { - return (this._componentW * percent) / 100; + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('minColumnWidth')) { + const value = percent( + parseSizeAttributeToPercent(this.minColumnWidth, this._componentW) ?? 0 + ); + this._columnResizeController.setMinColumnWidth(value); + } } private _memoizeComponentDimensions() { @@ -220,7 +216,6 @@ export class VscodeTable extends VscElement { this._componentH = cr.height; this._componentW = cr.width; - this._componentX = cr.x; } private _queryHeaderCells() { @@ -325,7 +320,7 @@ export class VscodeTable extends VscElement { this._resizeTableBody(); }; - private _calcColWidthPercentages(): number[] { + private _calculateInitialColumnWidths(): number[] { const numCols = this._getHeaderCells().length; let cols: (string | number)[] = this.columns.slice(0, numCols); const numAutoCols = @@ -333,7 +328,7 @@ export class VscodeTable extends VscElement { let availablePercent = 100; cols = cols.map((col) => { - const percentage = rawValueToPercentage(col, this._componentW); + const percentage = parseSizeAttributeToPercent(col, this._componentW); if (percentage === null) { return 'auto'; @@ -389,7 +384,10 @@ export class VscodeTable extends VscElement { } private _initDefaultColumnSizes() { - const colWidths = this._calcColWidthPercentages(); + const colWidths = this._calculateInitialColumnWidths(); + this._columnResizeController.setColumWidths( + colWidths.map((c) => percent(c)) + ); this._initHeaderCellSizes(colWidths); this._initBodyColumnSizes(colWidths); @@ -454,6 +452,32 @@ export class VscodeTable extends VscElement { } } + private _stopDrag(event: PointerEvent) { + const activeSplitter = this._columnResizeController.getActiveSplitter(); + + if (activeSplitter) { + activeSplitter.removeEventListener( + 'pointermove', + this._handleSplitterPointerMove + ); + activeSplitter.removeEventListener( + 'pointerup', + this._handleSplitterPointerUp + ); + activeSplitter.removeEventListener( + 'pointercancel', + this._handleSplitterPointerCancel + ); + } + + this._columnResizeController.stopDrag(event); + this._resizeColumns(true); + + this._sashHovers[this._activeSashElementIndex] = false; + this._isDragging = false; + this._activeSashElementIndex = -1; + } + private _onDefaultSlotChange() { this._assignedElements.forEach((el) => { if (el.tagName.toLowerCase() === 'vscode-table-header') { @@ -513,115 +537,45 @@ export class VscodeTable extends VscElement { this.requestUpdate(); } - private _onSashMouseDown(event: MouseEvent) { - event.stopPropagation(); - - const {pageX, currentTarget} = event; - const el = currentTarget as HTMLDivElement; - const index = Number(el.dataset.index); - const cr = el.getBoundingClientRect(); - const elX = cr.x; - - this._isDragging = true; - this._activeSashElementIndex = index; - this._sashHovers[this._activeSashElementIndex] = true; - this._activeSashCursorOffset = this._px2Percent(pageX - elX); + private _resizeColumns(resizeBodyCells = true) { + const widths = this._columnResizeController.columnWidths; const headerCells = this._getHeaderCells(); - this._headerCellsToResize = []; - this._headerCellsToResize.push(headerCells[index]); + headerCells.forEach((h, i) => (h.style.width = `${widths[i]}%`)); - if (headerCells[index + 1]) { - this._headerCellsToResize[1] = headerCells[index + 1]; + if (resizeBodyCells) { + const firstRowCells = this._getCellsOfFirstRow(); + firstRowCells.forEach((c, i) => (c.style.width = `${widths[i]}%`)); } + } - const tbody = this._bodySlot.assignedElements()[0]; - const cells = tbody.querySelectorAll( - 'vscode-table-row:first-child > vscode-table-cell' - ); - this._cellsToResize = []; - this._cellsToResize.push(cells[index]); + private _handleSplitterPointerDown(event: PointerEvent) { + event.stopPropagation(); - if (cells[index + 1]) { - this._cellsToResize.push(cells[index + 1]); - } + const activeSplitter = event.currentTarget as HTMLElement; - document.addEventListener('mousemove', this._onResizingMouseMove); - document.addEventListener('mouseup', this._onResizingMouseUp); - } + this._columnResizeController + .saveHostDimensions() + .setActiveSplitter(activeSplitter) + .startDrag(event); - private _updateActiveSashPosition(mouseX: number) { - const {prevSashPos, nextSashPos} = this._getSashPositions(); - let minColumnWidth = rawValueToPercentage( - this.minColumnWidth, - this._componentW + activeSplitter.addEventListener( + 'pointermove', + this._handleSplitterPointerMove ); - - if (minColumnWidth === null) { - minColumnWidth = 0; - } - - const minX = prevSashPos ? prevSashPos + minColumnWidth : minColumnWidth; - const maxX = nextSashPos - ? nextSashPos - minColumnWidth - : COMPONENT_WIDTH_PERCENTAGE - minColumnWidth; - let newX = this._px2Percent( - mouseX - this._componentX - this._percent2Px(this._activeSashCursorOffset) + activeSplitter.addEventListener('pointerup', this._handleSplitterPointerUp); + activeSplitter.addEventListener( + 'pointercancel', + this._handleSplitterPointerCancel ); - - newX = Math.max(newX, minX); - newX = Math.min(newX, maxX); - - this._sashPositions[this._activeSashElementIndex] = newX; - this.requestUpdate(); - } - - private _getSashPositions(): { - sashPos: number; - prevSashPos: number; - nextSashPos: number; - } { - const sashPos = this._sashPositions[this._activeSashElementIndex]; - const prevSashPos = - this._sashPositions[this._activeSashElementIndex - 1] || 0; - const nextSashPos = - this._sashPositions[this._activeSashElementIndex + 1] || - COMPONENT_WIDTH_PERCENTAGE; - - return { - sashPos, - prevSashPos, - nextSashPos, - }; } - private _resizeColumns(resizeBodyCells = true) { - const {sashPos, prevSashPos, nextSashPos} = this._getSashPositions(); - - const prevColW = sashPos - prevSashPos; - const nextColW = nextSashPos - sashPos; - const prevColCss = `${prevColW}%`; - const nextColCss = `${nextColW}%`; - - this._headerCellsToResize[0].style.width = prevColCss; - - if (this._headerCellsToResize[1]) { - this._headerCellsToResize[1].style.width = nextColCss; - } - - if (resizeBodyCells && this._cellsToResize[0]) { - this._cellsToResize[0].style.width = prevColCss; - - if (this._cellsToResize[1]) { - this._cellsToResize[1].style.width = nextColCss; - } + private _handleSplitterPointerMove = (event: PointerEvent) => { + if (!this._columnResizeController.shouldDrag(event)) { + return; } - } - - private _onResizingMouseMove = (event: MouseEvent) => { - event.stopPropagation(); - this._updateActiveSashPosition(event.pageX); + this._columnResizeController.drag(event); if (!this.delayedResizing) { this._resizeColumns(true); } else { @@ -629,19 +583,18 @@ export class VscodeTable extends VscElement { } }; - private _onResizingMouseUp = (event: MouseEvent) => { - this._resizeColumns(true); - this._updateActiveSashPosition(event.pageX); - this._sashHovers[this._activeSashElementIndex] = false; - this._isDragging = false; - this._activeSashElementIndex = -1; + private _handleSplitterPointerUp = (event: PointerEvent) => { + this._stopDrag(event); + }; - document.removeEventListener('mousemove', this._onResizingMouseMove); - document.removeEventListener('mouseup', this._onResizingMouseUp); + private _handleSplitterPointerCancel = (event: PointerEvent) => { + this._stopDrag(event); }; override render(): TemplateResult { - const sashes = this._sashPositions.map((val, index) => { + const splitterPositions = this._columnResizeController.splitterPositions; + + const sashes = splitterPositions.map((val, index) => { const classes = classMap({ sash: true, hover: this._sashHovers[index], @@ -656,7 +609,7 @@ export class VscodeTable extends VscElement { class=${classes} data-index=${index} .style=${stylePropertyMap({left})} - @mousedown=${this._onSashMouseDown} + @pointerdown=${this._handleSplitterPointerDown} @mouseover=${this._onSashMouseOver} @mouseout=${this._onSashMouseOut} > @@ -675,8 +628,8 @@ export class VscodeTable extends VscElement { const wrapperClasses = classMap({ wrapper: true, - 'select-disabled': this._isDragging, - 'resize-cursor': this._isDragging, + 'select-disabled': this._columnResizeController.isDragging, + 'resize-cursor': this._columnResizeController.isDragging, 'compact-view': this.compact, });