Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 52 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ ClipShot keeps it simple: **paste → saved → path inserted**.
- **Smart Paste**: Press `Ctrl+Shift+V` (or `Cmd+Shift+V` on macOS) to paste clipboard images
- **Non-intrusive**: Uses a dedicated shortcut to avoid conflicts with standard paste
- **Automatic Saving**: Images are saved to a configurable directory in your workspace
- **Image Resizing**: Automatically resize images to fit within specified dimensions (great for AI chat token optimization)
- **Cross-Platform**: Works on Windows, macOS, and Linux
- **Multiple Formats**: Supports PNG, JPEG, and WebP output formats
- **Secure by Design**: Prevents command injection and path traversal attacks
- **Configurable**: File naming, output format, quality settings, insertion format, and more
- **Configurable**: File naming, output format, quality settings, resize options, insertion format, and more

---

Expand Down Expand Up @@ -101,15 +102,56 @@ By default (`auto` mode), ClipShot detects the file type and uses the appropriat

## Configuration

| Setting | Default | Description |
| ----------------------------- | ------------------------------------------------- | --------------------------------------- |
| `clipshot.enabled` | `true` | Enable/disable the extension |
| `clipshot.saveDirectory` | `.clipshot` | Directory to save images |
| `clipshot.fileName.pattern` | `image_${yyyy}${MM}${dd}_${HH}${mm}${ss}_${seq3}` | File name pattern |
| `clipshot.output.format` | `png` | Output format (png/jpeg/webp) |
| `clipshot.output.jpegQuality` | `80` | JPEG quality (1-100) |
| `clipshot.output.webpQuality` | `80` | WebP quality (1-100) |
| `clipshot.insert.format` | `auto` | Insert format (auto/path/markdown/html) |
| Setting | Default | Description |
| ----------------------------- | ------------------------------------------------- | ---------------------------------------------------- |
| `clipshot.enabled` | `true` | Enable/disable the extension |
| `clipshot.saveDirectory` | `.clipshot` | Directory to save images |
| `clipshot.fileName.pattern` | `image_${yyyy}${MM}${dd}_${HH}${mm}${ss}_${seq3}` | File name pattern |
| `clipshot.output.format` | `png` | Output format (png/jpeg/webp) |
| `clipshot.output.jpegQuality` | `80` | JPEG quality (1-100) |
| `clipshot.output.webpQuality` | `80` | WebP quality (1-100) |
| `clipshot.resize.mode` | `off` | Resize mode (off/fit) |
| `clipshot.resize.maxWidth` | `1200` | Maximum width in pixels (1-16384) |
| `clipshot.resize.maxHeight` | `1200` | Maximum height in pixels (1-16384) |
| `clipshot.resize.preset` | `null` | Resize preset (null/ai-optimized) |
| `clipshot.insert.format` | `auto` | Insert format (auto/path/markdown/html) |

### Image Resizing

ClipShot can automatically resize images to fit within specified dimensions while maintaining aspect ratio. This is useful for:

- **AI Chat Optimization**: Reduce image size to lower token consumption when pasting into AI assistants
- **Documentation**: Ensure consistent image sizes in your documentation

#### Resize Modes

- `off` - No resizing (default)
- `fit` - Resize to fit within maxWidth/maxHeight while maintaining aspect ratio

#### Resize Presets

- `ai-optimized` - Optimized for AI chat (1200x1200 max, reduces token usage)

When a preset is set, it overrides manual maxWidth/maxHeight settings.

**Example: Enable AI-optimized resizing**

```json
{
"clipshot.resize.mode": "fit",
"clipshot.resize.preset": "ai-optimized"
}
```

**Example: Custom dimensions**

```json
{
"clipshot.resize.mode": "fit",
"clipshot.resize.maxWidth": 800,
"clipshot.resize.maxHeight": 600
}
```

### File Name Pattern Tokens

Expand Down
45 changes: 44 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "clipshot",
"displayName": "ClipShot",
"description": "Paste clipboard images with automatic file saving - perfect for AI chat and documentation",
"version": "0.1.5",
"version": "0.1.6",
"publisher": "kkdev92",
"author": {
"name": "kkdev92",
Expand Down Expand Up @@ -115,6 +115,49 @@
"maximum": 100,
"description": "WebP quality (1-100)"
},
"clipshot.resize.mode": {
"type": "string",
"default": "off",
"enum": [
"off",
"fit"
],
"enumDescriptions": [
"No resizing - save images at original size",
"Resize images to fit within maxWidth/maxHeight while maintaining aspect ratio"
],
"description": "Image resize mode"
},
"clipshot.resize.maxWidth": {
"type": "integer",
"default": 1200,
"minimum": 1,
"maximum": 16384,
"description": "Maximum image width in pixels (1-16384). Ignored if preset is set."
},
"clipshot.resize.maxHeight": {
"type": "integer",
"default": 1200,
"minimum": 1,
"maximum": 16384,
"description": "Maximum image height in pixels (1-16384). Ignored if preset is set."
},
"clipshot.resize.preset": {
"type": [
"string",
"null"
],
"default": null,
"enum": [
null,
"ai-optimized"
],
"enumDescriptions": [
"Use manual maxWidth/maxHeight settings",
"Optimized for AI chat (1200x1200 max, reduces token usage)"
],
"description": "Resize preset. Overrides maxWidth/maxHeight when set."
},
"clipshot.insert.format": {
"type": "string",
"default": "auto",
Expand Down
199 changes: 199 additions & 0 deletions src/config/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
VALID_PATTERN_TOKENS,
PATTERN_TOKENS,
DANGEROUS_SHELL_CHARS,
VALID_RESIZE_MODES,
RESIZE_PRESETS,
} from '../core/constants';
import { containsParentTraversal, isAbsolutePath } from '../security/path-validator';

Expand Down Expand Up @@ -168,6 +170,32 @@ export function validateJpegQuality(value: number): ValidationResult {
};
}

/**
* Validate the webpQuality setting
*
* @param value - The WebP quality value
* @returns Validation result
*/
export function validateWebpQuality(value: number): ValidationResult {
const errors: string[] = [];

if (typeof value !== 'number' || !Number.isInteger(value)) {
errors.push('WebP quality must be an integer');
return { valid: false, errors };
}

if (value < LIMITS.MIN_WEBP_QUALITY || value > LIMITS.MAX_WEBP_QUALITY) {
errors.push(
`WebP quality must be between ${LIMITS.MIN_WEBP_QUALITY} and ${LIMITS.MAX_WEBP_QUALITY}`
);
}

return {
valid: errors.length === 0,
errors,
};
}

/**
* Validate the maxFileSizeMB setting
*
Expand Down Expand Up @@ -219,6 +247,85 @@ export function validateAltLiteral(value: string): ValidationResult {
};
}

/**
* Validate resize mode
*
* @param value - The resize mode value
* @returns Validation result
*/
export function validateResizeMode(value: string): ValidationResult {
const errors: string[] = [];

// Use VALID_RESIZE_MODES constant for type-safe validation
if (!VALID_RESIZE_MODES.includes(value as typeof VALID_RESIZE_MODES[number])) {
errors.push(`Resize mode must be one of: ${VALID_RESIZE_MODES.join(', ')}`);
}

return {
valid: errors.length === 0,
errors,
};
}

/**
* Validate image dimension (maxWidth/maxHeight)
*
* @param value - The dimension value (number or null)
* @param fieldName - Name of the field for error messages
* @returns Validation result
*/
export function validateImageDimension(
value: number | null,
fieldName: string
): ValidationResult {
const errors: string[] = [];

if (value === null) {
return { valid: true, errors: [] };
}

if (typeof value !== 'number' || !Number.isInteger(value)) {
errors.push(`${fieldName} must be an integer or null`);
return { valid: false, errors };
}

if (value < LIMITS.MIN_IMAGE_DIMENSION || value > LIMITS.MAX_IMAGE_DIMENSION) {
errors.push(
`${fieldName} must be between ${LIMITS.MIN_IMAGE_DIMENSION} and ${LIMITS.MAX_IMAGE_DIMENSION}`
);
}

return {
valid: errors.length === 0,
errors,
};
}

/**
* Validate resize preset
*
* @param value - The preset value (string or null)
* @returns Validation result
*/
export function validateResizePreset(value: string | null): ValidationResult {
const errors: string[] = [];

if (value === null) {
return { valid: true, errors: [] };
}

// Use RESIZE_PRESETS keys as source of truth for valid presets
const validPresets = Object.keys(RESIZE_PRESETS);
if (!validPresets.includes(value)) {
errors.push(`Resize preset must be one of: ${validPresets.join(', ')} or null`);
}

return {
valid: errors.length === 0,
errors,
};
}

/**
* Validate the entire extension configuration
*
Expand Down Expand Up @@ -252,6 +359,12 @@ export function validateConfiguration(config: Partial<ExtensionConfig>): Validat
allErrors.push(...result.errors);
}

// Validate output.webpQuality
if (config.output?.webpQuality !== undefined) {
const result = validateWebpQuality(config.output.webpQuality);
allErrors.push(...result.errors);
}

// Validate limits.maxFileSizeMB
if (config.limits?.maxFileSizeMB !== undefined) {
const result = validateMaxFileSizeMB(config.limits.maxFileSizeMB);
Expand All @@ -264,6 +377,30 @@ export function validateConfiguration(config: Partial<ExtensionConfig>): Validat
allErrors.push(...result.errors);
}

// Validate resize.mode
if (config.resize?.mode !== undefined) {
const result = validateResizeMode(config.resize.mode);
allErrors.push(...result.errors);
}

// Validate resize.maxWidth
if (config.resize?.maxWidth !== undefined) {
const result = validateImageDimension(config.resize.maxWidth, 'Maximum width');
allErrors.push(...result.errors);
}

// Validate resize.maxHeight
if (config.resize?.maxHeight !== undefined) {
const result = validateImageDimension(config.resize.maxHeight, 'Maximum height');
allErrors.push(...result.errors);
}

// Validate resize.preset
if (config.resize?.preset !== undefined) {
const result = validateResizePreset(config.resize.preset);
allErrors.push(...result.errors);
}

return {
valid: allErrors.length === 0,
errors: allErrors,
Expand Down Expand Up @@ -329,5 +466,67 @@ export function sanitizeConfiguration(config: Partial<ExtensionConfig>): Partial
};
}

// Clamp resize dimensions to valid ranges
if (sanitized.resize?.maxWidth !== undefined && sanitized.resize.maxWidth !== null) {
sanitized.resize = {
...sanitized.resize,
maxWidth: Math.max(
LIMITS.MIN_IMAGE_DIMENSION,
Math.min(LIMITS.MAX_IMAGE_DIMENSION, sanitized.resize.maxWidth)
),
};
}

if (sanitized.resize?.maxHeight !== undefined && sanitized.resize.maxHeight !== null) {
sanitized.resize = {
...sanitized.resize,
maxHeight: Math.max(
LIMITS.MIN_IMAGE_DIMENSION,
Math.min(LIMITS.MAX_IMAGE_DIMENSION, sanitized.resize.maxHeight)
),
};
}

return sanitized;
}

/**
* Get configuration warnings (non-blocking issues)
*
* Returns warnings for configuration combinations that are valid but may be
* confusing or have unexpected behavior:
* - Resize dimensions set when mode is 'off' (dimensions are ignored)
* - Preset set when manual dimensions are also set (preset overrides)
*
* @param config - The configuration object
* @returns Array of warning messages
*/
export function getConfigurationWarnings(config: Partial<ExtensionConfig>): string[] {
const warnings: string[] = [];

// Warn if resize dimensions are set but mode is 'off'
if (config.resize?.mode === 'off') {
if (config.resize.maxWidth !== null || config.resize.maxHeight !== null) {
warnings.push(
'resize.maxWidth and resize.maxHeight are ignored when resize.mode is "off"'
);
}
}

// Warn if preset is set and will override manual dimensions
if (config.resize?.preset !== null && config.resize?.preset !== undefined) {
if (config.resize.maxWidth !== null || config.resize.maxHeight !== null) {
// Check if preset exists in RESIZE_PRESETS
const presetKey = config.resize.preset;
if (presetKey in RESIZE_PRESETS) {
const preset = RESIZE_PRESETS[presetKey as keyof typeof RESIZE_PRESETS];
warnings.push(
`Preset "${presetKey}" overrides resize.maxWidth/maxHeight ` +
`(using ${preset.maxWidth}x${preset.maxHeight})`
);
}
}
}

return warnings;
}
Loading