diff --git a/packages/inline-image-editing/package.json b/packages/inline-image-editing/package.json new file mode 100644 index 00000000..a0b6352d --- /dev/null +++ b/packages/inline-image-editing/package.json @@ -0,0 +1,28 @@ +{ + "name": "@warpspeed/inline-image-editing", + "version": "0.1.0", + "description": "Inline image editing component for React Native - crop, rotate, brightness/contrast, annotations, undo/redo, save-as", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "lint": "eslint src/ --ext .ts,.tsx", + "storybook": "storybook dev -p 6008", + "build-storybook": "storybook build" + }, + "dependencies": { + "react": "^18.3.1", + "react-native": "^0.74.3", + "react-native-gesture-handler": "^2.17.1", + "react-native-reanimated": "^3.14.0", + "react-native-svg": "^15.3.0", + "react-native-view-shot": "^3.10.0" + }, + "devDependencies": { + "@storybook/addon-actions": "^8.1.10", + "@storybook/react-native": "^8.1.10", + "@types/react": "^18.3.3", + "typescript": "^5.5.3" + } +} diff --git a/packages/inline-image-editing/src/ImageEditor.tsx b/packages/inline-image-editing/src/ImageEditor.tsx new file mode 100644 index 00000000..1307bd74 --- /dev/null +++ b/packages/inline-image-editing/src/ImageEditor.tsx @@ -0,0 +1,453 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { + View, + Image, + TouchableOpacity, + Text, + StyleSheet, + Dimensions, + Alert, +} from 'react-native'; +import type { ImageEditorProps, EditingMode, CropRegion, ImageAdjustments, Annotation } from '../types'; +import { useEditHistory } from '../hooks/useEditHistory'; +import { CropTool } from './CropTool'; +import { AdjustControls } from './AdjustControls'; +import { AnnotationOverlay } from './AnnotationOverlay'; + +const SCREEN_WIDTH = Dimensions.get('window').width; + +/** + * Inline Image Editor for React Native. + * + * Features: + * - Crop with aspect ratio presets + * - Rotation (90° increments) + * - Brightness & Contrast adjustment + * - Annotations (text labels, arrows, shapes) + * - Undo / Redo + * - Save / Save As + */ +export const ImageEditor: React.FC = ({ + imageUri, + onSave, + onCancel, + onSaveAs, + maxZoom = 3, + allowedModes, +}) => { + // Image dimensions (simulated - in prod, use Image.getSize) + const [imageSize] = useState({ width: SCREEN_WIDTH, height: SCREEN_WIDTH * 0.75 }); + + const [mode, setMode] = useState('none'); + const [rotation, setRotation] = useState(0); + const [cropRegion, setCropRegion] = useState(null); + const [adjustments, setAdjustments] = useState({ brightness: 0, contrast: 0 }); + const [annotations, setAnnotations] = useState([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + + const { currentSnapshot, canUndo, canRedo, pushSnapshot, undo, redo } = useEditHistory(); + + // ── Mode switching ────────────────────────────────────────────── + const enterMode = useCallback( + (newMode: EditingMode) => { + if (newMode !== 'none') { + // Snapshot current state before entering a tool + pushSnapshot({ + cropRegion, + rotation, + adjustments, + annotations, + }); + } + setMode(newMode); + }, + [cropRegion, rotation, adjustments, annotations, pushSnapshot] + ); + + // ── Crop ──────────────────────────────────────────────────────── + const handleCropApply = useCallback(() => { + // In production, actually crop the image + pushSnapshot({ cropRegion, rotation, adjustments, annotations }); + setMode('none'); + }, [cropRegion, rotation, adjustments, annotations, pushSnapshot]); + + const handleCropCancel = useCallback(() => { + // Restore previous state from undo + const prev = undo(); + if (prev) { + setCropRegion(prev.cropRegion); + setRotation(prev.rotation); + setAdjustments(prev.adjustments); + setAnnotations(prev.annotations); + } + setMode('none'); + }, [undo]); + + // ── Rotation ──────────────────────────────────────────────────── + const rotateImage = useCallback(() => { + const newRotation = (rotation + 90) % 360; + setRotation(newRotation); + pushSnapshot({ cropRegion, rotation: newRotation, adjustments, annotations }); + }, [rotation, cropRegion, adjustments, annotations, pushSnapshot]); + + // ── Adjust ────────────────────────────────────────────────────── + const handleAdjustApply = useCallback( + (newAdjustments: ImageAdjustments) => { + setAdjustments(newAdjustments); + pushSnapshot({ cropRegion, rotation, adjustments: newAdjustments, annotations }); + setMode('none'); + }, + [cropRegion, rotation, annotations, pushSnapshot] + ); + + const handleAdjustCancel = useCallback(() => { + const prev = undo(); + if (prev) { + setCropRegion(prev.cropRegion); + setRotation(prev.rotation); + setAdjustments(prev.adjustments); + setAnnotations(prev.annotations); + } + setMode('none'); + }, [undo]); + + // ── Annotations ───────────────────────────────────────────────── + const addAnnotation = useCallback( + (type: Annotation['type']) => { + const newAnn: Annotation = { + id: `ann-${Date.now()}`, + type, + color: '#FF3B30', + strokeWidth: 2, + text: type === 'text' ? 'Text' : undefined, + position: type === 'text' ? { x: 50, y: 50 } : undefined, + points: type === 'arrow' || type === 'freehand' ? [{ x: 50, y: 50 }, { x: 150, y: 150 }] : undefined, + origin: type === 'rect' || type === 'circle' ? { x: 50, y: 50 } : undefined, + size: type === 'rect' || type === 'circle' ? { width: 100, height: 80 } : undefined, + fontSize: type === 'text' ? 16 : undefined, + }; + const updated = [...annotations, newAnn]; + setAnnotations(updated); + pushSnapshot({ cropRegion, rotation, adjustments, annotations: updated }); + }, + [annotations, cropRegion, rotation, adjustments, pushSnapshot] + ); + + const clearAnnotations = useCallback(() => { + setAnnotations([]); + setSelectedAnnotationId(null); + pushSnapshot({ cropRegion, rotation, adjustments, annotations: [] }); + }, [cropRegion, rotation, adjustments, pushSnapshot]); + + // ── Undo / Redo ───────────────────────────────────────────────── + const handleUndo = useCallback(() => { + const prev = undo(); + if (prev) { + setCropRegion(prev.cropRegion); + setRotation(prev.rotation); + setAdjustments(prev.adjustments); + setAnnotations(prev.annotations); + } + }, [undo]); + + const handleRedo = useCallback(() => { + const next = redo(); + if (next) { + setCropRegion(next.cropRegion); + setRotation(next.rotation); + setAdjustments(next.adjustments); + setAnnotations(next.annotations); + } + }, [redo]); + + // ── Save / Save As ────────────────────────────────────────────── + const handleSave = useCallback(() => { + // In production, export the edited image as a new URI + const editedUri = imageUri; + onSave(editedUri); + }, [imageUri, onSave]); + + const handleSaveAs = useCallback(() => { + if (onSaveAs) { + const editedUri = imageUri; + onSaveAs(editedUri); + } else { + Alert.alert('Save As', 'Save As is not available'); + } + }, [imageUri, onSaveAs]); + + // ── Render ────────────────────────────────────────────────────── + const modes: EditingMode[] = allowedModes ?? ['crop', 'rotate', 'adjust', 'annotate']; + + // Determine if the image has been modified + const hasChanges = rotation !== 0 || adjustments.brightness !== 0 || adjustments.contrast !== 0 || annotations.length > 0; + + return ( + + {/* Image preview area */} + + + + {/* Crop overlay */} + {mode === 'crop' && ( + + )} + + {/* Annotation overlay */} + {(mode === 'annotate' || annotations.length > 0) && mode !== 'crop' && ( + + )} + + + {/* Adjust controls panel */} + {mode === 'adjust' && ( + + )} + + {/* Annotation toolbar */} + {mode === 'annotate' && ( + + addAnnotation('text')} /> + addAnnotation('arrow')} /> + addAnnotation('rect')} /> + addAnnotation('circle')} /> + addAnnotation('freehand')} /> + setMode('none')}> + Done + + + )} + + {/* Bottom toolbar */} + + {/* Mode buttons */} + {mode === 'none' && ( + + {modes.includes('crop') && ( + enterMode('crop')} /> + )} + {modes.includes('rotate') && ( + + )} + {modes.includes('adjust') && ( + enterMode('adjust')} /> + )} + {modes.includes('annotate') && ( + enterMode('annotate')} /> + )} + + )} + + {/* Undo / Redo / Save row */} + + + Undo + + + + Redo + + + + + Cancel + + + {onSaveAs && ( + + Save As + + )} + + + Save + + + + + + ); +}; + +// ── Small helper component ───────────────────────────────────────── +interface ToolButtonProps { + label: string; + onPress: () => void; + active?: boolean; +} + +const ToolButton: React.FC = ({ label, onPress, active }) => ( + + + {label} + + +); + +// ── Styles ─────────────────────────────────────────────────────────── +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000000', + }, + imageContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + image: { + // dynamic width/height set inline + }, + annotationToolbar: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 8, + paddingVertical: 12, + backgroundColor: '#1C1C1E', + flexWrap: 'wrap', + }, + doneButton: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: '#007AFF', + borderRadius: 8, + }, + doneText: { + color: '#FFFFFF', + fontSize: 15, + fontWeight: '600', + }, + bottomToolbar: { + backgroundColor: '#1C1C1E', + paddingBottom: 20, + }, + modeRow: { + flexDirection: 'row', + justifyContent: 'center', + gap: 8, + paddingVertical: 12, + }, + toolButton: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: '#2C2C2E', + }, + activeTool: { + backgroundColor: '#007AFF', + }, + toolButtonText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '500', + }, + activeToolText: { + fontWeight: '700', + }, + actionRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#38383A', + }, + historyButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + backgroundColor: '#2C2C2E', + }, + disabledButton: { + opacity: 0.4, + }, + historyText: { + color: '#FFFFFF', + fontSize: 13, + }, + saveRow: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + }, + cancelButton: { + paddingHorizontal: 12, + paddingVertical: 6, + }, + cancelText: { + color: '#FF3B30', + fontSize: 15, + }, + saveAsButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + borderWidth: 1, + borderColor: '#007AFF', + }, + saveAsText: { + color: '#007AFF', + fontSize: 15, + }, + saveButton: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: '#007AFF', + }, + saveText: { + color: '#FFFFFF', + fontSize: 15, + fontWeight: '600', + }, +}); + +export default ImageEditor; diff --git a/packages/inline-image-editing/src/components/AdjustControls.tsx b/packages/inline-image-editing/src/components/AdjustControls.tsx new file mode 100644 index 00000000..e5e96e3c --- /dev/null +++ b/packages/inline-image-editing/src/components/AdjustControls.tsx @@ -0,0 +1,198 @@ +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import type { ImageAdjustments } from '../types'; + +interface AdjustControlsProps { + adjustments: ImageAdjustments; + onChange: (adjustments: ImageAdjustments) => void; + onApply: () => void; + onCancel: () => void; +} + +/** + * Slider-based controls for brightness and contrast adjustment. + * Simple +/- buttons since React Native doesn't have Slider in core. + */ +export const AdjustControls: React.FC = ({ + adjustments, + onChange, + onApply, + onCancel, +}) => { + const [localAdjustments, setLocalAdjustments] = useState(adjustments); + + const updateBrightness = (delta: number) => { + const next = Math.max(-1, Math.min(1, localAdjustments.brightness + delta)); + const updated = { ...localAdjustments, brightness: next }; + setLocalAdjustments(updated); + onChange(updated); + }; + + const updateContrast = (delta: number) => { + const next = Math.max(-1, Math.min(1, localAdjustments.contrast + delta)); + const updated = { ...localAdjustments, contrast: next }; + setLocalAdjustments(updated); + onChange(updated); + }; + + const reset = () => { + const resetVal: ImageAdjustments = { brightness: 0, contrast: 0 }; + setLocalAdjustments(resetVal); + onChange(resetVal); + }; + + return ( + + Adjust + + {/* Brightness */} + + Brightness + + updateBrightness(-0.1)} + disabled={localAdjustments.brightness <= -1} + > + + + + {Math.round(localAdjustments.brightness * 100)}% + + updateBrightness(0.1)} + disabled={localAdjustments.brightness >= 1} + > + + + + + + + {/* Contrast */} + + Contrast + + updateContrast(-0.1)} + disabled={localAdjustments.contrast <= -1} + > + + + + {Math.round(localAdjustments.contrast * 100)}% + + updateContrast(0.1)} + disabled={localAdjustments.contrast >= 1} + > + + + + + + + {/* Actions */} + + + Reset + + + + Cancel + + + Apply + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#1C1C1E', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + padding: 20, + }, + title: { + fontSize: 17, + fontWeight: '600', + color: '#FFFFFF', + marginBottom: 20, + textAlign: 'center', + }, + controlRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + label: { + fontSize: 15, + color: '#FFFFFF', + flex: 1, + }, + valueRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + stepButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#2C2C2E', + justifyContent: 'center', + alignItems: 'center', + }, + stepText: { + fontSize: 20, + color: '#FFFFFF', + fontWeight: '500', + }, + valueText: { + fontSize: 15, + color: '#8E8E93', + minWidth: 40, + textAlign: 'center', + }, + actionRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 8, + }, + resetText: { + color: '#FF9F0A', + fontSize: 16, + }, + rightActions: { + flexDirection: 'row', + gap: 12, + }, + cancelButton: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + cancelText: { + color: '#FF3B30', + fontSize: 16, + }, + applyButton: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: '#007AFF', + borderRadius: 8, + }, + applyText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, +}); + +export default AdjustControls; diff --git a/packages/inline-image-editing/src/components/AnnotationOverlay.tsx b/packages/inline-image-editing/src/components/AnnotationOverlay.tsx new file mode 100644 index 00000000..bb021967 --- /dev/null +++ b/packages/inline-image-editing/src/components/AnnotationOverlay.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import type { Annotation } from '../types'; + +interface AnnotationOverlayProps { + annotations: Annotation[]; + imageWidth: number; + imageHeight: number; + selectedId: string | null; + onSelect: (id: string | null) => void; +} + +/** + * Renders annotations (arrows, text, shapes, freehand drawings) + * on top of the image using absolute positioning. + * In a real implementation, this would use react-native-svg Path/Polyline etc. + */ +export const AnnotationOverlay: React.FC = ({ + annotations, + imageWidth, + imageHeight, + selectedId, + onSelect, +}) => { + return ( + + {annotations.map((ann) => ( + onSelect(ann.id === selectedId ? null : ann.id)} + > + {/* Render different annotation types */} + {ann.type === 'text' && ann.text && ann.position && ( + + + + {ann.text} + + + + )} + + {/* Arrow / freehand placeholder - would use SVG in production */} + {ann.type === 'arrow' && ann.points && ann.points.length >= 2 && ( + + )} + + {/* Rect / Circle */} + {(ann.type === 'rect' || ann.type === 'circle') && ann.origin && ann.size && ( + + )} + + {/* Freehand path placeholder */} + {ann.type === 'freehand' && ann.points && ann.points.length > 1 && ( + + )} + + ))} + + ); +}; + +// Need to import Text +import { Text } from 'react-native'; + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 0, + left: 0, + }, + annotation: { + position: 'absolute', + borderWidth: 1, + }, + textBox: { + position: 'absolute', + }, + textBackground: { + padding: 4, + borderRadius: 4, + borderWidth: 1, + }, + lineIndicator: { + position: 'absolute', + borderWidth: 2, + borderRadius: 4, + }, + rectShape: { + position: 'absolute', + borderWidth: 2, + borderRadius: 2, + }, + circleShape: { + position: 'absolute', + borderWidth: 2, + borderRadius: 999, + }, + freehandIndicator: { + position: 'absolute', + width: 10, + height: 10, + borderRadius: 5, + borderWidth: 2, + }, +}); + +export default AnnotationOverlay; diff --git a/packages/inline-image-editing/src/components/CropTool.tsx b/packages/inline-image-editing/src/components/CropTool.tsx new file mode 100644 index 00000000..680d1fbd --- /dev/null +++ b/packages/inline-image-editing/src/components/CropTool.tsx @@ -0,0 +1,194 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import type { CropRegion } from '../types'; + +interface CropToolProps { + imageWidth: number; + imageHeight: number; + initialCrop?: CropRegion; + onCropChange: (crop: CropRegion) => void; + onApply: () => void; + onCancel: () => void; +} + +/** + * A crop tool that overlays the image with a draggable crop rectangle. + * Uses a simple ratio-locked resize approach. + */ +export const CropTool: React.FC = ({ + imageWidth, + imageHeight, + initialCrop, + onCropChange, + onApply, + onCancel, +}) => { + const defaultCrop: CropRegion = initialCrop ?? { + x: imageWidth * 0.1, + y: imageHeight * 0.1, + width: imageWidth * 0.8, + height: imageHeight * 0.8, + }; + + const [crop, setCrop] = useState(defaultCrop); + + const updateCrop = useCallback( + (delta: Partial) => { + const next = { ...crop, ...delta }; + // Clamp within image bounds + next.x = Math.max(0, Math.min(next.x, imageWidth - 20)); + next.y = Math.max(0, Math.min(next.y, imageHeight - 20)); + next.width = Math.max(20, Math.min(next.width, imageWidth - next.x)); + next.height = Math.max(20, Math.min(next.height, imageHeight - next.y)); + setCrop(next); + onCropChange(next); + }, + [crop, imageWidth, imageHeight, onCropChange] + ); + + // For demonstration, we provide preset ratios + const setRatio = useCallback( + (ratio: number) => { + const w = crop.width; + const h = w / ratio; + if (crop.y + h > imageHeight) { + // Adjust height to fit + updateCrop({ height: imageHeight - crop.y, width: (imageHeight - crop.y) * ratio }); + } else { + updateCrop({ height }); + } + }, + [crop, imageHeight, updateCrop] + ); + + return ( + + {/* Crop area visualization */} + + + {/* Corner handles */} + + + + + + + + {/* Aspect ratio presets */} + + setRatio(1)}> + 1:1 + + setRatio(4 / 3)}> + 4:3 + + setRatio(16 / 9)}> + 16:9 + + setRatio(3 / 2)}> + 3:2 + + setRatio(9 / 16)}> + 9:16 + + + + {/* Action buttons */} + + + Cancel + + + Apply Crop + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'flex-end', + paddingBottom: 20, + }, + cropOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + cropBox: { + position: 'absolute', + borderWidth: 2, + borderColor: '#FFFFFF', + borderStyle: 'dashed', + }, + handle: { + position: 'absolute', + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#007AFF', + borderWidth: 2, + borderColor: '#FFFFFF', + }, + handleTL: { top: -12, left: -12 }, + handleTR: { top: -12, right: -12 }, + handleBL: { bottom: -12, left: -12 }, + handleBR: { bottom: -12, right: -12 }, + ratioRow: { + flexDirection: 'row', + justifyContent: 'center', + gap: 8, + paddingVertical: 12, + backgroundColor: '#1C1C1E', + }, + ratioButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + backgroundColor: '#2C2C2E', + }, + ratioText: { + color: '#FFFFFF', + fontSize: 13, + fontWeight: '600', + }, + actionRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: '#1C1C1E', + }, + cancelButton: { + paddingHorizontal: 16, + paddingVertical: 10, + }, + cancelText: { + color: '#FF3B30', + fontSize: 16, + }, + applyButton: { + paddingHorizontal: 16, + paddingVertical: 10, + backgroundColor: '#007AFF', + borderRadius: 8, + }, + applyText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, +}); + +export default CropTool; diff --git a/packages/inline-image-editing/src/hooks/useEditHistory.ts b/packages/inline-image-editing/src/hooks/useEditHistory.ts new file mode 100644 index 00000000..07962747 --- /dev/null +++ b/packages/inline-image-editing/src/hooks/useEditHistory.ts @@ -0,0 +1,66 @@ +import { useState, useCallback, useRef } from 'react'; +import type { EditorSnapshot, CropRegion, ImageAdjustments, Annotation } from '../types'; + +/** + * Hook that manages undo/redo history for image editing operations. + * Each editor action pushes a snapshot onto the undo stack. + */ +export function useEditHistory(initialSnapshot?: Partial) { + const [undoStack, setUndoStack] = useState(() => { + const base: EditorSnapshot = { + cropRegion: null, + rotation: 0, + adjustments: { brightness: 0, contrast: 0 }, + annotations: [], + }; + if (initialSnapshot) { + Object.assign(base, initialSnapshot); + } + return [base]; + }); + + const [redoStack, setRedoStack] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + + const canUndo = currentIndex > 0; + const canRedo = currentIndex < undoStack.length - 1; + + const pushSnapshot = useCallback( + (snapshot: EditorSnapshot) => { + setUndoStack((prev) => { + const trimmed = prev.slice(0, currentIndex + 1); + return [...trimmed, snapshot]; + }); + setRedoStack([]); + setCurrentIndex((prev) => prev + 1); + }, + [currentIndex] + ); + + const undo = useCallback(() => { + if (!canUndo) return null; + setRedoStack((prev) => [...prev, undoStack[currentIndex]]); + const newIndex = currentIndex - 1; + setCurrentIndex(newIndex); + return undoStack[newIndex]; + }, [canUndo, currentIndex, undoStack]); + + const redo = useCallback(() => { + if (!canRedo) return null; + const newIndex = currentIndex + 1; + setCurrentIndex(newIndex); + setRedoStack((prev) => prev.slice(0, -1)); + return undoStack[newIndex]; + }, [canRedo, currentIndex, undoStack]); + + const currentSnapshot: EditorSnapshot = undoStack[currentIndex]; + + return { + currentSnapshot, + canUndo, + canRedo, + pushSnapshot, + undo, + redo, + }; +} diff --git a/packages/inline-image-editing/src/index.ts b/packages/inline-image-editing/src/index.ts new file mode 100644 index 00000000..1265d056 --- /dev/null +++ b/packages/inline-image-editing/src/index.ts @@ -0,0 +1,15 @@ +export { ImageEditor } from './ImageEditor'; +export { CropTool } from './components/CropTool'; +export { AdjustControls } from './components/AdjustControls'; +export { AnnotationOverlay } from './components/AnnotationOverlay'; +export { useEditHistory } from './hooks/useEditHistory'; +export type { + EditingMode, + CropRegion, + ImageAdjustments, + Annotation, + Annotation as AnnotationType, + ImageEditState, + EditorSnapshot, + ImageEditorProps, +} from './types'; diff --git a/packages/inline-image-editing/src/types.ts b/packages/inline-image-editing/src/types.ts new file mode 100644 index 00000000..bde05be4 --- /dev/null +++ b/packages/inline-image-editing/src/types.ts @@ -0,0 +1,58 @@ +// Types for Inline Image Editing + +export type EditingMode = 'none' | 'crop' | 'rotate' | 'adjust' | 'annotate'; + +export interface CropRegion { + x: number; + y: number; + width: number; + height: number; +} + +export interface ImageAdjustments { + brightness: number; // -1 to 1, default 0 + contrast: number; // -1 to 1, default 0 +} + +export interface Annotation { + id: string; + type: 'arrow' | 'text' | 'rect' | 'circle' | 'freehand'; + color: string; + strokeWidth: number; + // Arrow / freehand + points?: Array<{ x: number; y: number }>; + // Text + text?: string; + position?: { x: number; y: number }; + fontSize?: number; + // Rect / Circle + origin?: { x: number; y: number }; + size?: { width: number; height: number }; +} + +export interface ImageEditState { + originalUri: string; + currentUri: string; + cropRegion: CropRegion | null; + rotation: number; // degrees: 0, 90, 180, 270 + adjustments: ImageAdjustments; + annotations: Annotation[]; + selectedAnnotationId: string | null; + mode: EditingMode; +} + +export interface EditorSnapshot { + cropRegion: CropRegion | null; + rotation: number; + adjustments: ImageAdjustments; + annotations: Annotation[]; +} + +export interface ImageEditorProps { + imageUri: string; + onSave: (uri: string) => void; + onCancel: () => void; + onSaveAs?: (uri: string) => void; + maxZoom?: number; + allowedModes?: EditingMode[]; +} diff --git a/packages/inline-image-editing/tsconfig.json b/packages/inline-image-editing/tsconfig.json new file mode 100644 index 00000000..8be5eadf --- /dev/null +++ b/packages/inline-image-editing/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ES2022"], + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}