This commit is contained in:
Danilo Reyes
2025-11-02 14:13:56 -06:00
parent cd8ce33f5e
commit ce0b692aee
23 changed files with 2049 additions and 50 deletions

View File

@@ -0,0 +1,180 @@
/**
* Image crop transformations
* Non-destructive rectangular cropping
*/
import Konva from 'konva';
export interface CropRegion {
x: number;
y: number;
width: number;
height: number;
}
/**
* Apply crop to image
*/
export function cropImage(image: Konva.Image | Konva.Group, cropRegion: CropRegion): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
const img = imageNode as Konva.Image;
// Validate crop region
const imageWidth = img.width();
const imageHeight = img.height();
const validCrop = {
x: Math.max(0, Math.min(cropRegion.x, imageWidth)),
y: Math.max(0, Math.min(cropRegion.y, imageHeight)),
width: Math.max(1, Math.min(cropRegion.width, imageWidth - cropRegion.x)),
height: Math.max(1, Math.min(cropRegion.height, imageHeight - cropRegion.y)),
};
// Apply crop using Konva's crop property
img.crop(validCrop);
}
/**
* Remove crop (reset to full image)
*/
export function removeCrop(image: Konva.Image | Konva.Group): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
(imageNode as Konva.Image).crop(undefined);
}
/**
* Get current crop region
*/
export function getCropRegion(image: Konva.Image | Konva.Group): CropRegion | null {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return null;
const crop = (imageNode as Konva.Image).crop();
if (!crop) return null;
return {
x: crop.x || 0,
y: crop.y || 0,
width: crop.width || 0,
height: crop.height || 0,
};
}
/**
* Check if image is cropped
*/
export function isCropped(image: Konva.Image | Konva.Group): boolean {
const crop = getCropRegion(image);
return crop !== null;
}
/**
* Crop to square (centered)
*/
export function cropToSquare(image: Konva.Image | Konva.Group): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
const img = imageNode as Konva.Image;
const width = img.width();
const height = img.height();
const size = Math.min(width, height);
const cropRegion: CropRegion = {
x: (width - size) / 2,
y: (height - size) / 2,
width: size,
height: size,
};
cropImage(image, cropRegion);
}
/**
* Create interactive crop tool (returns cleanup function)
*/
export function enableCropTool(
image: Konva.Image | Konva.Group,
layer: Konva.Layer,
onCropComplete: (cropRegion: CropRegion) => void
): () => void {
let cropRect: Konva.Rect | null = null;
let isDragging = false;
let startPos: { x: number; y: number } | null = null;
function handleMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
const pos = e.target.getStage()?.getPointerPosition();
if (!pos) return;
isDragging = true;
startPos = pos;
cropRect = new Konva.Rect({
x: pos.x,
y: pos.y,
width: 0,
height: 0,
stroke: '#3b82f6',
strokeWidth: 2,
dash: [4, 2],
listening: false,
});
layer.add(cropRect);
}
function handleMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
if (!isDragging || !startPos || !cropRect) return;
const pos = e.target.getStage()?.getPointerPosition();
if (!pos) return;
const width = pos.x - startPos.x;
const height = pos.y - startPos.y;
cropRect.width(width);
cropRect.height(height);
layer.batchDraw();
}
function handleMouseUp() {
if (!isDragging || !startPos || !cropRect) return;
const cropRegion: CropRegion = {
x: Math.min(startPos.x, cropRect.x() + cropRect.width()),
y: Math.min(startPos.y, cropRect.y() + cropRect.height()),
width: Math.abs(cropRect.width()),
height: Math.abs(cropRect.height()),
};
if (cropRegion.width > 10 && cropRegion.height > 10) {
onCropComplete(cropRegion);
}
cropRect.destroy();
cropRect = null;
isDragging = false;
startPos = null;
layer.batchDraw();
}
image.on('mousedown', handleMouseDown);
image.on('mousemove', handleMouseMove);
image.on('mouseup', handleMouseUp);
return () => {
image.off('mousedown', handleMouseDown);
image.off('mousemove', handleMouseMove);
image.off('mouseup', handleMouseUp);
if (cropRect) {
cropRect.destroy();
layer.batchDraw();
}
};
}

View File

@@ -0,0 +1,100 @@
/**
* Image flip transformations
* Non-destructive horizontal and vertical flipping
*/
import type Konva from 'konva';
/**
* Flip image horizontally
*/
export function flipImageHorizontal(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
const currentScaleX = image.scaleX();
const newScaleX = -currentScaleX;
if (animate) {
image.to({
scaleX: newScaleX,
duration: 0.3,
});
} else {
image.scaleX(newScaleX);
}
}
/**
* Flip image vertically
*/
export function flipImageVertical(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
const currentScaleY = image.scaleY();
const newScaleY = -currentScaleY;
if (animate) {
image.to({
scaleY: newScaleY,
duration: 0.3,
});
} else {
image.scaleY(newScaleY);
}
}
/**
* Check if image is flipped horizontally
*/
export function isFlippedHorizontal(image: Konva.Image | Konva.Group): boolean {
return image.scaleX() < 0;
}
/**
* Check if image is flipped vertically
*/
export function isFlippedVertical(image: Konva.Image | Konva.Group): boolean {
return image.scaleY() < 0;
}
/**
* Reset horizontal flip
*/
export function resetFlipHorizontal(image: Konva.Image | Konva.Group): void {
const scale = Math.abs(image.scaleX());
image.scaleX(scale);
}
/**
* Reset vertical flip
*/
export function resetFlipVertical(image: Konva.Image | Konva.Group): void {
const scale = Math.abs(image.scaleY());
image.scaleY(scale);
}
/**
* Reset both flips
*/
export function resetAllFlips(image: Konva.Image | Konva.Group): void {
const scaleX = Math.abs(image.scaleX());
const scaleY = Math.abs(image.scaleY());
image.scale({ x: scaleX, y: scaleY });
}
/**
* Set flip state explicitly
*/
export function setFlipState(
image: Konva.Image | Konva.Group,
horizontal: boolean,
vertical: boolean
): void {
const currentScaleX = Math.abs(image.scaleX());
const currentScaleY = Math.abs(image.scaleY());
image.scaleX(horizontal ? -currentScaleX : currentScaleX);
image.scaleY(vertical ? -currentScaleY : currentScaleY);
}

View File

@@ -0,0 +1,70 @@
/**
* Image greyscale filter transformation
* Non-destructive greyscale conversion
*/
import Konva from 'konva';
/**
* Apply greyscale filter to image
*/
export function applyGreyscale(image: Konva.Image | Konva.Group): void {
// Find the actual image node
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
// Apply greyscale filter using Konva.Filters
(imageNode as Konva.Image).filters([Konva.Filters.Grayscale]);
(imageNode as Konva.Image).cache();
}
/**
* Remove greyscale filter from image
*/
export function removeGreyscale(image: Konva.Image | Konva.Group): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
(imageNode as Konva.Image).filters([]);
(imageNode as Konva.Image).clearCache();
}
/**
* Toggle greyscale filter
*/
export function toggleGreyscale(image: Konva.Image | Konva.Group): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
const filters = (imageNode as Konva.Image).filters() || [];
if (filters.length > 0 && filters.some((f) => f.name === 'Grayscale')) {
removeGreyscale(image);
} else {
applyGreyscale(image);
}
}
/**
* Check if greyscale is applied
*/
export function isGreyscaleApplied(image: Konva.Image | Konva.Group): boolean {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return false;
const filters = (imageNode as Konva.Image).filters() || [];
return filters.some((f) => f.name === 'Grayscale');
}
/**
* Set greyscale state explicitly
*/
export function setGreyscale(image: Konva.Image | Konva.Group, enabled: boolean): void {
const isCurrentlyGreyscale = isGreyscaleApplied(image);
if (enabled && !isCurrentlyGreyscale) {
applyGreyscale(image);
} else if (!enabled && isCurrentlyGreyscale) {
removeGreyscale(image);
}
}

View File

@@ -0,0 +1,96 @@
/**
* Image opacity transformations
* Non-destructive opacity adjustment (0-100%)
*/
import type Konva from 'konva';
const MIN_OPACITY = 0.0;
const MAX_OPACITY = 1.0;
/**
* Set image opacity (0.0 to 1.0)
*/
export function setImageOpacity(
image: Konva.Image | Konva.Group,
opacity: number,
animate: boolean = false
): void {
// Clamp to 0.0-1.0
const clampedOpacity = Math.max(MIN_OPACITY, Math.min(MAX_OPACITY, opacity));
if (animate) {
image.to({
opacity: clampedOpacity,
duration: 0.3,
});
} else {
image.opacity(clampedOpacity);
}
}
/**
* Set opacity by percentage (0-100)
*/
export function setImageOpacityPercent(
image: Konva.Image | Konva.Group,
percent: number,
animate: boolean = false
): void {
const opacity = Math.max(0, Math.min(100, percent)) / 100;
setImageOpacity(image, opacity, animate);
}
/**
* Increase opacity by delta
*/
export function increaseOpacity(image: Konva.Image | Konva.Group, delta: number = 0.1): void {
const currentOpacity = image.opacity();
setImageOpacity(image, currentOpacity + delta);
}
/**
* Decrease opacity by delta
*/
export function decreaseOpacity(image: Konva.Image | Konva.Group, delta: number = 0.1): void {
const currentOpacity = image.opacity();
setImageOpacity(image, currentOpacity - delta);
}
/**
* Reset opacity to 100% (1.0)
*/
export function resetImageOpacity(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
setImageOpacity(image, 1.0, animate);
}
/**
* Get current opacity
*/
export function getImageOpacity(image: Konva.Image | Konva.Group): number {
return image.opacity();
}
/**
* Get opacity as percentage (0-100)
*/
export function getImageOpacityPercent(image: Konva.Image | Konva.Group): number {
return Math.round(image.opacity() * 100);
}
/**
* Check if image is fully opaque
*/
export function isFullyOpaque(image: Konva.Image | Konva.Group): boolean {
return image.opacity() >= MAX_OPACITY;
}
/**
* Check if image is fully transparent
*/
export function isFullyTransparent(image: Konva.Image | Konva.Group): boolean {
return image.opacity() <= MIN_OPACITY;
}

View File

@@ -0,0 +1,106 @@
/**
* Reset transformations to original state
* Resets all non-destructive transformations
*/
import Konva from 'konva';
import { resetImageRotation } from './rotate';
import { resetImageScale } from './scale';
import { resetAllFlips } from './flip';
import { resetImageOpacity } from './opacity';
import { removeCrop } from './crop';
import { removeGreyscale } from './greyscale';
/**
* Reset all transformations to original state
*/
export function resetAllTransformations(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
// Reset rotation
resetImageRotation(image, animate);
// Reset scale
resetImageScale(image, animate);
// Reset flips
resetAllFlips(image);
// Reset opacity
resetImageOpacity(image, animate);
// Remove crop
removeCrop(image);
// Remove greyscale
removeGreyscale(image);
// Redraw
image.getLayer()?.batchDraw();
}
/**
* Reset only geometric transformations (position, scale, rotation)
*/
export function resetGeometricTransformations(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
resetImageRotation(image, animate);
resetImageScale(image, animate);
resetAllFlips(image);
image.getLayer()?.batchDraw();
}
/**
* Reset only visual transformations (opacity, greyscale, crop)
*/
export function resetVisualTransformations(image: Konva.Image | Konva.Group): void {
resetImageOpacity(image);
removeCrop(image);
removeGreyscale(image);
image.getLayer()?.batchDraw();
}
/**
* Check if image has any transformations applied
*/
export function hasTransformations(image: Konva.Image | Konva.Group): boolean {
const hasRotation = image.rotation() !== 0;
const hasScale = Math.abs(image.scaleX()) !== 1.0 || Math.abs(image.scaleY()) !== 1.0;
const hasOpacity = image.opacity() !== 1.0;
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
const hasCrop = imageNode ? (imageNode as Konva.Image).crop() !== undefined : false;
const hasGreyscale = imageNode ? ((imageNode as Konva.Image).filters() || []).length > 0 : false;
return hasRotation || hasScale || hasOpacity || hasCrop || hasGreyscale;
}
/**
* Get transformation summary
*/
export function getTransformationSummary(image: Konva.Image | Konva.Group): {
rotation: number;
scale: number;
opacity: number;
flippedH: boolean;
flippedV: boolean;
cropped: boolean;
greyscale: boolean;
} {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
return {
rotation: image.rotation(),
scale: Math.abs(image.scaleX()),
opacity: image.opacity(),
flippedH: image.scaleX() < 0,
flippedV: image.scaleY() < 0,
cropped: imageNode ? (imageNode as Konva.Image).crop() !== undefined : false,
greyscale: imageNode ? ((imageNode as Konva.Image).filters() || []).length > 0 : false,
};
}

View File

@@ -0,0 +1,79 @@
/**
* Image rotation transformations
* Non-destructive rotation of canvas images
*/
import type Konva from 'konva';
/**
* Rotate image to specific angle (0-360 degrees)
*/
export function rotateImageTo(
image: Konva.Image | Konva.Group,
degrees: number,
animate: boolean = false
): void {
// Normalize to 0-360
const normalizedDegrees = ((degrees % 360) + 360) % 360;
if (animate) {
image.to({
rotation: normalizedDegrees,
duration: 0.3,
});
} else {
image.rotation(normalizedDegrees);
}
}
/**
* Rotate image by delta degrees
*/
export function rotateImageBy(
image: Konva.Image | Konva.Group,
degrees: number,
animate: boolean = false
): void {
const currentRotation = image.rotation();
const newRotation = (((currentRotation + degrees) % 360) + 360) % 360;
rotateImageTo(image, newRotation, animate);
}
/**
* Rotate image by 90 degrees clockwise
*/
export function rotateImage90CW(image: Konva.Image | Konva.Group): void {
rotateImageBy(image, 90);
}
/**
* Rotate image by 90 degrees counter-clockwise
*/
export function rotateImage90CCW(image: Konva.Image | Konva.Group): void {
rotateImageBy(image, -90);
}
/**
* Flip image to 180 degrees
*/
export function rotateImage180(image: Konva.Image | Konva.Group): void {
rotateImageTo(image, 180);
}
/**
* Reset rotation to 0 degrees
*/
export function resetImageRotation(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
rotateImageTo(image, 0, animate);
}
/**
* Get current rotation angle
*/
export function getImageRotation(image: Konva.Image | Konva.Group): number {
return image.rotation();
}

View File

@@ -0,0 +1,109 @@
/**
* Image scaling transformations
* Non-destructive scaling with resize handles
*/
import Konva from 'konva';
const MIN_SCALE = 0.01;
const MAX_SCALE = 10.0;
/**
* Scale image to specific factor
*/
export function scaleImageTo(
image: Konva.Image | Konva.Group,
scale: number,
animate: boolean = false
): void {
// Clamp to min/max
const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
if (animate) {
image.to({
scaleX: clampedScale,
scaleY: clampedScale,
duration: 0.3,
});
} else {
image.scale({ x: clampedScale, y: clampedScale });
}
}
/**
* Scale image by factor (multiply current scale)
*/
export function scaleImageBy(
image: Konva.Image | Konva.Group,
factor: number,
animate: boolean = false
): void {
const currentScale = image.scaleX();
const newScale = currentScale * factor;
scaleImageTo(image, newScale, animate);
}
/**
* Scale image to fit specific dimensions
*/
export function scaleImageToFit(
image: Konva.Image | Konva.Group,
maxWidth: number,
maxHeight: number,
animate: boolean = false
): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
const width = (imageNode as Konva.Image).width();
const height = (imageNode as Konva.Image).height();
const scaleX = maxWidth / width;
const scaleY = maxHeight / height;
const scale = Math.min(scaleX, scaleY);
scaleImageTo(image, scale, animate);
}
/**
* Reset scale to 1.0 (original size)
*/
export function resetImageScale(image: Konva.Image | Konva.Group, animate: boolean = false): void {
scaleImageTo(image, 1.0, animate);
}
/**
* Double image size
*/
export function doubleImageSize(image: Konva.Image | Konva.Group): void {
scaleImageBy(image, 2.0);
}
/**
* Half image size
*/
export function halfImageSize(image: Konva.Image | Konva.Group): void {
scaleImageBy(image, 0.5);
}
/**
* Get current scale
*/
export function getImageScale(image: Konva.Image | Konva.Group): number {
return image.scaleX();
}
/**
* Check if image is at minimum scale
*/
export function isAtMinScale(image: Konva.Image | Konva.Group): boolean {
return image.scaleX() <= MIN_SCALE;
}
/**
* Check if image is at maximum scale
*/
export function isAtMaxScale(image: Konva.Image | Konva.Group): boolean {
return image.scaleX() >= MAX_SCALE;
}

View File

@@ -0,0 +1,401 @@
<script lang="ts">
/**
* Transformation panel for canvas image manipulation
* Provides UI controls for rotate, scale, flip, crop, opacity, greyscale
*/
import { createEventDispatcher } from 'svelte';
export let rotation: number = 0;
export let scale: number = 1.0;
export let opacity: number = 1.0;
export let flippedH: boolean = false;
export let flippedV: boolean = false;
export let greyscale: boolean = false;
export let hasCrop: boolean = false;
export let disabled: boolean = false;
const dispatch = createEventDispatcher();
function handleRotationChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
dispatch('rotation-change', { rotation: value });
}
function handleScaleChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
dispatch('scale-change', { scale: value });
}
function handleOpacityChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
dispatch('opacity-change', { opacity: value });
}
function handleFlipH() {
dispatch('flip-horizontal', { flipped: !flippedH });
}
function handleFlipV() {
dispatch('flip-vertical', { flipped: !flippedV });
}
function handleGreyscaleToggle() {
dispatch('greyscale-toggle', { enabled: !greyscale });
}
function handleCropStart() {
dispatch('crop-start');
}
function handleRemoveCrop() {
dispatch('crop-remove');
}
function handleRotate90CW() {
dispatch('rotate-90-cw');
}
function handleRotate90CCW() {
dispatch('rotate-90-ccw');
}
function handleResetAll() {
dispatch('reset-all');
}
</script>
<div class="transform-panel" class:disabled>
<div class="panel-header">
<h3>Transform</h3>
<button
class="reset-button"
on:click={handleResetAll}
{disabled}
title="Reset all transformations"
>
Reset All
</button>
</div>
<div class="panel-content">
<!-- Rotation Controls -->
<div class="control-group">
<label for="rotation">
Rotation
<span class="value">{Math.round(rotation)}°</span>
</label>
<div class="control-row">
<button class="icon-button" on:click={handleRotate90CCW} {disabled} title="Rotate 90° CCW">
</button>
<input
id="rotation"
type="range"
min="0"
max="360"
step="1"
value={rotation}
on:input={handleRotationChange}
{disabled}
/>
<button class="icon-button" on:click={handleRotate90CW} {disabled} title="Rotate 90° CW">
</button>
</div>
</div>
<!-- Scale Controls -->
<div class="control-group">
<label for="scale">
Scale
<span class="value">{(scale * 100).toFixed(0)}%</span>
</label>
<input
id="scale"
type="range"
min="0.01"
max="10"
step="0.01"
value={scale}
on:input={handleScaleChange}
{disabled}
/>
</div>
<!-- Opacity Controls -->
<div class="control-group">
<label for="opacity">
Opacity
<span class="value">{Math.round(opacity * 100)}%</span>
</label>
<input
id="opacity"
type="range"
min="0"
max="1"
step="0.01"
value={opacity}
on:input={handleOpacityChange}
{disabled}
/>
</div>
<!-- Flip Controls -->
<div class="control-group">
<div class="label-text">Flip</div>
<div class="button-row">
<button
class="toggle-button"
class:active={flippedH}
on:click={handleFlipH}
{disabled}
title="Flip Horizontal"
>
⇄ Horizontal
</button>
<button
class="toggle-button"
class:active={flippedV}
on:click={handleFlipV}
{disabled}
title="Flip Vertical"
>
⇅ Vertical
</button>
</div>
</div>
<!-- Filters -->
<div class="control-group">
<div class="label-text">Filters</div>
<div class="button-row">
<button
class="toggle-button"
class:active={greyscale}
on:click={handleGreyscaleToggle}
{disabled}
title="Toggle Greyscale"
>
Greyscale
</button>
</div>
</div>
<!-- Crop Controls -->
<div class="control-group">
<div class="label-text">Crop</div>
<div class="button-row">
{#if hasCrop}
<button class="action-button" on:click={handleRemoveCrop} {disabled}>
Remove Crop
</button>
{:else}
<button class="action-button" on:click={handleCropStart} {disabled}>
Start Crop Tool
</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.transform-panel {
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
min-width: 280px;
}
.transform-panel.disabled {
opacity: 0.6;
pointer-events: none;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.reset-button {
padding: 0.375rem 0.75rem;
background-color: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.reset-button:hover:not(:disabled) {
background-color: var(--color-bg-hover, #e5e7eb);
}
.reset-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.panel-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.label-text {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
}
.value {
font-weight: 600;
color: var(--color-text, #374151);
}
.control-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type='range'] {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--color-bg-secondary, #e5e7eb);
outline: none;
appearance: none;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
}
input[type='range']::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
border: none;
}
input[type='range']:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-button {
width: 32px;
height: 32px;
padding: 0;
background-color: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button:hover:not(:disabled) {
background-color: var(--color-bg-hover, #e5e7eb);
}
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.button-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.toggle-button {
flex: 1;
padding: 0.5rem 0.75rem;
background-color: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.toggle-button:hover:not(:disabled) {
background-color: var(--color-bg-hover, #e5e7eb);
}
.toggle-button.active {
background-color: var(--color-primary, #3b82f6);
border-color: var(--color-primary, #3b82f6);
color: white;
}
.toggle-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.action-button {
width: 100%;
padding: 0.5rem 0.75rem;
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.action-button:hover:not(:disabled) {
background-color: var(--color-primary-hover, #2563eb);
}
.action-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style>