@eternalheart/ngx-file-preview
Version:
A powerful Angular file preview component library supporting multiple file formats including images, videos, PDFs, Office documents, text files and more.
1,099 lines (1,086 loc) • 308 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, inject, ApplicationRef, createComponent, ChangeDetectorRef, Input, Directive, HostListener, Component, HostBinding, CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, Pipe, ViewChild, ChangeDetectionStrategy, EventEmitter, Output, ViewEncapsulation, ViewChildren, ContentChild } from '@angular/core';
import * as i1 from '@angular/common';
import { CommonModule, NgIf } from '@angular/common';
import { BehaviorSubject, Subject, from, firstValueFrom, fromEvent, timer, merge } from 'rxjs';
import * as XLSX from 'xlsx';
import { renderAsync } from 'docx-preview';
import * as i2 from 'ngx-extended-pdf-viewer';
import { NgxExtendedPdfViewerModule, NgxExtendedPdfViewerComponent } from 'ngx-extended-pdf-viewer';
import Hls from 'hls.js';
import { init } from 'pptx-preview';
import { filter, switchMap, takeUntil, tap } from 'rxjs/operators';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import * as i1$1 from '@angular/platform-browser';
class ThemeService {
constructor(renderer) {
this.renderer = renderer;
this.THEME_KEY = 'fp-theme-mode';
this.themeSubject$ = new BehaviorSubject('dark');
this.autoConfig = {
dark: { start: 18, end: 6 }
};
this.systemThemeQuery = null;
this.systemThemeListener = null;
this.localDomElement = null;
}
/**
* 绑定最外围元素
* @param domElement
*/
bindElement(domElement) {
this.localDomElement = domElement;
}
ngOnInit() {
if (window.matchMedia) {
this.systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.systemThemeListener = (e) => {
if (this.theme === 'auto') {
this.checkAndApplyAutoTheme();
}
};
this.systemThemeQuery.addEventListener('change', this.systemThemeListener);
}
this.applyTheme('dark');
}
get theme() {
return this.themeSubject$.getValue();
}
getThemeObservable() {
return this.themeSubject$.asObservable();
}
setMode(mode) {
this.themeSubject$.next(mode);
if (mode === 'auto') {
this.startAutoCheck();
}
else {
this.stopAutoCheck();
this.applyTheme(mode);
}
}
setAutoConfig(config) {
this.autoConfig = { ...this.autoConfig, ...config };
if (this.themeSubject$.getValue() === 'auto') {
this.checkAndApplyAutoTheme();
}
}
startAutoCheck() {
this.checkAndApplyAutoTheme();
this.autoChangeTimer = setInterval(() => {
this.checkAndApplyAutoTheme();
}, 60000); // 每分钟检查一次
}
stopAutoCheck() {
if (this.autoChangeTimer) {
clearInterval(this.autoChangeTimer);
this.autoChangeTimer = null;
}
}
checkAndApplyAutoTheme() {
const hour = new Date().getHours();
const { start, end } = this.autoConfig.dark;
// 检查是否在暗色时间范围内
const isDarkTime = start > end
? (hour >= start || hour < end) // 跨夜间
: (hour >= start && hour < end); // 同一天内
// 检查系统主题
const prefersDark = this.systemThemeQuery?.matches ?? false;
// 优先使用时间判断,其次使用系统主题
this.applyTheme(isDarkTime || prefersDark ? 'dark' : 'light');
}
applyTheme(theme) {
// 更新当前主题
this.themeSubject$.next(theme);
if (this.localDomElement) {
// 移除现有主题
this.renderer.removeAttribute(this.localDomElement, 'data-nfp-theme');
// 应用新主题
if (theme === 'dark') {
this.renderer.setAttribute(this.localDomElement, 'data-nfp-theme', 'dark');
}
else {
this.renderer.setAttribute(this.localDomElement, 'data-nfp-theme', 'light');
}
}
// 保存到本地存储
localStorage.setItem(this.THEME_KEY, theme);
}
toggleTheme() {
const newTheme = this.theme === 'light' ? 'dark' : 'light';
this.setMode(newTheme);
}
ngOnDestroy() {
this.stopAutoCheck();
// 清理系统主题监听
if (this.systemThemeQuery && this.systemThemeListener) {
this.systemThemeQuery.removeEventListener('change', this.systemThemeListener);
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThemeService, deps: [{ token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThemeService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThemeService, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: i0.Renderer2 }] });
var loading$1 = "加载中...";
var list$1 = {
title: "文件列表",
empty: "暂无文件",
total: "共${0}个文件"
};
var preview$1 = {
toolbar: {
resetZoom: "重置缩放",
zoomIn: "放大",
zoomOut: "缩小",
fullscreen: "全屏",
download: "下载",
close: "关闭",
previous: "上一个",
next: "下一个",
reset: "重置",
rotate: "旋转",
rotate90: "旋转90度",
"rotate-90": "旋转-90度",
originSize: "原始尺寸",
autoFit: "自适应",
wrap: "换行",
nowrap: "不换行",
play: "播放",
pause: "暂停",
back15s: "后退15秒",
forward15s: "前进15秒",
pip: "画中画",
light: "日间模式",
dark: "夜间模式"
},
error: {
noFiles: "没有文件可预览!"
}
};
var zip$1 = {
type: "类型",
size: "大小",
unknownSize: "大小未知",
types: {
zip: "ZIP 压缩文件",
rar: "RAR 压缩文件",
"7z": "7-Zip 压缩文件",
tar: "TAR 归档文件",
gz: "GZip 压缩文件",
unknown: "压缩文件"
}
};
var types$1 = {
audio: "音频文件",
image: "图片文件",
video: "视频文件",
pdf: "PDF文档",
word: "WORD文档",
excel: "Excel文档",
ppt: "PPT文档",
txt: "文本文件",
markdown: "Markdown文件",
zip: "压缩文件",
unknown: "未知文件"
};
var unknownFileTips$1 = "暂不支持该文件类型的预览";
var ZH = {
loading: loading$1,
list: list$1,
preview: preview$1,
zip: zip$1,
types: types$1,
unknownFileTips: unknownFileTips$1
};
var loading = "Loading...";
var list = {
title: "File list",
empty: "No files",
total: "A total of files is ${0}"
};
var preview = {
toolbar: {
resetZoom: "Reset Zoom",
zoomIn: "Zoom In",
zoomOut: "Zoom Out",
fullscreen: "Full Screen",
download: "Download",
close: "Close",
previous: "Previous",
next: "Next",
reset: "Reset",
rotate: "Rotate",
rotate90: "Rotate 90 degrees",
"rotate-90": "Rotate -90 degrees",
originSize: "Original Size",
autoFit: "Auto Fit",
wrap: "Wrap",
nowrap: "No Wrap",
play: "Play",
pause: "Pause",
back15s: "Back 15 seconds",
forward15s: "Forward 15 seconds",
pip: "Picture in Picture",
light: "Light Mode",
dark: "Dark Mode"
},
error: {
noFiles: "No files can be previewed!"
}
};
var zip = {
type: "Type",
size: "Size",
unknownSize: "Unknown Size",
types: {
zip: "ZIP Archive File",
rar: "RAR Archive File",
"7z": "7-Zip Archive File",
tar: "TAR Archive File",
gz: "GZip Archive File",
unknown: "Archive File"
}
};
var types = {
audio: "Audio File",
image: "Image File",
video: "Video File",
pdf: "PDF Document",
word: "Word Document",
excel: "Excel Document",
ppt: "PPT Document",
txt: "Text File",
markdown: "Markdown File",
zip: "Archive File",
unknown: "Unknown File"
};
var unknownFileTips = "File preview is not supported for this file type.";
var EN = {
loading: loading,
list: list,
preview: preview,
zip: zip,
types: types,
unknownFileTips: unknownFileTips
};
const LangMapping = {
'zh': ZH,
};
const I18nUtils = {
/**
* 获取语言包
* @param locale
*/
get(locale) {
return new I18nParser(locale || 'zh');
},
/**
* 注册语言包
*/
register(locale, langJson) {
LangMapping[locale] = langJson;
}
};
/**
* 注册使用示例
*/
I18nUtils.register('en', EN);
/**
* 单例模式优化语言包的获取 单个语言只会创建一个语言转化实例
*/
class I18nParser {
static { this.InstanceMap = {}; }
constructor(locale) {
this.locale = 'zh';
this.locale = locale;
if (I18nParser.InstanceMap[locale])
return I18nParser.InstanceMap[locale];
I18nParser.InstanceMap[locale] = this;
}
// 翻译
t(key, ...args) {
const translated = I18nParser.getValue(LangMapping[this.locale], key);
if (args.length > 0)
return translated.replace(/\${(\d+)}/g, (match, index) => args[index]);
if (translated)
return translated;
return key;
}
// 获取深层值
static getValue(data, prop) {
let ps = Array.isArray(prop) ? prop : prop.split('.');
try {
return ps.length == 1 ? data[ps.shift()] : I18nParser.getValue(data[ps.shift()], ps);
}
catch (e) {
return undefined;
}
}
}
const INITIAL_PREVIEW_STATE = {
isVisible: false,
currentIndex: 0,
files: []
};
class PreviewService {
constructor() {
this.appRef = inject(ApplicationRef);
this.lang = 'zh';
this.loading = new BehaviorSubject(false);
// endregion
// region 状态管理
this.stateSubject = new BehaviorSubject(INITIAL_PREVIEW_STATE);
}
/**
* 初始化
* @param injector
* @param envInjector
*/
init(injector, envInjector) {
this.envInjector = envInjector;
this.injector = injector;
}
/**
* 设置语言
* @param lang
*/
setLang(lang) {
this.lang = lang;
}
/**
* 获取实际的lang parser
*/
getLangParser() {
return I18nUtils.get(this.lang);
}
get state() {
return this.stateSubject.getValue();
}
getStateObservable() {
return this.stateSubject.asObservable();
}
previous() {
const state = this.state;
const newIndex = Math.max(0, state.currentIndex - 1);
this.updatePreviewState(true, state.files, newIndex);
}
next() {
const state = this.state;
const newIndex = Math.min(state.files.length - 1, state.currentIndex + 1);
this.updatePreviewState(true, state.files, newIndex);
}
updatePreviewState(isVisible, files, index) {
const currentFile = files[index];
this.stateSubject.next({
isVisible,
currentFile,
currentIndex: index,
files
});
}
/**
* 设置加载中状态
* @param loading
*/
setLoading(loading) {
this.loading.next(loading);
}
getLoadingObservable() {
return this.loading.asObservable();
}
get modalElement() {
return this.modalRef?.location.nativeElement;
}
open(options) {
const { files, index = 0 } = options;
if (this.modalRef) {
this.cleanupModal();
}
try {
this.modalRef = createComponent(PreviewModalComponent, {
environmentInjector: this.envInjector,
elementInjector: this.injector,
});
Object.assign(this.modalRef.instance, options);
this.injector.get(ThemeService).bindElement(this.modalRef.location.nativeElement);
document.body.appendChild(this.modalRef.location.nativeElement);
this.modalRef.changeDetectorRef.detectChanges();
this.updatePreviewState(true, files, index);
this.appRef.attachView(this.modalRef.hostView);
}
catch (error) {
console.error('Error creating preview-list modal:', error);
this.cleanupModal();
}
}
close() {
if (document.fullscreenElement) {
document?.exitFullscreen();
}
this.updatePreviewState(false, [], 0);
this.cleanupModal();
}
cleanupModal() {
if (!this.modalRef)
return;
try {
// 从 DOM 中移除模态框
const element = this.modalRef.location.nativeElement;
if (element.parentNode) {
element.parentNode.removeChild(element);
}
// 从 ApplicationRef 中分离视图
this.appRef.detachView(this.modalRef.hostView);
// 销毁组件
this.modalRef.destroy();
}
catch (error) {
console.error('Error cleaning up modal:', error);
}
finally {
this.modalRef = undefined;
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PreviewService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PreviewService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PreviewService, decorators: [{
type: Injectable
}] });
class FileReaderService {
constructor() {
this.responseSubject = new Subject();
}
async readFileData(file, fileType) {
try {
const response = await fetch(file.url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = { type: 'success' };
switch (fileType) {
case 'arraybuffer':
result.data = await response.arrayBuffer();
break;
case 'text':
result.text = await response.text();
break;
case 'json':
const text = await response.text();
try {
result.json = JSON.parse(text);
}
catch (parseError) {
throw new Error(`JSON parse failed: ${parseError.message}`);
}
break;
}
return result;
}
catch (error) {
return {
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
readFile(file, fileType = 'arraybuffer') {
return from(this.readFileData(file, fileType));
}
ngOnDestroy() {
this.responseSubject.complete();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FileReaderService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FileReaderService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FileReaderService, decorators: [{
type: Injectable
}] });
class BasePreviewComponent {
constructor() {
this.fileReader = inject(FileReaderService);
this.previewService = inject(PreviewService);
this.cdr = inject(ChangeDetectorRef);
}
get isLoading() {
return this.previewService.getLoadingObservable();
}
t(key, ...args) {
return this.previewService?.getLangParser()?.t(key, ...args);
}
async loadFile(fileType) {
if (!this.file)
return;
this.startLoading();
try {
const content = await firstValueFrom(this.fileReader.readFile(this.file, fileType));
await this.handleFileContent(content);
}
catch (error) {
console.error('Failed to read file:', error);
}
finally {
this.stopLoading();
}
}
startLoading() {
this.previewService.setLoading(true);
this.cdr.markForCheck();
}
stopLoading() {
this.previewService.setLoading(false);
this.cdr.markForCheck();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BasePreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "17.3.12", type: BasePreviewComponent, isStandalone: true, inputs: { file: "file", themeMode: ["themeMode", "themeMode", (value) => value], autoThemeConfig: "autoThemeConfig" }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BasePreviewComponent, decorators: [{
type: Directive,
args: [{
standalone: true,
}]
}], propDecorators: { file: [{
type: Input
}], themeMode: [{
type: Input,
args: [{ transform: (value) => value }]
}], autoThemeConfig: [{
type: Input
}] } });
class TooltipDirective {
constructor(el, renderer, viewContainer, previewService) {
this.el = el;
this.renderer = renderer;
this.viewContainer = viewContainer;
this.previewService = previewService;
this.delay = 500;
this.positions = ['top', 'bottom', 'left', 'right'];
this.currentPosition = 'top';
}
onMouseEnter() {
this.clearTimers();
this.showTimeout = setTimeout(() => this.show(), this.delay);
}
onMouseLeave() {
this.clearTimers();
this.hideTimeout = setTimeout(() => this.hide(), 100);
}
show() {
if (!this.content)
return;
if (!this.tooltip) {
// 动态创建组件
const factory = this.viewContainer.createComponent(TooltipComponent);
this.tooltip = factory.location.nativeElement;
factory.instance.content = this.content;
// 立即显示内容
this.renderer.addClass(this.tooltip, 'visible');
this.previewService.modalElement?.querySelector('.nfp-modal__overlay').appendChild(this.tooltip);
factory.changeDetectorRef.detectChanges();
}
// 计算最佳位置
const hostRect = this.el.nativeElement.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 检查每个位置的可用空间
const spaces = {
top: hostRect.top,
bottom: viewportHeight - (hostRect.bottom),
left: hostRect.left,
right: viewportWidth - (hostRect.right)
};
// 找到最佳位置
this.currentPosition = this.positions.reduce((best, current) => spaces[current] > spaces[best] ? current : best);
// 根据位置设置样式类
this.positions.forEach(pos => this.renderer.removeClass(this.tooltip, pos));
this.renderer.addClass(this.tooltip, this.currentPosition);
// 根据位置计算坐标
let top, left;
switch (this.currentPosition) {
case 'top':
top = hostRect.top - tooltipRect.height - 8;
left = hostRect.left + (hostRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = hostRect.bottom + 8;
left = hostRect.left + (hostRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = hostRect.top + (hostRect.height - tooltipRect.height) / 2;
left = hostRect.left - tooltipRect.width - 8;
break;
case 'right':
top = hostRect.top + (hostRect.height - tooltipRect.height) / 2;
left = hostRect.right + 8;
break;
}
// 确保tooltip不超出视口
top = Math.max(8, Math.min(viewportHeight - tooltipRect.height - 8, top));
left = Math.max(8, Math.min(viewportWidth - tooltipRect.width - 8, left));
this.renderer.setStyle(this.tooltip, 'top', `${top}px`);
this.renderer.setStyle(this.tooltip, 'left', `${left}px`);
}
hide() {
if (this.tooltip) {
this.renderer.removeClass(this.tooltip, 'visible');
setTimeout(() => {
this.viewContainer.clear();
this.tooltip = null;
}, 300); // 增加动画时间
}
}
clearTimers() {
clearTimeout(this.showTimeout);
clearTimeout(this.hideTimeout);
}
ngOnDestroy() {
this.viewContainer.clear();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TooltipDirective, deps: [{ token: i0.ElementRef }, { token: i0.Renderer2 }, { token: i0.ViewContainerRef }, { token: PreviewService }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: TooltipDirective, isStandalone: true, selector: "[tooltip]", inputs: { content: ["tooltip", "content"], delay: "delay" }, host: { listeners: { "mouseenter": "onMouseEnter()", "mouseleave": "onMouseLeave()" } }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TooltipDirective, decorators: [{
type: Directive,
args: [{ selector: '[tooltip]', standalone: true }]
}], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.Renderer2 }, { type: i0.ViewContainerRef }, { type: PreviewService }], propDecorators: { content: [{
type: Input,
args: ['tooltip']
}], delay: [{
type: Input
}], onMouseEnter: [{
type: HostListener,
args: ['mouseenter']
}], onMouseLeave: [{
type: HostListener,
args: ['mouseleave']
}] } });
class TooltipComponent {
constructor() {
this.content = "";
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TooltipComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: TooltipComponent, isStandalone: true, selector: "ngx-file-tooltip", inputs: { content: "content" }, ngImport: i0, template: `{{ content }}`, isInline: true, styles: [":host{position:absolute;background:#000000d9;color:#fff;font-size:14px;padding:6px 8px;border-radius:2px;box-shadow:0 3px 6px -4px #0000001f,0 6px 16px #00000014,0 9px 28px 8px #0000000d;max-width:250px;min-height:24px;word-wrap:break-word;z-index:999;pointer-events:none;opacity:0;display:flex;justify-content:center;align-items:center;transform:scale(.8);transform-origin:center;transition:opacity .2s ease-in-out,transform .2s ease-in-out}:host.visible{opacity:1;transform:scale(1)}:host:after{content:\"\";position:absolute;width:0;height:0;border:5px solid transparent}:host.top:after{border-top-color:#000000d9;bottom:-10px;left:50%;transform:translate(-50%)}:host.bottom:after{border-bottom-color:#000000d9;top:-10px;left:50%;transform:translate(-50%)}:host.left:after{border-left-color:#000000d9;right:-10px;top:50%;transform:translateY(-50%)}:host.right:after{border-right-color:#000000d9;left:-10px;top:50%;transform:translateY(-50%)}\n"] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TooltipComponent, decorators: [{
type: Component,
args: [{ selector: 'ngx-file-tooltip', template: `{{ content }}`, standalone: true, styles: [":host{position:absolute;background:#000000d9;color:#fff;font-size:14px;padding:6px 8px;border-radius:2px;box-shadow:0 3px 6px -4px #0000001f,0 6px 16px #00000014,0 9px 28px 8px #0000000d;max-width:250px;min-height:24px;word-wrap:break-word;z-index:999;pointer-events:none;opacity:0;display:flex;justify-content:center;align-items:center;transform:scale(.8);transform-origin:center;transition:opacity .2s ease-in-out,transform .2s ease-in-out}:host.visible{opacity:1;transform:scale(1)}:host:after{content:\"\";position:absolute;width:0;height:0;border:5px solid transparent}:host.top:after{border-top-color:#000000d9;bottom:-10px;left:50%;transform:translate(-50%)}:host.bottom:after{border-bottom-color:#000000d9;top:-10px;left:50%;transform:translate(-50%)}:host.left:after{border-left-color:#000000d9;right:-10px;top:50%;transform:translateY(-50%)}:host.right:after{border-right-color:#000000d9;left:-10px;top:50%;transform:translateY(-50%)}\n"] }]
}], propDecorators: { content: [{
type: Input
}] } });
class PreviewIconComponent {
constructor() {
this.name = "";
this.svg = "";
this.size = '16px';
this.title = "";
this.cursor = 'pointer';
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PreviewIconComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "16.1.0", version: "17.3.12", type: PreviewIconComponent, isStandalone: true, selector: "preview-icon", inputs: { name: "name", svg: "svg", size: ["size", "size", (v) => typeof v === 'number' ? `${v}px` : v], color: "color", themeMode: "themeMode", title: "title", cursor: "cursor" }, host: { properties: { "style.cursor": "this.cursor" } }, ngImport: i0, template: `
<ng-container *ngIf="name">
<i [tooltip]="title" class="fp-font-icon NGX-FILE-PREVIEW" [class]="'nfp-'+name"
[style.width]="size" [style.font-size]="size"
[style.color]="color ? color: (themeMode=='light'?'#333333':'#FFFFFF')"></i>
</ng-container>
<ng-container *ngIf="svg">
<svg class="fp-svg-icon" [style.width]="size" [style.height]="size" [tooltip]="title" aria-hidden="true">
<use [attr.xlink:href]="'#nfp-' + svg">"></use>
</svg>
</ng-container>
`, isInline: true, styles: [":host{display:inline-block;line-height:0}:host .fp-svg-icon{width:1em;height:1em;vertical-align:-.15em;fill:currentColor;overflow:hidden}:host .fp-font-icon{color:#fff;display:inline-flex;justify-content:center;align-items:center;aspect-ratio:1;overflow:hidden}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: TooltipDirective, selector: "[tooltip]", inputs: ["tooltip", "delay"] }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PreviewIconComponent, decorators: [{
type: Component,
args: [{ selector: "preview-icon", template: `
<ng-container *ngIf="name">
<i [tooltip]="title" class="fp-font-icon NGX-FILE-PREVIEW" [class]="'nfp-'+name"
[style.width]="size" [style.font-size]="size"
[style.color]="color ? color: (themeMode=='light'?'#333333':'#FFFFFF')"></i>
</ng-container>
<ng-container *ngIf="svg">
<svg class="fp-svg-icon" [style.width]="size" [style.height]="size" [tooltip]="title" aria-hidden="true">
<use [attr.xlink:href]="'#nfp-' + svg">"></use>
</svg>
</ng-container>
`, imports: [CommonModule, TooltipDirective], standalone: true, schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA], styles: [":host{display:inline-block;line-height:0}:host .fp-svg-icon{width:1em;height:1em;vertical-align:-.15em;fill:currentColor;overflow:hidden}:host .fp-font-icon{color:#fff;display:inline-flex;justify-content:center;align-items:center;aspect-ratio:1;overflow:hidden}\n"] }]
}], propDecorators: { name: [{
type: Input
}], svg: [{
type: Input
}], size: [{
type: Input,
args: [{ transform: (v) => typeof v === 'number' ? `${v}px` : v }]
}], color: [{
type: Input
}], themeMode: [{
type: Input
}], title: [{
type: Input
}], cursor: [{
type: Input
}, {
type: HostBinding,
args: ['style.cursor']
}] } });
class I18nPipe {
constructor(previewService) {
this.previewService = previewService;
}
transform(key, ...args) {
const parser = this.previewService.getLangParser();
return parser.t(key, ...args);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: I18nPipe, deps: [{ token: PreviewService }], target: i0.ɵɵFactoryTarget.Pipe }); }
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: I18nPipe, isStandalone: true, name: "i18n" }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: I18nPipe, decorators: [{
type: Pipe,
args: [{
name: 'i18n',
standalone: true
}]
}], ctorParameters: () => [{ type: PreviewService }] });
class ExcelPreviewComponent extends BasePreviewComponent {
constructor() {
super(...arguments);
this.scale = 1;
this.sheets = [];
this.currentSheet = '';
this.tableData = { headers: [], rows: [] };
this.displayRows = [];
this.extraRows = 100; // 增加额外显示的空行数
this.extraColumns = Array(5).fill(0);
this.visibleRows = [];
this.SCALE_STEP = 0.1;
this.MAX_SCALE = 3;
this.MIN_SCALE = 0.1;
this.isDragging = false;
this.startX = 0;
this.startY = 0;
this.scrollLeft = 0;
this.scrollTop = 0;
this.DEFAULT_SCALE = 1;
}
get totalColumns() {
const total = (this.tableData.headers.length + this.extraColumns.length) || 0;
return Array(total).fill(0);
}
ngOnChanges(changes) {
if (changes['file'] && this.file) {
this.loadFile();
}
}
ngAfterViewInit() {
this.setupDragListeners();
this.setupKeyboardListeners();
}
ngOnDestroy() {
this.removeDragListeners();
this.removeKeyboardListeners();
}
async handleFileContent(content) {
const { data } = content;
this.workbook = XLSX.read(data, { type: 'array' });
this.sheets = this.workbook.SheetNames;
if (this.sheets.length > 0) {
await this.switchSheet(this.sheets[0]);
}
}
setupDragListeners() {
this.mouseMoveListener = (e) => this.onDrag(e);
this.mouseUpListener = () => this.stopDrag();
document.addEventListener('mousemove', this.mouseMoveListener);
document.addEventListener('mouseup', this.mouseUpListener);
}
removeDragListeners() {
if (this.mouseMoveListener) {
document.removeEventListener('mousemove', this.mouseMoveListener);
}
if (this.mouseUpListener) {
document.removeEventListener('mouseup', this.mouseUpListener);
}
}
startDrag(e) {
// 如果点击的是滚动条,不启动拖动
const wrapper = this.tableWrapper.nativeElement;
const rect = wrapper.getBoundingClientRect();
const isClickOnScrollbarX = e.clientY > (rect.bottom - 12);
const isClickOnScrollbarY = e.clientX > (rect.right - 12);
if (isClickOnScrollbarX || isClickOnScrollbarY) {
return;
}
this.isDragging = true;
this.startX = e.pageX - wrapper.offsetLeft;
this.startY = e.pageY - wrapper.offsetTop;
this.scrollLeft = wrapper.scrollLeft;
this.scrollTop = wrapper.scrollTop;
}
onDrag(e) {
if (!this.isDragging)
return;
e.preventDefault();
const wrapper = this.tableWrapper.nativeElement;
const x = e.pageX - wrapper.offsetLeft;
const y = e.pageY - wrapper.offsetTop;
const walkX = (x - this.startX) * 1.5; // 增加一些移动速度
const walkY = (y - this.startY) * 1.5;
wrapper.scrollLeft = this.scrollLeft - walkX;
wrapper.scrollTop = this.scrollTop - walkY;
}
stopDrag() {
this.isDragging = false;
}
async switchSheet(sheetName) {
if (!this.workbook)
return;
try {
const worksheet = this.workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
// 确保所有行的长度一致
const maxLength = Math.max(...jsonData.map((row) => row?.length || 0), 0);
this.displayRows = jsonData.map((row) => {
const paddedRow = Array.isArray(row) ? [...row] : [];
while (paddedRow.length < maxLength) {
paddedRow.push(null);
}
return paddedRow;
});
// 添加额外的空行
const emptyRows = Array(this.extraRows).fill(0).map(() => Array(maxLength).fill(null));
this.visibleRows = [...this.displayRows, ...emptyRows];
this.tableData = {
headers: Array(maxLength).fill(''),
rows: this.displayRows
};
this.currentSheet = sheetName;
this.cdr.markForCheck();
}
catch (error) {
console.error('切换工作表失败:', error);
}
}
zoomIn() {
if (this.scale < this.MAX_SCALE) {
this.scale = Math.min(this.MAX_SCALE, this.scale + this.SCALE_STEP);
this.applyZoom();
}
}
zoomOut() {
if (this.scale > this.MIN_SCALE) {
this.scale = Math.max(this.MIN_SCALE, this.scale - this.SCALE_STEP);
this.applyZoom();
}
}
toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
}
else {
document.exitFullscreen();
}
}
getColumnName(index) {
let name = '';
let num = index;
do {
name = String.fromCharCode(65 + (num % 26)) + name;
num = Math.floor(num / 26) - 1;
} while (num >= 0);
return name;
}
getRowNumber(index) {
return index + 1;
}
handleWheel(event) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
const delta = event.deltaY || event.detail || 0;
if (delta < 0) {
this.zoomIn();
}
else {
this.zoomOut();
}
}
}
applyZoom() {
if (this.tableWrapper) {
const wrapper = this.tableWrapper.nativeElement;
// 保存当前滚动位置的相对百分比
const scrollLeftPercent = wrapper.scrollLeft / (wrapper.scrollWidth - wrapper.clientWidth);
const scrollTopPercent = wrapper.scrollTop / (wrapper.scrollHeight - wrapper.clientHeight);
// 应用缩放
wrapper.style.transform = `scale(${this.scale})`;
// 在下一个事件循环中恢复滚动位置
setTimeout(() => {
wrapper.scrollLeft = scrollLeftPercent * (wrapper.scrollWidth - wrapper.clientWidth);
wrapper.scrollTop = scrollTopPercent * (wrapper.scrollHeight - wrapper.clientHeight);
});
}
this.cdr.markForCheck();
}
setupKeyboardListeners() {
this.keydownListener = (e) => {
// 按下 Ctrl/Command + 0 重置缩放
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
e.preventDefault();
this.resetZoom();
}
};
document.addEventListener('keydown', this.keydownListener);
}
removeKeyboardListeners() {
if (this.keydownListener) {
document.removeEventListener('keydown', this.keydownListener);
}
}
resetZoom() {
this.scale = this.DEFAULT_SCALE;
this.applyZoom();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ExcelPreviewComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: ExcelPreviewComponent, isStandalone: true, selector: "ngx-excel-preview", viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true }, { propertyName: "tableWrapper", first: true, predicate: ["tableWrapper"], descendants: true }], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: `
<div class="excel-container" #container>
<div class="toolbar">
<div class="left-controls">
<button class="tool-btn" (click)="zoomOut()">
<preview-icon [themeMode]="themeMode" name="zoom-out" [title]="'preview.toolbar.zoomOut'|i18n"></preview-icon>
</button>
<span class="zoom-text" (click)="resetZoom()" [title]="'preview.toolbar.resetZoom'|i18n">
{{ (scale * 100).toFixed(0) }}%
</span>
<button class="tool-btn" (click)="zoomIn()">
<preview-icon [themeMode]="themeMode" name="zoom-in" [title]="'preview.toolbar.zoomIn'|i18n"></preview-icon>
</button>
</div>
<div class="sheet-controls" *ngIf="sheets.length > 0">
<button class="sheet-btn"
*ngFor="let sheet of sheets"
[class.active]="currentSheet === sheet"
(click)="switchSheet(sheet)">
{{ sheet }}
</button>
</div>
<div class="right-controls">
<button class="tool-btn" (click)="toggleFullscreen()">
<preview-icon [themeMode]="themeMode" name="fullscreen" [title]="'preview.toolbar.fullscreen'|i18n"></preview-icon>
</button>
</div>
</div>
<div class="preview-container">
<div class="preview-content">
<div class="table-wrapper"
#tableWrapper
(mousedown)="startDrag($event)"
(wheel)="handleWheel($event)"
[class.dragging]="isDragging"
[style.transform]="'scale(' + scale + ')'">
<table *ngIf="tableData">
<colgroup>
<col class="row-header-col">
<col *ngFor="let header of tableData.headers" class="data-col">
<col *ngFor="let i of extraColumns" class="data-col">
</colgroup>
<thead>
<tr>
<th class="corner-cell"></th>
<th *ngFor="let header of tableData.headers; let i = index">
{{ getColumnName(i) }}
</th>
<th *ngFor="let i of extraColumns;let j=index" class="empty-column">
{{ getColumnName(tableData.headers.length + j) }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of visibleRows; let rowIndex = index">
<td class="row-header">{{ getRowNumber(rowIndex) }}</td>
<td *ngFor="let cell of row; let colIndex = index"
[class.empty-cell]="!cell && cell !== 0">
{{ cell }}
</td>
<td *ngFor="let i of extraColumns" class="empty-cell"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
`, isInline: true, styles: [":root{--nfp-primary-color: #177ddc;--nfp-primary-hover: #1890ff;--nfp-primary-active: #0050b3;--nfp-error-color: #d32029;--nfp-warning-color: #d89614;--nfp-success-color: #49aa19;--nfp-text-primary: rgba(0, 0, 0, .85);--nfp-text-secondary: rgba(0, 0, 0, .65);--nfp-text-disabled: rgba(0, 0, 0, .25);--nfp-bg-container: #ffffff;--nfp-bg-elevated: #fafafa;--nfp-bg-layout: #f0f2f5;--nfp-hover-bg: rgba(0, 0, 0, .04);--nfp-border-color: #d9d9d9;--nfp-split-color: rgba(0, 0, 0, .06);--nfp-scrollbar-bg: #ffffff;--nfp-scrollbar-thumb: #d9d9d9;--nfp-toolbar-bg: #fafafa;--nfp-toolbar-border: #d9d9d9;--nfp-toolbar-hover: rgba(0, 0, 0, .04);--nfp-toolbar-active: #e6f4ff;--nfp-preview-mask: rgba(0, 0, 0, .3);--nfp-preview-loading-bg: rgba(255, 255, 255, .8);--nfp-preview-toolbar-bg: rgba(0, 0, 0, .1);--nfp-theme-transition-duration: .3s}[data-nfp-theme=dark]{--nfp-primary-color: #177ddc;--nfp-primary-hover: #1890ff;--nfp-primary-active: #0050b3;--nfp-error-color: #a61d24;--nfp-warning-color: #d89614;--nfp-success-color: #49aa19;--nfp-text-primary: rgba(255, 255, 255, .85);--nfp-text-secondary: rgba(255, 255, 255, .65);--nfp-text-disabled: rgba(255, 255, 255, .25);--nfp-bg-container: #1a1a1a;--nfp-bg-elevated: #262626;--nfp-bg-layout: #141414;--nfp-hover-bg: rgba(255, 255, 255, .08);--nfp-border-color: #303030;--nfp-split-color: rgba(255, 255, 255, .12);--nfp-scrollbar-bg: #1a1a1a;--nfp-scrollbar-thumb: #404040;--nfp-toolbar-bg: #262626;--nfp-toolbar-border: #303030;--nfp-toolbar-hover: rgba(255, 255, 255, .08);--nfp-toolbar-active: #111b26;--nfp-preview-mask: rgba(0, 0, 0, .65);--nfp-preview-loading-bg: rgba(0, 0, 0, .8);--nfp-preview-toolbar-bg: rgba(0, 0, 0, .4);--nfp-theme-transition-duration: .3s}*{transition:background-color var(--nfp-theme-transition-duration) var(--theme-transition-timing),border-color var(--nfp-theme-transition-duration) var(--theme-transition-timing),color var(--nfp-theme-transition-duration) var(--theme-transition-timing)}.no-transition,.no-transition *{transition:none!important}\n", ":host{display:block;width:100%;height:100%}.excel-container{width:100%;height:100%;background:var(--nfp-bg-container);display:flex;flex-direction:column;border-radius:8px;overflow:hidden}.toolbar{height:48px;min-height:48px;background:var(--nfp-toolbar-bg);display:flex;justify-content:space-between;align-items:center;padding:0 16px;border-bottom:1px solid var(--nfp-toolbar-border);gap:16px}.left-controls{display:flex;align-items:center;gap:8px}.sheet-controls{flex:1;display:flex;align-items:center;gap:1px;overflow-x:auto;scrollbar-width:none}.sheet-controls::-webkit-scrollbar{display:none}.sheet-btn{background:var(--nfp-bg-container);border:none;color:var(--nfp-text-primary);padding:6px 16px;font-size:13px;cursor:pointer;white-space:nowrap;height:32px;display:flex;align-items:center;position:relative}.sheet-btn:hover{background:var(--nfp-toolbar-hover)}.sheet-btn.active{background:var(--nfp-toolbar-bg);color:var(--nfp-primary-color)}.sheet-btn.active:after{content:\"\";position:absolute;bottom:0;left:0;right:0;height:2px;background:var(--nfp-primary-color)}.preview-container{flex:1;position:relative;background:var(--nfp-toolbar-bg);display:flex;height:100%;flex-direction:column}.preview-content{width:100%;height:100%;display:flex;flex-direction:column;background:var(--nfp-toolbar-bg);overflow:hidden}.preview-content .table-wrapper{width:100%;height:100%;overflow:auto;cursor:default;transform-origin:0 0}.preview-content .table-wrapper.dragging,.preview-content .table-wrapper.dragging *{cursor:grab;-webkit-user-select:none;user-select:none}.preview-content .table-wrapper table{border-collapse:collapse;table-layout:fixed;background:var(--nfp-toolbar-bg);color:var(--nfp-text-primary);-webkit-user-select:none;user-select:none;width:max-content;min-width:100%}.preview-content .table-wrapper table .row-header-col{width:50px;min-width:50px}.preview-content .table-wrapper table .data-col{width:120px;min-width:120px}.preview-content .table-wrapper table thead{position:sticky;top:-1px;z-index:2;background:var(--nfp-toolbar-bg);margin-bottom:-1px}.preview-content .table-wrapper table tbody{background:var(--nfp-toolbar-bg)}.preview-content .table-wrapper table th,.preview-content .table-wrapper table td{height:24px;padding:4px 8px;border:1px solid var(--nfp-border-color);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.preview-content .table-wrapper table th{background:var(--nfp-bg-elevated);font-weight:500;text-align:center;border-bottom:2px solid var(--nfp-border-color);color:var(--nfp-text-primary)}.preview-content .table-wrapper table .corner-cell{position:sticky;left:0;z-index:3;background:var(--nfp-toolbar-bg);border-right:2px solid var(--nfp-border-color);border-bottom:2px solid var(--nfp-border-color)}.preview-content .table-wrapper table .row-header{position:sticky;left:0;background:var(--nfp-bg-elevated);text-align:center;font-weight:500;z-index:1;border-right:2px solid var(--nfp-border-color);color:var(--nfp-text-primary)}.preview-content .table-wrapper table td{background:var(--nfp-toolbar-bg);text-align:left}.preview-content .table-wrapper table td.empty-cell{color:transparent;background:var(--nfp-bg-container)}.preview-content .table-wrapper table tbody tr:hover td{background:var(--nfp-hover-bg)}.preview-content .table-wrapper table tbody tr:hover td.empty-cell{background:var(--nfp-bg-container)}.preview-content .table-wrapper table tbody tr:hover td.row-header{background:var(--nfp-bg-elevated)}.preview-content .table-wrapper::-webkit-scrollbar{width:12px;height:12px}.preview-content .table-wrapper::-webkit-scrollbar-track{background:var(--nfp-scrollbar-bg)}.preview-content .table-wrapper::-webkit-scrollbar-thumb{background:var(--nfp-scrollbar-thumb);border:2px solid var(--nfp-scrollbar-bg);border-radius:6px}.preview-content .table-wrapper::-webkit-scrollbar-thumb:hover{background:var(--nfp-primary-color)}.tool-btn{background:transparent;border:none;color:var(--nfp-text-primary);width:32px;height:32px;padding:0;cursor:pointer;border-radius:4px;display:flex;align-items:center;justify-content:center;transition:all .2s}.tool-btn:hover{background:var(--nfp-toolbar-hover);color:var(--nfp-primary-color)}.zoom-text{color:var(--nfp-text-primary);font-size:13px;min-width:48px;text-align:center;cursor:pointer;padding:4px;border-radius:4px}.zoom-text:hover{background:var(--nfp-toolbar-hover);color:var(--nfp-primary-color)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: PreviewIconComponent, selector: "preview-icon", inputs: ["name", "svg", "size", "color", "themeMode", "title", "cursor"] }, { kind: "pipe", type: I18nPipe, name: "i18n" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ExcelPreviewComponent, decorators: [{
type: Component,
args: [{ selector: 'ngx-excel-preview', standalone: true, imports: [CommonModule, PreviewIconComponent, I18nPipe], template: `
<div class="excel-container" #container>
<div class="toolbar">
<div class="left-controls">
<button class="tool-btn" (click)="zoomOut()">
<preview-icon [themeMode]="themeMode" name="zoom-out" [title]="'preview.toolbar.zoomOut'|i18n"></preview-icon>
</button>
<span class="zoom-text" (click)="resetZoom()" [title]="'preview.toolbar.resetZoom'|i18n">
{{ (scale * 100).toFixed(0) }}%
</span>
<button class="tool-btn" (click)="zoomIn()">
<preview-icon [themeMode]="themeMode" name="zoom-in" [title]="'preview.toolbar.zoomIn'|i18n"></preview-icon>
</button>
</div>
<div class="sheet-controls" *ngIf="sheets.length > 0">
<button class="sheet-btn"
*ngFor="let sheet of sheets"
[class.active]="currentSheet === sheet"
(click)="switchSheet(sheet)">
{{ sheet }}
</button>
</div>
<div class="right-controls">
<button class="tool-btn" (click)="toggleFullscreen()">
<preview-icon [themeMode]="themeMode" name="fullscreen" [title]="'preview.toolbar.fullscreen'|i18n"></preview-icon>
</button>
</div>
</div>
<div class="preview-container">
<div class="preview-content">
<div class="table-wrapper"
#tableWrapper
(mousedown)="startDrag($event)"
(wheel)="handleWheel($event)"
[class.dragging]="isDragging"
[style.transform]="'scale(' + scale + ')'">
<table *ngIf="tableData">
<colgroup>
<col class="row-header-col">
<col *ngFor="let header of tableData.headers" class="data-col">
<col *ngFor="let i of extraColumns" class="data-col">
</colgroup>
<thead>
<tr>
<th class="corner-cell"></th>
<th *ngFor="let header of tableData.headers; let i = index">
{{ getColumnName(i) }}
</th>
<th *ngFor="let i of extraColumns;let j=index" class="empty-column">
{{ getColumnName(tableData.headers.length + j) }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of visibleRows; let rowIndex = index">
<td class="row-header">{{ getRowNumber(rowIndex) }}</td>
<td *ngFor="let cell of row; let colIndex = index"
[class.empty-cell]="!cell && cell !== 0">
{{ cell }}
</td>
<td *ngFor="let i of extraColumns" class="empty-cell"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":root{--nfp-primary-color: #177ddc;--nfp-primary-hover: #1890ff;--nfp-primary-active: #0050b3;--nfp-error-color: #d32029;--nfp-warning-color: #d89614;--nfp-success-color: #49aa19;--nfp-text-primary: rgba(0, 0, 0, .85);--nfp-text-secondary: rgba(0, 0, 0, .65);--nfp-text-disabled: rgba(0, 0, 0, .25);--nfp-bg-container: #ffffff;--nfp-bg-elevated: #fafafa;--n