UNPKG

angular-split

Version:

Angular UI library to split views and allow dragging to resize areas using CSS grid layout.

812 lines (802 loc) 62.6 kB
import * as i0 from '@angular/core'; import { InjectionToken, inject, TemplateRef, Directive, ElementRef, computed, signal, untracked, NgZone, numberAttribute, input, output, ViewContainerRef, effect, Injector, Renderer2, contentChildren, contentChild, booleanAttribute, isDevMode, Component, ChangeDetectionStrategy, HostBinding, NgModule } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { merge, fromEvent, filter, Observable, switchMap, take, map, takeUntil, tap, timeInterval, scan, mergeMap, of, delay, repeat, Subject, startWith, pairwise, skipWhile } from 'rxjs'; import { DOCUMENT, NgStyle, NgTemplateOutlet } from '@angular/common'; const defaultOptions = { dir: 'ltr', direction: 'horizontal', disabled: false, gutterDblClickDuration: 0, gutterSize: 11, gutterStep: 1, gutterClickDeltaPx: 2, restrictMove: false, unit: 'percent', useTransition: false, }; 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 */ function provideAngularSplitOptions(options) { return { provide: ANGULAR_SPLIT_DEFAULT_OPTIONS, useFactory: () => ({ ...inject(ANGULAR_SPLIT_DEFAULT_OPTIONS, { skipSelf: true }), ...options, }), }; } class SplitGutterDirective { constructor() { this.template = inject(TemplateRef); /** * The map holds reference to the drag handle elements inside instances * of the provided template. * * @internal */ this._gutterToHandleElementMap = new Map(); /** * The map holds reference to the excluded drag elements inside instances * of the provided template. * * @internal */ this._gutterToExcludeDragElementMap = new Map(); } /** * @internal */ _canStartDragging(originElement, gutterNum) { if (this._gutterToExcludeDragElementMap.has(gutterNum)) { const isInsideExclude = this._gutterToExcludeDragElementMap .get(gutterNum) .some((gutterExcludeElement) => gutterExcludeElement.nativeElement.contains(originElement)); if (isInsideExclude) { return false; } } if (this._gutterToHandleElementMap.has(gutterNum)) { return this._gutterToHandleElementMap .get(gutterNum) .some((gutterHandleElement) => gutterHandleElement.nativeElement.contains(originElement)); } return true; } /** * @internal */ _addToMap(map, gutterNum, elementRef) { if (map.has(gutterNum)) { map.get(gutterNum).push(elementRef); } else { map.set(gutterNum, [elementRef]); } } /** * @internal */ _removedFromMap(map, gutterNum, elementRef) { const elements = map.get(gutterNum); elements.splice(elements.indexOf(elementRef), 1); if (elements.length === 0) { map.delete(gutterNum); } } static ngTemplateContextGuard(_dir, ctx) { return true; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitGutterDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.5", type: SplitGutterDirective, isStandalone: true, selector: "[asSplitGutter]", ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitGutterDirective, decorators: [{ type: Directive, args: [{ selector: '[asSplitGutter]', standalone: true, }] }] }); /** * Identifies the gutter by number through DI * to allow SplitGutterDragHandleDirective and SplitGutterExcludeFromDragDirective to know * the gutter template context without inputs */ const GUTTER_NUM_TOKEN = new InjectionToken('Gutter num'); class SplitGutterDragHandleDirective { constructor() { this.gutterNum = inject(GUTTER_NUM_TOKEN); this.elementRef = inject(ElementRef); this.gutterDir = inject(SplitGutterDirective); this.gutterDir._addToMap(this.gutterDir._gutterToHandleElementMap, this.gutterNum, this.elementRef); } ngOnDestroy() { this.gutterDir._removedFromMap(this.gutterDir._gutterToHandleElementMap, this.gutterNum, this.elementRef); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitGutterDragHandleDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.5", type: SplitGutterDragHandleDirective, isStandalone: true, selector: "[asSplitGutterDragHandle]", ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitGutterDragHandleDirective, decorators: [{ type: Directive, args: [{ selector: '[asSplitGutterDragHandle]', standalone: true, }] }], ctorParameters: () => [] }); class SplitGutterExcludeFromDragDirective { constructor() { this.gutterNum = inject(GUTTER_NUM_TOKEN); this.elementRef = inject(ElementRef); this.gutterDir = inject(SplitGutterDirective); this.gutterDir._addToMap(this.gutterDir._gutterToExcludeDragElementMap, this.gutterNum, this.elementRef); } ngOnDestroy() { this.gutterDir._removedFromMap(this.gutterDir._gutterToExcludeDragElementMap, this.gutterNum, this.elementRef); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitGutterExcludeFromDragDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.5", type: SplitGutterExcludeFromDragDirective, isStandalone: true, selector: "[asSplitGutterExcludeFromDrag]", ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitGutterExcludeFromDragDirective, decorators: [{ type: Directive, args: [{ selector: '[asSplitGutterExcludeFromDrag]', standalone: true, }] }], ctorParameters: () => [] }); /** * Only supporting a single {@link TouchEvent} point */ function getPointFromEvent(event) { // NOTE: In firefox TouchEvent is only defined for touch capable devices const isTouchEvent = (e) => window.TouchEvent && event instanceof TouchEvent; if (isTouchEvent(event)) { if (event.changedTouches.length === 0) { return undefined; } const { clientX, clientY } = event.changedTouches[0]; return { x: clientX, y: clientY, }; } if (event instanceof KeyboardEvent) { const target = event.target; // Calculate element midpoint return { x: target.offsetLeft + target.offsetWidth / 2, y: target.offsetTop + target.offsetHeight / 2, }; } return { x: event.clientX, y: event.clientY, }; } function gutterEventsEqualWithDelta(startEvent, endEvent, deltaInPx, gutterElement) { if (!gutterElement.contains(startEvent.target) || !gutterElement.contains(endEvent.target)) { return false; } const startPoint = getPointFromEvent(startEvent); const endPoint = getPointFromEvent(endEvent); return Math.abs(endPoint.x - startPoint.x) <= deltaInPx && Math.abs(endPoint.y - startPoint.y) <= deltaInPx; } function fromMouseDownEvent(target) { 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 })); } function fromMouseMoveEvent(target) { return merge(fromEvent(target, 'mousemove'), fromEvent(target, 'touchmove')); } function fromMouseUpEvent(target, includeTouchCancel = false) { const withoutTouchCancel = merge(fromEvent(target, 'mouseup'), fromEvent(target, 'touchend')); return includeTouchCancel ? merge(withoutTouchCancel, fromEvent(target, 'touchcancel')) : withoutTouchCancel; } function sum(array, fn) { return array.reduce((sum, item) => sum + fn(item), 0); } function toRecord(array, fn) { return array.reduce((record, item, index) => { const [key, value] = fn(item, index); record[key] = value; return record; }, {}); } function createClassesString(classesRecord) { return Object.entries(classesRecord) .filter(([, value]) => value) .map(([key]) => key) .join(' '); } /** * 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. */ function mirrorSignal(outer) { const inner = computed(() => signal(outer())); const mirror = () => inner()(); mirror.set = (value) => untracked(inner).set(value); mirror.reset = () => untracked(() => inner().set(outer())); return mirror; } function leaveNgZone() { return (source) => new Observable((observer) => inject(NgZone).runOutsideAngular(() => source.subscribe(observer))); } const numberAttributeWithFallback = (fallback) => (value) => numberAttribute(value, fallback); const assertUnreachable = (value, name) => { throw new Error(`as-split: unknown value "${value}" for "${name}"`); }; /* eslint-disable @angular-eslint/no-output-native */ /* eslint-disable @angular-eslint/no-output-rename */ /* eslint-disable @angular-eslint/no-input-rename */ /** * 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 */ class SplitCustomEventsBehaviorDirective { constructor() { this.elementRef = inject(ElementRef); this.document = inject(DOCUMENT); this.multiClickThreshold = input.required({ alias: 'asSplitCustomMultiClickThreshold' }); this.deltaInPx = input.required({ alias: 'asSplitCustomClickDeltaInPx' }); this.mouseDown = output({ alias: 'asSplitCustomMouseDown' }); this.click = output({ alias: 'asSplitCustomClick' }); this.dblClick = output({ alias: 'asSplitCustomDblClick' }); this.keyDown = output({ alias: 'asSplitCustomKeyDown' }); 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(); } }); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitCustomEventsBehaviorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.0.5", type: SplitCustomEventsBehaviorDirective, isStandalone: true, selector: "[asSplitCustomEventsBehavior]", inputs: { multiClickThreshold: { classPropertyName: "multiClickThreshold", publicName: "asSplitCustomMultiClickThreshold", isSignal: true, isRequired: true, transformFunction: null }, deltaInPx: { classPropertyName: "deltaInPx", publicName: "asSplitCustomClickDeltaInPx", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { mouseDown: "asSplitCustomMouseDown", click: "asSplitCustomClick", dblClick: "asSplitCustomDblClick", keyDown: "asSplitCustomKeyDown" }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitCustomEventsBehaviorDirective, decorators: [{ type: Directive, args: [{ selector: '[asSplitCustomEventsBehavior]', standalone: true, }] }], ctorParameters: () => [] }); function areAreasValid(areas, unit, logWarnings) { if (areas.length === 0) { return true; } const areaSizes = areas.map((area) => { const size = area.size(); return size === 'auto' ? '*' : size; }); const wildcardAreas = areaSizes.filter((areaSize) => areaSize === '*'); if (wildcardAreas.length > 1) { if (logWarnings) { console.warn('as-split: Maximum one * area is allowed'); } return false; } if (unit === 'pixel') { if (wildcardAreas.length === 1) { return true; } if (logWarnings) { console.warn('as-split: Pixel mode must have exactly one * area'); } return false; } const sumPercent = sum(areaSizes, (areaSize) => (areaSize === '*' ? 0 : areaSize)); // 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 (logWarnings) { console.warn(`as-split: Percent areas must total 100%`); } return false; } if (sumPercent < 99.9 || sumPercent > 100.1) { if (logWarnings) { console.warn('as-split: Percent areas must total 100%'); } return false; } return true; } /** * This directive allows creating a dynamic injector inside ngFor * with dynamic gutter num and expose the injector for ngTemplateOutlet usage */ class SplitGutterDynamicInjectorDirective { constructor() { this.vcr = inject(ViewContainerRef); this.templateRef = inject(TemplateRef); this.gutterNum = input.required({ alias: 'asSplitGutterDynamicInjector' }); effect(() => { this.vcr.clear(); const injector = Injector.create({ providers: [ { provide: GUTTER_NUM_TOKEN, useValue: this.gutterNum(), }, ], parent: this.vcr.injector, }); this.vcr.createEmbeddedView(this.templateRef, { $implicit: injector }); }); } static ngTemplateContextGuard(_dir, ctx) { return true; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitGutterDynamicInjectorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.0.5", type: SplitGutterDynamicInjectorDirective, isStandalone: true, selector: "[asSplitGutterDynamicInjector]", inputs: { gutterNum: { classPropertyName: "gutterNum", publicName: "asSplitGutterDynamicInjector", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitGutterDynamicInjectorDirective, decorators: [{ type: Directive, args: [{ selector: '[asSplitGutterDynamicInjector]', standalone: true, }] }], ctorParameters: () => [] }); const SPLIT_AREA_CONTRACT = new InjectionToken('Split Area Contract'); class SplitComponent { get hostClassesBinding() { return this.hostClasses(); } get hostDirBinding() { return this.dir(); } constructor() { this.document = inject(DOCUMENT); this.renderer = inject(Renderer2); this.elementRef = inject(ElementRef); this.ngZone = inject(NgZone); this.defaultOptions = inject(ANGULAR_SPLIT_DEFAULT_OPTIONS); this.gutterMouseDownSubject = new Subject(); this.dragProgressSubject = new Subject(); /** * @internal */ this._areas = contentChildren(SPLIT_AREA_CONTRACT); this.customGutter = contentChild(SplitGutterDirective); this.gutterSize = input(this.defaultOptions.gutterSize, { transform: numberAttributeWithFallback(this.defaultOptions.gutterSize), }); this.gutterStep = input(this.defaultOptions.gutterStep, { transform: numberAttributeWithFallback(this.defaultOptions.gutterStep), }); this.disabled = input(this.defaultOptions.disabled, { transform: booleanAttribute }); this.gutterClickDeltaPx = input(this.defaultOptions.gutterClickDeltaPx, { transform: numberAttributeWithFallback(this.defaultOptions.gutterClickDeltaPx), }); this.direction = input(this.defaultOptions.direction); this.dir = input(this.defaultOptions.dir); this.unit = input(this.defaultOptions.unit); this.gutterAriaLabel = input(); this.restrictMove = input(this.defaultOptions.restrictMove, { transform: booleanAttribute }); this.useTransition = input(this.defaultOptions.useTransition, { transform: booleanAttribute }); this.gutterDblClickDuration = input(this.defaultOptions.gutterDblClickDuration, { transform: numberAttributeWithFallback(this.defaultOptions.gutterDblClickDuration), }); this.gutterClick = output(); this.gutterDblClick = output(); this.dragStart = output(); this.dragEnd = output(); this.transitionEnd = output(); this.dragProgress$ = this.dragProgressSubject.asObservable(); /** * @internal */ this._visibleAreas = computed(() => this._areas().filter((area) => area.visible())); this.gridTemplateColumnsStyle = computed(() => this.createGridTemplateColumnsStyle()); this.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(), })); this.draggedGutterIndex = signal(undefined); /** * @internal */ this._isDragging = computed(() => this.draggedGutterIndex() !== undefined); /** * @internal * Should only be used by {@link SplitAreaComponent._internalSize} */ this._alignedVisibleAreasSizes = computed(() => this.createAlignedVisibleAreasSize()); if (isDevMode()) { // Logs warnings to console when the provided areas sizes are invalid effect(() => { // Special mode when no size input was declared which is a valid mode if (this.unit() === 'percent' && this._visibleAreas().every((area) => area.size() === 'auto')) { return; } areAreasValid(this._visibleAreas(), this.unit(), 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, 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()))); } gutterClicked(gutterIndex) { this.ngZone.run(() => this.gutterClick.emit(this.createDragInteractionEvent(gutterIndex))); } gutterDoubleClicked(gutterIndex) { this.ngZone.run(() => this.gutterDblClick.emit(this.createDragInteractionEvent(gutterIndex))); } gutterMouseDown(e, gutterElement, gutterIndex, areaBeforeGutterIndex, areaAfterGutterIndex) { if (this.disabled()) { return; } e.preventDefault(); e.stopPropagation(); this.gutterMouseDownSubject.next({ mouseDownEvent: e, gutterElement, gutterIndex, areaBeforeGutterIndex, areaAfterGutterIndex, }); } gutterKeyDown(e, gutterIndex, areaBeforeGutterIndex, areaAfterGutterIndex) { 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.ngZone.run(() => { this.dragEnd.emit(this.createDragInteractionEvent(gutterIndex)); this.draggedGutterIndex.set(undefined); }); } getGutterGridStyle(nextAreaIndex) { const gutterNum = nextAreaIndex * 2; const style = `${gutterNum} / ${gutterNum}`; return { ['grid-column']: this.direction() === 'horizontal' ? style : '1', ['grid-row']: this.direction() === 'vertical' ? style : '1', }; } getAriaAreaSizeText(area) { const size = area._internalSize(); if (size === '*') { return undefined; } return `${size.toFixed(0)} ${this.unit()}`; } getAriaValue(size) { return size === '*' ? undefined : size; } createDragInteractionEvent(gutterIndex) { return { gutterNum: gutterIndex + 1, sizes: this.createAreaSizes(), }; } createAreaSizes() { return this._visibleAreas().map((area) => area._internalSize()); } createDragStartContext(startEvent, areaBeforeGutterIndex, areaAfterGutterIndex) { 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) => (percent / 100) * totalAreasPixelSize; const value = this.unit() === 'pixel' ? { min: area._normalizedMinSize(), max: area._normalizedMaxSize(), } : { min: percentToPixels(area._normalizedMinSize()), max: percentToPixels(area._normalizedMaxSize()), }; return [index.toString(), value]; }), }; } mouseDragMove(moveEvent, dragStartContext) { moveEvent.preventDefault(); moveEvent.stopPropagation(); const endPoint = getPointFromEvent(moveEvent); this.dragMoveToPoint(endPoint, dragStartContext); } dragMoveToPoint(endPoint, 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. const areasIndices = tempAreasPixelSizes.map((_, index) => index); // The two variables below are ordered for iterations with real area indices inside. // We must also remove the invisible ones as we can't expand or shrink them. const areasIndicesBeforeGutter = this.restrictMove() ? [dragStartContext.areaBeforeGutterIndex] : areasIndices .slice(0, dragStartContext.areaBeforeGutterIndex + 1) .filter((index) => this._areas()[index].visible()) .reverse(); const areasIndicesAfterGutter = this.restrictMove() ? [dragStartContext.areaAfterGutterIndex] : areasIndices.slice(dragStartContext.areaAfterGutterIndex).filter((index) => this._areas()[index].visible()); // 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())); } createGridTemplateColumnsStyle() { const columns = []; 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) => { const unit = this.unit(); const areaSize = area._internalSize(); // Add area size column if (!area.visible()) { columns.push(unit === 'percent' || areaSize === '*' ? '0fr' : '0px'); } else { 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`; } createAlignedVisibleAreasSize() { const visibleAreasSizes = this._visibleAreas().map((area) => { const size = area.size(); return size === 'auto' ? '*' : size; }); const isValid = areAreasValid(this._visibleAreas(), this.unit(), false); if (isValid) { return visibleAreasSizes; } const unit = this.unit(); if (unit === 'percent') { // Distribute sizes equally const defaultPercentSize = 100 / visibleAreasSizes.length; return visibleAreasSizes.map(() => defaultPercentSize); } if (unit === 'pixel') { // Make sure only one wildcard area const wildcardAreas = visibleAreasSizes.filter((areaSize) => areaSize === '*'); if (wildcardAreas.length === 0) { return ['*', ...visibleAreasSizes.slice(1)]; } else { const firstWildcardIndex = visibleAreasSizes.findIndex((areaSize) => areaSize === '*'); const defaultPxSize = 100; return visibleAreasSizes.map((areaSize, index) => index === firstWildcardIndex || areaSize !== '*' ? areaSize : defaultPxSize); } } return assertUnreachable(unit, 'SplitUnit'); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.0.5", type: SplitComponent, isStandalone: true, selector: "as-split", inputs: { gutterSize: { classPropertyName: "gutterSize", publicName: "gutterSize", isSignal: true, isRequired: false, transformFunction: null }, gutterStep: { classPropertyName: "gutterStep", publicName: "gutterStep", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, gutterClickDeltaPx: { classPropertyName: "gutterClickDeltaPx", publicName: "gutterClickDeltaPx", isSignal: true, isRequired: false, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, dir: { classPropertyName: "dir", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null }, unit: { classPropertyName: "unit", publicName: "unit", isSignal: true, isRequired: false, transformFunction: null }, gutterAriaLabel: { classPropertyName: "gutterAriaLabel", publicName: "gutterAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, restrictMove: { classPropertyName: "restrictMove", publicName: "restrictMove", isSignal: true, isRequired: false, transformFunction: null }, useTransition: { classPropertyName: "useTransition", publicName: "useTransition", isSignal: true, isRequired: false, transformFunction: null }, gutterDblClickDuration: { classPropertyName: "gutterDblClickDuration", publicName: "gutterDblClickDuration", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { gutterClick: "gutterClick", gutterDblClick: "gutterDblClick", dragStart: "dragStart", dragEnd: "dragEnd", transitionEnd: "transitionEnd" }, host: { properties: { "class": "this.hostClassesBinding", "dir": "this.hostDirBinding" } }, queries: [{ propertyName: "_areas", predicate: SPLIT_AREA_CONTRACT, isSignal: true }, { propertyName: "customGutter", first: true, predicate: SplitGutterDirective, descendants: true, isSignal: true }], exportAs: ["asSplit"], ngImport: i0, template: "<ng-content></ng-content>\n@for (area of _areas(); track area) {\n @if (!$last) {\n <div\n #gutter\n class=\"as-split-gutter\"\n role=\"separator\"\n tabindex=\"0\"\n [attr.aria-label]=\"gutterAriaLabel()\"\n [attr.aria-orientation]=\"direction()\"\n [attr.aria-valuemin]=\"getAriaValue(area.minSize())\"\n [attr.aria-valuemax]=\"getAriaValue(area.maxSize())\"\n [attr.aria-valuenow]=\"getAriaValue(area._internalSize())\"\n [attr.aria-valuetext]=\"getAriaAreaSizeText(area)\"\n [ngStyle]=\"getGutterGridStyle($index + 1)\"\n [class.as-dragged]=\"draggedGutterIndex() === $index\"\n asSplitCustomEventsBehavior\n [asSplitCustomMultiClickThreshold]=\"gutterDblClickDuration()\"\n [asSplitCustomClickDeltaInPx]=\"gutterClickDeltaPx()\"\n (asSplitCustomClick)=\"gutterClicked($index)\"\n (asSplitCustomDblClick)=\"gutterDoubleClicked($index)\"\n (asSplitCustomMouseDown)=\"gutterMouseDown($event, gutter, $index, $index, $index + 1)\"\n (asSplitCustomKeyDown)=\"gutterKeyDown($event, $index, $index, $index + 1)\"\n >\n @if (customGutter()?.template) {\n <ng-container *asSplitGutterDynamicInjector=\"$index + 1; let injector\">\n <ng-container\n *ngTemplateOutlet=\"\n customGutter().template;\n context: {\n areaBefore: area,\n areaAfter: _areas()[$index + 1],\n gutterNum: $index + 1,\n first: $first,\n last: $index === _areas().length - 2,\n isDragged: draggedGutterIndex() === $index\n };\n injector: injector\n \"\n ></ng-container>\n </ng-container>\n } @else {\n <div class=\"as-split-gutter-icon\"></div>\n }\n </div>\n }\n}\n", styles: ["@property --as-gutter-background-color{syntax: \"<color>\"; inherits: true; initial-value: #eeeeee;}@property --as-gutter-icon-horizontal{syntax: \"<url>\"; inherits: true; initial-value: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==);}@property --as-gutter-icon-vertical{syntax: \"<url>\"; inherits: true; initial-value: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFCAMAAABl/6zIAAAABlBMVEUAAADMzMzIT8AyAAAAAXRSTlMAQObYZgAAABRJREFUeAFjYGRkwIMJSeMHlBkOABP7AEGzSuPKAAAAAElFTkSuQmCC);}@property --as-gutter-icon-disabled{syntax: \"<url>\"; inherits: true; initial-value: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==);}@property --as-transition-duration{syntax: \"<time>\"; inherits: true; initial-value: .3s;}@property --as-gutter-disabled-cursor{syntax: \"*\"; inherits: true; initial-value: default;}: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, .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)>.as-split-gutter{cursor:col-resize;height:100%}:host(.as-vertical)>.as-split-gutter{cursor:row-resize;width:100%}:host(.as-disabled)>.as-split-gutter{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>.as-split-gutter-icon{background-image:var(--_as-gutter-icon-horizontal)}:host(.as-vertical)>.as-split-gutter>.as-split-gutter-icon{background-image:var(--_as-gutter-icon-vertical)}:host(.as-disabled)>.as-split-gutter>.as-split-gutter-icon{background-image:var(--_as-gutter-icon-disabled)}\n"], dependencies: [{ kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: SplitCustomEventsBehaviorDirective, selector: "[asSplitCustomEventsBehavior]", inputs: ["asSplitCustomMultiClickThreshold", "asSplitCustomClickDeltaInPx"], outputs: ["asSplitCustomMouseDown", "asSplitCustomClick", "asSplitCustomDblClick", "asSplitCustomKeyDown"] }, { kind: "directive", type: SplitGutterDynamicInjectorDirective, selector: "[asSplitGutterDynamicInjector]", inputs: ["asSplitGutterDynamicInjector"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SplitComponent, decorators: [{ type: Component, args: [{ selector: 'as-split', imports: [NgStyle, SplitCustomEventsBehaviorDirective, SplitGutterDynamicInjectorDirective, NgTemplateOutlet], exportAs: 'asSplit', changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-content></ng-content>\n@for (area of _areas(); track area) {\n @if (!$last) {\n <div\n #gutter\n class=\"as-split-gutter\"\n role=\"separator\"\n tabindex=\"0\"\n [attr.aria-label]=\"gutterAriaLabel()\"\n [attr.aria-orientation]=\"direction()\"\n [attr.aria-valuemin]=\"getAriaValue(area.minSize())\"\n [attr.aria-valuemax]=\"getAriaValue(area.maxSize())\"\n [attr.aria-valuenow]=\"getAriaValue(area._internalSize())\"\n [attr.aria-valuetext]=\"getAriaAreaSizeText(area)\"\n [ngStyle]=\"getGutterGridStyle($index + 1)\"\n [class.as-dragged]=\"draggedGutterIndex() === $index\"\n asSplitCustomEventsBehavior\n [asSplitCustomMultiClickThreshold]=\"gutterDblClickDuration()\"\n [asSplitCustomClickDeltaInPx]=\"gutterClickDeltaPx()\"\n (asSplitCustomClick)=\"gutterClicked($index)\"\n (asSplitCustomDblClick)=\"gutterDoubleClicked($index)\"\n (asSplitCustomMouseDown)=\"gutterMouseDown($event, gutter, $index, $index, $index + 1)\"\n (asSplitCustomKeyDown)=\"gutterKeyDown($event, $index, $index, $index + 1)\"\n >\n @if (customGutter()?.template) {\n <ng-container *asSplitGutterDynamicInjector=\"$index + 1; let injector\">\n <ng-container\n *ngTemplateOutlet=\"\n customGutter().template;\n context: {\n areaBefore: area,\n areaAfter: _areas()[$index + 1],\n gutterNum: $index + 1,\n first: $first,\n last: $index === _areas().length - 2,\n isDragged: draggedGutterIndex() === $index\n };\n injector: injector\n \"\n ></ng-container>\n </ng-container>\n } @else {\n <div class=\"as-split-gutter-icon\"></div>\n }\n </div>\n }\n}\n", styles: ["@property --as-gutter-background-color{syntax: \"<color>\"; inherits: true; initial-value: #eeeeee;}@property --as-gutter-icon-horizontal{syntax: \"<url>\"; inherits: true; initial-value: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==);