diff --git a/cypress/e2e/7.click.cy.js b/cypress/e2e/7.click.cy.js index c8f0ed88..f7863c01 100644 --- a/cypress/e2e/7.click.cy.js +++ b/cypress/e2e/7.click.cy.js @@ -35,32 +35,32 @@ context('Gutter click example page tests', () => { it('Click gutters to switch area sizes between 0 and X', () => { cy.get('.as-split-gutter').eq(0).click() - cy.wait(1500) + cy.wait(2000) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [0, 792, 264]) cy.wait(10) cy.get('.as-split-gutter').eq(0).click() - cy.wait(1500) + cy.wait(2000) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [264, 528, 264]) cy.wait(10) cy.get('.as-split-gutter').eq(0).click() - cy.wait(1500) + cy.wait(2000) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [0, 792, 264]) cy.wait(10) cy.get('.as-split-gutter').eq(1).click() - cy.wait(1500) + cy.wait(2000) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [0, 1056, 0]) cy.wait(10) cy.get('.as-split-gutter').eq(0).click() - cy.wait(1500) + cy.wait(2000) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [264, 792, 0]) cy.wait(10) cy.get('.as-split-gutter').eq(1).click() - cy.wait(1500) + cy.wait(2000) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [264, 528, 264]) cy.wait(10) @@ -123,7 +123,7 @@ context('Gutter click example page tests', () => { checkEventCount({ dragStartCount: 2, dragEndCount: 2, gutterClickCount: 4, transitionEndCount: 4 }) // It should fire Click Event on mouseup if the mouse cursor is not moved. - cy.get('.as-split-gutter').eq(0).trigger('mousedown', { which: 1, clientX: 0, clientY: 0 }) + cy.get('.as-split-gutter').eq(0).trigger('mousedown', { which: 1, clientX: 0, clientY: 0, button: 0 }) cy.get('.as-split-gutter').eq(0).trigger('mousemove', { which: 1, clientX: 0, clientY: 0 }) cy.wait(10) checkEventCount({ dragStartCount: 2, dragEndCount: 2, gutterClickCount: 4, transitionEndCount: 4 }) @@ -132,15 +132,23 @@ context('Gutter click example page tests', () => { cy.wait(2000) checkEventCount({ dragStartCount: 2, dragEndCount: 2, gutterClickCount: 5, transitionEndCount: 5 }) - // It should fire dragStart and should not fire Click Event on mousemove if the mouse cursor is moved. - cy.get('.as-split-gutter').eq(0).trigger('mousedown', { which: 1, clientX: 0, clientY: 0 }) + // It should fire click only when moved inside delta (2 pixels) + cy.get('.as-split-gutter').eq(0).trigger('mousedown', { which: 1, clientX: 0, clientY: 0, button: 0 }) cy.get('.as-split-gutter').eq(0).trigger('mousemove', { which: 1, clientX: 1, clientY: 0 }) cy.wait(10) - checkEventCount({ dragStartCount: 3, dragEndCount: 2, gutterClickCount: 5, transitionEndCount: 5 }) + checkEventCount({ dragStartCount: 2, dragEndCount: 2, gutterClickCount: 5, transitionEndCount: 5 }) + cy.get('.as-split-gutter').eq(0).trigger('mouseup', { which: 1, clientX: 0, clientY: 0 }) + cy.wait(20) + checkEventCount({ dragStartCount: 2, dragEndCount: 2, gutterClickCount: 6, transitionEndCount: 6 }) + // It should fire drag start and end only when moved outside delta (2 pixels) + cy.get('.as-split-gutter').eq(0).trigger('mousedown', { which: 1, clientX: 0, clientY: 0, button: 0 }) + cy.get('.as-split-gutter').eq(0).trigger('mousemove', { which: 1, clientX: 3, clientY: 0 }) + cy.wait(10) + checkEventCount({ dragStartCount: 3, dragEndCount: 2, gutterClickCount: 6, transitionEndCount: 6 }) cy.get('.as-split-gutter').eq(0).trigger('mouseup', { which: 1, clientX: 0, clientY: 0 }) cy.wait(20) - checkEventCount({ dragStartCount: 3, dragEndCount: 3, gutterClickCount: 5, transitionEndCount: 5 }) + checkEventCount({ dragStartCount: 3, dragEndCount: 3, gutterClickCount: 6, transitionEndCount: 6 }) }) it('Test double click event', () => { diff --git a/cypress/e2e/9.geek.cy.js b/cypress/e2e/9.geek.cy.js index 43c92e47..41d979de 100644 --- a/cypress/e2e/9.geek.cy.js +++ b/cypress/e2e/9.geek.cy.js @@ -242,16 +242,16 @@ context('Geek demo example page tests', () => { // SET gutterStep to 10px cy.get('.opts-prop').contains('Gutter step:').parent().contains('10').click() - // Move gutter 5px > no move - moveGutterByMouse('.split-example .as-split-gutter', 0, 5, 0) + // Move gutter 4px > no move + moveGutterByMouse('.split-example .as-split-gutter', 0, 4, 0) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [268.484375, 522, 263.5]) // Move gutter 6px > move 10px moveGutterByMouse('.split-example .as-split-gutter', 0, 6, 0) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [278.5, 511.984375, 263.5]) - // Move gutter 15px > move 10px - moveGutterByMouse('.split-example .as-split-gutter', 0, 15, 0) + // Move gutter 14px > move 10px + moveGutterByMouse('.split-example .as-split-gutter', 0, 14, 0) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [288.515625, 501.984375, 263.5]) // Move gutter 16px > move 20px @@ -266,8 +266,8 @@ context('Geek demo example page tests', () => { moveGutterByMouse('.split-example .as-split-gutter', 0, 20, 0) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [308.515625, 481.984375, 263.5]) - // Move gutter 25px > nomove - moveGutterByMouse('.split-example .as-split-gutter', 0, 25, 0) + // Move gutter 24px > nomove + moveGutterByMouse('.split-example .as-split-gutter', 0, 24, 0) checkSplitDirAndSizes('.split-example > as-split', 'horizontal', W, H, GUTTER, [308.515625, 481.984375, 263.5]) // Move gutter 26px > move 50px diff --git a/cypress/support/splitUtils.js b/cypress/support/splitUtils.js index 7611cc31..068f99c3 100644 --- a/cypress/support/splitUtils.js +++ b/cypress/support/splitUtils.js @@ -1,7 +1,7 @@ export function moveGutterByMouse(gutters, num, x, y) { cy.get(gutters) .eq(num) - .trigger('mousedown', { which: 1, clientX: 0, clientY: 0 }) + .trigger('mousedown', { which: 1, clientX: 0, clientY: 0, button: 0 }) .trigger('mousemove', { clientX: x * 0.25, clientY: y * 0.25 }) .trigger('mousemove', { clientX: x * 0.5, clientY: y * 0.5 }) .trigger('mousemove', { clientX: x * 0.75, clientY: y * 0.75 }) @@ -64,20 +64,22 @@ export function checkSplitDirAndSizes(el, dir, w, h, gutter, sizes) { const total = sizes.reduce((acc, v) => acc + v, 0) + gutter * (sizes.length - 1) // expect(total).to.eq((dir === 'horizontal') ? w : h); - const propFlexDir = dir === 'horizontal' ? 'row' : 'column' - cy.get(el).should('have.css', 'flex-direction', propFlexDir) + const propGridDir = dir === 'vertical' ? 'grid-template-rows' : 'grid-template-columns' + cy.get(el).should('have.css', propGridDir).should('include', ' ') const propSize = dir === 'horizontal' ? 'width' : 'height' const propSize2 = propSize === 'width' ? 'height' : 'width' const propValue2 = propSize === 'width' ? h : w + const round = (value) => Math.round(value * 10) / 10 + cy.get(`${el} > .as-split-gutter`).should('have.length', sizes.length - 1) cy.get(`${el} > .as-split-gutter`).then(($el) => { const rect = $el[0].getBoundingClientRect() expect(rect[propSize]).to.be.eq(gutter) - expect(rect[propSize2]).to.be.eq(propValue2) + expect(round(rect[propSize2])).to.be.eq(round(propValue2)) }) cy.get(`${el} > .as-split-area`) @@ -85,7 +87,7 @@ export function checkSplitDirAndSizes(el, dir, w, h, gutter, sizes) { .each(($li, index) => { const rect = $li[0].getBoundingClientRect() - expect(rect[propSize]).to.be.eq(sizes[index]) - expect(rect[propSize2]).to.be.eq(propValue2) + expect(round(rect[propSize])).to.be.eq(round(sizes[index])) + expect(round(rect[propSize2])).to.be.eq(round(propValue2)) }) } diff --git a/package-lock.json b/package-lock.json index be2e64db..39561de5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "@typescript-eslint/eslint-plugin": "6.19.0", "@typescript-eslint/parser": "6.19.0", "angular-cli-ghpages": "^2.0.0-beta.2", - "cypress": "12.17.4", + "cypress": "^13.10.0", "eslint": "^8.56.0", "husky": "^8.0.3", "lint-staged": "^14.0.1", @@ -3053,9 +3053,9 @@ } }, "node_modules/@cypress/request": { - "version": "2.88.12", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", - "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", "dev": true, "dependencies": { "aws-sign2": "~0.7.0", @@ -3071,7 +3071,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.10.3", + "qs": "6.10.4", "safe-buffer": "^5.1.2", "tough-cookie": "^4.1.3", "tunnel-agent": "^0.6.0", @@ -6091,9 +6091,9 @@ } }, "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", + "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==", "dev": true }, "node_modules/axios": { @@ -7756,21 +7756,20 @@ } }, "node_modules/cypress": { - "version": "12.17.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz", - "integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.10.0.tgz", + "integrity": "sha512-tOhwRlurVOQbMduX+KonoMeQILs2cwR3yHGGENoFvvSoLUBHmJ8b9/n21gFSDqjlOJ+SRVcwuh+fG/JDsHsT6Q==", "dev": true, "hasInstallScript": true, "dependencies": { - "@cypress/request": "2.88.12", + "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", - "@types/node": "^16.18.39", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", - "buffer": "^5.6.0", + "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", @@ -7788,7 +7787,7 @@ "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", - "is-ci": "^3.0.0", + "is-ci": "^3.0.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", @@ -7810,15 +7809,9 @@ "cypress": "bin/cypress" }, "engines": { - "node": "^14.0.0 || ^16.0.0 || >=18.0.0" + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, - "node_modules/cypress/node_modules/@types/node": { - "version": "16.18.96", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.96.tgz", - "integrity": "sha512-84iSqGXoO+Ha16j8pRZ/L90vDMKX04QTYMTfYeE1WrjWaZXuchBehGUZEpNgx7JnmlrIHdnABmpjrQjhCnNldQ==", - "dev": true - }, "node_modules/cypress/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/package.json b/package.json index ec2c1c77..4caf2c2c 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,6 @@ "zone.js": "~0.14.2" }, "devDependencies": { - "lint-staged": "^14.0.1", - "angular-cli-ghpages": "^2.0.0-beta.2", - "husky": "^8.0.3", - "prettier": "^3.0.2", "@angular-devkit/architect": "^0.1701.0", "@angular-devkit/build-angular": "^17.1.0", "@angular-devkit/core": "^17.1.0", @@ -62,10 +58,14 @@ "@types/node": "20.5.4", "@typescript-eslint/eslint-plugin": "6.19.0", "@typescript-eslint/parser": "6.19.0", - "cypress": "12.17.4", + "angular-cli-ghpages": "^2.0.0-beta.2", + "cypress": "^13.10.0", "eslint": "^8.56.0", + "husky": "^8.0.3", + "lint-staged": "^14.0.1", "ng-packagr": "^17.0.0", "postcss": "8.4.28", + "prettier": "^3.0.2", "serve": "^14.2.1", "ts-node": "10.9.1", "tslib": "^2.6.2", diff --git a/projects/angular-split/src/lib/angular-split-config.token.ts b/projects/angular-split/src/lib/angular-split-config.token.ts index e24411d6..2085dcff 100644 --- a/projects/angular-split/src/lib/angular-split-config.token.ts +++ b/projects/angular-split/src/lib/angular-split-config.token.ts @@ -1,4 +1,47 @@ -import { InjectionToken } from '@angular/core' -import { IDefaultOptions } from './interface' +import { InjectionToken, Provider, inject } from '@angular/core' +import { SplitDir, SplitDirection, SplitUnit } from './models' -export const ANGULAR_SPLIT_DEFAULT_OPTIONS = new InjectionToken>('angular-split-global-config') +export interface AngularSplitDefaultOptions { + dir: SplitDir + direction: SplitDirection + disabled: boolean + gutterDblClickDuration: number + gutterSize: number + gutterStep: number + gutterClickDeltaPx: number + restrictMove: boolean + unit: SplitUnit + useTransition: boolean +} + +const defaultOptions: AngularSplitDefaultOptions = { + dir: 'ltr', + direction: 'horizontal', + disabled: false, + gutterDblClickDuration: 0, + gutterSize: 11, + gutterStep: 1, + gutterClickDeltaPx: 2, + restrictMove: false, + unit: 'percent', + useTransition: false, +} + +export const ANGULAR_SPLIT_DEFAULT_OPTIONS = new InjectionToken( + 'angular-split-global-config', + { providedIn: 'root', factory: () => defaultOptions }, +) + +/** + * Provides default options for angular split. The options object has hierarchical inheritance + * which means only the declared properties will be overridden + */ +export function provideAngularSplitOptions(options: Partial): Provider { + return { + provide: ANGULAR_SPLIT_DEFAULT_OPTIONS, + useFactory: (): AngularSplitDefaultOptions => ({ + ...inject(ANGULAR_SPLIT_DEFAULT_OPTIONS, { skipSelf: true }), + ...options, + }), + } +} diff --git a/projects/angular-split/src/lib/component/split.component.css b/projects/angular-split/src/lib/component/split.component.css deleted file mode 100644 index d376b5dc..00000000 --- a/projects/angular-split/src/lib/component/split.component.css +++ /dev/null @@ -1,132 +0,0 @@ -@import '../../../_theme.css'; - -as-split { - --_as-gutter-background-color: var(--as-gutter-background-color, #eeeeee); - --_as-gutter-icon-horizontal: var( - --as-gutter-icon-horizontal, - url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==') - ); - --_as-gutter-icon-vertical: var( - --as-gutter-icon-vertical, - url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFCAMAAABl/6zIAAAABlBMVEUAAADMzMzIT8AyAAAAAXRSTlMAQObYZgAAABRJREFUeAFjYGRkwIMJSeMHlBkOABP7AEGzSuPKAAAAAElFTkSuQmCC') - ); - --_as-gutter-icon-disabled: var( - --as-gutter-icon-disabled, - url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==') - ); - --_as-transition-duration: var(--as-transition-duration, 0.3s); - --_as-gutter-disabled-cursor: var(--as-gutter-disabled-cursor, default); -} - -as-split { - display: flex; - flex-wrap: nowrap; - justify-content: flex-start; - align-items: stretch; - overflow: hidden; - width: 100%; - height: 100%; - - /* Add transition only when transition enabled + split initialized + not currently dragging. */ - &.as-transition.as-init:not(.as-dragging) { - & > .as-split-gutter, - & > .as-split-area { - transition: flex-basis var(--_as-transition-duration); - } - } - - & > .as-split-gutter { - border: none; - flex-grow: 0; - flex-shrink: 0; - background-color: var(--_as-gutter-background-color); - display: flex; - align-items: center; - justify-content: center; - - &.as-split-gutter-collapsed { - flex-basis: 1px !important; - pointer-events: none; - } - - & > .as-split-gutter-icon { - width: 100%; - height: 100%; - background-position: center center; - background-repeat: no-repeat; - } - } - - & > .as-split-area { - flex-grow: 0; - flex-shrink: 0; - overflow-x: hidden; - overflow-y: auto; - - &.as-hidden { - /* When force size to 0. */ - flex: 0 1 0px !important; - overflow-x: hidden; - overflow-y: hidden; - } - - & > .as-iframe-fix { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } - } - - &.as-horizontal { - flex-direction: row; - - & > .as-split-gutter { - flex-direction: row; - cursor: col-resize; - /* Fix safari bug about gutter height when direction is horizontal. */ - height: 100%; - - & > .as-split-gutter-icon { - background-image: var(--_as-gutter-icon-horizontal); - } - } - - & > .as-split-area { - height: 100%; - } - } - - &.as-vertical { - flex-direction: column; - - & > .as-split-gutter { - flex-direction: column; - cursor: row-resize; - width: 100%; - - & > .as-split-gutter-icon { - background-image: var(--_as-gutter-icon-vertical); - } - } - - & > .as-split-area { - width: 100%; - - &.as-hidden { - max-width: 0; - } - } - } - - &.as-disabled { - & > .as-split-gutter { - cursor: var(--_as-gutter-disabled-cursor); - - & > .as-split-gutter-icon { - background-image: var(--_as-gutter-icon-disabled); - } - } - } -} diff --git a/projects/angular-split/src/lib/component/split.component.ts b/projects/angular-split/src/lib/component/split.component.ts deleted file mode 100644 index e1dff2f4..00000000 --- a/projects/angular-split/src/lib/component/split.component.ts +++ /dev/null @@ -1,973 +0,0 @@ -import { - Component, - Input, - Output, - ChangeDetectionStrategy, - ChangeDetectorRef, - Renderer2, - AfterViewInit, - OnDestroy, - ElementRef, - NgZone, - ViewChildren, - QueryList, - EventEmitter, - Inject, - Optional, - ContentChild, - ViewEncapsulation, -} from '@angular/core' -import { Observable, Subscriber, Subject } from 'rxjs' -import { debounceTime } from 'rxjs/operators' -import { - IArea, - IPoint, - ISplitSnapshot, - IAreaSnapshot, - IOutputData, - IOutputAreaSizes, - IDefaultOptions, - IAreaSize, - ISplitDirection, - ISplitDir, - ISplitUnit, -} from '../interface' -import { SplitAreaDirective } from '../directive/split-area.directive' -import { - getInputPositiveNumber, - getInputBoolean, - getAreaMinSize, - getAreaMaxSize, - getPointFromEvent, - getElementPixelSize, - getGutterSideAbsorptionCapacity, - isUserSizesValid, - pointDeltaEquals, - updateAreaSize, - getKeyboardEndpoint, -} from '../utils' -import { ANGULAR_SPLIT_DEFAULT_OPTIONS } from '../angular-split-config.token' -import { SplitGutterDirective } from '../gutter/split-gutter.directive' - -/** - * angular-split - * - * - * PERCENT MODE ([unit]="'percent'") - * ___________________________________________________________________________________________ - * | A [g1] B [g2] C [g3] D [g4] E | - * |-------------------------------------------------------------------------------------------| - * | 20 30 20 15 15 | <-- [size]="x" - * | 10px 10px 10px 10px | <-- [gutterSize]="10" - * |calc(20% - 8px) calc(30% - 12px) calc(20% - 8px) calc(15% - 6px) calc(15% - 6px)| <-- CSS flex-basis property (with flex-grow&shrink at 0) - * | 152px 228px 152px 114px 114px | <-- el.getBoundingClientRect().width - * |___________________________________________________________________________________________| - * 800px <-- el.getBoundingClientRect().width - * flex-basis = calc( { area.size }% - { area.size/100 * nbGutter*gutterSize }px ); - * - * - * PIXEL MODE ([unit]="'pixel'") - * ___________________________________________________________________________________________ - * | A [g1] B [g2] C [g3] D [g4] E | - * |-------------------------------------------------------------------------------------------| - * | 100 250 * 150 100 | <-- [size]="y" - * | 10px 10px 10px 10px | <-- [gutterSize]="10" - * | 0 0 100px 0 0 250px 1 1 auto 0 0 150px 0 0 100px | <-- CSS flex property (flex-grow/flex-shrink/flex-basis) - * | 100px 250px 200px 150px 100px | <-- el.getBoundingClientRect().width - * |___________________________________________________________________________________________| - * 800px <-- el.getBoundingClientRect().width - * - */ - -@Component({ - selector: 'as-split', - exportAs: 'asSplit', - changeDetection: ChangeDetectionStrategy.OnPush, - styleUrls: [`./split.component.css`], - template: ` - - - `, - encapsulation: ViewEncapsulation.None, -}) -export class SplitComponent implements AfterViewInit, OnDestroy { - @ContentChild(SplitGutterDirective) customGutter: SplitGutterDirective - @Input() set direction(v: ISplitDirection) { - this._direction = v === 'vertical' ? 'vertical' : 'horizontal' - - this.renderer.addClass(this.elRef.nativeElement, `as-${this._direction}`) - this.renderer.removeClass( - this.elRef.nativeElement, - `as-${this._direction === 'vertical' ? 'horizontal' : 'vertical'}`, - ) - - this.build(false, false) - } - - get direction(): ISplitDirection { - return this._direction - } - - @Input() set unit(v: ISplitUnit) { - this._unit = v === 'pixel' ? 'pixel' : 'percent' - - this.renderer.addClass(this.elRef.nativeElement, `as-${this._unit}`) - this.renderer.removeClass(this.elRef.nativeElement, `as-${this._unit === 'pixel' ? 'percent' : 'pixel'}`) - - this.build(false, true) - } - - get unit(): ISplitUnit { - return this._unit - } - - @Input() set gutterSize(v: number | `${number}` | null | undefined) { - this._gutterSize = getInputPositiveNumber(v, 11) - - this.build(false, false) - } - - get gutterSize(): number { - return this._gutterSize - } - - @Input() set gutterStep(v: number | `${number}`) { - this._gutterStep = getInputPositiveNumber(v, 1) - } - - get gutterStep(): number { - return this._gutterStep - } - - @Input() set restrictMove(v: boolean | `${boolean}`) { - this._restrictMove = getInputBoolean(v) - } - - get restrictMove(): boolean { - return this._restrictMove - } - - @Input() set useTransition(v: boolean | `${boolean}`) { - this._useTransition = getInputBoolean(v) - - if (this._useTransition) { - this.renderer.addClass(this.elRef.nativeElement, 'as-transition') - } else { - this.renderer.removeClass(this.elRef.nativeElement, 'as-transition') - } - } - - get useTransition(): boolean { - return this._useTransition - } - - @Input() set disabled(v: boolean | `${boolean}`) { - this._disabled = getInputBoolean(v) - - if (this._disabled) { - this.renderer.addClass(this.elRef.nativeElement, 'as-disabled') - } else { - this.renderer.removeClass(this.elRef.nativeElement, 'as-disabled') - } - } - - get disabled(): boolean { - return this._disabled - } - - @Input() set dir(v: ISplitDir) { - this._dir = v === 'rtl' ? 'rtl' : 'ltr' - - this.renderer.setAttribute(this.elRef.nativeElement, 'dir', this._dir) - } - - get dir(): ISplitDir { - return this._dir - } - - @Input() set gutterDblClickDuration(v: number | `${number}`) { - this._gutterDblClickDuration = getInputPositiveNumber(v, 0) - } - - @Input() gutterClickDeltaPx = 2 - - @Input() gutterAriaLabel: string - - get gutterDblClickDuration(): number { - return this._gutterDblClickDuration - } - @Output() get transitionEnd(): Observable { - return new Observable( - (subscriber: Subscriber) => (this.transitionEndSubscriber = subscriber), - ).pipe(debounceTime(20)) - } - - private _config: IDefaultOptions = { - direction: 'horizontal', - unit: 'percent', - gutterSize: 11, - gutterStep: 1, - restrictMove: false, - useTransition: false, - disabled: false, - dir: 'ltr', - gutterDblClickDuration: 0, - } - - constructor( - private ngZone: NgZone, - private elRef: ElementRef, - private cdRef: ChangeDetectorRef, - private renderer: Renderer2, - @Optional() @Inject(ANGULAR_SPLIT_DEFAULT_OPTIONS) globalConfig: IDefaultOptions, - ) { - // To force adding default class, could be override by user @Input() or not - this.direction = this._direction - this._config = globalConfig ? Object.assign(this._config, globalConfig) : this._config - Object.keys(this._config).forEach((property) => { - this[property] = this._config[property] - }) - } - private _direction: ISplitDirection - - private _unit: ISplitUnit - - private _gutterSize: number - - private _gutterStep: number - - private _restrictMove: boolean - - private _useTransition: boolean - - private _disabled: boolean - - private _dir: ISplitDir - - private _gutterDblClickDuration: number - - @Output() dragStart = new EventEmitter(false) - @Output() dragEnd = new EventEmitter(false) - @Output() gutterClick = new EventEmitter(false) - @Output() gutterDblClick = new EventEmitter(false) - - private transitionEndSubscriber: Subscriber - - private dragProgressSubject = new Subject() - dragProgress$ = this.dragProgressSubject.asObservable() - - private isDragging = false - private isWaitingClear = false - private isWaitingInitialMove = false - private dragListeners: Array<() => void> = [] - private snapshot: ISplitSnapshot | null = null - private startPoint: IPoint | null = null - private endPoint: IPoint | null = null - - readonly displayedAreas: Array = [] - private readonly hiddenAreas: Array = [] - - @ViewChildren('gutterEls') private gutterEls: QueryList - - _clickTimeout: number | null = null - draggedGutterNum: number = undefined - - ngAfterViewInit() { - this.ngZone.runOutsideAngular(() => { - // To avoid transition at first rendering - setTimeout(() => this.renderer.addClass(this.elRef.nativeElement, 'as-init')) - }) - } - - private getNbGutters(): number { - return this.displayedAreas.length === 0 ? 0 : this.displayedAreas.length - 1 - } - - addArea(component: SplitAreaDirective): void { - const newArea: IArea = { - component, - order: 0, - size: 0, - minSize: null, - maxSize: null, - sizeBeforeCollapse: null, - gutterBeforeCollapse: 0, - } - - if (component.visible === true) { - this.displayedAreas.push(newArea) - - this.build(true, true) - } else { - this.hiddenAreas.push(newArea) - } - } - - removeArea(component: SplitAreaDirective): void { - if (this.displayedAreas.some((a) => a.component === component)) { - const area = this.displayedAreas.find((a) => a.component === component) - this.displayedAreas.splice(this.displayedAreas.indexOf(area), 1) - - this.build(true, true) - } else if (this.hiddenAreas.some((a) => a.component === component)) { - const area = this.hiddenAreas.find((a) => a.component === component) - this.hiddenAreas.splice(this.hiddenAreas.indexOf(area), 1) - } - } - - updateArea(component: SplitAreaDirective, resetOrders: boolean, resetSizes: boolean): void { - if (component.visible === true) { - this.build(resetOrders, resetSizes) - } - } - - showArea(component: SplitAreaDirective): void { - const area = this.hiddenAreas.find((a) => a.component === component) - if (area === undefined) { - return - } - - const areas = this.hiddenAreas.splice(this.hiddenAreas.indexOf(area), 1) - this.displayedAreas.push(...areas) - - this.build(true, true) - } - - hideArea(comp: SplitAreaDirective): void { - const area = this.displayedAreas.find((a) => a.component === comp) - if (area === undefined) { - return - } - - const areas = this.displayedAreas.splice(this.displayedAreas.indexOf(area), 1) - areas.forEach((item) => { - item.order = 0 - item.size = 0 - }) - this.hiddenAreas.push(...areas) - - this.build(true, true) - } - - getVisibleAreaSizes(): IOutputAreaSizes { - return this.displayedAreas.map((a) => a.size) - } - - setVisibleAreaSizes(sizes: IOutputAreaSizes): boolean { - if (sizes.length !== this.displayedAreas.length) { - return false - } - - const formattedSizes = sizes.map((s) => getInputPositiveNumber(s, '*')) - const isValid = isUserSizesValid(this.unit, formattedSizes) - - if (isValid === false) { - return false - } - - // @@ts-expect-error - this.displayedAreas.forEach((area, i) => (area.component.size = formattedSizes[i])) - - this.build(false, true) - return true - } - - private build(resetOrders: boolean, resetSizes: boolean): void { - this.stopDragging() - - // ¤ AREAS ORDER - - if (resetOrders === true) { - // If user provided 'order' for each area, use it to sort them. - if (this.displayedAreas.every((a) => a.component.order !== null)) { - this.displayedAreas.sort((a, b) => a.component.order - b.component.order) - } - - // Then set real order with multiples of 2, numbers between will be used by gutters. - this.displayedAreas.forEach((area, i) => { - area.order = i * 2 - area.component.setStyleOrder(area.order) - }) - } - - // ¤ AREAS SIZE - - if (resetSizes === true) { - const useUserSizes = isUserSizesValid( - this.unit, - this.displayedAreas.map((a) => a.component.size), - ) - - switch (this.unit) { - case 'percent': { - const defaultSize = 100 / this.displayedAreas.length - - this.displayedAreas.forEach((area) => { - area.size = useUserSizes ? area.component.size : defaultSize - area.minSize = getAreaMinSize(area) - area.maxSize = getAreaMaxSize(area) - }) - break - } - case 'pixel': { - if (useUserSizes) { - this.displayedAreas.forEach((area) => { - area.size = area.component.size - area.minSize = getAreaMinSize(area) - area.maxSize = getAreaMaxSize(area) - }) - } else { - const wildcardSizeAreas = this.displayedAreas.filter((a) => a.component.size === '*') - - // No wildcard area > Need to select one arbitrarily > first - if (wildcardSizeAreas.length === 0 && this.displayedAreas.length > 0) { - this.displayedAreas.forEach((area, i) => { - area.size = i === 0 ? '*' : area.component.size - area.minSize = i === 0 ? area.component.minSize : getAreaMinSize(area) - area.maxSize = i === 0 ? null : getAreaMaxSize(area) - }) - } else if (wildcardSizeAreas.length > 1) { - // More than one wildcard area > Need to keep only one arbitrarily > first - let alreadyGotOne = false - this.displayedAreas.forEach((area) => { - if (area.component.size === '*') { - if (alreadyGotOne === false) { - area.size = '*' - area.minSize = null - area.maxSize = null - alreadyGotOne = true - } else { - area.size = 100 - area.minSize = null - area.maxSize = null - } - } else { - area.size = area.component.size - area.minSize = getAreaMinSize(area) - area.maxSize = getAreaMaxSize(area) - } - }) - } - } - break - } - } - } - - this.refreshStyleSizes() - this.cdRef.markForCheck() - } - - private refreshStyleSizes(): void { - /////////////////////////////////////////// - // PERCENT MODE - if (this.unit === 'percent') { - // Only one area > flex-basis 100% - if (this.displayedAreas.length === 1) { - this.displayedAreas[0].component.setStyleFlex(0, 0, `100%`, false, false) - } else { - // Multiple areas > use each percent basis - const sumGutterSize = this.getNbGutters() * this.gutterSize - - this.displayedAreas.forEach((area) => { - // Area with wildcard size - if (area.size === '*') { - if (this.displayedAreas.length === 1) { - area.component.setStyleFlex(1, 1, `100%`, false, false) - } else { - area.component.setStyleFlex(1, 1, `auto`, false, false) - } - } else { - area.component.setStyleFlex( - 0, - 0, - `calc( ${area.size}% - ${(area.size / 100) * sumGutterSize}px )`, - area.minSize !== null && area.minSize === area.size, - area.maxSize !== null && area.maxSize === area.size, - ) - } - }) - } - } else if (this.unit === 'pixel') { - /////////////////////////////////////////// - // PIXEL MODE - this.displayedAreas.forEach((area) => { - // Area with wildcard size - if (area.size === '*') { - if (this.displayedAreas.length === 1) { - area.component.setStyleFlex(1, 1, `100%`, false, false) - } else { - area.component.setStyleFlex(1, 1, `auto`, false, false) - } - } else { - // Area with pixel size - // Only one area > flex-basis 100% - if (this.displayedAreas.length === 1) { - area.component.setStyleFlex(0, 0, `100%`, false, false) - } else { - // Multiple areas > use each pixel basis - area.component.setStyleFlex( - 0, - 0, - `${area.size}px`, - area.minSize !== null && area.minSize === area.size, - area.maxSize !== null && area.maxSize === area.size, - ) - } - } - }) - } - } - - clickGutter(event: MouseEvent | TouchEvent, gutterNum: number): void { - const tempPoint = getPointFromEvent(event) - - // Be sure mouseup/touchend happened if touch/cursor is not moved. - if ( - this.startPoint && - pointDeltaEquals(this.startPoint, tempPoint, this.gutterClickDeltaPx) && - (!this.isDragging || this.isWaitingInitialMove) - ) { - // If timeout in progress and new click > clearTimeout & dblClickEvent - if (this._clickTimeout !== null) { - window.clearTimeout(this._clickTimeout) - this._clickTimeout = null - this.notify('dblclick', gutterNum) - this.stopDragging() - } else { - // Else start timeout to call clickEvent at end - this._clickTimeout = window.setTimeout(() => { - this._clickTimeout = null - this.notify('click', gutterNum) - this.stopDragging() - }, this.gutterDblClickDuration) - } - } - } - - startKeyboardDrag(event: KeyboardEvent, gutterOrder: number, gutterNum: number) { - if (this.disabled === true || this.isWaitingClear === true) { - return - } - - const endPoint = getKeyboardEndpoint(event, this.direction) - if (endPoint === null) { - return - } - this.endPoint = endPoint - this.startPoint = getPointFromEvent(event) - - event.preventDefault() - event.stopPropagation() - - this.setupForDragEvent(gutterOrder, gutterNum) - this.startDragging() - this.drag() - this.stopDragging() - } - - startMouseDrag(event: MouseEvent | TouchEvent, gutterOrder: number, gutterNum: number): void { - if (this.customGutter && !this.customGutter.canStartDragging(event.target as HTMLElement, gutterNum)) { - return - } - - event.preventDefault() - event.stopPropagation() - - this.startPoint = getPointFromEvent(event) - if (this.startPoint === null || this.disabled === true || this.isWaitingClear === true) { - return - } - - this.setupForDragEvent(gutterOrder, gutterNum) - - this.dragListeners.push(this.renderer.listen('document', 'mouseup', this.stopDragging.bind(this))) - this.dragListeners.push(this.renderer.listen('document', 'touchend', this.stopDragging.bind(this))) - this.dragListeners.push(this.renderer.listen('document', 'touchcancel', this.stopDragging.bind(this))) - - this.ngZone.runOutsideAngular(() => { - this.dragListeners.push(this.renderer.listen('document', 'mousemove', this.mouseDragEvent.bind(this))) - this.dragListeners.push(this.renderer.listen('document', 'touchmove', this.mouseDragEvent.bind(this))) - }) - - this.startDragging() - } - - private setupForDragEvent(gutterOrder: number, gutterNum: number) { - this.snapshot = { - gutterNum, - lastSteppedOffset: 0, - allAreasSizePixel: getElementPixelSize(this.elRef, this.direction) - this.getNbGutters() * this.gutterSize, - allInvolvedAreasSizePercent: 100, - areasBeforeGutter: [], - areasAfterGutter: [], - } - - this.displayedAreas.forEach((area) => { - const areaSnapshot: IAreaSnapshot = { - area, - sizePixelAtStart: getElementPixelSize(area.component.elRef, this.direction), - sizePercentAtStart: this.unit === 'percent' ? area.size : -1, // If pixel mode, anyway, will not be used. - } - - if (area.order < gutterOrder) { - if (this.restrictMove === true) { - this.snapshot.areasBeforeGutter = [areaSnapshot] - } else { - this.snapshot.areasBeforeGutter.unshift(areaSnapshot) - } - } else if (area.order > gutterOrder) { - if (this.restrictMove === true) { - if (this.snapshot.areasAfterGutter.length === 0) { - this.snapshot.areasAfterGutter = [areaSnapshot] - } - } else { - this.snapshot.areasAfterGutter.push(areaSnapshot) - } - } - }) - - // allInvolvedAreasSizePercent is only relevant if there is restrictMove as otherwise the sum - // is always 100. - // Pixel mode doesn't have browser % problem which is the origin of allInvolvedAreasSizePercent. - if (this.restrictMove && this.unit === 'percent') { - const areaSnapshotBefore = this.snapshot.areasBeforeGutter[0] - const areaSnapshotAfter = this.snapshot.areasAfterGutter[0] - - // We have a wildcard size area beside the dragged gutter. - // In this case we can only calculate the size based on the move restricted areas. - if (areaSnapshotBefore.area.size === '*' || areaSnapshotAfter.area.size === '*') { - const notInvolvedAreasSizesPercent = this.displayedAreas.reduce((accum, area) => { - if (areaSnapshotBefore.area !== area && areaSnapshotAfter.area !== area) { - return accum + (area.size as number) - } - - return accum - }, 0) - - this.snapshot.allInvolvedAreasSizePercent = 100 - notInvolvedAreasSizesPercent - } else { - // No wildcard or not beside the gutter - we can just sum the areas beside gutter percents. - this.snapshot.allInvolvedAreasSizePercent = [ - ...this.snapshot.areasBeforeGutter, - ...this.snapshot.areasAfterGutter, - ].reduce((t, a) => t + (a.sizePercentAtStart as number), 0) - } - } - - if (this.snapshot.areasBeforeGutter.length === 0 || this.snapshot.areasAfterGutter.length === 0) { - return - } - } - - private startDragging() { - this.displayedAreas.forEach((area) => area.component.lockEvents()) - - this.isDragging = true - this.isWaitingInitialMove = true - } - - private mouseDragEvent(event: MouseEvent | TouchEvent): void { - event.preventDefault() - event.stopPropagation() - - const tempPoint = getPointFromEvent(event) - if (this._clickTimeout !== null && !pointDeltaEquals(this.startPoint, tempPoint, this.gutterClickDeltaPx)) { - window.clearTimeout(this._clickTimeout) - this._clickTimeout = null - } - - if (this.isDragging === false) { - return - } - - this.endPoint = getPointFromEvent(event) - if (this.endPoint === null) { - return - } - - this.drag() - } - - private drag() { - if (this.isWaitingInitialMove) { - if (this.startPoint.x !== this.endPoint.x || this.startPoint.y !== this.endPoint.y) { - this.ngZone.run(() => { - this.isWaitingInitialMove = false - - this.renderer.addClass(this.elRef.nativeElement, 'as-dragging') - this.draggedGutterNum = this.snapshot.gutterNum - this.cdRef.markForCheck() - - this.notify('start', this.snapshot.gutterNum) - }) - } else { - return - } - } - - // Calculate steppedOffset - - let offset = - this.direction === 'horizontal' ? this.startPoint.x - this.endPoint.x : this.startPoint.y - this.endPoint.y - - // RTL requires negative offset only in horizontal mode as in vertical - // RTL has no effect on drag direction. - if (this.dir === 'rtl' && this.direction === 'horizontal') { - offset = -offset - } - - const steppedOffset = Math.round(offset / this.gutterStep) * this.gutterStep - - if (steppedOffset === this.snapshot.lastSteppedOffset) { - return - } - - this.snapshot.lastSteppedOffset = steppedOffset - - // Need to know if each gutter side areas could reacts to steppedOffset - - let areasBefore = getGutterSideAbsorptionCapacity( - this.unit, - this.snapshot.areasBeforeGutter, - -steppedOffset, - this.snapshot.allAreasSizePixel, - ) - let areasAfter = getGutterSideAbsorptionCapacity( - this.unit, - this.snapshot.areasAfterGutter, - steppedOffset, - this.snapshot.allAreasSizePixel, - ) - - // Each gutter side areas can't absorb all offset - if (areasBefore.remain !== 0 && areasAfter.remain !== 0) { - // TODO: fix this emty block - if (Math.abs(areasBefore.remain) === Math.abs(areasAfter.remain)) { - /* empty */ - } else if (Math.abs(areasBefore.remain) > Math.abs(areasAfter.remain)) { - areasAfter = getGutterSideAbsorptionCapacity( - this.unit, - this.snapshot.areasAfterGutter, - steppedOffset + areasBefore.remain, - this.snapshot.allAreasSizePixel, - ) - } else { - areasBefore = getGutterSideAbsorptionCapacity( - this.unit, - this.snapshot.areasBeforeGutter, - -(steppedOffset - areasAfter.remain), - this.snapshot.allAreasSizePixel, - ) - } - } else if (areasBefore.remain !== 0) { - // Areas before gutter can't absorbs all offset > need to recalculate sizes for areas after gutter. - areasAfter = getGutterSideAbsorptionCapacity( - this.unit, - this.snapshot.areasAfterGutter, - steppedOffset + areasBefore.remain, - this.snapshot.allAreasSizePixel, - ) - } else if (areasAfter.remain !== 0) { - // Areas after gutter can't absorbs all offset > need to recalculate sizes for areas before gutter. - areasBefore = getGutterSideAbsorptionCapacity( - this.unit, - this.snapshot.areasBeforeGutter, - -(steppedOffset - areasAfter.remain), - this.snapshot.allAreasSizePixel, - ) - } - - if (this.unit === 'percent') { - // Hack because of browser messing up with sizes using calc(X% - Ypx) -> el.getBoundingClientRect() - // If not there, playing with gutters makes total going down to 99.99875% then 99.99286%, 99.98986%,.. - const all = [...areasBefore.list, ...areasAfter.list] - const wildcardArea = all.find((a) => a.percentAfterAbsorption === '*') - // In case we have a wildcard area - always align the percents on the wildcard area. - const areaToReset = - wildcardArea ?? - all.find( - (a) => - a.percentAfterAbsorption !== 0 && - a.percentAfterAbsorption !== a.areaSnapshot.area.minSize && - a.percentAfterAbsorption !== a.areaSnapshot.area.maxSize, - ) - - if (areaToReset) { - areaToReset.percentAfterAbsorption = - this.snapshot.allInvolvedAreasSizePercent - - all.filter((a) => a !== areaToReset).reduce((total, a) => total + (a.percentAfterAbsorption as number), 0) - } - } - - // Now we know areas could absorb steppedOffset, time to really update sizes - - areasBefore.list.forEach((item) => updateAreaSize(this.unit, item)) - areasAfter.list.forEach((item) => updateAreaSize(this.unit, item)) - - this.refreshStyleSizes() - this.notify('progress', this.snapshot.gutterNum) - } - - private stopDragging(event?: Event): void { - if (event) { - event.preventDefault() - event.stopPropagation() - } - - if (this.isDragging === false) { - return - } - - this.displayedAreas.forEach((area) => area.component.unlockEvents()) - - while (this.dragListeners.length > 0) { - const fct = this.dragListeners.pop() - if (fct) { - fct() - } - } - - // Warning: Have to be before "notify('end')" - // because "notify('end')"" can be linked to "[size]='x'" > "build()" > "stopDragging()" - this.isDragging = false - - // If moved from starting point, notify end - if (this.isWaitingInitialMove === false) { - this.notify('end', this.snapshot.gutterNum) - } - - this.renderer.removeClass(this.elRef.nativeElement, 'as-dragging') - this.draggedGutterNum = undefined - this.cdRef.markForCheck() - - this.snapshot = null - this.isWaitingClear = true - - // Needed to let (click)="clickGutter(...)" event run and verify if mouse moved or not - this.ngZone.runOutsideAngular(() => { - setTimeout(() => { - this.startPoint = null - this.endPoint = null - this.isWaitingClear = false - }) - }) - } - - notify(type: 'start' | 'progress' | 'end' | 'click' | 'dblclick' | 'transitionEnd', gutterNum: number): void { - const sizes = this.getVisibleAreaSizes() - - if (type === 'start') { - this.dragStart.emit({ gutterNum, sizes }) - } else if (type === 'end') { - this.dragEnd.emit({ gutterNum, sizes }) - } else if (type === 'click') { - this.gutterClick.emit({ gutterNum, sizes }) - } else if (type === 'dblclick') { - this.gutterDblClick.emit({ gutterNum, sizes }) - } else if (type === 'transitionEnd') { - if (this.transitionEndSubscriber) { - this.ngZone.run(() => this.transitionEndSubscriber.next(sizes)) - } - } else if (type === 'progress') { - // Stay outside zone to allow users do what they want about change detection mechanism. - this.dragProgressSubject.next({ gutterNum, sizes }) - } - } - - ngOnDestroy(): void { - this.stopDragging() - } - - collapseArea(comp: SplitAreaDirective, newSize: number, gutter: 'left' | 'right'): void { - const area = this.displayedAreas.find((a) => a.component === comp) - if (area === undefined) { - return - } - const whichGutter = gutter === 'right' ? 1 : -1 - if (!area.sizeBeforeCollapse) { - area.sizeBeforeCollapse = area.size - area.gutterBeforeCollapse = whichGutter - } - area.size = newSize - const gtr = this.gutterEls.find((f) => f.nativeElement.style.order === `${area.order + whichGutter}`) - if (gtr) { - this.renderer.addClass(gtr.nativeElement, 'as-split-gutter-collapsed') - } - this.updateArea(comp, false, false) - } - - expandArea(comp: SplitAreaDirective): void { - const area = this.displayedAreas.find((a) => a.component === comp) - if (area === undefined) { - return - } - if (!area.sizeBeforeCollapse) { - return - } - area.size = area.sizeBeforeCollapse - area.sizeBeforeCollapse = null - const gtr = this.gutterEls.find((f) => f.nativeElement.style.order === `${area.order + area.gutterBeforeCollapse}`) - if (gtr) { - this.renderer.removeClass(gtr.nativeElement, 'as-split-gutter-collapsed') - } - this.updateArea(comp, false, false) - } - - getAriaAreaSizeText(size: IAreaSize): string { - if (size === '*') { - return null - } - - return size.toFixed(0) + ' ' + this.unit - } -} diff --git a/projects/angular-split/src/lib/directive/split-area.directive.ts b/projects/angular-split/src/lib/directive/split-area.directive.ts deleted file mode 100644 index 3b491494..00000000 --- a/projects/angular-split/src/lib/directive/split-area.directive.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { Directive, ElementRef, Input, NgZone, OnDestroy, OnInit, Renderer2 } from '@angular/core' -import { Subscription } from 'rxjs' -import { SplitComponent } from '../component/split.component' -import { getInputBoolean, getInputPositiveNumber } from '../utils' -import { IAreaSize } from '../interface' - -@Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: 'as-split-area, [as-split-area]', - exportAs: 'asSplitArea', -}) -export class SplitAreaDirective implements OnInit, OnDestroy { - private _order: number | null = null - - @Input() set order(v: number | `${number}` | null | undefined) { - this._order = getInputPositiveNumber(v, null) - - this.split.updateArea(this, true, false) - } - - get order(): number | null { - return this._order - } - - private _size: IAreaSize = '*' - - @Input() set size(v: IAreaSize | `${number}` | null | undefined) { - this._size = getInputPositiveNumber(v, '*') - - this.split.updateArea(this, false, true) - } - - get size(): IAreaSize { - return this._size - } - - private _minSize: number | null = null - - @Input() set minSize(v: number | `${number}` | null | undefined) { - this._minSize = getInputPositiveNumber(v, null) - - this.split.updateArea(this, false, true) - } - - get minSize(): number | null { - return this._minSize - } - - private _maxSize: number | null = null - - @Input() set maxSize(v: number | `${number}` | null | undefined) { - this._maxSize = getInputPositiveNumber(v, null) - - this.split.updateArea(this, false, true) - } - - get maxSize(): number | null { - return this._maxSize - } - - private _lockSize = false - - @Input() set lockSize(v: boolean | `${boolean}`) { - this._lockSize = getInputBoolean(v) - - this.split.updateArea(this, false, true) - } - - get lockSize(): boolean { - return this._lockSize - } - - private _visible = true - - @Input() set visible(v: boolean | `${boolean}`) { - this._visible = getInputBoolean(v) - - if (this._visible) { - this.split.showArea(this) - this.renderer.removeClass(this.elRef.nativeElement, 'as-hidden') - } else { - this.split.hideArea(this) - this.renderer.addClass(this.elRef.nativeElement, 'as-hidden') - } - } - - get visible(): boolean { - return this._visible - } - - private transitionListener: () => void - private dragStartSubscription: Subscription - private dragEndSubscription: Subscription - private readonly lockListeners: Array<() => void> = [] - - constructor( - private ngZone: NgZone, - private renderer: Renderer2, - private split: SplitComponent, - readonly elRef: ElementRef, - ) { - this.renderer.addClass(this.elRef.nativeElement, 'as-split-area') - } - - ngOnInit(): void { - this.split.addArea(this) - - this.ngZone.runOutsideAngular(() => { - this.transitionListener = this.renderer.listen( - this.elRef.nativeElement, - 'transitionend', - (event: TransitionEvent) => { - // Limit only flex-basis transition to trigger the event - if (event.propertyName === 'flex-basis') { - this.split.notify('transitionEnd', -1) - } - }, - ) - }) - - const iframeFixDiv = this.renderer.createElement('div') - this.renderer.addClass(iframeFixDiv, 'as-iframe-fix') - - this.dragStartSubscription = this.split.dragStart.subscribe(() => { - this.renderer.setStyle(this.elRef.nativeElement, 'position', 'relative') - this.renderer.appendChild(this.elRef.nativeElement, iframeFixDiv) - }) - - this.dragEndSubscription = this.split.dragEnd.subscribe(() => { - this.renderer.removeStyle(this.elRef.nativeElement, 'position') - this.renderer.removeChild(this.elRef.nativeElement, iframeFixDiv) - }) - } - - setStyleOrder(value: number): void { - this.renderer.setStyle(this.elRef.nativeElement, 'order', value) - } - - setStyleFlex(grow: number, shrink: number, basis: string, isMin: boolean, isMax: boolean): void { - // Need 3 separated properties to work on IE11 (https://github.com/angular/flex-layout/issues/323) - this.renderer.setStyle(this.elRef.nativeElement, 'flex-grow', grow) - this.renderer.setStyle(this.elRef.nativeElement, 'flex-shrink', shrink) - this.renderer.setStyle(this.elRef.nativeElement, 'flex-basis', basis) - - if (isMin === true) { - this.renderer.addClass(this.elRef.nativeElement, 'as-min') - } else { - this.renderer.removeClass(this.elRef.nativeElement, 'as-min') - } - - if (isMax === true) { - this.renderer.addClass(this.elRef.nativeElement, 'as-max') - } else { - this.renderer.removeClass(this.elRef.nativeElement, 'as-max') - } - } - - lockEvents(): void { - this.ngZone.runOutsideAngular(() => { - this.lockListeners.push(this.renderer.listen(this.elRef.nativeElement, 'selectstart', () => false)) - this.lockListeners.push(this.renderer.listen(this.elRef.nativeElement, 'dragstart', () => false)) - }) - } - - unlockEvents(): void { - while (this.lockListeners.length > 0) { - const fct = this.lockListeners.pop() - if (fct) { - fct() - } - } - } - - ngOnDestroy(): void { - this.unlockEvents() - - if (this.transitionListener) { - this.transitionListener() - } - - this.dragStartSubscription?.unsubscribe() - this.dragEndSubscription?.unsubscribe() - - this.split.removeArea(this) - } - - collapse(newSize = 0, gutter: 'left' | 'right' = 'right'): void { - this.split.collapseArea(this, newSize, gutter) - } - - expand(): void { - this.split.expandArea(this) - } -} diff --git a/projects/angular-split/src/lib/gutter/split-gutter-drag-handle.directive.ts b/projects/angular-split/src/lib/gutter/split-gutter-drag-handle.directive.ts index a73364cd..8fa9ea2a 100644 --- a/projects/angular-split/src/lib/gutter/split-gutter-drag-handle.directive.ts +++ b/projects/angular-split/src/lib/gutter/split-gutter-drag-handle.directive.ts @@ -4,6 +4,7 @@ import { GUTTER_NUM_TOKEN } from './gutter-num-token' @Directive({ selector: '[asSplitGutterDragHandle]', + standalone: true, }) export class SplitGutterDragHandleDirective implements OnInit, OnDestroy { constructor( diff --git a/projects/angular-split/src/lib/gutter/split-gutter-dynamic-injector.directive.ts b/projects/angular-split/src/lib/gutter/split-gutter-dynamic-injector.directive.ts index b42224c1..47aeae94 100644 --- a/projects/angular-split/src/lib/gutter/split-gutter-dynamic-injector.directive.ts +++ b/projects/angular-split/src/lib/gutter/split-gutter-dynamic-injector.directive.ts @@ -11,6 +11,7 @@ interface SplitGutterDynamicInjectorTemplateContext { */ @Directive({ selector: '[asSplitGutterDynamicInjector]', + standalone: true, }) export class SplitGutterDynamicInjectorDirective { @Input('asSplitGutterDynamicInjector') diff --git a/projects/angular-split/src/lib/gutter/split-gutter-exclude-from-drag.directive.ts b/projects/angular-split/src/lib/gutter/split-gutter-exclude-from-drag.directive.ts index 24a1c324..97cccedb 100644 --- a/projects/angular-split/src/lib/gutter/split-gutter-exclude-from-drag.directive.ts +++ b/projects/angular-split/src/lib/gutter/split-gutter-exclude-from-drag.directive.ts @@ -4,6 +4,7 @@ import { GUTTER_NUM_TOKEN } from './gutter-num-token' @Directive({ selector: '[asSplitGutterExcludeFromDrag]', + standalone: true, }) export class SplitGutterExcludeFromDragDirective implements OnInit, OnDestroy { constructor( diff --git a/projects/angular-split/src/lib/gutter/split-gutter.directive.ts b/projects/angular-split/src/lib/gutter/split-gutter.directive.ts index 90ad9a35..56ee38a4 100644 --- a/projects/angular-split/src/lib/gutter/split-gutter.directive.ts +++ b/projects/angular-split/src/lib/gutter/split-gutter.directive.ts @@ -1,17 +1,17 @@ import { Directive, ElementRef, TemplateRef } from '@angular/core' -import { IArea } from '../interface' +import { SplitAreaComponent } from '../split-area/split-area.component' export interface SplitGutterTemplateContext { /** * The area before the gutter. * In RTL the right area and in LTR the left area */ - areaBefore: IArea + areaBefore: SplitAreaComponent /** * The area after the gutter. * In RTL the left area and in LTR the right area */ - areaAfter: IArea + areaAfter: SplitAreaComponent /** * The absolute number of the gutter based on direction (RTL and LTR). * First gutter is 1, second is 2, etc... @@ -35,6 +35,7 @@ export interface SplitGutterTemplateContext { @Directive({ selector: '[asSplitGutter]', + standalone: true, }) export class SplitGutterDirective { /** diff --git a/projects/angular-split/src/lib/interface.ts b/projects/angular-split/src/lib/interface.ts deleted file mode 100644 index f079109f..00000000 --- a/projects/angular-split/src/lib/interface.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { SplitAreaDirective } from './directive/split-area.directive' - -export type ISplitDirection = 'horizontal' | 'vertical' - -export type ISplitDir = 'ltr' | 'rtl' - -export type IAreaSize = number | '*' - -export type ISplitUnit = 'percent' | 'pixel' - -export interface IPoint { - x: number - y: number -} - -export interface IArea { - component: SplitAreaDirective - order: number - size: IAreaSize - minSize: number | null - maxSize: number | null - sizeBeforeCollapse: IAreaSize | null - gutterBeforeCollapse: number -} - -// CREATED ON DRAG START - -export interface ISplitSnapshot { - gutterNum: number - allAreasSizePixel: number - allInvolvedAreasSizePercent: number - lastSteppedOffset: number - areasBeforeGutter: Array - areasAfterGutter: Array -} - -export interface IAreaSnapshot { - area: IArea - sizePixelAtStart: number - sizePercentAtStart: IAreaSize -} - -// CREATED ON DRAG PROGRESS - -export interface ISplitSideAbsorptionCapacity { - remain: number - list: Array -} - -export interface IAreaAbsorptionCapacity { - areaSnapshot: IAreaSnapshot - pixelAbsorb: number - percentAfterAbsorption: IAreaSize - pixelRemain: number -} - -export interface IDefaultOptions { - dir: ISplitDir - direction: ISplitDirection - disabled: boolean - gutterDblClickDuration: number - gutterSize: number | null - gutterStep: number - restrictMove: boolean - unit: ISplitUnit - useTransition: boolean -} - -// CREATED TO SEND OUTSIDE - -export interface IOutputData { - gutterNum: number - sizes: IOutputAreaSizes -} - -export interface IOutputAreaSizes extends Array {} diff --git a/projects/angular-split/src/lib/models.ts b/projects/angular-split/src/lib/models.ts new file mode 100644 index 00000000..70bec0fd --- /dev/null +++ b/projects/angular-split/src/lib/models.ts @@ -0,0 +1,23 @@ +export type SplitAreaSize = number | '*' + +export type SplitAreaSizeInput = SplitAreaSize | `${number}` | undefined | null + +const internalAreaSizeTransform = (areaSize: SplitAreaSizeInput): SplitAreaSize => + areaSize === undefined || areaSize === null || areaSize === '*' ? '*' : +areaSize + +export const areaSizeTransform = (areaSize: SplitAreaSizeInput): SplitAreaSize | 'auto' => + internalAreaSizeTransform(areaSize) + +export const boundaryAreaSizeTransform = (areaSize: SplitAreaSizeInput): SplitAreaSize => + internalAreaSizeTransform(areaSize) + +export type SplitDirection = 'horizontal' | 'vertical' + +export type SplitDir = 'ltr' | 'rtl' + +export type SplitUnit = 'pixel' | 'percent' + +export interface SplitGutterInteractionEvent { + gutterNum: number + sizes: SplitAreaSize[] +} diff --git a/projects/angular-split/src/lib/split-area/split-area.component.css b/projects/angular-split/src/lib/split-area/split-area.component.css new file mode 100644 index 00000000..b0206839 --- /dev/null +++ b/projects/angular-split/src/lib/split-area/split-area.component.css @@ -0,0 +1,20 @@ +:host { + overflow-x: hidden; + overflow-y: auto; + + .as-horizontal > & { + height: 100%; + } + + .as-vertical > & { + width: 100%; + } +} + +.as-iframe-fix { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/projects/angular-split/src/lib/split-area/split-area.component.html b/projects/angular-split/src/lib/split-area/split-area.component.html new file mode 100644 index 00000000..fb723258 --- /dev/null +++ b/projects/angular-split/src/lib/split-area/split-area.component.html @@ -0,0 +1,4 @@ + +@if (split._isDragging()) { +
+} diff --git a/projects/angular-split/src/lib/split-area/split-area.component.ts b/projects/angular-split/src/lib/split-area/split-area.component.ts new file mode 100644 index 00000000..788cccb3 --- /dev/null +++ b/projects/angular-split/src/lib/split-area/split-area.component.ts @@ -0,0 +1,158 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + Signal, + booleanAttribute, + computed, + inject, + input, + isDevMode, +} from '@angular/core' +import { SplitComponent } from '../split/split.component' +import { createClassesString, mirrorSignal } from '../utils' +import { SplitAreaSize, areaSizeTransform, boundaryAreaSizeTransform } from '../models' + +@Component({ + selector: 'as-split-area', + standalone: true, + exportAs: 'asSplitArea', + templateUrl: './split-area.component.html', + styleUrl: './split-area.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SplitAreaComponent { + protected readonly split = inject(SplitComponent) + + readonly size = input('auto', { transform: areaSizeTransform }) + readonly minSize = input('*', { transform: boundaryAreaSizeTransform }) + readonly maxSize = input('*', { transform: boundaryAreaSizeTransform }) + readonly lockSize = input(false, { transform: booleanAttribute }) + readonly visible = input(true, { transform: booleanAttribute }) + + /** + * @internal + */ + readonly _internalSize = mirrorSignal( + // As size is an input and we can change the size without the outside + // listening to the change we need an intermediate writeable signal + computed((): SplitAreaSize => { + if (!this.visible()) { + return 0 + } + + const size = this.size() + // auto acts the same as * in all calculations + return size === 'auto' ? '*' : size + }), + ) + /** + * @internal + */ + readonly _normalizedMinSize = computed(() => this.normalizeMinSize()) + /** + * @internal + */ + readonly _normalizedMaxSize = computed(() => this.normalizeMaxSize()) + private readonly index = computed(() => this.split._areas().findIndex((area) => area === this)) + private readonly gridAreaNum = computed(() => this.index() * 2 + 1) + private readonly hostClasses = computed(() => + createClassesString({ + ['as-split-area']: true, + ['as-min']: this.visible() && this._internalSize() === this._normalizedMinSize(), + ['as-max']: this.visible() && this._internalSize() === this._normalizedMaxSize(), + ['as-hidden']: !this.visible(), + }), + ) + + @HostBinding('class') protected get hostClassesBinding() { + return this.hostClasses() + } + @HostBinding('style.grid-column') protected get hostGridColumnStyleBinding() { + return this.split.direction() === 'horizontal' ? `${this.gridAreaNum()} / ${this.gridAreaNum()}` : undefined + } + @HostBinding('style.grid-row') protected get hostGridRowStyleBinding() { + return this.split.direction() === 'vertical' ? `${this.gridAreaNum()} / ${this.gridAreaNum()}` : undefined + } + @HostBinding('style.position') protected get hostPositionStyleBinding() { + return this.split._isDragging() ? 'relative' : undefined + } + + private normalizeMinSize() { + const defaultMinSize = 0 + + if (!this.visible()) { + return defaultMinSize + } + + const minSize = this.normalizeSizeBoundary(this.minSize, defaultMinSize) + const size = this.size() + + if (size !== '*' && size !== 'auto' && size < minSize) { + if (isDevMode()) { + console.warn('as-split: size cannot be smaller than minSize') + } + + return defaultMinSize + } + + return minSize + } + + private normalizeMaxSize() { + const defaultMaxSize = Infinity + + if (!this.visible()) { + return defaultMaxSize + } + + const maxSize = this.normalizeSizeBoundary(this.maxSize, defaultMaxSize) + const size = this.size() + + if (size !== '*' && size !== 'auto' && size > maxSize) { + if (isDevMode()) { + console.warn('as-split: size cannot be larger than maxSize') + } + + return defaultMaxSize + } + + return maxSize + } + + private normalizeSizeBoundary(sizeBoundarySignal: Signal, defaultBoundarySize: number): number { + const size = this.size() + const lockSize = this.lockSize() + const boundarySize = sizeBoundarySignal() + + if (lockSize) { + if (isDevMode() && boundarySize !== '*') { + console.warn('as-split: lockSize overwrites maxSize/minSize') + } + + if (size === '*' || size === 'auto') { + if (isDevMode()) { + console.warn(`as-split: lockSize isn't supported on area with * size or without size`) + } + + return defaultBoundarySize + } + + return size + } + + if (boundarySize === '*') { + return defaultBoundarySize + } + + if (size === '*' || size === 'auto') { + if (isDevMode()) { + console.warn('as-split: maxSize/minSize not allowed on * or without size') + } + + return defaultBoundarySize + } + + return boundarySize + } +} diff --git a/projects/angular-split/src/lib/split-custom-events-behavior.directive.ts b/projects/angular-split/src/lib/split-custom-events-behavior.directive.ts new file mode 100644 index 00000000..02ac8477 --- /dev/null +++ b/projects/angular-split/src/lib/split-custom-events-behavior.directive.ts @@ -0,0 +1,109 @@ +import { Directive, ElementRef, inject, input, output } from '@angular/core' +import { + gutterEventsEqualWithDelta, + fromMouseDownEvent, + fromMouseMoveEvent, + fromMouseUpEvent, + leaveNgZone, +} from './utils' +import { + delay, + filter, + fromEvent, + map, + mergeMap, + of, + repeat, + scan, + switchMap, + take, + takeUntil, + tap, + timeInterval, +} from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { DOCUMENT } from '@angular/common' + +/** + * Emits mousedown, click, double click and keydown out of zone + * + * Emulates browser behavior of click and double click with new features: + * 1. Supports touch events (tap and double tap) + * 2. Ignores the first click in a double click with the side effect of a bit slower emission of the click event + * 3. Allow customizing the delay after mouse down to count another mouse down as a double click + */ +@Directive({ + selector: '[asSplitCustomEventsBehavior]', + standalone: true, +}) +export class SplitCustomEventsBehaviorDirective { + private readonly elementRef = inject>(ElementRef) + private readonly document = inject(DOCUMENT) + + readonly multiClickThreshold = input.required({ alias: 'asSplitCustomMultiClickThreshold' }) + readonly deltaInPx = input.required({ alias: 'asSplitCustomClickDeltaInPx' }) + readonly mouseDown = output({ alias: 'asSplitCustomMouseDown' }) + readonly click = output({ alias: 'asSplitCustomClick' }) + readonly dblClick = output({ alias: 'asSplitCustomDblClick' }) + readonly keyDown = output({ alias: 'asSplitCustomKeyDown' }) + + constructor() { + fromEvent(this.elementRef.nativeElement, 'keydown') + .pipe(leaveNgZone(), takeUntilDestroyed()) + .subscribe((e) => this.keyDown.emit(e)) + + // We just need to know when drag start to cancel all click related interactions + const dragStarted$ = fromMouseDownEvent(this.elementRef.nativeElement).pipe( + switchMap((mouseDownEvent) => + fromMouseMoveEvent(this.document).pipe( + filter( + (e) => !gutterEventsEqualWithDelta(mouseDownEvent, e, this.deltaInPx(), this.elementRef.nativeElement), + ), + take(1), + map(() => true), + takeUntil(fromMouseUpEvent(this.document)), + ), + ), + ) + + fromMouseDownEvent(this.elementRef.nativeElement) + .pipe( + tap((e) => this.mouseDown.emit(e)), + // Gather mousedown events intervals to identify whether it is a single double or more click + timeInterval(), + // We only count a click as part of a multi click if the multiClickThreshold wasn't reached + scan((sum, { interval }) => (interval >= this.multiClickThreshold() ? 1 : sum + 1), 0), + // As mouseup always comes after mousedown if the delayed mouseup has yet to come + // but a new mousedown arrived we can discard the older mouseup as we are part of a multi click + switchMap((numOfConsecutiveClicks) => + // In case of a double click we directly emit as we don't care about more than two consecutive clicks + // so we don't have to wait compared to a single click that might be followed by another for a double. + // In case of a mouse up that was too long after the mouse down + // we don't have to wait as we know it won't be a multi click but a single click + fromMouseUpEvent(this.elementRef.nativeElement).pipe( + timeInterval(), + take(1), + numOfConsecutiveClicks === 2 + ? map(() => numOfConsecutiveClicks) + : mergeMap(({ interval }) => + interval >= this.multiClickThreshold() + ? of(numOfConsecutiveClicks) + : of(numOfConsecutiveClicks).pipe(delay(this.multiClickThreshold() - interval)), + ), + ), + ), + // Discard everything once drag started and listen again (repeat) to mouse down + takeUntil(dragStarted$), + repeat(), + leaveNgZone(), + takeUntilDestroyed(), + ) + .subscribe((amount) => { + if (amount === 1) { + this.click.emit() + } else if (amount === 2) { + this.dblClick.emit() + } + }) + } +} diff --git a/projects/angular-split/src/lib/module.ts b/projects/angular-split/src/lib/split-module.module.ts similarity index 60% rename from projects/angular-split/src/lib/module.ts rename to projects/angular-split/src/lib/split-module.module.ts index db90ee92..8156ea38 100644 --- a/projects/angular-split/src/lib/module.ts +++ b/projects/angular-split/src/lib/split-module.module.ts @@ -1,25 +1,21 @@ import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { SplitComponent } from './component/split.component' -import { SplitAreaDirective } from './directive/split-area.directive' +import { SplitAreaComponent } from './split-area/split-area.component' +import { SplitComponent } from './split/split.component' import { SplitGutterDirective } from './gutter/split-gutter.directive' import { SplitGutterDragHandleDirective } from './gutter/split-gutter-drag-handle.directive' -import { SplitGutterDynamicInjectorDirective } from './gutter/split-gutter-dynamic-injector.directive' import { SplitGutterExcludeFromDragDirective } from './gutter/split-gutter-exclude-from-drag.directive' @NgModule({ - imports: [CommonModule], - declarations: [ + imports: [ SplitComponent, - SplitAreaDirective, + SplitAreaComponent, SplitGutterDirective, SplitGutterDragHandleDirective, - SplitGutterDynamicInjectorDirective, SplitGutterExcludeFromDragDirective, ], exports: [ SplitComponent, - SplitAreaDirective, + SplitAreaComponent, SplitGutterDirective, SplitGutterDragHandleDirective, SplitGutterExcludeFromDragDirective, diff --git a/projects/angular-split/src/lib/split/split.component.css b/projects/angular-split/src/lib/split/split.component.css new file mode 100644 index 00000000..b0ef82a5 --- /dev/null +++ b/projects/angular-split/src/lib/split/split.component.css @@ -0,0 +1,71 @@ +@import '../../../_theme.css'; + +:host { + --_as-gutter-background-color: var(--as-gutter-background-color, #eeeeee); + --_as-gutter-icon-horizontal: var( + --as-gutter-icon-horizontal, + url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==') + ); + --_as-gutter-icon-vertical: var( + --as-gutter-icon-vertical, + url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFCAMAAABl/6zIAAAABlBMVEUAAADMzMzIT8AyAAAAAXRSTlMAQObYZgAAABRJREFUeAFjYGRkwIMJSeMHlBkOABP7AEGzSuPKAAAAAElFTkSuQmCC') + ); + --_as-gutter-icon-disabled: var( + --as-gutter-icon-disabled, + url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==') + ); + --_as-transition-duration: var(--as-transition-duration, 0.3s); + --_as-gutter-disabled-cursor: var(--as-gutter-disabled-cursor, default); +} + +:host { + display: grid; + overflow: hidden; + height: 100%; + width: 100%; +} + +:host(.as-transition) { + transition: grid-template var(--_as-transition-duration); +} + +.as-split-gutter { + background-color: var(--_as-gutter-background-color); + display: flex; + align-items: center; + justify-content: center; + touch-action: none; + + :host(.as-horizontal) > & { + cursor: col-resize; + height: 100%; + } + + :host(.as-vertical) > & { + cursor: row-resize; + width: 100%; + } + + :host(.as-disabled) > & { + cursor: var(--_as-gutter-disabled-cursor); + } +} + +.as-split-gutter-icon { + width: 100%; + height: 100%; + background-position: center center; + background-repeat: no-repeat; + + :host(.as-horizontal) > .as-split-gutter > & { + background-image: var(--_as-gutter-icon-horizontal); + } + + :host(.as-vertical) > .as-split-gutter > & { + background-image: var(--_as-gutter-icon-vertical); + } + + :host(.as-disabled) > .as-split-gutter > & { + background-image: var(--_as-gutter-icon-disabled); + } +} diff --git a/projects/angular-split/src/lib/split/split.component.html b/projects/angular-split/src/lib/split/split.component.html new file mode 100644 index 00000000..cddba06b --- /dev/null +++ b/projects/angular-split/src/lib/split/split.component.html @@ -0,0 +1,47 @@ + +@for (area of _areas(); track area) { + @if (!$last) { + + } +} diff --git a/projects/angular-split/src/lib/split/split.component.ts b/projects/angular-split/src/lib/split/split.component.ts new file mode 100644 index 00000000..42ee10c0 --- /dev/null +++ b/projects/angular-split/src/lib/split/split.component.ts @@ -0,0 +1,590 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostBinding, + NgZone, + Renderer2, + booleanAttribute, + computed, + contentChild, + contentChildren, + effect, + inject, + input, + output, + signal, + untracked, +} from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { SplitAreaComponent } from '../split-area/split-area.component' +import { Subject, filter, fromEvent, map, pairwise, skipWhile, startWith, switchMap, take, takeUntil, tap } from 'rxjs' +import { + ClientPoint, + createClassesString, + gutterEventsEqualWithDelta, + fromMouseMoveEvent, + fromMouseUpEvent, + getPointFromEvent, + leaveNgZone, + numberAttributeWithFallback, + sum, + toRecord, +} from '../utils' +import { DOCUMENT, NgStyle, NgTemplateOutlet } from '@angular/common' +import { SplitGutterInteractionEvent, SplitAreaSize } from '../models' +import { SplitCustomEventsBehaviorDirective } from '../split-custom-events-behavior.directive' +import { areAreasValid } from '../validations' +import { SplitGutterDirective } from '../gutter/split-gutter.directive' +import { SplitGutterDynamicInjectorDirective } from '../gutter/split-gutter-dynamic-injector.directive' +import { ANGULAR_SPLIT_DEFAULT_OPTIONS } from '../angular-split-config.token' + +interface MouseDownContext { + mouseDownEvent: MouseEvent | TouchEvent + gutterIndex: number + gutterElement: HTMLElement + areaBeforeGutterIndex: number + areaAfterGutterIndex: number +} + +interface AreaBoundary { + min: number + max: number +} + +interface DragStartContext { + startEvent: MouseEvent | TouchEvent | KeyboardEvent + areasPixelSizes: number[] + totalAreasPixelSize: number + areaIndexToBoundaries: Record + areaBeforeGutterIndex: number + areaAfterGutterIndex: number +} + +@Component({ + selector: 'as-split', + standalone: true, + imports: [NgStyle, SplitCustomEventsBehaviorDirective, SplitGutterDynamicInjectorDirective, NgTemplateOutlet], + exportAs: 'asSplit', + templateUrl: './split.component.html', + styleUrl: './split.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SplitComponent { + private readonly document = inject(DOCUMENT) + private readonly renderer = inject(Renderer2) + private readonly elementRef = inject>(ElementRef) + private readonly ngZone = inject(NgZone) + private readonly defaultOptions = inject(ANGULAR_SPLIT_DEFAULT_OPTIONS) + + private readonly gutterMouseDownSubject = new Subject() + private readonly dragProgressSubject = new Subject() + + /** + * @internal + */ + readonly _areas = contentChildren(SplitAreaComponent) + protected readonly customGutter = contentChild(SplitGutterDirective) + readonly gutterSize = input(this.defaultOptions.gutterSize, { + transform: numberAttributeWithFallback(this.defaultOptions.gutterSize), + }) + readonly gutterStep = input(this.defaultOptions.gutterStep, { + transform: numberAttributeWithFallback(this.defaultOptions.gutterStep), + }) + readonly disabled = input(this.defaultOptions.disabled, { transform: booleanAttribute }) + readonly gutterClickDeltaPx = input(this.defaultOptions.gutterClickDeltaPx, { + transform: numberAttributeWithFallback(this.defaultOptions.gutterClickDeltaPx), + }) + readonly direction = input(this.defaultOptions.direction) + readonly dir = input(this.defaultOptions.dir) + readonly unit = input(this.defaultOptions.unit) + readonly gutterAriaLabel = input() + readonly restrictMove = input(this.defaultOptions.restrictMove, { transform: booleanAttribute }) + readonly useTransition = input(this.defaultOptions.useTransition, { transform: booleanAttribute }) + readonly gutterDblClickDuration = input(this.defaultOptions.gutterDblClickDuration, { + transform: numberAttributeWithFallback(this.defaultOptions.gutterDblClickDuration), + }) + readonly gutterClick = output() + readonly gutterDblClick = output() + readonly dragStart = output() + readonly dragEnd = output() + readonly transitionEnd = output() + + readonly dragProgress$ = this.dragProgressSubject.asObservable() + + private readonly visibleAreas = computed(() => this._areas().filter((area) => area.visible())) + private readonly gridTemplateColumnsStyle = computed(() => { + const columns: string[] = [] + const sumNonWildcardSizes = sum(this.visibleAreas(), (area) => { + const size = area._internalSize() + return size === '*' ? 0 : size + }) + const visibleAreasCount = this.visibleAreas().length + + let visitedVisibleAreas = 0 + + this._areas().forEach((area, index, areas) => { + // Add area size column + if (!area.visible()) { + columns.push('0fr') + } else { + const areaSize = area._internalSize() + const unit = this.unit() + + if (unit === 'pixel') { + const columnValue = areaSize === '*' ? '1fr' : `${areaSize}px` + columns.push(columnValue) + } else { + const percentSize = areaSize === '*' ? 100 - sumNonWildcardSizes : areaSize + const columnValue = `${percentSize}fr` + columns.push(columnValue) + } + + visitedVisibleAreas++ + } + + const isLastArea = index === areas.length - 1 + + if (isLastArea) { + return + } + + const remainingVisibleAreas = visibleAreasCount - visitedVisibleAreas + + // Only add gutter with size if this area is visible and there are more visible areas after this one + // to avoid ghost gutters + if (area.visible() && remainingVisibleAreas > 0) { + columns.push(`${this.gutterSize()}px`) + } else { + columns.push('0px') + } + }) + + return this.direction() === 'horizontal' ? `1fr / ${columns.join(' ')}` : `${columns.join(' ')} / 1fr` + }) + private readonly hostClasses = computed(() => + createClassesString({ + [`as-${this.direction()}`]: true, + [`as-${this.unit()}`]: true, + ['as-disabled']: this.disabled(), + ['as-dragging']: this._isDragging(), + ['as-transition']: this.useTransition() && !this._isDragging(), + }), + ) + protected readonly draggedGutterIndex = signal(undefined) + /** + * @internal + */ + readonly _isDragging = computed(() => this.draggedGutterIndex() !== undefined) + + @HostBinding('class') protected get hostClassesBinding() { + return this.hostClasses() + } + + @HostBinding('dir') protected get hostDirBinding() { + return this.dir() + } + + constructor() { + effect( + () => { + const visibleAreas = this.visibleAreas() + const unit = this.unit() + const isInAutoMode = visibleAreas.every((area) => area.size() === 'auto') + + untracked(() => { + // Special mode when no size input was declared which is a valid mode + if (unit === 'percent' && visibleAreas.length > 1 && isInAutoMode) { + visibleAreas.forEach((area) => area._internalSize.set(100 / visibleAreas.length)) + return + } + + visibleAreas.forEach((area) => area._internalSize.reset()) + + const isValid = areAreasValid(visibleAreas, unit) + + if (isValid) { + return + } + + if (unit === 'percent') { + // Distribute sizes equally + const defaultSize = 100 / visibleAreas.length + visibleAreas.forEach((area) => area._internalSize.set(defaultSize)) + } else if (unit === 'pixel') { + const wildcardAreas = visibleAreas.filter((area) => area._internalSize() === '*') + + // Make sure only one wildcard area + if (wildcardAreas.length === 0) { + visibleAreas[0]._internalSize.set('*') + } else if (wildcardAreas.length > 1) { + wildcardAreas.filter((_, i) => i !== 0).forEach((area) => area._internalSize.set(100)) + } + } + }) + }, + { allowSignalWrites: true }, + ) + + // Responsible for updating grid template style. Must be this way and not based on HostBinding + // as change detection for host binding is bound to the parent component and this style + // is updated on every mouse move. Doing it this way will prevent change detection cycles in parent. + effect(() => { + const gridTemplateColumnsStyle = this.gridTemplateColumnsStyle() + this.renderer.setStyle(this.elementRef.nativeElement, 'grid-template', gridTemplateColumnsStyle) + }) + + this.gutterMouseDownSubject + .pipe( + filter( + (context) => + !this.customGutter() || + this.customGutter().canStartDragging(context.mouseDownEvent.target as HTMLElement, context.gutterIndex + 1), + ), + switchMap((mouseDownContext) => + // As we have gutterClickDeltaPx we can't just start the drag but need to make sure + // we are out of the delta pixels. As the delta can be any number we make sure + // we always start the drag if we go out of the gutter (delta based on mouse position is larger than gutter). + // As moving can start inside the drag and end outside of it we always keep track of the previous event + // so once the current is out of the delta size we use the previous one as the drag start baseline. + fromMouseMoveEvent(this.document).pipe( + startWith(mouseDownContext.mouseDownEvent), + pairwise(), + skipWhile(([, currMoveEvent]) => + gutterEventsEqualWithDelta( + mouseDownContext.mouseDownEvent, + currMoveEvent, + this.gutterClickDeltaPx(), + mouseDownContext.gutterElement, + ), + ), + take(1), + takeUntil(fromMouseUpEvent(this.document, true)), + tap(() => { + this.ngZone.run(() => { + this.dragStart.emit(this.createDragInteractionEvent(mouseDownContext.gutterIndex)) + this.draggedGutterIndex.set(mouseDownContext.gutterIndex) + }) + }), + map(([prevMouseEvent]) => + this.createDragStartContext( + prevMouseEvent, + mouseDownContext.areaBeforeGutterIndex, + mouseDownContext.areaAfterGutterIndex, + ), + ), + switchMap((dragStartContext) => + fromMouseMoveEvent(this.document).pipe( + tap((moveEvent) => this.mouseDragMove(moveEvent, dragStartContext)), + takeUntil(fromMouseUpEvent(this.document, true)), + tap({ + complete: () => + this.ngZone.run(() => { + this.dragEnd.emit(this.createDragInteractionEvent(this.draggedGutterIndex())) + this.draggedGutterIndex.set(undefined) + }), + }), + ), + ), + ), + ), + takeUntilDestroyed(), + ) + .subscribe() + + fromEvent(this.elementRef.nativeElement, 'transitionend') + .pipe( + filter((e) => e.propertyName.startsWith('grid-template')), + leaveNgZone(), + takeUntilDestroyed(), + ) + .subscribe(() => this.ngZone.run(() => this.transitionEnd.emit(this.createAreaSizes()))) + } + + protected gutterClicked(gutterIndex: number) { + this.ngZone.run(() => this.gutterClick.emit(this.createDragInteractionEvent(gutterIndex))) + } + + protected gutterDoubleClicked(gutterIndex: number) { + this.ngZone.run(() => this.gutterDblClick.emit(this.createDragInteractionEvent(gutterIndex))) + } + + protected gutterMouseDown( + e: MouseEvent | TouchEvent, + gutterElement: HTMLElement, + gutterIndex: number, + areaBeforeGutterIndex: number, + areaAfterGutterIndex: number, + ) { + if (this.disabled()) { + return + } + + e.preventDefault() + e.stopPropagation() + + this.gutterMouseDownSubject.next({ + mouseDownEvent: e, + gutterElement, + gutterIndex, + areaBeforeGutterIndex, + areaAfterGutterIndex, + }) + } + + protected gutterKeyDown( + e: KeyboardEvent, + gutterIndex: number, + areaBeforeGutterIndex: number, + areaAfterGutterIndex: number, + ) { + if (this.disabled()) { + return + } + + const pixelsToMove = 50 + const pageMoveMultiplier = 10 + + let xPointOffset = 0 + let yPointOffset = 0 + + if (this.direction() === 'horizontal') { + // Even though we are going in the x axis we support page up and down + switch (e.key) { + case 'ArrowLeft': + xPointOffset -= pixelsToMove + break + case 'ArrowRight': + xPointOffset += pixelsToMove + break + case 'PageUp': + if (this.dir() === 'rtl') { + xPointOffset -= pixelsToMove * pageMoveMultiplier + } else { + xPointOffset += pixelsToMove * pageMoveMultiplier + } + break + case 'PageDown': + if (this.dir() === 'rtl') { + xPointOffset += pixelsToMove * pageMoveMultiplier + } else { + xPointOffset -= pixelsToMove * pageMoveMultiplier + } + break + default: + return + } + } else { + switch (e.key) { + case 'ArrowUp': + yPointOffset -= pixelsToMove + break + case 'ArrowDown': + yPointOffset += pixelsToMove + break + case 'PageUp': + yPointOffset -= pixelsToMove * pageMoveMultiplier + break + case 'PageDown': + yPointOffset += pixelsToMove * pageMoveMultiplier + break + default: + return + } + } + + e.preventDefault() + e.stopPropagation() + + const gutterMidPoint = getPointFromEvent(e) + const dragStartContext = this.createDragStartContext(e, areaBeforeGutterIndex, areaAfterGutterIndex) + + this.ngZone.run(() => { + this.dragStart.emit(this.createDragInteractionEvent(gutterIndex)) + this.draggedGutterIndex.set(gutterIndex) + + this.dragMoveToPoint({ x: gutterMidPoint.x + xPointOffset, y: gutterMidPoint.y + yPointOffset }, dragStartContext) + + this.dragEnd.emit(this.createDragInteractionEvent(gutterIndex)) + this.draggedGutterIndex.set(undefined) + }) + } + + protected getGutterGridStyle(nextAreaIndex: number) { + const gutterNum = nextAreaIndex * 2 + const style = `${gutterNum} / ${gutterNum}` + + return { + ['grid-column']: this.direction() === 'horizontal' ? style : '1', + ['grid-row']: this.direction() === 'vertical' ? style : '1', + } + } + + protected getAriaAreaSizeText(area: SplitAreaComponent): string { + const size = area._internalSize() + + if (size === '*') { + return undefined + } + + return `${size.toFixed(0)} ${this.unit()}` + } + + protected getAriaValue(size: SplitAreaSize) { + return size === '*' ? undefined : size + } + + private createDragInteractionEvent(gutterIndex: number): SplitGutterInteractionEvent { + return { + gutterNum: gutterIndex + 1, + sizes: this.createAreaSizes(), + } + } + + private createAreaSizes() { + return this.visibleAreas().map((area) => area._internalSize()) + } + + private createDragStartContext( + startEvent: MouseEvent | TouchEvent | KeyboardEvent, + areaBeforeGutterIndex: number, + areaAfterGutterIndex: number, + ): DragStartContext { + const splitBoundingRect = this.elementRef.nativeElement.getBoundingClientRect() + const splitSize = this.direction() === 'horizontal' ? splitBoundingRect.width : splitBoundingRect.height + const totalAreasPixelSize = splitSize - (this.visibleAreas().length - 1) * this.gutterSize() + // Use the internal size and split size to calculate the pixel size from wildcard and percent areas + const areaPixelSizesWithWildcard = this._areas().map((area) => { + if (this.unit() === 'pixel') { + return area._internalSize() + } else { + const size = area._internalSize() + + if (size === '*') { + return size + } + + return (size / 100) * totalAreasPixelSize + } + }) + const remainingSize = Math.max( + 0, + totalAreasPixelSize - sum(areaPixelSizesWithWildcard, (size) => (size === '*' ? 0 : size)), + ) + const areasPixelSizes = areaPixelSizesWithWildcard.map((size) => (size === '*' ? remainingSize : size)) + + return { + startEvent, + areaBeforeGutterIndex, + areaAfterGutterIndex, + areasPixelSizes, + totalAreasPixelSize, + areaIndexToBoundaries: toRecord(this._areas(), (area, index) => { + const percentToPixels = (percent: number) => (percent / 100) * totalAreasPixelSize + + const value: AreaBoundary = + this.unit() === 'pixel' + ? { + min: area._normalizedMinSize(), + max: area._normalizedMaxSize(), + } + : { + min: percentToPixels(area._normalizedMinSize()), + max: percentToPixels(area._normalizedMaxSize()), + } + + return [index.toString(), value] + }), + } + } + + private mouseDragMove(moveEvent: MouseEvent | TouchEvent, dragStartContext: DragStartContext) { + moveEvent.preventDefault() + moveEvent.stopPropagation() + + const endPoint = getPointFromEvent(moveEvent) + + this.dragMoveToPoint(endPoint, dragStartContext) + } + + private dragMoveToPoint(endPoint: ClientPoint, dragStartContext: DragStartContext) { + const startPoint = getPointFromEvent(dragStartContext.startEvent) + const preDirOffset = this.direction() === 'horizontal' ? endPoint.x - startPoint.x : endPoint.y - startPoint.y + const offset = this.direction() === 'horizontal' && this.dir() === 'rtl' ? -preDirOffset : preDirOffset + const isDraggingForward = offset > 0 + // Align offset with gutter step and abs it as we need absolute pixels movement + const absSteppedOffset = Math.abs(Math.round(offset / this.gutterStep()) * this.gutterStep()) + // Copy as we don't want to edit the original array + const tempAreasPixelSizes = [...dragStartContext.areasPixelSizes] + // As we are going to shuffle the areas order for easier iterations we should work with area indices array + // instead of actual area sizes array. + // We must also remove the invisible ones as we can't expand or shrink them. + const areasIndices = tempAreasPixelSizes.map((_, index) => index).filter((index) => this._areas()[index].visible()) + // The two variables below are ordered for iterations with real area indices inside. + const areasIndicesBeforeGutter = this.restrictMove() + ? [dragStartContext.areaBeforeGutterIndex] + : areasIndices.slice(0, dragStartContext.areaBeforeGutterIndex + 1).reverse() + const areasIndicesAfterGutter = this.restrictMove() + ? [dragStartContext.areaAfterGutterIndex] + : areasIndices.slice(dragStartContext.areaAfterGutterIndex) + // Based on direction we need to decide which areas are expanding and which are shrinking + const potentialAreasIndicesArrToShrink = isDraggingForward ? areasIndicesAfterGutter : areasIndicesBeforeGutter + const potentialAreasIndicesArrToExpand = isDraggingForward ? areasIndicesBeforeGutter : areasIndicesAfterGutter + + let remainingPixels = absSteppedOffset + let potentialShrinkArrIndex = 0 + let potentialExpandArrIndex = 0 + + // We gradually run in both expand and shrink direction transferring pixels from the offset. + // We stop once no pixels are left or we reached the end of either the expanding areas or the shrinking areas + while ( + remainingPixels !== 0 && + potentialShrinkArrIndex < potentialAreasIndicesArrToShrink.length && + potentialExpandArrIndex < potentialAreasIndicesArrToExpand.length + ) { + const areaIndexToShrink = potentialAreasIndicesArrToShrink[potentialShrinkArrIndex] + const areaIndexToExpand = potentialAreasIndicesArrToExpand[potentialExpandArrIndex] + const areaToShrinkSize = tempAreasPixelSizes[areaIndexToShrink] + const areaToExpandSize = tempAreasPixelSizes[areaIndexToExpand] + const areaToShrinkMinSize = dragStartContext.areaIndexToBoundaries[areaIndexToShrink].min + const areaToExpandMaxSize = dragStartContext.areaIndexToBoundaries[areaIndexToExpand].max + // We can only transfer pixels based on the shrinking area min size and the expanding area max size + // to avoid overflow. If any pixels left they will be handled by the next area in the next `while` iteration + const maxPixelsToShrink = areaToShrinkSize - areaToShrinkMinSize + const maxPixelsToExpand = areaToExpandMaxSize - areaToExpandSize + const pixelsToTransfer = Math.min(maxPixelsToShrink, maxPixelsToExpand, remainingPixels) + + // Actual pixels transfer + tempAreasPixelSizes[areaIndexToShrink] -= pixelsToTransfer + tempAreasPixelSizes[areaIndexToExpand] += pixelsToTransfer + remainingPixels -= pixelsToTransfer + + // Once min threshold reached we need to move to the next area in turn + if (tempAreasPixelSizes[areaIndexToShrink] === areaToShrinkMinSize) { + potentialShrinkArrIndex++ + } + + // Once max threshold reached we need to move to the next area in turn + if (tempAreasPixelSizes[areaIndexToExpand] === areaToExpandMaxSize) { + potentialExpandArrIndex++ + } + } + + this._areas().forEach((area, index) => { + // No need to update wildcard size + if (area._internalSize() === '*') { + return + } + + if (this.unit() === 'pixel') { + area._internalSize.set(tempAreasPixelSizes[index]) + } else { + const percentSize = (tempAreasPixelSizes[index] / dragStartContext.totalAreasPixelSize) * 100 + // Fix javascript only working with float numbers which are inaccurate compared to decimals + area._internalSize.set(parseFloat(percentSize.toFixed(10))) + } + }) + + this.dragProgressSubject.next(this.createDragInteractionEvent(this.draggedGutterIndex())) + } +} diff --git a/projects/angular-split/src/lib/utils.ts b/projects/angular-split/src/lib/utils.ts index daf76343..76043b12 100644 --- a/projects/angular-split/src/lib/utils.ts +++ b/projects/angular-split/src/lib/utils.ts @@ -1,362 +1,130 @@ -import { ElementRef } from '@angular/core' -import { - IArea, - IAreaAbsorptionCapacity, - IAreaSize, - IAreaSnapshot, - IPoint, - ISplitDirection, - ISplitSideAbsorptionCapacity, - ISplitUnit, -} from './interface' +import { NgZone, Signal, computed, inject, numberAttribute, signal, untracked } from '@angular/core' +import { Observable, filter, fromEvent, merge } from 'rxjs' -export function getPointFromEvent(event: MouseEvent | TouchEvent | KeyboardEvent): IPoint { - // TouchEvent - if ((event).changedTouches !== undefined && (event).changedTouches.length > 0) { - return { - x: (event).changedTouches[0].clientX, - y: (event).changedTouches[0].clientY, - } - } - // MouseEvent - else if ((event).clientX !== undefined && (event).clientY !== undefined) { - return { - x: (event).clientX, - y: (event).clientY, +export interface ClientPoint { + x: number + y: number +} + +/** + * Only supporting a single {@link TouchEvent} point + */ +export function getPointFromEvent(event: MouseEvent | TouchEvent | KeyboardEvent): ClientPoint { + if (event instanceof TouchEvent) { + if (event.changedTouches.length === 0) { + return undefined } - } - // KeyboardEvent - else if ((event).currentTarget !== undefined) { - const gutterEl = event.currentTarget as HTMLElement + + const { clientX, clientY } = event.changedTouches[0] + return { - x: gutterEl.offsetLeft, - y: gutterEl.offsetTop, + x: clientX, + y: clientY, } } - return null -} -export function pointDeltaEquals(lhs: IPoint, rhs: IPoint, deltaPx: number) { - return Math.abs(lhs.x - rhs.x) <= deltaPx && Math.abs(lhs.y - rhs.y) <= deltaPx -} + if (event instanceof KeyboardEvent) { + const target = event.target as HTMLElement -export function getKeyboardEndpoint(event: KeyboardEvent, direction: ISplitDirection): IPoint | null { - // Return null if direction keys on the opposite axis were pressed - if (direction === 'horizontal') { - switch (event.key) { - case 'ArrowLeft': - case 'ArrowRight': - case 'PageUp': - case 'PageDown': - break - default: - return null - } - } - if (direction === 'vertical') { - switch (event.key) { - case 'ArrowUp': - case 'ArrowDown': - case 'PageUp': - case 'PageDown': - break - default: - return null + // Calculate element midpoint + return { + x: target.offsetLeft + target.offsetWidth / 2, + y: target.offsetTop + target.offsetHeight / 2, } } - const gutterEl = event.currentTarget as HTMLElement - const offset = event.key === 'PageUp' || event.key === 'PageDown' ? 50 * 10 : 50 - let offsetX = gutterEl.offsetLeft, - offsetY = gutterEl.offsetTop - switch (event.key) { - case 'ArrowLeft': - offsetX -= offset - break - case 'ArrowRight': - offsetX += offset - break - case 'ArrowUp': - offsetY -= offset - break - case 'ArrowDown': - offsetY += offset - break - case 'PageUp': - if (direction === 'vertical') { - offsetY -= offset - } else { - offsetX += offset - } - break - case 'PageDown': - if (direction === 'vertical') { - offsetY += offset - } else { - offsetX -= offset - } - break - default: - return null - } - return { - x: offsetX, - y: offsetY, + x: event.clientX, + y: event.clientY, } } -export function getElementPixelSize(elRef: ElementRef, direction: ISplitDirection): number { - const rect = (elRef.nativeElement).getBoundingClientRect() +export function gutterEventsEqualWithDelta( + startEvent: MouseEvent | TouchEvent, + endEvent: MouseEvent | TouchEvent, + deltaInPx: number, + gutterElement: HTMLElement, +) { + if ( + !gutterElement.contains(startEvent.target as HTMLElement) || + !gutterElement.contains(endEvent.target as HTMLElement) + ) { + return false + } - return direction === 'horizontal' ? rect.width : rect.height -} + const startPoint = getPointFromEvent(startEvent) + const endPoint = getPointFromEvent(endEvent) -export function getInputBoolean(v: boolean | `${boolean}`): boolean { - return typeof v === 'boolean' ? v : v !== 'false' + return Math.abs(endPoint.x - startPoint.x) <= deltaInPx && Math.abs(endPoint.y - startPoint.y) <= deltaInPx } -export function getInputPositiveNumber(v: number | `${number}` | '*', defaultValue: T): number | T { - if (v === null || v === undefined) return defaultValue - - v = Number(v) - return !isNaN(v) && v >= 0 ? v : defaultValue +export function fromMouseDownEvent(target: HTMLElement | Document) { + return merge( + fromEvent(target, 'mousedown').pipe(filter((e) => e.button === 0)), + // We must prevent default here so we declare it as non passive explicitly + fromEvent(target, 'touchstart', { passive: false }), + ) } -export function isUserSizesValid(unit: ISplitUnit, sizes: Array): boolean { - // All sizes total must be 100 unless there are wildcards. - // While having wildcards all other sizes sum should be less than 100. - // There should be maximum one wildcard. - if (unit === 'percent') { - const total = sizes.reduce((total, s) => (s !== '*' ? total + s : total), 0) - const wildcardSizeAreas = sizes.filter((size) => size === '*') - - if (wildcardSizeAreas.length > 1) { - return false - } - - if (wildcardSizeAreas.length === 1) { - return total < 100.1 - } - - return total > 99.9 && total < 100.1 - } - - // A size at null is mandatory but only one. - if (unit === 'pixel') { - return sizes.filter((s) => s === '*').length === 1 - } +export function fromMouseMoveEvent(target: HTMLElement | Document) { + return merge(fromEvent(target, 'mousemove'), fromEvent(target, 'touchmove')) } -export function getAreaMinSize(a: IArea): number | null { - if (a.size === '*') { - return null - } - - if (a.component.lockSize === true) { - return a.size - } - - if (a.component.minSize === null) { - return null - } +export function fromMouseUpEvent(target: HTMLElement | Document, includeTouchCancel = false) { + const withoutTouchCancel = merge(fromEvent(target, 'mouseup'), fromEvent(target, 'touchend')) - return a.component.minSize + return includeTouchCancel + ? merge(withoutTouchCancel, fromEvent(target, 'touchcancel')) + : withoutTouchCancel } -export function getAreaMaxSize(a: IArea): number | null { - if (a.size === '*') { - return null - } - - if (a.component.lockSize === true) { - return a.size - } - - if (a.component.maxSize === null) { - return null - } - - if (a.component.maxSize < a.size) { - return a.size - } - - return a.component.maxSize +export function sum(array: T[] | readonly T[], fn: (item: T) => number) { + return (array as T[]).reduce((sum, item) => sum + fn(item), 0) } -export function getGutterSideAbsorptionCapacity( - unit: ISplitUnit, - sideAreas: Array, - pixels: number, - allAreasSizePixel: number, -): ISplitSideAbsorptionCapacity { - return sideAreas.reduce( - (acc, area) => { - const res = getAreaAbsorptionCapacity(unit, area, acc.remain, allAreasSizePixel) - acc.list.push(res) - acc.remain = res.pixelRemain - return acc +export function toRecord( + array: TItem[] | readonly TItem[], + fn: (item: TItem, index: number) => [TKey, TValue], +): Record { + return (array as TItem[]).reduce>( + (record, item, index) => { + const [key, value] = fn(item, index) + record[key] = value + return record }, - { remain: pixels, list: [] }, + {} as Record, ) } -function getAreaAbsorptionCapacity( - unit: ISplitUnit, - areaSnapshot: IAreaSnapshot, - pixels: number, - allAreasSizePixel: number, -): IAreaAbsorptionCapacity { - // No pain no gain - if (pixels === 0) { - return { - areaSnapshot, - pixelAbsorb: 0, - percentAfterAbsorption: areaSnapshot.sizePercentAtStart, - pixelRemain: 0, - } - } - - // Area start at zero and need to be reduced, not possible - if (areaSnapshot.sizePixelAtStart === 0 && pixels < 0) { - return { - areaSnapshot, - pixelAbsorb: 0, - percentAfterAbsorption: 0, - pixelRemain: pixels, - } - } - - if (unit === 'percent') { - return getAreaAbsorptionCapacityPercent(areaSnapshot, pixels, allAreasSizePixel) - } - - if (unit === 'pixel') { - return getAreaAbsorptionCapacityPixel(areaSnapshot, pixels) - } +export function createClassesString(classesRecord: Record) { + return Object.entries(classesRecord) + .filter(([, value]) => value) + .map(([key]) => key) + .join(' ') } -function getAreaAbsorptionCapacityPercent( - areaSnapshot: IAreaSnapshot, - pixels: number, - allAreasSizePixel: number, -): IAreaAbsorptionCapacity { - const tempPixelSize = areaSnapshot.sizePixelAtStart + pixels - const tempPercentSize = (tempPixelSize / allAreasSizePixel) * 100 - - // ENLARGE AREA - - if (pixels > 0) { - // If maxSize & newSize bigger than it > absorb to max and return remaining pixels - if (areaSnapshot.area.maxSize !== null && tempPercentSize > areaSnapshot.area.maxSize) { - // Use area.area.maxSize as newPercentSize and return calculate pixels remaining - const maxSizePixel = (areaSnapshot.area.maxSize / 100) * allAreasSizePixel - return { - areaSnapshot, - pixelAbsorb: maxSizePixel, - percentAfterAbsorption: areaSnapshot.area.maxSize, - pixelRemain: areaSnapshot.sizePixelAtStart + pixels - maxSizePixel, - } - } - return { - areaSnapshot, - pixelAbsorb: pixels, - percentAfterAbsorption: tempPercentSize > 100 ? 100 : tempPercentSize, - pixelRemain: 0, - } - } - - // REDUCE AREA - else if (pixels < 0) { - // If minSize & newSize smaller than it > absorb to min and return remaining pixels - if (areaSnapshot.area.minSize !== null && tempPercentSize < areaSnapshot.area.minSize) { - // Use area.area.minSize as newPercentSize and return calculate pixels remaining - const minSizePixel = (areaSnapshot.area.minSize / 100) * allAreasSizePixel - return { - areaSnapshot, - pixelAbsorb: minSizePixel, - percentAfterAbsorption: areaSnapshot.area.minSize, - pixelRemain: areaSnapshot.sizePixelAtStart + pixels - minSizePixel, - } - } - // If reduced under zero > return remaining pixels - else if (tempPercentSize < 0) { - // Use 0 as newPercentSize and return calculate pixels remaining - return { - areaSnapshot, - pixelAbsorb: -areaSnapshot.sizePixelAtStart, - percentAfterAbsorption: 0, - pixelRemain: pixels + areaSnapshot.sizePixelAtStart, - } - } - return { - areaSnapshot, - pixelAbsorb: pixels, - percentAfterAbsorption: tempPercentSize, - pixelRemain: 0, - } - } +export interface MirrorSignal { + (): T + set(value: T): void + reset(): void } -function getAreaAbsorptionCapacityPixel(areaSnapshot: IAreaSnapshot, pixels: number): IAreaAbsorptionCapacity { - const tempPixelSize = areaSnapshot.sizePixelAtStart + pixels - - // ENLARGE AREA - - if (pixels > 0) { - // If maxSize & newSize bigger than it > absorb to max and return remaining pixels - if (areaSnapshot.area.maxSize !== null && tempPixelSize > areaSnapshot.area.maxSize) { - return { - areaSnapshot, - pixelAbsorb: areaSnapshot.area.maxSize - areaSnapshot.sizePixelAtStart, - percentAfterAbsorption: -1, - pixelRemain: tempPixelSize - areaSnapshot.area.maxSize, - } - } - return { - areaSnapshot, - pixelAbsorb: pixels, - percentAfterAbsorption: -1, - pixelRemain: 0, - } - } - - // REDUCE AREA - else if (pixels < 0) { - // If minSize & newSize smaller than it > absorb to min and return remaining pixels - if (areaSnapshot.area.minSize !== null && tempPixelSize < areaSnapshot.area.minSize) { - return { - areaSnapshot, - pixelAbsorb: areaSnapshot.area.minSize + pixels - tempPixelSize, - percentAfterAbsorption: -1, - pixelRemain: tempPixelSize - areaSnapshot.area.minSize, - } - } - // If reduced under zero > return remaining pixels - else if (tempPixelSize < 0) { - return { - areaSnapshot, - pixelAbsorb: -areaSnapshot.sizePixelAtStart, - percentAfterAbsorption: -1, - pixelRemain: pixels + areaSnapshot.sizePixelAtStart, - } - } - return { - areaSnapshot, - pixelAbsorb: pixels, - percentAfterAbsorption: -1, - pixelRemain: 0, - } - } +/** + * Creates a semi signal which allows writes but is based on an existing signal + * Whenever the original signal changes the mirror signal gets aligned + * overriding the current value inside. + */ +export function mirrorSignal(outer: Signal): MirrorSignal { + const inner = computed(() => signal(outer())) + const mirror: MirrorSignal = () => inner()() + mirror.set = (value: T) => untracked(inner).set(value) + mirror.reset = () => untracked(() => inner().set(outer())) + return mirror } -export function updateAreaSize(unit: ISplitUnit, item: IAreaAbsorptionCapacity) { - // Update size except for the wildcard size area - if (item.areaSnapshot.area.size !== '*') { - if (unit === 'percent') { - item.areaSnapshot.area.size = item.percentAfterAbsorption - } else if (unit === 'pixel') { - item.areaSnapshot.area.size = item.areaSnapshot.sizePixelAtStart + item.pixelAbsorb - } - } +export function leaveNgZone() { + return (source: Observable) => + new Observable((observer) => inject(NgZone).runOutsideAngular(() => source.subscribe(observer))) } + +export const numberAttributeWithFallback = (fallback: number) => (value: unknown) => numberAttribute(value, fallback) diff --git a/projects/angular-split/src/lib/validations.ts b/projects/angular-split/src/lib/validations.ts new file mode 100644 index 00000000..d0c84327 --- /dev/null +++ b/projects/angular-split/src/lib/validations.ts @@ -0,0 +1,60 @@ +import { isDevMode } from '@angular/core' +import { SplitUnit } from './models' +import { SplitAreaComponent } from './split-area/split-area.component' +import { sum } from './utils' + +export function areAreasValid(areas: readonly SplitAreaComponent[], unit: SplitUnit): boolean { + if (areas.length === 0) { + return true + } + + const wildcardAreas = areas.filter((area) => area._internalSize() === '*') + + if (wildcardAreas.length > 1) { + if (isDevMode()) { + console.warn('as-split: Maximum one * area is allowed') + } + + return false + } + + if (unit === 'pixel') { + if (wildcardAreas.length === 1) { + return true + } + + if (isDevMode()) { + console.warn('as-split: Pixel mode must have exactly one * area') + } + + return false + } + + const sumPercent = sum(areas, (area) => { + const size = area._internalSize() + return size === '*' ? 0 : size + }) + + // As percent calculation isn't perfect we allow for a small margin of error + if (wildcardAreas.length === 1) { + if (sumPercent <= 100.1) { + return true + } + + if (isDevMode()) { + console.warn(`as-split: Percent areas must total 100%`) + } + + return false + } + + if (sumPercent < 99.9 || sumPercent > 100.1) { + if (isDevMode()) { + console.warn('as-split: Percent areas must total 100%') + } + + return false + } + + return true +} diff --git a/projects/angular-split/src/public_api.ts b/projects/angular-split/src/public_api.ts index 9fe5caad..429b60ab 100644 --- a/projects/angular-split/src/public_api.ts +++ b/projects/angular-split/src/public_api.ts @@ -2,11 +2,18 @@ * Public API Surface of angular-split */ -export { AngularSplitModule } from './lib/module' -export { SplitComponent } from './lib/component/split.component' +export { AngularSplitModule } from './lib/split-module.module' +export { SplitComponent } from './lib/split/split.component' export { SplitGutterDirective, SplitGutterTemplateContext } from './lib/gutter/split-gutter.directive' export { SplitGutterDragHandleDirective } from './lib/gutter/split-gutter-drag-handle.directive' export { SplitGutterExcludeFromDragDirective } from './lib/gutter/split-gutter-exclude-from-drag.directive' -export { SplitAreaDirective } from './lib/directive/split-area.directive' -export { ANGULAR_SPLIT_DEFAULT_OPTIONS } from './lib/angular-split-config.token' -export * from './lib/interface' +export { SplitAreaComponent } from './lib/split-area/split-area.component' +export { AngularSplitDefaultOptions, provideAngularSplitOptions } from './lib/angular-split-config.token' +export { + SplitAreaSize, + SplitAreaSizeInput, + SplitGutterInteractionEvent, + SplitUnit, + SplitDir, + SplitDirection, +} from './lib/models' diff --git a/src/app/examples/access-from-class/access-from-class.component.ts b/src/app/examples/access-from-class/access-from-class.component.ts index d0024c28..eac869a3 100644 --- a/src/app/examples/access-from-class/access-from-class.component.ts +++ b/src/app/examples/access-from-class/access-from-class.component.ts @@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, HostBinding, } from '@angular/core' -import { SplitComponent, SplitAreaDirective } from 'angular-split' +import { SplitComponent, SplitAreaComponent, SplitDirection, SplitDir } from 'angular-split' import { AComponent } from '../../ui/components/AComponent' @@ -31,7 +31,7 @@ import { AComponent } from '../../ui/components/AComponent'
- +

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam @@ -44,14 +44,7 @@ import { AComponent } from '../../ui/components/AComponent' illum qui dolorem eum fugiat quo voluptas nulla pariatur?

- -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tiam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in - voluptate velit esse cillum dolore eu fugiat nulla pariatur. -

-
- +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tiam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in @@ -70,46 +63,28 @@ import { AComponent } from '../../ui/components/AComponent'

-
-
-
- - - +
@@ -122,7 +97,12 @@ export class AccessFromClassComponent extends AComponent implements AfterViewIni @HostBinding('class') class = 'split-example-page' @ViewChild(SplitComponent) splitEl: SplitComponent - @ViewChildren(SplitAreaDirective) areasEl: QueryList + @ViewChildren(SplitAreaComponent) areasEl: QueryList + + direction: SplitDirection = 'horizontal' + disabled = false + dir: SplitDir = 'ltr' + gutterSize = 11 ngAfterViewInit() { console.log('Area Components: ', this.areasEl) diff --git a/src/app/examples/collapse-expand/collapse-expand.component.ts b/src/app/examples/collapse-expand/collapse-expand.component.ts index 7951693b..3e6f1912 100644 --- a/src/app/examples/collapse-expand/collapse-expand.component.ts +++ b/src/app/examples/collapse-expand/collapse-expand.component.ts @@ -1,5 +1,5 @@ import { Component, ViewChild, ViewChildren, QueryList, ChangeDetectionStrategy, HostBinding } from '@angular/core' -import { SplitComponent, SplitAreaDirective } from 'angular-split' +import { SplitComponent, SplitAreaComponent } from 'angular-split' import { AComponent } from '../../ui/components/AComponent' @@ -55,20 +55,20 @@ import { AComponent } from '../../ui/components/AComponent'

- - + +
- - + +
- +
- +
@@ -78,21 +78,5 @@ export class CollapseExpandComponent extends AComponent { @HostBinding('class') class = 'split-example-page' @ViewChild(SplitComponent) splitEl: SplitComponent - @ViewChildren(SplitAreaDirective) areasEl: QueryList - - onClose1(newSize = 0) { - this.areasEl.first.collapse(newSize) - } - - onClose3(newSize = 0) { - this.areasEl.last.collapse(newSize, 'left') - } - - onExpand1() { - this.areasEl.first.expand() - } - - onExpand3() { - this.areasEl.last.expand() - } + @ViewChildren(SplitAreaComponent) areasEl: QueryList } diff --git a/src/app/examples/custom-gutter-style/custom-gutter-style.component.ts b/src/app/examples/custom-gutter-style/custom-gutter-style.component.ts index 23936aec..a617ea96 100644 --- a/src/app/examples/custom-gutter-style/custom-gutter-style.component.ts +++ b/src/app/examples/custom-gutter-style/custom-gutter-style.component.ts @@ -1,6 +1,6 @@ import { Component, ChangeDetectionStrategy, HostBinding } from '@angular/core' import { AComponent } from '../../ui/components/AComponent' -import { IAreaSize, IOutputData, ISplitDirection } from 'angular-split' +import { SplitAreaSize, SplitGutterInteractionEvent, SplitDirection } from 'angular-split' @Component({ selector: 'sp-ex-custom-gutter-style', @@ -129,10 +129,10 @@ import { IAreaSize, IOutputData, ISplitDirection } from 'angular-split' export class CustomGutterStyleComponent extends AComponent { @HostBinding('class') class = 'split-example-page' - direction: ISplitDirection = 'horizontal' - exampleCSizes: IAreaSize[] = [30, 10, 40, 20] + direction: SplitDirection = 'horizontal' + exampleCSizes: SplitAreaSize[] = [30, 10, 40, 20] - exampleCDragEnd(e: IOutputData) { + exampleCDragEnd(e: SplitGutterInteractionEvent) { this.exampleCSizes = e.sizes } diff --git a/src/app/examples/dir-rtl/dir-rtl.component.ts b/src/app/examples/dir-rtl/dir-rtl.component.ts index d7e3fb8c..b65b0e9d 100644 --- a/src/app/examples/dir-rtl/dir-rtl.component.ts +++ b/src/app/examples/dir-rtl/dir-rtl.component.ts @@ -1,6 +1,6 @@ import { Component, ChangeDetectionStrategy, HostBinding } from '@angular/core' import { AComponent } from '../../ui/components/AComponent' -import { ISplitDir, ISplitDirection } from 'angular-split' +import { SplitDir, SplitDirection } from 'angular-split' @Component({ selector: 'sp-ex-dir-rtl', @@ -73,6 +73,6 @@ import { ISplitDir, ISplitDirection } from 'angular-split' export class DirRtlComponent extends AComponent { @HostBinding('class') class = 'split-example-page' - dir: ISplitDir = 'rtl' - direction: ISplitDirection = 'horizontal' + dir: SplitDir = 'rtl' + direction: SplitDirection = 'horizontal' } diff --git a/src/app/examples/geek-demo/geek-demo.component.ts b/src/app/examples/geek-demo/geek-demo.component.ts index d0a949be..b6fd53d3 100644 --- a/src/app/examples/geek-demo/geek-demo.component.ts +++ b/src/app/examples/geek-demo/geek-demo.component.ts @@ -1,7 +1,7 @@ import { Component, ViewChild, ChangeDetectionStrategy, HostBinding } from '@angular/core' import { SortableComponent } from 'ngx-bootstrap/sortable' import { AComponent } from '../../ui/components/AComponent' -import { IAreaSize, ISplitDirection } from 'angular-split' +import { SplitAreaSize, SplitDirection } from 'angular-split' @Component({ selector: 'sp-ex-geek-demo', @@ -82,13 +82,13 @@ import { IAreaSize, ISplitDirection } from 'angular-split' [style.width]="d.width" [style.height]="d.height" [useTransition]="d.useTransition" + gutterClickDeltaPx="0" style="background-color: #ffffff;" > {{ area.id }} {{ '[visible]="' + item.value.visible + '"' }} @@ -183,14 +183,14 @@ export class GeekDemoComponent extends AComponent { @HostBinding('class') class = 'split-example-page' d: { - dir: ISplitDirection + dir: SplitDirection restrictMove: boolean useTransition: boolean gutterSize: number | null gutterStep: number | null width: number | null height: number | null - areas: { id: number; color: string; size: IAreaSize; present: boolean; visible: boolean }[] + areas: { id: number; color: string; size: SplitAreaSize; present: boolean; visible: boolean }[] } = { dir: 'horizontal', restrictMove: true, @@ -218,15 +218,31 @@ export class GeekDemoComponent extends AComponent { present: true, visible: true, }) + this.alignAreaSizes() this.sortableComponent.writeValue(this.d.areas) } - removeArea(area: { id: number; color: string; size: IAreaSize; present: boolean; visible: boolean }) { + removeArea(area: { id: number; color: string; size: SplitAreaSize; present: boolean; visible: boolean }) { this.d.areas.splice(this.d.areas.indexOf(area), 1) + this.alignAreaSizes() this.sortableComponent.writeValue(this.d.areas) } + + hideArea(area: { id: number; color: string; size: SplitAreaSize; present: boolean; visible: boolean }) { + this.d.areas.find((a) => a === area).visible = false + this.alignAreaSizes() + } + + showArea(area: { id: number; color: string; size: SplitAreaSize; present: boolean; visible: boolean }) { + this.d.areas.find((a) => a === area).visible = true + this.alignAreaSizes() + } + + private alignAreaSizes() { + this.d.areas.filter((area) => area.visible).forEach((area, _, arr) => (area.size = 100 / arr.length)) + } } function getRandomNum(): number { diff --git a/src/app/examples/global-options/global-options.component.ts b/src/app/examples/global-options/global-options.component.ts index 70704d94..17745fd7 100644 --- a/src/app/examples/global-options/global-options.component.ts +++ b/src/app/examples/global-options/global-options.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, HostBinding, ViewChild } from '@angular/core' import { AComponent } from '../../ui/components/AComponent' -import { SplitComponent, SplitAreaDirective } from 'angular-split' +import { SplitComponent, SplitAreaComponent } from 'angular-split' @Component({ selector: 'sp-global-options', @@ -41,8 +41,8 @@ export class GlobalOptionsComponent extends AComponent { @HostBinding('class') class = 'split-example-page' @ViewChild('split') split: SplitComponent - @ViewChild('area1') area1: SplitAreaDirective - @ViewChild('area2') area2: SplitAreaDirective + @ViewChild('area1') area1: SplitAreaComponent + @ViewChild('area2') area2: SplitAreaComponent constructor() { super() diff --git a/src/app/examples/global-options/global-options.module.ts b/src/app/examples/global-options/global-options.module.ts index d8a415ca..abe27809 100644 --- a/src/app/examples/global-options/global-options.module.ts +++ b/src/app/examples/global-options/global-options.module.ts @@ -2,9 +2,8 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' import { RouterModule } from '@angular/router' import { GlobalOptionsComponent } from './global-options.component' -import { AngularSplitModule } from 'angular-split' +import { AngularSplitModule, provideAngularSplitOptions } from 'angular-split' import { UiModule } from '../../ui/ui.module' -import { ANGULAR_SPLIT_DEFAULT_OPTIONS } from 'angular-split' @NgModule({ declarations: [GlobalOptionsComponent], @@ -15,13 +14,10 @@ import { ANGULAR_SPLIT_DEFAULT_OPTIONS } from 'angular-split' UiModule, ], providers: [ - { - provide: ANGULAR_SPLIT_DEFAULT_OPTIONS, - useValue: { - direction: 'vertical', - gutterSize: 30, - }, - }, + provideAngularSplitOptions({ + direction: 'vertical', + gutterSize: 30, + }), ], }) export class GlobalOptionsModule {} diff --git a/src/app/examples/gutter-click-roll-unroll/gutter-click-roll-unroll.component.ts b/src/app/examples/gutter-click-roll-unroll/gutter-click-roll-unroll.component.ts index c5b142d9..db252694 100644 --- a/src/app/examples/gutter-click-roll-unroll/gutter-click-roll-unroll.component.ts +++ b/src/app/examples/gutter-click-roll-unroll/gutter-click-roll-unroll.component.ts @@ -7,7 +7,7 @@ import { OnDestroy, ViewChild, } from '@angular/core' -import { IAreaSize, IOutputAreaSizes, IOutputData, SplitComponent } from 'angular-split' +import { SplitAreaSize, SplitGutterInteractionEvent, SplitComponent } from 'angular-split' import { Subscription } from 'rxjs' import { AComponent } from '../../ui/components/AComponent' import { formatDate } from '../../utils/format-date' @@ -67,7 +67,7 @@ import { formatDate } from '../../utils/format-date' (gutterDblClick)="log('gutterDblClick', $event)" (transitionEnd)="log('transitionEnd', $event)" > - +

{{ a.content }}

@@ -120,7 +120,7 @@ export class GutterClickRollUnrollComponent extends AComponent implements AfterV useTransition = true dblClickTime = 0 logMessages: Array<{ type: string; text: string }> = [] - areas: { size: IAreaSize; order: number; content: string }[] = [ + areas: { size: SplitAreaSize; order: number; content: string }[] = [ { size: 25, order: 1, content: 'fg fdkjuh dfskhf dkujv fd vifdk hvdkuh fg' }, { size: '*', order: 2, content: 'sd h vdshhf deuyf gduyeg hudeg hudfg fd vifdk hvdkuh fg' }, { size: 25, order: 3, content: 'sd jslfd ijgil dfhlt jkgvbnhj fl bhjgflh jfglhj fl h fg' }, @@ -143,8 +143,8 @@ export class GutterClickRollUnrollComponent extends AComponent implements AfterV log( ...[type, e]: - | [type: 'dragStart' | 'dragEnd' | 'gutterClick' | 'gutterDblClick', e: IOutputData] - | [type: 'transitionEnd', e: IOutputAreaSizes] + | [type: 'dragStart' | 'dragEnd' | 'gutterClick' | 'gutterDblClick', e: SplitGutterInteractionEvent] + | [type: 'transitionEnd', e: SplitAreaSize[]] ) { this.logMessages.push({ type, text: `${formatDate(new Date())} > ${type} event > ${JSON.stringify(e)}` }) @@ -163,7 +163,7 @@ export class GutterClickRollUnrollComponent extends AComponent implements AfterV } } - gutterClick(e: IOutputData) { + gutterClick(e: SplitGutterInteractionEvent) { if (e.gutterNum === 1) { if ((this.areas[0].size as number) > 0) { this.areas[0].size = 0 diff --git a/src/app/examples/simple-split/simple-split.component.ts b/src/app/examples/simple-split/simple-split.component.ts index 0e6924f2..0f1386b1 100644 --- a/src/app/examples/simple-split/simple-split.component.ts +++ b/src/app/examples/simple-split/simple-split.component.ts @@ -1,5 +1,5 @@ import { Component, ChangeDetectionStrategy, ViewChild, HostBinding } from '@angular/core' -import { SplitComponent, SplitAreaDirective, ISplitDirection } from 'angular-split' +import { SplitComponent, SplitAreaComponent, SplitDirection } from 'angular-split' import { AComponent } from '../../ui/components/AComponent' @Component({ @@ -162,11 +162,11 @@ import { AComponent } from '../../ui/components/AComponent' }) export class SimpleSplitComponent extends AComponent { @ViewChild('split') split: SplitComponent - @ViewChild('area1') area1: SplitAreaDirective - @ViewChild('area2') area2: SplitAreaDirective + @ViewChild('area1') area1: SplitAreaComponent + @ViewChild('area2') area2: SplitAreaComponent @HostBinding('class') class = 'split-example-page' - direction: ISplitDirection = 'horizontal' + direction: SplitDirection = 'horizontal' sizes = { percentWithoutWildcards: { area1: 30, diff --git a/src/app/examples/split-transitions/split-transitions.component.ts b/src/app/examples/split-transitions/split-transitions.component.ts index b608a596..e39f364c 100644 --- a/src/app/examples/split-transitions/split-transitions.component.ts +++ b/src/app/examples/split-transitions/split-transitions.component.ts @@ -1,7 +1,7 @@ import { Component, ViewChild, ElementRef, ChangeDetectionStrategy, HostBinding } from '@angular/core' import { AComponent } from '../../ui/components/AComponent' import { formatDate } from '../../utils/format-date' -import { IAreaSize } from 'angular-split' +import { SplitAreaSize } from 'angular-split' @Component({ selector: 'sp-ex-transitions', @@ -237,9 +237,9 @@ import { IAreaSize } from 'angular-split' }) export class SplitTransitionsComponent extends AComponent { action: { - a1s: IAreaSize - a2s: IAreaSize - a3s: IAreaSize + a1s: SplitAreaSize + a2s: SplitAreaSize + a3s: SplitAreaSize a1v: boolean a2v: boolean a3v: boolean diff --git a/src/app/examples/sync-split/sync-split.component.ts b/src/app/examples/sync-split/sync-split.component.ts index 5d9e1d8b..717137b0 100644 --- a/src/app/examples/sync-split/sync-split.component.ts +++ b/src/app/examples/sync-split/sync-split.component.ts @@ -1,10 +1,18 @@ -import { Component, ChangeDetectionStrategy, ViewChild, AfterViewInit, OnDestroy, HostBinding } from '@angular/core' +import { + Component, + ChangeDetectionStrategy, + ViewChild, + AfterViewInit, + OnDestroy, + HostBinding, + inject, + ChangeDetectorRef, + signal, +} from '@angular/core' import { Subscription, merge } from 'rxjs' -import { map } from 'rxjs/operators' -import { SplitComponent } from 'angular-split' +import { SplitAreaSize, SplitComponent } from 'angular-split' import { AComponent } from '../../ui/components/AComponent' -import { formatDate } from '../../utils/format-date' @Component({ selector: 'sp-ex-sync', @@ -15,26 +23,26 @@ import { formatDate } from '../../utils/format-date'
-
+ -
A 1
- A 2 + A 1 + A 2
-
-
+ + -
B 1
- B 2 + B 1 + B 2
-
+ - C 1 -
+ C 1 + C 2
Open devTools to view console.log() statements.
-
+
@@ -43,47 +51,22 @@ import { formatDate } from '../../utils/format-date' `, }) export class SyncSplitComponent extends AComponent implements AfterViewInit, OnDestroy { + private z = inject(ChangeDetectorRef) @ViewChild('mySplitA') mySplitAEl: SplitComponent @ViewChild('mySplitB') mySplitBEl: SplitComponent @ViewChild('mySplitC') mySplitCEl: SplitComponent @HostBinding('class') class = 'split-example-page' - sizes = [25, 75] + sizes = signal([25, 75]) sub: Subscription ngAfterViewInit() { this.sub = merge( - this.mySplitAEl.dragProgress$.pipe(map((data) => ({ name: 'A', data }))), - this.mySplitBEl.dragProgress$.pipe(map((data) => ({ name: 'B', data }))), - this.mySplitCEl.dragProgress$.pipe(map((data) => ({ name: 'C', data }))), - ).subscribe((d) => { - if (d.name === 'A') { - // If split A changed > update BC - const sizesSplitA = this.mySplitAEl.getVisibleAreaSizes() //d.data.sizes; <-- Could have use these values too - - this.mySplitBEl.setVisibleAreaSizes(sizesSplitA) - this.mySplitCEl.setVisibleAreaSizes(sizesSplitA) - } else if (d.name === 'B') { - // Else if split B changed > update AC - const sizesSplitB = this.mySplitBEl.getVisibleAreaSizes() //d.data.sizes; <-- Could have use these values too - - this.mySplitAEl.setVisibleAreaSizes(sizesSplitB) - this.mySplitCEl.setVisibleAreaSizes(sizesSplitB) - } else if (d.name === 'C') { - // Else if split C changed > update AB - const sizesSplitC = this.mySplitCEl.getVisibleAreaSizes() //d.data.sizes; <-- Could have use these values too - - this.mySplitAEl.setVisibleAreaSizes(sizesSplitC) - this.mySplitBEl.setVisibleAreaSizes(sizesSplitC) - } - - console.log( - `${formatDate( - new Date(), - )} > dragProgress$ observable emitted, splits synchronized but current component change detection didn't run! (from split ${ - d.name - })`, - ) + this.mySplitAEl.dragProgress$, + this.mySplitBEl.dragProgress$, + this.mySplitCEl.dragProgress$, + ).subscribe((t) => { + this.sizes.set(t.sizes) }) } diff --git a/src/app/examples/toggling-dom-and-visibility/toggling-dom-and-visibility.component.ts b/src/app/examples/toggling-dom-and-visibility/toggling-dom-and-visibility.component.ts index a5841aa2..9a085368 100644 --- a/src/app/examples/toggling-dom-and-visibility/toggling-dom-and-visibility.component.ts +++ b/src/app/examples/toggling-dom-and-visibility/toggling-dom-and-visibility.component.ts @@ -21,13 +21,13 @@ import { AComponent } from '../../ui/components/AComponent'
- +

A

- +

B

- +

C

diff --git a/src/app/examples/workspace-localstorage/workspace-localstorage.component.ts b/src/app/examples/workspace-localstorage/workspace-localstorage.component.ts index a8893723..4523b8a0 100644 --- a/src/app/examples/workspace-localstorage/workspace-localstorage.component.ts +++ b/src/app/examples/workspace-localstorage/workspace-localstorage.component.ts @@ -1,14 +1,14 @@ import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core' import { AComponent } from '../../ui/components/AComponent' -import { IAreaSize, IOutputData } from 'angular-split' +import { SplitAreaSize, SplitGutterInteractionEvent } from 'angular-split' interface IConfig { columns: Array<{ visible: boolean - size: IAreaSize + size: SplitAreaSize rows: Array<{ visible: boolean - size: IAreaSize + size: SplitAreaSize type: string }> }> @@ -86,10 +86,10 @@ const defaultConfig: IConfig = { template: ` - + - +
@@ -101,7 +101,7 @@ const defaultConfig: IConfig = {