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
JavaScript
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==);