mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: add resizable directive and styles for dynamic element resizing
This commit is contained in:
1 parent
93e1d098d5
commit
6c52ee349d
3 files changed
+482
No files matched your search
@@ -19,3 +19,4 @@ export * from './lib/recent-playlists/recent-playlists.component';
|
||||
export * from './lib/video-player/sidebar/sidebar.component';
|
||||
export * from './lib/video-player/toolbar/toolbar.component';
|
||||
export * from './lib/vjs-player/vjs-player.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
Renderer2,
|
||||
input,
|
||||
output,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
effect,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
} from '@angular/core';
|
||||
|
||||
/**
|
||||
* ResizableDirective - Makes an element horizontally resizable by dragging its edge.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <div appResizable [minWidth]="200" [maxWidth]="600" [storageKey]="'sidebar-width'">
|
||||
* Content here
|
||||
* </div>
|
||||
* ```
|
||||
*
|
||||
* Features:
|
||||
* - Smooth drag handle with visual feedback
|
||||
* - Persists width to localStorage
|
||||
* - Respects min/max width constraints
|
||||
* - Emits width changes for reactive updates
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
|
||||
/** Minimum width in pixels */
|
||||
readonly minWidth = input<number>(200);
|
||||
|
||||
/** Maximum width in pixels */
|
||||
readonly maxWidth = input<number>(600);
|
||||
|
||||
/** LocalStorage key for persisting width. If not set, width won't be persisted. */
|
||||
readonly storageKey = input<string>('');
|
||||
|
||||
/** Default width to use when no stored value exists */
|
||||
readonly defaultWidth = input<number>(400);
|
||||
|
||||
/** Position of the resize handle: 'right' or 'left' */
|
||||
readonly handlePosition = input<'right' | 'left'>('right');
|
||||
|
||||
/** Emits when width changes during resize */
|
||||
readonly widthChange = output<number>();
|
||||
|
||||
/** Emits when resize starts */
|
||||
readonly resizeStart = output<void>();
|
||||
|
||||
/** Emits when resize ends */
|
||||
readonly resizeEnd = output<number>();
|
||||
|
||||
private handleElement: HTMLElement | null = null;
|
||||
private isResizing = signal(false);
|
||||
private startX = 0;
|
||||
private startWidth = 0;
|
||||
private currentWidth = signal(0);
|
||||
|
||||
// Event listener references for cleanup
|
||||
private boundMouseMove: ((e: MouseEvent) => void) | null = null;
|
||||
private boundMouseUp: ((e: MouseEvent) => void) | null = null;
|
||||
private boundTouchMove: ((e: TouchEvent) => void) | null = null;
|
||||
private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly el: ElementRef<HTMLElement>,
|
||||
private readonly renderer: Renderer2
|
||||
) {
|
||||
// Apply resizing class when dragging
|
||||
effect(() => {
|
||||
if (this.isResizing()) {
|
||||
this.renderer.addClass(document.body, 'resizing-active');
|
||||
this.renderer.addClass(this.el.nativeElement, 'is-resizing');
|
||||
} else {
|
||||
this.renderer.removeClass(document.body, 'resizing-active');
|
||||
this.renderer.removeClass(this.el.nativeElement, 'is-resizing');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load persisted width FIRST to avoid flash of default width
|
||||
this.loadPersistedWidth();
|
||||
this.setupStyles();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.createResizeHandle();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.removeGlobalListeners();
|
||||
if (this.handleElement) {
|
||||
this.handleElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private setupStyles(): void {
|
||||
const el = this.el.nativeElement;
|
||||
|
||||
// Ensure the element has position relative for handle positioning
|
||||
const computedStyle = window.getComputedStyle(el);
|
||||
if (computedStyle.position === 'static') {
|
||||
this.renderer.setStyle(el, 'position', 'relative');
|
||||
}
|
||||
|
||||
// Add transition for smooth width changes (only when not dragging)
|
||||
this.renderer.addClass(el, 'resizable-element');
|
||||
}
|
||||
|
||||
private createResizeHandle(): void {
|
||||
const handle = this.renderer.createElement('div');
|
||||
this.handleElement = handle;
|
||||
|
||||
// Set up handle classes based on position
|
||||
this.renderer.addClass(handle, 'resize-handle');
|
||||
this.renderer.addClass(
|
||||
handle,
|
||||
`resize-handle--${this.handlePosition()}`
|
||||
);
|
||||
|
||||
// Create inner visual elements for the handle
|
||||
const handleLine = this.renderer.createElement('div');
|
||||
this.renderer.addClass(handleLine, 'resize-handle__line');
|
||||
this.renderer.appendChild(handle, handleLine);
|
||||
|
||||
// Create grip dots
|
||||
const gripDots = this.renderer.createElement('div');
|
||||
this.renderer.addClass(gripDots, 'resize-handle__grip');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dot = this.renderer.createElement('span');
|
||||
this.renderer.appendChild(gripDots, dot);
|
||||
}
|
||||
this.renderer.appendChild(handle, gripDots);
|
||||
|
||||
// Mouse events
|
||||
this.renderer.listen(handle, 'mousedown', (e: MouseEvent) =>
|
||||
this.onMouseDown(e)
|
||||
);
|
||||
|
||||
// Touch events for mobile support
|
||||
this.renderer.listen(handle, 'touchstart', (e: TouchEvent) =>
|
||||
this.onTouchStart(e)
|
||||
);
|
||||
|
||||
// Append handle to element
|
||||
this.renderer.appendChild(this.el.nativeElement, handle);
|
||||
}
|
||||
|
||||
private loadPersistedWidth(): void {
|
||||
const key = this.storageKey();
|
||||
let widthToApply = this.defaultWidth();
|
||||
|
||||
if (key) {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const width = parseInt(stored, 10);
|
||||
if (!isNaN(width)) {
|
||||
widthToApply = this.clampWidth(width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply width immediately without transition to avoid flash
|
||||
this.setWidthImmediate(widthToApply);
|
||||
}
|
||||
|
||||
/** Set width immediately without CSS transition */
|
||||
private setWidthImmediate(width: number): void {
|
||||
this.currentWidth.set(width);
|
||||
// Temporarily disable transition for immediate application
|
||||
this.renderer.setStyle(this.el.nativeElement, 'transition', 'none');
|
||||
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
|
||||
|
||||
// Re-enable transition after a frame
|
||||
requestAnimationFrame(() => {
|
||||
this.renderer.removeStyle(this.el.nativeElement, 'transition');
|
||||
});
|
||||
}
|
||||
|
||||
private onMouseDown(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.startResize(e.clientX);
|
||||
|
||||
this.boundMouseMove = (event: MouseEvent) => this.onMouseMove(event);
|
||||
this.boundMouseUp = (event: MouseEvent) => this.onMouseUp(event);
|
||||
|
||||
document.addEventListener('mousemove', this.boundMouseMove);
|
||||
document.addEventListener('mouseup', this.boundMouseUp);
|
||||
}
|
||||
|
||||
private onTouchStart(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.startResize(e.touches[0].clientX);
|
||||
|
||||
this.boundTouchMove = (event: TouchEvent) => this.onTouchMove(event);
|
||||
this.boundTouchEnd = (event: TouchEvent) => this.onTouchEnd(event);
|
||||
|
||||
document.addEventListener('touchmove', this.boundTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener('touchend', this.boundTouchEnd);
|
||||
}
|
||||
|
||||
private startResize(clientX: number): void {
|
||||
this.isResizing.set(true);
|
||||
this.startX = clientX;
|
||||
this.startWidth = this.el.nativeElement.offsetWidth;
|
||||
this.resizeStart.emit();
|
||||
}
|
||||
|
||||
private onMouseMove(e: MouseEvent): void {
|
||||
if (!this.isResizing()) return;
|
||||
this.resize(e.clientX);
|
||||
}
|
||||
|
||||
private onTouchMove(e: TouchEvent): void {
|
||||
if (!this.isResizing() || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
this.resize(e.touches[0].clientX);
|
||||
}
|
||||
|
||||
private resize(clientX: number): void {
|
||||
const delta =
|
||||
this.handlePosition() === 'right'
|
||||
? clientX - this.startX
|
||||
: this.startX - clientX;
|
||||
|
||||
const newWidth = this.clampWidth(this.startWidth + delta);
|
||||
this.setWidth(newWidth);
|
||||
this.widthChange.emit(newWidth);
|
||||
}
|
||||
|
||||
private onMouseUp(_e: MouseEvent): void {
|
||||
this.endResize();
|
||||
}
|
||||
|
||||
private onTouchEnd(_e: TouchEvent): void {
|
||||
this.endResize();
|
||||
}
|
||||
|
||||
private endResize(): void {
|
||||
if (!this.isResizing()) return;
|
||||
|
||||
this.isResizing.set(false);
|
||||
this.removeGlobalListeners();
|
||||
this.persistWidth();
|
||||
this.resizeEnd.emit(this.currentWidth());
|
||||
}
|
||||
|
||||
private removeGlobalListeners(): void {
|
||||
if (this.boundMouseMove) {
|
||||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||||
this.boundMouseMove = null;
|
||||
}
|
||||
if (this.boundMouseUp) {
|
||||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||||
this.boundMouseUp = null;
|
||||
}
|
||||
if (this.boundTouchMove) {
|
||||
document.removeEventListener('touchmove', this.boundTouchMove);
|
||||
this.boundTouchMove = null;
|
||||
}
|
||||
if (this.boundTouchEnd) {
|
||||
document.removeEventListener('touchend', this.boundTouchEnd);
|
||||
this.boundTouchEnd = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clampWidth(width: number): number {
|
||||
return Math.max(this.minWidth(), Math.min(this.maxWidth(), width));
|
||||
}
|
||||
|
||||
private setWidth(width: number): void {
|
||||
this.currentWidth.set(width);
|
||||
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
|
||||
}
|
||||
|
||||
private persistWidth(): void {
|
||||
const key = this.storageKey();
|
||||
if (key) {
|
||||
localStorage.setItem(key, this.currentWidth().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Resizable Directive Styles
|
||||
*
|
||||
* A refined, subtle resize handle that provides clear affordance
|
||||
* without being visually intrusive. Uses a vertical grip pattern
|
||||
* that becomes more prominent on hover/active states.
|
||||
*/
|
||||
|
||||
// Prevent text selection during resize
|
||||
body.resizing-active {
|
||||
user-select: none !important;
|
||||
cursor: col-resize !important;
|
||||
|
||||
* {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Base element styles
|
||||
.resizable-element {
|
||||
// Smooth transition when not actively resizing
|
||||
&:not(.is-resizing) {
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
|
||||
&.is-resizing {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize handle base styles
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
z-index: 100;
|
||||
cursor: col-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// Visual feedback on hover/active
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
&:active::before {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
// Position variants
|
||||
&--right {
|
||||
right: -6px;
|
||||
|
||||
&::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: -6px;
|
||||
|
||||
&::before {
|
||||
right: 50%;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
}
|
||||
|
||||
// The main visible line
|
||||
&__line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover &__line {
|
||||
width: 2px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active &__line {
|
||||
width: 2px;
|
||||
background: var(--mdc-theme-primary, #6366f1);
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
// Grip dots indicator
|
||||
&__grip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 1;
|
||||
|
||||
span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__grip {
|
||||
opacity: 1;
|
||||
|
||||
span {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
&:active &__grip {
|
||||
opacity: 1;
|
||||
|
||||
span {
|
||||
background: var(--mdc-theme-primary, #6366f1);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme adjustments (if needed)
|
||||
.light-theme {
|
||||
.resize-handle {
|
||||
&::before {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
&:active::before {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&__line {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&:hover &__line {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:active &__line {
|
||||
background: var(--mdc-theme-primary, #6366f1);
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
&__grip {
|
||||
span {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__grip {
|
||||
span {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user