diff --git a/copy-to-mobile.bat b/copy-to-mobile.bat new file mode 100644 index 0000000..9e76d55 --- /dev/null +++ b/copy-to-mobile.bat @@ -0,0 +1,22 @@ +@echo off +echo 正在复制文件到移动设备... +echo. + +REM 请修改下面的路径为你的 Obsidian 库路径 +set OBSIDIAN_PATH="../Doc/.obsidian\plugins\cf-imageBed" + +REM 创建插件目录(如果不存在) +if not exist %OBSIDIAN_PATH% ( + mkdir %OBSIDIAN_PATH% +) + +REM 复制文件 +copy main.js %OBSIDIAN_PATH%\ +copy manifest.json %OBSIDIAN_PATH%\ +copy styles.css %OBSIDIAN_PATH%\ + +echo. +echo 文件复制完成! +echo 请在 Obsidian 中重新加载插件或重启应用 +echo. +pause diff --git a/src/settings/settingsTab.ts b/src/settings/settingsTab.ts index 94f1641..b77b59a 100644 --- a/src/settings/settingsTab.ts +++ b/src/settings/settingsTab.ts @@ -1,5 +1,6 @@ import { App, PluginSettingTab, Setting } from 'obsidian'; import { CFImageBedSettings } from '../types'; +import { SettingsValidator } from './settingsValidator'; export class CFImageBedSettingTab extends PluginSettingTab { plugin: any; @@ -13,11 +14,60 @@ export class CFImageBedSettingTab extends PluginSettingTab { const {containerEl} = this; containerEl.empty(); + + // 添加插件专用的CSS类名,限制样式作用域 + containerEl.addClass('cf-imagebed-settings'); containerEl.createEl('h2', { text: 'CF ImageBed 设置' }); + + // 创建选项卡容器 + const tabContainer = containerEl.createDiv('cf-imagebed-tabs'); + const tabContent = containerEl.createDiv('cf-imagebed-tab-content'); + + // 创建选项卡按钮 + const basicTab = tabContainer.createEl('button', { text: '基础设置', cls: 'cf-tab-button active' }); + const advancedTab = tabContainer.createEl('button', { text: '高级设置', cls: 'cf-tab-button' }); + const userTab = tabContainer.createEl('button', { text: '用户体验', cls: 'cf-tab-button' }); + const backupTab = tabContainer.createEl('button', { text: '备份设置', cls: 'cf-tab-button' }); + + // 创建选项卡内容区域 + const basicContent = tabContent.createDiv('cf-tab-panel active'); + const advancedContent = tabContent.createDiv('cf-tab-panel'); + const userContent = tabContent.createDiv('cf-tab-panel'); + const backupContent = tabContent.createDiv('cf-tab-panel'); + + // 选项卡切换逻辑 + const switchTab = (activeTab: HTMLElement, activeContent: HTMLElement) => { + // 移除所有活动状态 + tabContainer.querySelectorAll('.cf-tab-button').forEach(btn => btn.classList.remove('active')); + tabContent.querySelectorAll('.cf-tab-panel').forEach(panel => panel.classList.remove('active')); + + // 添加活动状态 + activeTab.classList.add('active'); + activeContent.classList.add('active'); + }; + + basicTab.addEventListener('click', () => switchTab(basicTab, basicContent)); + advancedTab.addEventListener('click', () => switchTab(advancedTab, advancedContent)); + userTab.addEventListener('click', () => switchTab(userTab, userContent)); + backupTab.addEventListener('click', () => switchTab(backupTab, backupContent)); + // 基础设置选项卡内容 + this.createBasicSettings(basicContent); + + // 高级设置选项卡内容 + this.createAdvancedSettings(advancedContent); + + // 用户体验选项卡内容 + this.createUserExperienceSettings(userContent); + + // 备份设置选项卡内容 + this.createBackupSettings(backupContent); + } + + private createBasicSettings(container: HTMLElement): void { // API URL 设置 - new Setting(containerEl) + new Setting(container) .setName('API URL') .setDesc('CloudFlare ImgBed 的 API 地址(例如:https://your.domain)') .addText(text => text @@ -29,7 +79,7 @@ export class CFImageBedSettingTab extends PluginSettingTab { })); // 认证码设置 - new Setting(containerEl) + new Setting(container) .setName('认证码') .setDesc('上传认证码') .addText(text => text @@ -41,7 +91,7 @@ export class CFImageBedSettingTab extends PluginSettingTab { })); // 上传渠道设置 - new Setting(containerEl) + new Setting(container) .setName('上传渠道') .setDesc('选择上传渠道') .addDropdown(dropdown => dropdown @@ -55,7 +105,7 @@ export class CFImageBedSettingTab extends PluginSettingTab { })); // 文件命名方式设置 - new Setting(containerEl) + new Setting(container) .setName('文件命名方式') .setDesc('选择文件命名方式') .addDropdown(dropdown => dropdown @@ -70,7 +120,7 @@ export class CFImageBedSettingTab extends PluginSettingTab { })); // 返回格式设置 - new Setting(containerEl) + new Setting(container) .setName('返回链接格式') .setDesc('选择返回链接格式') .addDropdown(dropdown => dropdown @@ -83,7 +133,7 @@ export class CFImageBedSettingTab extends PluginSettingTab { })); // 上传目录设置 - new Setting(containerEl) + new Setting(container) .setName('上传目录') .setDesc('上传目录,用相对路径表示(例如:img/test)') .addText(text => text @@ -95,7 +145,7 @@ export class CFImageBedSettingTab extends PluginSettingTab { })); // 服务端压缩设置 - new Setting(containerEl) + new Setting(container) .setName('服务端压缩') .setDesc('启用服务端压缩(仅针对 Telegram 渠道的图片文件)') .addToggle(toggle => toggle @@ -106,7 +156,7 @@ export class CFImageBedSettingTab extends PluginSettingTab { })); // 自动重试设置 - new Setting(containerEl) + new Setting(container) .setName('自动重试') .setDesc('失败时自动切换渠道重试') .addToggle(toggle => toggle @@ -116,4 +166,238 @@ export class CFImageBedSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); } + + private createAdvancedSettings(container: HTMLElement): void { + // 文件大小限制 + new Setting(container) + .setName('最大文件大小') + .setDesc('设置上传文件的最大大小(MB)') + .addSlider(slider => slider + .setLimits(1, 50, 1) + .setValue(this.plugin.settings.maxFileSize) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.maxFileSize = value; + await this.plugin.saveSettings(); + })); + + // 允许的文件类型 + new Setting(container) + .setName('允许的文件类型') + .setDesc('设置允许上传的文件类型(用逗号分隔)') + .addText(text => text + .setPlaceholder('jpg,jpeg,png,gif,webp,bmp') + .setValue(this.plugin.settings.allowedFileTypes.join(',')) + .onChange(async (value) => { + this.plugin.settings.allowedFileTypes = value.split(',').map(t => t.trim()); + await this.plugin.saveSettings(); + })); + + // 水印设置 + new Setting(container) + .setName('启用水印') + .setDesc('为上传的图片添加水印') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableWatermark) + .onChange(async (value) => { + this.plugin.settings.enableWatermark = value; + await this.plugin.saveSettings(); + })); + + // 水印文字 + new Setting(container) + .setName('水印文字') + .setDesc('设置水印文字内容') + .addText(text => text + .setPlaceholder('水印文字') + .setValue(this.plugin.settings.watermarkText) + .setDisabled(!this.plugin.settings.enableWatermark) + .onChange(async (value) => { + this.plugin.settings.watermarkText = value; + await this.plugin.saveSettings(); + })); + + // 水印位置 + new Setting(container) + .setName('水印位置') + .setDesc('设置水印在图片中的位置') + .addDropdown(dropdown => dropdown + .addOption('top-left', '左上角') + .addOption('top-right', '右上角') + .addOption('bottom-left', '左下角') + .addOption('bottom-right', '右下角') + .addOption('center', '居中') + .setValue(this.plugin.settings.watermarkPosition) + .setDisabled(!this.plugin.settings.enableWatermark) + .onChange(async (value) => { + this.plugin.settings.watermarkPosition = value; + await this.plugin.saveSettings(); + })); + + // 水印字体大小 + new Setting(container) + .setName('水印字体大小') + .setDesc('设置水印文字的字体大小(像素)') + .addSlider(slider => slider + .setLimits(12, 72, 2) + .setValue(this.plugin.settings.watermarkSize) + .setDynamicTooltip() + .setDisabled(!this.plugin.settings.enableWatermark) + .onChange(async (value) => { + this.plugin.settings.watermarkSize = value; + await this.plugin.saveSettings(); + })); + + // 水印透明度 + new Setting(container) + .setName('水印透明度') + .setDesc('设置水印的透明度(0-1)') + .addSlider(slider => slider + .setLimits(0.1, 1, 0.1) + .setValue(this.plugin.settings.watermarkOpacity) + .setDynamicTooltip() + .setDisabled(!this.plugin.settings.enableWatermark) + .onChange(async (value) => { + this.plugin.settings.watermarkOpacity = value; + await this.plugin.saveSettings(); + })); + + // 客户端压缩设置 + new Setting(container) + .setName('启用客户端压缩') + .setDesc('在上传前自动压缩图片以减少文件大小') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableClientCompress) + .onChange(async (value) => { + this.plugin.settings.enableClientCompress = value; + await this.plugin.saveSettings(); + })); + + // 压缩阈值 + new Setting(container) + .setName('压缩阈值') + .setDesc('设置图片大小阈值,超过此值将自动压缩(MB)') + .addSlider(slider => slider + .setLimits(0.5, 10, 0.5) + .setValue(this.plugin.settings.compressThreshold) + .setDynamicTooltip() + .setDisabled(!this.plugin.settings.enableClientCompress) + .onChange(async (value) => { + this.plugin.settings.compressThreshold = value; + await this.plugin.saveSettings(); + })); + + // 期望大小 + new Setting(container) + .setName('期望大小') + .setDesc('设置压缩后图片大小期望值(MB)') + .addSlider(slider => slider + .setLimits(0.1, 5, 0.1) + .setValue(this.plugin.settings.targetSize) + .setDynamicTooltip() + .setDisabled(!this.plugin.settings.enableClientCompress) + .onChange(async (value) => { + this.plugin.settings.targetSize = value; + await this.plugin.saveSettings(); + })); + } + + private createUserExperienceSettings(container: HTMLElement): void { + // 显示上传进度 + new Setting(container) + .setName('显示上传进度') + .setDesc('在上传过程中显示进度条') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showUploadProgress) + .onChange(async (value) => { + this.plugin.settings.showUploadProgress = value; + await this.plugin.saveSettings(); + })); + + // 显示成功通知 + new Setting(container) + .setName('显示成功通知') + .setDesc('上传成功后显示通知消息') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showSuccessNotification) + .onChange(async (value) => { + this.plugin.settings.showSuccessNotification = value; + await this.plugin.saveSettings(); + })); + + // 显示错误通知 + new Setting(container) + .setName('显示错误通知') + .setDesc('上传失败时显示错误消息') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showErrorNotification) + .onChange(async (value) => { + this.plugin.settings.showErrorNotification = value; + await this.plugin.saveSettings(); + })); + + // 通知持续时间 + new Setting(container) + .setName('通知持续时间') + .setDesc('设置通知消息显示的持续时间(秒)') + .addSlider(slider => slider + .setLimits(1, 10, 1) + .setValue(this.plugin.settings.notificationDuration) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.notificationDuration = value; + await this.plugin.saveSettings(); + })); + + // 快捷键设置 + new Setting(container) + .setName('启用快捷键') + .setDesc('启用快捷键快速上传图片') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableHotkey) + .onChange(async (value) => { + this.plugin.settings.enableHotkey = value; + await this.plugin.saveSettings(); + })); + + // 快捷键 + new Setting(container) + .setName('快捷键') + .setDesc('设置上传图片的快捷键') + .addText(text => text + .setPlaceholder('Ctrl+Shift+U') + .setValue(this.plugin.settings.hotkey) + .setDisabled(!this.plugin.settings.enableHotkey) + .onChange(async (value) => { + this.plugin.settings.hotkey = value; + await this.plugin.saveSettings(); + })); + } + + private createBackupSettings(container: HTMLElement): void { + // 启用本地备份 + new Setting(container) + .setName('启用本地备份') + .setDesc('在上传到云端的同时,在本地保存一份备份') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableLocalBackup) + .onChange(async (value) => { + this.plugin.settings.enableLocalBackup = value; + await this.plugin.saveSettings(); + })); + + // 备份路径 + new Setting(container) + .setName('备份路径') + .setDesc('设置本地备份的存储路径(相对于库根目录)') + .addText(text => text + .setPlaceholder('attachments/backup') + .setValue(this.plugin.settings.backupPath) + .setDisabled(!this.plugin.settings.enableLocalBackup) + .onChange(async (value) => { + this.plugin.settings.backupPath = value; + await this.plugin.saveSettings(); + })); + } + } diff --git a/src/settings/settingsValidator.ts b/src/settings/settingsValidator.ts new file mode 100644 index 0000000..e29f710 --- /dev/null +++ b/src/settings/settingsValidator.ts @@ -0,0 +1,103 @@ +import { Notice } from 'obsidian'; +import { CFImageBedSettings } from '../types'; + +export class SettingsValidator { + static validateSettings(settings: CFImageBedSettings): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + // 验证基础配置 + if (!settings.apiUrl) { + errors.push('API URL 不能为空'); + } else if (!this.isValidUrl(settings.apiUrl)) { + errors.push('API URL 格式不正确'); + } + + if (!settings.authCode) { + errors.push('认证码不能为空'); + } + + // 验证文件大小 + if (settings.maxFileSize < 1 || settings.maxFileSize > 100) { + errors.push('文件大小限制应在 1-100 MB 之间'); + } + + // 验证客户端压缩配置 + if (settings.enableClientCompress) { + if (settings.compressThreshold < 0.1 || settings.compressThreshold > 20) { + errors.push('压缩阈值应在 0.1-20 MB 之间'); + } + + if (settings.targetSize < 0.1 || settings.targetSize > 10) { + errors.push('期望大小应在 0.1-10 MB 之间'); + } + + if (settings.targetSize >= settings.compressThreshold) { + errors.push('期望大小应小于压缩阈值'); + } + } + + // 验证通知持续时间 + if (settings.notificationDuration < 1 || settings.notificationDuration > 30) { + errors.push('通知持续时间应在 1-30 秒之间'); + } + + // 验证文件类型 + if (settings.allowedFileTypes.length === 0) { + errors.push('至少需要指定一种允许的文件类型'); + } + + // 验证水印设置 + if (settings.enableWatermark) { + if (!settings.watermarkText.trim()) { + errors.push('启用水印时必须设置水印文字'); + } + + if (settings.watermarkSize < 8 || settings.watermarkSize > 100) { + errors.push('水印字体大小应在 8-100 像素之间'); + } + + if (settings.watermarkOpacity < 0.1 || settings.watermarkOpacity > 1) { + errors.push('水印透明度应在 0.1-1 之间'); + } + } + + // 验证备份路径 + if (settings.enableLocalBackup && !settings.backupPath.trim()) { + errors.push('启用本地备份时必须设置备份路径'); + } + + return { + isValid: errors.length === 0, + errors + }; + } + + static isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } + } + + static validateFileType(fileName: string, allowedTypes: string[]): boolean { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension ? allowedTypes.includes(extension) : false; + } + + static validateFileSize(fileSize: number, maxSizeMB: number): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024; + return fileSize <= maxSizeBytes; + } + + static showValidationErrors(errors: string[]): void { + if (errors.length > 0) { + new Notice(`配置验证失败:\n${errors.join('\n')}`, 5000); + } + } + + static showValidationSuccess(): void { + new Notice('配置验证通过', 2000); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 1533351..a6507fe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ export interface CFImageBedSettings { + // 基础配置 apiUrl: string; authCode: string; uploadChannel: string; @@ -7,9 +8,38 @@ export interface CFImageBedSettings { uploadFolder: string; serverCompress: boolean; autoRetry: boolean; + + // 高级配置 + maxFileSize: number; // MB + allowedFileTypes: string[]; + enableWatermark: boolean; + watermarkText: string; + watermarkPosition: string; + watermarkSize: number; // 水印字体大小 + watermarkOpacity: number; // 水印透明度 0-1 + + // 客户端压缩配置 + enableClientCompress: boolean; + compressThreshold: number; // MB - 压缩阈值 + targetSize: number; // MB - 期望大小 + + // 用户体验配置 + showUploadProgress: boolean; + showSuccessNotification: boolean; + showErrorNotification: boolean; + notificationDuration: number; // 秒 + + // 备份配置 + enableLocalBackup: boolean; + backupPath: string; + + // 快捷键配置 + enableHotkey: boolean; + hotkey: string; } export const DEFAULT_SETTINGS: CFImageBedSettings = { + // 基础配置 apiUrl: '', authCode: '', uploadChannel: 'telegram', @@ -17,5 +47,33 @@ export const DEFAULT_SETTINGS: CFImageBedSettings = { returnFormat: 'default', uploadFolder: '', serverCompress: true, - autoRetry: true + autoRetry: true, + + // 高级配置 + maxFileSize: 10, // 10MB + allowedFileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], + enableWatermark: false, + watermarkText: '', + watermarkPosition: 'bottom-right', + watermarkSize: 24, // 字体大小 + watermarkOpacity: 0.7, // 透明度 + + // 客户端压缩配置 + enableClientCompress: false, + compressThreshold: 2, // 2MB + targetSize: 1, // 1MB + + // 用户体验配置 + showUploadProgress: false, + showSuccessNotification: true, + showErrorNotification: false, + notificationDuration: 5, + + // 备份配置 + enableLocalBackup: false, + backupPath: '', + + // 快捷键配置 + enableHotkey: false, + hotkey: 'Ctrl+Shift+U' }; diff --git a/src/upload/uploadService.ts b/src/upload/uploadService.ts index c9a3eab..24de6b8 100644 --- a/src/upload/uploadService.ts +++ b/src/upload/uploadService.ts @@ -1,5 +1,7 @@ import { Notice } from 'obsidian'; import { CFImageBedSettings } from '../types'; +import { ClientCompressor } from '../utils/clientCompressor'; +import { ClientWatermark } from '../utils/clientWatermark'; export class UploadService { constructor(private settings: CFImageBedSettings) {} @@ -11,8 +13,51 @@ export class UploadService { } try { + // 检查文件类型 + if (!this.isAllowedFileType(file)) { + new Notice(`不支持的文件类型: ${file.type}`); + return null; + } + + // 检查文件大小 + if (!this.isFileSizeAllowed(file)) { + new Notice(`文件大小超过限制: ${ClientCompressor.formatFileSize(file.size)}`); + return null; + } + + // 客户端处理(水印 + 压缩) + let processedFile = file; + + // 1. 添加水印 + if (this.settings.enableWatermark && ClientWatermark.isWatermarkable(file)) { + console.log('CF ImageBed: 开始添加水印'); + processedFile = await ClientWatermark.addWatermark( + processedFile, + this.settings.watermarkText, + this.settings.watermarkPosition, + this.settings.watermarkSize, + this.settings.watermarkOpacity + ); + } + + // 2. 客户端压缩 + if (this.settings.enableClientCompress && ClientCompressor.isCompressible(processedFile)) { + console.log('CF ImageBed: 开始客户端压缩处理'); + processedFile = await ClientCompressor.compressImage( + processedFile, + this.settings.targetSize, + this.settings.compressThreshold + ); + + // 显示压缩结果 + const originalSize = ClientCompressor.formatFileSize(file.size); + const processedSize = ClientCompressor.formatFileSize(processedFile.size); + console.log(`CF ImageBed: 处理完成 - 原始: ${originalSize}, 处理后: ${processedSize}`); + } + + // 创建上传参数 const formData = new FormData(); - formData.append('file', file); + formData.append('file', processedFile); const params = new URLSearchParams({ authCode: this.settings.authCode, @@ -27,6 +72,7 @@ export class UploadService { params.append('uploadFolder', this.settings.uploadFolder); } + // 上传文件 const response = await fetch(`${this.settings.apiUrl}/upload?${params}`, { method: 'POST', body: formData @@ -56,4 +102,20 @@ export class UploadService { return null; } } + + /** + * 检查文件类型是否允许 + */ + private isAllowedFileType(file: File): boolean { + const extension = file.name.split('.').pop()?.toLowerCase(); + return extension ? this.settings.allowedFileTypes.includes(extension) : false; + } + + /** + * 检查文件大小是否允许 + */ + private isFileSizeAllowed(file: File): boolean { + const maxSizeBytes = this.settings.maxFileSize * 1024 * 1024; + return file.size <= maxSizeBytes; + } } diff --git a/src/utils/clientCompressor.ts b/src/utils/clientCompressor.ts new file mode 100644 index 0000000..3c6d2ea --- /dev/null +++ b/src/utils/clientCompressor.ts @@ -0,0 +1,164 @@ +import { Notice } from 'obsidian'; + +export class ClientCompressor { + /** + * 压缩图片文件 + * @param file 原始图片文件 + * @param targetSizeMB 目标大小(MB) + * @param thresholdMB 压缩阈值(MB) + * @returns 压缩后的文件或原始文件 + */ + static async compressImage( + file: File, + targetSizeMB: number, + thresholdMB: number + ): Promise { + // 检查文件大小是否超过阈值 + const fileSizeMB = file.size / (1024 * 1024); + + if (fileSizeMB <= thresholdMB) { + console.log(`CF ImageBed: 文件大小 ${fileSizeMB.toFixed(2)}MB 未超过阈值 ${thresholdMB}MB,跳过压缩`); + return file; + } + + console.log(`CF ImageBed: 开始压缩图片,原始大小: ${fileSizeMB.toFixed(2)}MB,目标大小: ${targetSizeMB}MB`); + + try { + // 创建图片对象 + const img = new Image(); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('无法创建画布上下文'); + } + + // 等待图片加载 + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = URL.createObjectURL(file); + }); + + // 计算压缩后的尺寸 + const { width, height } = this.calculateCompressedDimensions( + img.width, + img.height, + targetSizeMB, + fileSizeMB + ); + + // 设置画布尺寸 + canvas.width = width; + canvas.height = height; + + // 绘制压缩后的图片 + ctx.drawImage(img, 0, 0, width, height); + + // 转换为 Blob + const compressedBlob = await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + throw new Error('压缩失败'); + } + }, 'image/jpeg', 0.8); // 使用 JPEG 格式,质量 0.8 + }); + + // 创建新的文件对象 + const compressedFile = new File([compressedBlob], file.name, { + type: 'image/jpeg', + lastModified: Date.now() + }); + + const compressedSizeMB = compressedFile.size / (1024 * 1024); + console.log(`CF ImageBed: 压缩完成,压缩后大小: ${compressedSizeMB.toFixed(2)}MB`); + + // 清理资源 + URL.revokeObjectURL(img.src); + + return compressedFile; + + } catch (error) { + console.error('CF ImageBed: 图片压缩失败:', error); + new Notice('图片压缩失败,将上传原始文件', 3000); + return file; + } + } + + /** + * 计算压缩后的图片尺寸 + * @param originalWidth 原始宽度 + * @param originalHeight 原始高度 + * @param targetSizeMB 目标大小(MB) + * @param originalSizeMB 原始大小(MB) + * @returns 压缩后的尺寸 + */ + private static calculateCompressedDimensions( + originalWidth: number, + originalHeight: number, + targetSizeMB: number, + originalSizeMB: number + ): { width: number; height: number } { + // 计算压缩比例(基于文件大小) + const sizeRatio = Math.sqrt(targetSizeMB / originalSizeMB); + + // 计算新尺寸 + let newWidth = Math.floor(originalWidth * sizeRatio); + let newHeight = Math.floor(originalHeight * sizeRatio); + + // 确保尺寸不会太小(最小 100px) + const minSize = 100; + if (newWidth < minSize || newHeight < minSize) { + const aspectRatio = originalWidth / originalHeight; + if (aspectRatio > 1) { + newWidth = Math.max(minSize, newWidth); + newHeight = Math.floor(newWidth / aspectRatio); + } else { + newHeight = Math.max(minSize, newHeight); + newWidth = Math.floor(newHeight * aspectRatio); + } + } + + // 确保尺寸不会太大(最大 4000px) + const maxSize = 4000; + if (newWidth > maxSize || newHeight > maxSize) { + const aspectRatio = originalWidth / originalHeight; + if (aspectRatio > 1) { + newWidth = Math.min(maxSize, newWidth); + newHeight = Math.floor(newWidth / aspectRatio); + } else { + newHeight = Math.min(maxSize, newHeight); + newWidth = Math.floor(newHeight * aspectRatio); + } + } + + return { width: newWidth, height: newHeight }; + } + + /** + * 检查文件类型是否支持压缩 + * @param file 文件对象 + * @returns 是否支持压缩 + */ + static isCompressible(file: File): boolean { + const compressibleTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; + return compressibleTypes.includes(file.type.toLowerCase()); + } + + /** + * 获取文件大小描述 + * @param bytes 字节数 + * @returns 格式化的文件大小 + */ + static formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} diff --git a/src/utils/clientWatermark.ts b/src/utils/clientWatermark.ts new file mode 100644 index 0000000..755a066 --- /dev/null +++ b/src/utils/clientWatermark.ts @@ -0,0 +1,178 @@ +import { Notice } from 'obsidian'; + +export class ClientWatermark { + /** + * 为图片添加水印 + * @param file 原始图片文件 + * @param watermarkText 水印文字 + * @param position 水印位置 + * @param fontSize 字体大小 + * @param opacity 透明度 + * @returns 带水印的图片文件 + */ + static async addWatermark( + file: File, + watermarkText: string, + position: string, + fontSize: number, + opacity: number + ): Promise { + if (!watermarkText.trim()) { + console.log('CF ImageBed: 水印文字为空,跳过水印处理'); + return file; + } + + console.log(`CF ImageBed: 开始添加水印 - 文字: ${watermarkText}, 位置: ${position}`); + + try { + // 创建图片对象 + const img = new Image(); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('无法创建画布上下文'); + } + + // 等待图片加载 + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = URL.createObjectURL(file); + }); + + // 设置画布尺寸 + canvas.width = img.width; + canvas.height = img.height; + + // 绘制原始图片 + ctx.drawImage(img, 0, 0); + + // 设置水印样式 + ctx.font = `bold ${fontSize}px Arial, sans-serif`; + ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`; + ctx.strokeStyle = `rgba(0, 0, 0, ${opacity * 0.5})`; + ctx.lineWidth = 2; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // 计算水印位置 + const positionData = this.calculateWatermarkPosition( + img.width, + img.height, + position, + fontSize, + watermarkText + ); + + // 绘制水印文字(带描边效果) + ctx.strokeText(watermarkText, positionData.x, positionData.y); + ctx.fillText(watermarkText, positionData.x, positionData.y); + + // 转换为 Blob + const watermarkedBlob = await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + throw new Error('水印添加失败'); + } + }, file.type, 0.9); + }); + + // 创建新的文件对象 + const watermarkedFile = new File([watermarkedBlob], file.name, { + type: file.type, + lastModified: Date.now() + }); + + console.log('CF ImageBed: 水印添加完成'); + + // 清理资源 + URL.revokeObjectURL(img.src); + + return watermarkedFile; + + } catch (error) { + console.error('CF ImageBed: 水印添加失败:', error); + new Notice('水印添加失败,将上传原始文件', 3000); + return file; + } + } + + /** + * 计算水印位置 + * @param imgWidth 图片宽度 + * @param imgHeight 图片高度 + * @param position 位置字符串 + * @param fontSize 字体大小 + * @param text 水印文字 + * @returns 水印位置坐标 + */ + private static calculateWatermarkPosition( + imgWidth: number, + imgHeight: number, + position: string, + fontSize: number, + text: string + ): { x: number; y: number } { + const padding = fontSize; // 边距 + const textWidth = text.length * fontSize * 0.6; // 估算文字宽度 + const textHeight = fontSize; + + let x: number, y: number; + + switch (position) { + case 'top-left': + x = padding + textWidth / 2; + y = padding + textHeight / 2; + break; + case 'top-right': + x = imgWidth - padding - textWidth / 2; + y = padding + textHeight / 2; + break; + case 'bottom-left': + x = padding + textWidth / 2; + y = imgHeight - padding - textHeight / 2; + break; + case 'bottom-right': + x = imgWidth - padding - textWidth / 2; + y = imgHeight - padding - textHeight / 2; + break; + case 'center': + x = imgWidth / 2; + y = imgHeight / 2; + break; + default: + // 默认右下角 + x = imgWidth - padding - textWidth / 2; + y = imgHeight - padding - textHeight / 2; + } + + return { x, y }; + } + + /** + * 检查是否支持添加水印 + * @param file 文件对象 + * @returns 是否支持水印 + */ + static isWatermarkable(file: File): boolean { + const watermarkableTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; + return watermarkableTypes.includes(file.type.toLowerCase()); + } + + /** + * 获取水印位置选项 + * @returns 位置选项数组 + */ + static getPositionOptions(): Array<{ value: string; label: string }> { + return [ + { value: 'top-left', label: '左上角' }, + { value: 'top-right', label: '右上角' }, + { value: 'bottom-left', label: '左下角' }, + { value: 'bottom-right', label: '右下角' }, + { value: 'center', label: '居中' } + ]; + } +} diff --git a/styles.css b/styles.css index 71cc60f..42edaaa 100644 --- a/styles.css +++ b/styles.css @@ -6,3 +6,174 @@ available in the app when your plugin is enabled. If your plugin does not need CSS, delete this file. */ + +/* CF ImageBed 插件专用样式 - 限制作用域避免影响全局 */ + +/* 设置界面选项卡样式 - 仅在插件设置页面生效 */ +.cf-imagebed-settings .cf-imagebed-tabs { + display: flex; + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 1rem; + gap: 0.5rem; +} + +.cf-imagebed-settings .cf-tab-button { + background: none; + border: none; + padding: 0.5rem 1rem; + cursor: pointer; + color: var(--text-muted); + border-bottom: 2px solid transparent; + transition: all 0.2s ease; + font-size: 0.9rem; +} + +.cf-imagebed-settings .cf-tab-button:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.cf-imagebed-settings .cf-tab-button.active { + color: var(--text-accent); + border-bottom-color: var(--text-accent); + background: var(--background-modifier-active); +} + +.cf-imagebed-settings .cf-imagebed-tab-content { + position: relative; +} + +.cf-imagebed-settings .cf-tab-panel { + display: none; + animation: cf-fadeIn 0.3s ease-in-out; +} + +.cf-imagebed-settings .cf-tab-panel.active { + display: block; +} + +@keyframes cf-fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 滑块样式优化 - 仅在插件设置页面生效 */ +.cf-imagebed-settings .setting-item .slider-container { + width: 100%; + margin-top: 0.5rem; +} + +.cf-imagebed-settings .setting-item .slider-container input[type="range"] { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--background-modifier-border); + outline: none; + -webkit-appearance: none; +} + +.cf-imagebed-settings .setting-item .slider-container input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--text-accent); + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.cf-imagebed-settings .setting-item .slider-container input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--text-accent); + cursor: pointer; + border: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +/* 按钮样式优化 - 仅在插件设置页面生效 */ +.cf-imagebed-settings .setting-item .mod-button { + padding: 0.5rem 1rem; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); + color: var(--text-normal); + cursor: pointer; + transition: all 0.2s ease; +} + +.cf-imagebed-settings .setting-item .mod-button:hover { + background: var(--background-modifier-hover); + border-color: var(--text-accent); +} + +.cf-imagebed-settings .setting-item .mod-button.mod-warning { + background: var(--text-error); + color: var(--text-on-accent); + border-color: var(--text-error); +} + +.cf-imagebed-settings .setting-item .mod-button.mod-warning:hover { + background: var(--text-error-hover); +} + +/* 设置项分组样式 - 仅在插件设置页面生效 */ +.cf-imagebed-settings .setting-item { + margin-bottom: 1rem; + padding: 0.75rem; + border-radius: 6px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + transition: all 0.2s ease; +} + +.cf-imagebed-settings .setting-item:hover { + background: var(--background-modifier-hover); + border-color: var(--text-accent); +} + +.cf-imagebed-settings .setting-item .setting-item-name { + font-weight: 600; + color: var(--text-normal); + margin-bottom: 0.25rem; +} + +.cf-imagebed-settings .setting-item .setting-item-description { + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.4; +} + +/* 禁用状态样式 - 仅在插件设置页面生效 */ +.cf-imagebed-settings .setting-item .setting-item-control input:disabled, +.cf-imagebed-settings .setting-item .setting-item-control select:disabled, +.cf-imagebed-settings .setting-item .setting-item-control button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 响应式设计 - 仅在插件设置页面生效 */ +@media (max-width: 768px) { + .cf-imagebed-settings .cf-imagebed-tabs { + flex-wrap: wrap; + gap: 0.25rem; + } + + .cf-imagebed-settings .cf-tab-button { + padding: 0.4rem 0.8rem; + font-size: 0.8rem; + } + + .cf-imagebed-settings .setting-item { + padding: 0.5rem; + margin-bottom: 0.75rem; + } +} \ No newline at end of file