UNPKG

ng-cw-v12

Version:

Angular UI Component Library

445 lines (440 loc) 21 kB
import * as i0 from '@angular/core'; import { EventEmitter, Directive, Input, Output, NgModule } from '@angular/core'; class ElectricBorderDirective { constructor(el, renderer, ngZone) { this.el = el; this.renderer = renderer; this.ngZone = ngZone; /** 边框圆角半径 单位px */ this.ncBorderRadius = 24; /** 边框线宽 单位px */ this.ncBorderWidth = 2; /** 电弧宽度 单位px */ this.ncElectricWidth = 1; /** 颜色 */ this.ncColor = '#5227FF'; /** 动画速度倍率 */ this.ncSpeed = 1; /** 扰动强度(振幅) */ this.ncChaos = 0.12; /** 停止动画时间,ms,0表示不停止 */ this.ncStopTime = 0; /** 是否启用动画 */ this._enabled = true; /** 双向绑定输出事件 */ this.ncEnabledChange = new EventEmitter(); // 动画状态 this.animationId = null; this.time = 0; this.lastFrameTime = 0; this.isInitialized = false; } set ncEnabled(val) { this._enabled = val !== null && val !== undefined && val !== false && val !== 'false'; } get ncEnabled() { return this._enabled; } ngOnInit() { this.setupHost(); this.createDom(); this.injectStyles(); this.isInitialized = true; if (this.ncEnabled) { this.startAnimation(); } } ngOnChanges(changes) { if (!this.isInitialized) return; // ncColor / ncSpeed / ncChaos / ncElectricWidth / ncBorderRadius 在 drawFrame() 中每帧直接读取,变更自动生效,无需处理。 // ncColor 除 canvas strokeStyle 外还驱动三个 glow DOM 元素,需显式更新。 if (changes['ncColor']) { this.applyColorVar(); } // ncBorderRadius 除 canvas路径圆角 外还影响宿主元素 CSS border-radius(glow 层用 inherit 会跟随),需显式更新。 if (changes['ncBorderRadius']) { this.applyBorderRadius(); } // ncBorderWidth 影响 glow 层 border 宽度(需显式更新) if (changes['ncBorderWidth']) { this.applyBorderWidth(); } // ncEnabled 变化时启停动画 if (changes['ncEnabled']) { if (this.ncEnabled) { this.startAnimation(); } else { this.stopAnimation(); } } } ngOnDestroy() { this.stopAnimation(); if (this.resizeObserver) { this.resizeObserver.disconnect(); } if (this.stopTimer) { clearTimeout(this.stopTimer); } } // ─── 初始化 ───────────────────────────────────────────────────────────────── setupHost() { const host = this.el.nativeElement; const pos = getComputedStyle(host).position; if (!pos || pos === 'static') { this.renderer.setStyle(host, 'position', 'relative'); } this.renderer.setStyle(host, 'isolation', 'isolate'); this.renderer.setStyle(host, 'overflow', 'visible'); this.applyColorVar(); this.applyBorderRadius(); } applyBorderRadius() { this.renderer.setStyle(this.el.nativeElement, 'border-radius', `${this.ncBorderRadius}px`); } applyBorderWidth() { if (this.glow1El) { this.renderer.setStyle(this.glow1El, 'border-width', `${this.ncBorderWidth}px`); } if (this.glow2El) { this.renderer.setStyle(this.glow2El, 'border-width', `${this.ncBorderWidth}px`); } } applyColorVar() { this.renderer.setStyle(this.el.nativeElement, '--electric-border-color', this.ncColor); // 同步更新已存在的 glow 层颜色(ngOnChanges 时也能生效) if (this.glow1El) { this.renderer.setStyle(this.glow1El, 'border-color', `oklch(from ${this.ncColor} l c h / 0.6)`); } if (this.glow2El) { this.renderer.setStyle(this.glow2El, 'border-color', this.ncColor); } if (this.bgGlowEl) { this.renderer.setStyle(this.bgGlowEl, 'background', `linear-gradient(-30deg, ${this.ncColor}, transparent, ${this.ncColor})`); } } createDom() { const host = this.el.nativeElement; // ── canvas 容器 ── this.canvasContainerEl = this.renderer.createElement('div'); this.renderer.addClass(this.canvasContainerEl, 'nc-eb-canvas-container'); this.setStyles(this.canvasContainerEl, { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', 'pointer-events': 'none', 'z-index': '2' }); // ── canvas ── this.canvasEl = this.renderer.createElement('canvas'); this.renderer.addClass(this.canvasEl, 'nc-eb-canvas'); this.renderer.setStyle(this.canvasEl, 'display', 'block'); this.renderer.appendChild(this.canvasContainerEl, this.canvasEl); this.renderer.appendChild(host, this.canvasContainerEl); // ── glow 层容器 ── this.layersEl = this.renderer.createElement('div'); this.renderer.addClass(this.layersEl, 'nc-eb-layers'); this.setStyles(this.layersEl, { position: 'absolute', inset: '0', 'border-radius': 'inherit', 'pointer-events': 'none', 'z-index': '0' }); const glowBase = { position: 'absolute', inset: '0', 'border-radius': 'inherit', 'pointer-events': 'none', 'box-sizing': 'border-box' }; // nc-eb-glow-1 this.glow1El = this.renderer.createElement('div'); this.renderer.addClass(this.glow1El, 'nc-eb-glow-1'); this.setStyles(this.glow1El, Object.assign(Object.assign({}, glowBase), { border: `${this.ncBorderWidth}px solid ${this.ncColor}`, filter: 'blur(1px)' })); // nc-eb-glow-2 this.glow2El = this.renderer.createElement('div'); this.renderer.addClass(this.glow2El, 'nc-eb-glow-2'); this.setStyles(this.glow2El, Object.assign(Object.assign({}, glowBase), { border: `${this.ncBorderWidth}px solid ${this.ncColor}`, filter: 'blur(4px)' })); // nc-eb-background-glow this.bgGlowEl = this.renderer.createElement('div'); this.renderer.addClass(this.bgGlowEl, 'nc-eb-background-glow'); this.setStyles(this.bgGlowEl, Object.assign(Object.assign({}, glowBase), { 'z-index': '-1', transform: 'scale(1.1)', filter: 'blur(32px)', opacity: '0.3', background: `linear-gradient(-30deg, ${this.ncColor}, transparent, ${this.ncColor})` })); this.renderer.appendChild(this.layersEl, this.glow1El); this.renderer.appendChild(this.layersEl, this.glow2El); this.renderer.appendChild(this.layersEl, this.bgGlowEl); this.renderer.appendChild(host, this.layersEl); } injectStyles() { // oklch() CSS 函数需要现代浏览器,此处通过 <style> 注入 eb-glow 透明度变量 this.styleEl = this.renderer.createElement('style'); const css = ` [ncElectricBorder] { --electric-light-color: oklch(from var(--electric-border-color) l c h); } `; this.renderer.appendChild(this.styleEl, this.renderer.createText(css)); this.renderer.appendChild(document.head, this.styleEl); } // ─── 动画控制 ──────────────────────────────────────────────────────────────── startAnimation() { if (!this.canvasEl) return; // 显示 canvas 容器和 glow 层 this.renderer.setStyle(this.canvasContainerEl, 'display', 'block'); this.renderer.setStyle(this.layersEl, 'display', 'block'); // 重置时间(避免首帧大幅跳变) this.lastFrameTime = 0; this.ngZone.runOutsideAngular(() => { // 启动 ResizeObserver this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize()); this.resizeObserver.observe(this.el.nativeElement); this.updateCanvasSize(); // 启动 rAF 循环 const loop = (currentTime) => { this.drawFrame(currentTime); this.animationId = requestAnimationFrame(loop); }; this.animationId = requestAnimationFrame(loop); }); // 如果设置了停止时间,则在指定时间后停止 if (this.ncStopTime > 0) { this.stopTimer = setTimeout(() => { this.stopAnimation(); this.ncEnabled = false; this.ncEnabledChange.emit(false); }, this.ncStopTime); } } stopAnimation() { if (this.animationId !== null) { cancelAnimationFrame(this.animationId); this.animationId = null; } if (this.resizeObserver) { this.resizeObserver.disconnect(); } if (this.stopTimer) { clearTimeout(this.stopTimer); this.stopTimer = null; } // 隐藏 canvas 容器和 glow 层 if (this.canvasContainerEl) { this.renderer.setStyle(this.canvasContainerEl, 'display', 'none'); } if (this.layersEl) { this.renderer.setStyle(this.layersEl, 'display', 'none'); } } // ─── Canvas 绘制 ───────────────────────────────────────────────────────────── updateCanvasSize() { const container = this.el.nativeElement; const rect = container.getBoundingClientRect(); const borderOffset = 60; const width = rect.width + borderOffset * 2; const height = rect.height + borderOffset * 2; const dpr = Math.min(window.devicePixelRatio || 1, 2); this.canvasEl.width = width * dpr; this.canvasEl.height = height * dpr; this.canvasEl.style.width = `${width}px`; this.canvasEl.style.height = `${height}px`; } drawFrame(currentTime) { const canvas = this.canvasEl; const ctx = canvas.getContext('2d'); if (!ctx) return; const deltaTime = this.lastFrameTime === 0 ? 0 : (currentTime - this.lastFrameTime) / 1000; this.time += deltaTime * this.ncSpeed; this.lastFrameTime = currentTime; const dpr = Math.min(window.devicePixelRatio || 1, 2); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.scale(dpr, dpr); ctx.strokeStyle = this.ncColor; ctx.lineWidth = this.ncElectricWidth; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const borderOffset = 60; const displacement = 60; const canvasW = canvas.width / dpr; const canvasH = canvas.height / dpr; const left = borderOffset; const top = borderOffset; const borderWidth = canvasW - 2 * borderOffset; const borderHeight = canvasH - 2 * borderOffset; const maxRadius = Math.min(borderWidth, borderHeight) / 2; const radius = Math.min(this.ncBorderRadius, maxRadius); const approximatePerimeter = 2 * (borderWidth + borderHeight) + 2 * Math.PI * radius; const sampleCount = Math.floor(approximatePerimeter / 2); // 噪声参数 const octaves = 10; const lacunarity = 1.6; const gain = 0.7; const amplitude = this.ncChaos; const frequency = 10; const baseFlatness = 0; ctx.beginPath(); for (let i = 0; i <= sampleCount; i++) { const progress = i / sampleCount; const point = this.getRoundedRectPoint(progress, left, top, borderWidth, borderHeight, radius); const xNoise = this.octavedNoise(progress * 8, octaves, lacunarity, gain, amplitude, frequency, this.time, 0, baseFlatness); const yNoise = this.octavedNoise(progress * 8, octaves, lacunarity, gain, amplitude, frequency, this.time, 1, baseFlatness); const displacedX = point.x + xNoise * displacement; const displacedY = point.y + yNoise * displacement; if (i === 0) { ctx.moveTo(displacedX, displacedY); } else { ctx.lineTo(displacedX, displacedY); } } ctx.closePath(); ctx.stroke(); } // ─── 几何与噪声工具方法 ─────────────────────────────────────────────────────── random(x) { return (Math.sin(x * 12.9898) * 43758.5453) % 1; } noise2D(x, y) { const i = Math.floor(x); const j = Math.floor(y); const fx = x - i; const fy = y - j; const a = this.random(i + j * 57); const b = this.random(i + 1 + j * 57); const c = this.random(i + (j + 1) * 57); const d = this.random(i + 1 + (j + 1) * 57); const ux = fx * fx * (3.0 - 2.0 * fx); const uy = fy * fy * (3.0 - 2.0 * fy); return a * (1 - ux) * (1 - uy) + b * ux * (1 - uy) + c * (1 - ux) * uy + d * ux * uy; } octavedNoise(x, octaves, lacunarity, gain, baseAmplitude, baseFrequency, time, seed, baseFlatness) { let y = 0; let amplitude = baseAmplitude; let frequency = baseFrequency; for (let i = 0; i < octaves; i++) { let octaveAmplitude = amplitude; if (i === 0) octaveAmplitude *= baseFlatness; y += octaveAmplitude * this.noise2D(frequency * x + seed * 100, time * frequency * 0.3); frequency *= lacunarity; amplitude *= gain; } return y; } getCornerPoint(centerX, centerY, radius, startAngle, arcLength, progress) { const angle = startAngle + progress * arcLength; return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) }; } getRoundedRectPoint(t, left, top, width, height, radius) { const straightWidth = width - 2 * radius; const straightHeight = height - 2 * radius; const cornerArc = (Math.PI * radius) / 2; const totalPerimeter = 2 * straightWidth + 2 * straightHeight + 4 * cornerArc; const distance = t * totalPerimeter; let accumulated = 0; // Top edge if (distance <= accumulated + straightWidth) { const progress = (distance - accumulated) / straightWidth; return { x: left + radius + progress * straightWidth, y: top }; } accumulated += straightWidth; // Top-right corner if (distance <= accumulated + cornerArc) { const progress = (distance - accumulated) / cornerArc; return this.getCornerPoint(left + width - radius, top + radius, radius, -Math.PI / 2, Math.PI / 2, progress); } accumulated += cornerArc; // Right edge if (distance <= accumulated + straightHeight) { const progress = (distance - accumulated) / straightHeight; return { x: left + width, y: top + radius + progress * straightHeight }; } accumulated += straightHeight; // Bottom-right corner if (distance <= accumulated + cornerArc) { const progress = (distance - accumulated) / cornerArc; return this.getCornerPoint(left + width - radius, top + height - radius, radius, 0, Math.PI / 2, progress); } accumulated += cornerArc; // Bottom edge if (distance <= accumulated + straightWidth) { const progress = (distance - accumulated) / straightWidth; return { x: left + width - radius - progress * straightWidth, y: top + height }; } accumulated += straightWidth; // Bottom-left corner if (distance <= accumulated + cornerArc) { const progress = (distance - accumulated) / cornerArc; return this.getCornerPoint(left + radius, top + height - radius, radius, Math.PI / 2, Math.PI / 2, progress); } accumulated += cornerArc; // Left edge if (distance <= accumulated + straightHeight) { const progress = (distance - accumulated) / straightHeight; return { x: left, y: top + height - radius - progress * straightHeight }; } accumulated += straightHeight; // Top-left corner const progress = (distance - accumulated) / cornerArc; return this.getCornerPoint(left + radius, top + radius, radius, Math.PI, Math.PI / 2, progress); } // ─── 工具 ──────────────────────────────────────────────────────────────────── setStyles(el, styles) { Object.entries(styles).forEach(([prop, value]) => { this.renderer.setStyle(el, prop, value); }); } } ElectricBorderDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "12.1.5", ngImport: i0, type: ElectricBorderDirective, deps: [{ token: i0.ElementRef }, { token: i0.Renderer2 }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Directive }); ElectricBorderDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "12.0.0", version: "12.1.5", type: ElectricBorderDirective, selector: "[ncElectricBorder]", inputs: { ncBorderRadius: "ncBorderRadius", ncBorderWidth: "ncBorderWidth", ncElectricWidth: "ncElectricWidth", ncColor: "ncColor", ncSpeed: "ncSpeed", ncChaos: "ncChaos", ncStopTime: "ncStopTime", ncEnabled: "ncEnabled" }, outputs: { ncEnabledChange: "ncEnabledChange" }, usesOnChanges: true, ngImport: i0 }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "12.1.5", ngImport: i0, type: ElectricBorderDirective, decorators: [{ type: Directive, args: [{ selector: '[ncElectricBorder]' }] }], ctorParameters: function () { return [{ type: i0.ElementRef }, { type: i0.Renderer2 }, { type: i0.NgZone }]; }, propDecorators: { ncBorderRadius: [{ type: Input }], ncBorderWidth: [{ type: Input }], ncElectricWidth: [{ type: Input }], ncColor: [{ type: Input }], ncSpeed: [{ type: Input }], ncChaos: [{ type: Input }], ncStopTime: [{ type: Input }], ncEnabled: [{ type: Input }], ncEnabledChange: [{ type: Output }] } }); class NcElectricBorderModule { } NcElectricBorderModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "12.1.5", ngImport: i0, type: NcElectricBorderModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); NcElectricBorderModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "12.0.0", version: "12.1.5", ngImport: i0, type: NcElectricBorderModule, declarations: [ElectricBorderDirective], exports: [ElectricBorderDirective] }); NcElectricBorderModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "12.1.5", ngImport: i0, type: NcElectricBorderModule, imports: [[]] }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "12.1.5", ngImport: i0, type: NcElectricBorderModule, decorators: [{ type: NgModule, args: [{ declarations: [ ElectricBorderDirective ], imports: [], exports: [ ElectricBorderDirective ] }] }] }); /** * Generated bundle index. Do not edit. */ export { ElectricBorderDirective, NcElectricBorderModule }; //# sourceMappingURL=ng-cw-v12-electric-border.js.map