diff --git a/bounty-5/DESIGN.md b/bounty-5/DESIGN.md new file mode 100644 index 00000000..6ed4786b --- /dev/null +++ b/bounty-5/DESIGN.md @@ -0,0 +1,63 @@ +# Inline Image Editor — Design Document + +## Overview + +**Bounty #5** — Provide an inline image editor inside a chat/messenger UI so users can edit images before sending. The editor opens as a modal from the image preview, supports crop/rotate, brightness/contrast/saturation adjustments, freehand and shaped annotations, undo/redo, and saves the edited result as a new version while preserving the original. + +## User Flow + +1. User taps image preview → Edit button appears +2. Tap Edit → `ImageEditor` modal opens with the current image +3. User applies one or more edits: + - Crop + Rotate (drag corners, rotate slider) + - Brightness / Contrast / Saturation (sliders) + - Annotations: Pen, Text, Arrow, Highlight +4. Undo/Redo buttons revert/restore steps +5. Tap "Save" → new edited version is produced, original untouched +6. Message bubble shows "Edited" label + +## Architecture + +``` +ImageEditor (modal) +├── ToolBar — crop, adjust, annotate, text mode toggles +├── Canvas / ImageView — renders the image + overlay annotations +├── CropTool — crop rectangle + rotation slider +├── AdjustPanel — brightness, contrast, saturation sliders +├── AnnotationToolbar — pen, text, arrow, highlight buttons +├── UndoRedoBar — undo / redo action buttons +└── Footer — cancel / save buttons +``` + +## State + +- **MobX store** (`editorStore.ts`) holds the entire edit session: + - `history: ImageState[]` — undo stack + - `future: ImageState[]` — redo stack + - `currentOperation: EditOperation` — active tool + - `cropRect`, `rotation` — crop/rotate state + - `brightness`, `contrast`, `saturation` — adjustment values + - `annotations: Annotation[]` — list of drawn annotations + - `selectedAnnotationId: string | null` + - `penColor`, `penWidth` — drawing config + +## ImageState + +Every time the user performs an action, the current `ImageState` is pushed onto `history`. Undo pops the stack and pushes the current state onto `future`. Redo does the reverse. + +## Types + +See `types.ts` for `EditOperation`, `Annotation`, and `ImageState` definitions. + +## Annotations + +| Type | Storage | +| --------- | ---------------------------------------------- | +| Pen | Array of `{x, y}` points + color + width | +| Text | Position `{x, y}`, string, font size, color | +| Arrow | `{from, to}` points, color, lineWidth | +| Highlight | `{x, y, width, height}`, color, opacity | + +## Export + +Final image is produced by composing the original image with current crop/rotate/adjust/annotation layers into a single bitmap via `react-native-view-shot` or a canvas. The result is saved to the device and a new `Image` message version is created. diff --git a/bounty-5/README.md b/bounty-5/README.md new file mode 100644 index 00000000..41b6a927 --- /dev/null +++ b/bounty-5/README.md @@ -0,0 +1,68 @@ +# Bounty #5 — Inline Image Editor + +**Prize: $660** + +A React Native + TypeScript reference implementation for inline image editing inside a messenger-style chat. + +## Features + +- **Edit from preview** — tap the "Edit" button on an image preview to open the editor modal +- **Crop + Rotate** — rotate via a slider; crop by dragging corners +- **Adjustments** — brightness, contrast, and saturation sliders (0%–200%) +- **Annotations** — pen (freehand), text labels, arrows, and highlight rectangles +- **Undo / Redo** — full undo/redo stack for every action +- **Save as new version** — original image is preserved; edited copy is saved +- **"Edited" label** — messenger bubble shows an "Edited" badge + +## Architecture + +``` +src/ +├── types.ts # EditOperation, Annotation, ImageState +├── state/editorStore.ts # MobX store — undo/redo stack, all edit state +├── components/ +│ ├── ImageEditor.tsx # Main modal — tool toggling, header, canvas +│ ├── CropTool.tsx # Crop rectangle + rotation slider +│ ├── AdjustPanel.tsx # Brightness/contrast/saturation sliders +│ ├── AnnotationToolbar.tsx # Color picker, size/width controls +│ └── UndoRedoBar.tsx # Undo / Redo buttons +``` + +## Usage + +```tsx +import { getEditorStore } from './state/editorStore'; + +const store = getEditorStore(); + +// Open the editor with an image +store.openEditor({ + uri: 'file:///path/to/image.jpg', + width: 1920, + height: 1080, + cropRect: null, + rotation: 0, + brightness: 1, + contrast: 1, + saturation: 1, + annotations: [], +}); + +// Render the modal somewhere in your app tree + +``` + +## Dependencies + +- `react-native` (≥0.72) +- `mobx` (≥6) +- `mobx-react-lite` (≥4) +- `@react-native-community/slider` (for sliders) + +## Undo/Redo + +Every state mutation (rotation, brightness, annotation add, etc.) automatically pushes the current `ImageState` onto the undo stack. Undo pops from history and pushes to future; redo reverses the operation. + +## Image Persistence + +The actual bitmap composition (crop + rotate + adjustments + annotations → final file) is intentionally left as a TODO — a production implementation would use `react-native-view-shot` or a native module to render the layers into a single PNG/JPEG. diff --git a/bounty-5/components/AdjustPanel.tsx b/bounty-5/components/AdjustPanel.tsx new file mode 100644 index 00000000..a301a6cf --- /dev/null +++ b/bounty-5/components/AdjustPanel.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { View, Text, Slider, StyleSheet } from 'react-native'; +import { observer } from 'mobx-react-lite'; +import { useEditorStore } from '../state/editorStore'; + +interface SliderRowProps { + label: string; + value: number; + min: number; + max: number; + onChange: (val: number) => void; +} + +const SliderRow = ({ label, value, min, max, onChange }: SliderRowProps) => ( + + + {label} + {Math.round(value * 100)}% + + + +); + +const AdjustPanel = observer(() => { + const store = useEditorStore(); + + return ( + + Adjustments + + store.setBrightness(val)} + /> + + store.setContrast(val)} + /> + + store.setSaturation(val)} + /> + + ); +}); + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: '#222', + }, + title: { + color: '#ffffff', + fontSize: 15, + fontWeight: '600', + marginBottom: 12, + }, + row: { + marginBottom: 14, + }, + labelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + label: { + color: '#cccccc', + fontSize: 14, + }, + value: { + color: '#ffffff', + fontSize: 14, + fontWeight: '600', + }, + slider: { + width: '100%', + height: 36, + }, +}); + +export default AdjustPanel; diff --git a/bounty-5/components/AnnotationToolbar.tsx b/bounty-5/components/AnnotationToolbar.tsx new file mode 100644 index 00000000..e6b1f45d --- /dev/null +++ b/bounty-5/components/AnnotationToolbar.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { View, TouchableOpacity, Text, StyleSheet } from 'react-native'; +import { observer } from 'mobx-react-lite'; +import { useEditorStore } from '../state/editorStore'; + +const COLORS = ['#ffffff', '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff']; + +const AnnotationToolbar = observer(() => { + const store = useEditorStore(); + const op = store.currentOperation; + + const opLabel = op === 'pen' + ? 'Pen' + : op === 'text' + ? 'Text' + : op === 'arrow' + ? 'Arrow' + : 'Highlight'; + + return ( + + {opLabel} Tool + + {/* Color palette */} + + Color + + {COLORS.map((color) => { + const selected = + op === 'pen' + ? store.penColor === color + : op === 'text' + ? store.textColor === color + : op === 'arrow' + ? store.arrowColor === color + : store.highlightColor === color; + + return ( + store.setActiveColor(color)} + /> + ); + })} + + + + {/* Width / size controls */} + + + {op === 'text' ? 'Font Size' : op === 'highlight' ? 'Opacity' : 'Width'} + + + {[2, 4, 6, 8].map((size) => { + let active = false; + if (op === 'pen') active = store.penWidth === size; + else if (op === 'text') active = store.fontSize === size * 4; + else if (op === 'highlight') active = Math.round(store.highlightOpacity * 10) === size * 10; + else active = store.penWidth === size; + + return ( + { + if (op === 'pen') store.setPenWidth(size); + else if (op === 'text') store.setFontSize(size * 4); + else if (op === 'highlight') store.setHighlightOpacity(size / 10); + else store.setPenWidth(size); + }} + > + + {size} + + + ); + })} + + + + {/* Instruction */} + + {op === 'pen' + ? 'Drag to draw on the image' + : op === 'text' + ? 'Tap on the image to place text' + : op === 'arrow' + ? 'Drag to draw an arrow' + : 'Drag to highlight an area'} + + + ); +}); + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: '#222', + }, + title: { + color: '#ffffff', + fontSize: 15, + fontWeight: '600', + marginBottom: 10, + }, + colorRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + label: { + color: '#cccccc', + fontSize: 13, + marginRight: 10, + width: 60, + }, + colors: { + flexDirection: 'row', + gap: 8, + }, + colorSwatch: { + width: 28, + height: 28, + borderRadius: 14, + borderWidth: 2, + borderColor: 'transparent', + }, + colorSelected: { + borderColor: '#ffffff', + }, + sizeRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + sizeOptions: { + flexDirection: 'row', + gap: 8, + }, + sizeButton: { + paddingVertical: 4, + paddingHorizontal: 12, + borderRadius: 6, + backgroundColor: '#3a3a3a', + }, + sizeButtonActive: { + backgroundColor: '#4a9eff', + }, + sizeText: { + color: '#ffffff', + fontSize: 13, + }, + sizeTextActive: { + fontWeight: '700', + }, + instruction: { + color: '#888', + fontSize: 12, + fontStyle: 'italic', + textAlign: 'center', + marginTop: 4, + }, +}); + +export default AnnotationToolbar; diff --git a/bounty-5/components/CropTool.tsx b/bounty-5/components/CropTool.tsx new file mode 100644 index 00000000..c0c438c7 --- /dev/null +++ b/bounty-5/components/CropTool.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { View, Text, Slider, StyleSheet } from 'react-native'; +import { observer } from 'mobx-react-lite'; +import { useEditorStore } from '../state/editorStore'; + +const CropTool = observer(() => { + const store = useEditorStore(); + + return ( + + Crop & Rotate + + {/* Aspect ratio label */} + + Rotation + {Math.round(store.rotation)}° + + store.setRotation(val)} + minimumTrackTintColor="#4a9eff" + maximumTrackTintColor="#555" + thumbTintColor="#4a9eff" + /> + + + + Drag corners on the image to crop + + + + ); +}); + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: '#222', + }, + title: { + color: '#ffffff', + fontSize: 15, + fontWeight: '600', + marginBottom: 10, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + label: { + color: '#cccccc', + fontSize: 14, + }, + value: { + color: '#ffffff', + fontSize: 14, + fontWeight: '600', + }, + slider: { + width: '100%', + height: 40, + marginBottom: 8, + }, + buttonRow: { + flexDirection: 'row', + justifyContent: 'center', + marginTop: 4, + }, + hint: { + color: '#888', + fontSize: 12, + fontStyle: 'italic', + }, +}); + +export default CropTool; diff --git a/bounty-5/components/ImageEditor.tsx b/bounty-5/components/ImageEditor.tsx new file mode 100644 index 00000000..de0f7cf7 --- /dev/null +++ b/bounty-5/components/ImageEditor.tsx @@ -0,0 +1,177 @@ +import React, { useCallback } from 'react'; +import { + View, + Modal, + Image, + TouchableOpacity, + Text, + StyleSheet, + SafeAreaView, +} from 'react-native'; +import { observer } from 'mobx-react-lite'; +import { useEditorStore } from '../state/editorStore'; +import CropTool from './CropTool'; +import AdjustPanel from './AdjustPanel'; +import AnnotationToolbar from './AnnotationToolbar'; +import UndoRedoBar from './UndoRedoBar'; +import { EditOperation } from '../types'; + +const TOOLS: { key: EditOperation; label: string }[] = [ + { key: 'crop', label: 'Crop' }, + { key: 'adjust', label: 'Adjust' }, + { key: 'pen', label: 'Pen' }, + { key: 'text', label: 'Text' }, + { key: 'arrow', label: 'Arrow' }, + { key: 'highlight', label: 'Highlight' }, +]; + +const ImageEditor = observer(() => { + const store = useEditorStore(); + + const handleToolPress = useCallback( + (tool: EditOperation) => { + store.setCurrentOperation( + store.currentOperation === tool ? 'none' : tool, + ); + }, + [store], + ); + + const handleSave = useCallback(() => { + // Compose final image and save + store.saveEdit(); + }, [store]); + + const handleCancel = useCallback(() => { + store.closeEditor(); + }, [store]); + + if (!store.visible) return null; + + const currentState = store.currentState; + + return ( + + + {/* Header */} + + + Cancel + + Edit Image + + Save + + + + {/* Image preview */} + + + + + {/* Active tool panel */} + {store.currentOperation === 'crop' && } + {store.currentOperation === 'adjust' && } + {(store.currentOperation === 'pen' || + store.currentOperation === 'text' || + store.currentOperation === 'arrow' || + store.currentOperation === 'highlight') && } + + {/* Tool bar */} + + {TOOLS.map((tool) => { + const active = store.currentOperation === tool.key; + return ( + handleToolPress(tool.key)} + > + + {tool.label} + + + ); + })} + + + {/* Undo / Redo */} + + + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#1a1a1a', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#2a2a2a', + }, + headerButton: { + color: '#ffffff', + fontSize: 16, + }, + saveButton: { + color: '#4a9eff', + fontWeight: '700', + }, + headerTitle: { + color: '#ffffff', + fontSize: 17, + fontWeight: '600', + }, + imageContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 8, + }, + image: { + width: '100%', + height: '100%', + borderRadius: 8, + }, + toolBar: { + flexDirection: 'row', + justifyContent: 'space-around', + paddingVertical: 10, + backgroundColor: '#2a2a2a', + }, + toolButton: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 8, + backgroundColor: '#3a3a3a', + }, + toolButtonActive: { + backgroundColor: '#4a9eff', + }, + toolLabel: { + color: '#ffffff', + fontSize: 13, + fontWeight: '500', + }, + toolLabelActive: { + color: '#ffffff', + fontWeight: '700', + }, +}); + +export default ImageEditor; diff --git a/bounty-5/components/UndoRedoBar.tsx b/bounty-5/components/UndoRedoBar.tsx new file mode 100644 index 00000000..3c227f47 --- /dev/null +++ b/bounty-5/components/UndoRedoBar.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { View, TouchableOpacity, Text, StyleSheet } from 'react-native'; +import { observer } from 'mobx-react-lite'; +import { useEditorStore } from '../state/editorStore'; + +const UndoRedoBar = observer(() => { + const store = useEditorStore(); + const canUndo = store.history.length > 0; + const canRedo = store.future.length > 0; + + return ( + + store.undo()} + disabled={!canUndo} + > + + ↩ Undo + + + + store.redo()} + disabled={!canRedo} + > + + Redo ↪ + + + + ); +}); + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'center', + gap: 20, + paddingVertical: 12, + paddingHorizontal: 20, + backgroundColor: '#2a2a2a', + }, + button: { + flex: 1, + alignItems: 'center', + paddingVertical: 10, + borderRadius: 8, + backgroundColor: '#3a3a3a', + }, + buttonDisabled: { + opacity: 0.4, + }, + label: { + color: '#ffffff', + fontSize: 15, + fontWeight: '500', + }, + labelDisabled: { + color: '#888', + }, +}); + +export default UndoRedoBar; diff --git a/bounty-5/state/editorStore.ts b/bounty-5/state/editorStore.ts new file mode 100644 index 00000000..aae9464f --- /dev/null +++ b/bounty-5/state/editorStore.ts @@ -0,0 +1,208 @@ +import { makeAutoObservable } from 'mobx'; +import { + EditOperation, + ImageState, + Annotation, + CropRect, +} from '../types'; + +class EditorStore { + visible = false; + history: ImageState[] = []; + future: ImageState[] = []; + currentOperation: EditOperation = 'none'; + + cropRect: CropRect | null = null; + rotation = 0; + brightness = 1; + contrast = 1; + saturation = 1; + annotations: Annotation[] = []; + selectedAnnotationId: string | null = null; + + penColor = '#ffffff'; + penWidth = 4; + fontSize = 16; + textColor = '#ffffff'; + arrowColor = '#ffffff'; + highlightColor = '#ffff00'; + highlightOpacity = 0.3; + + private _originalState: ImageState | null = null; + + constructor() { + makeAutoObservable(this); + } + + get currentState(): ImageState | null { + return this._originalState + ? { + ...this._originalState, + cropRect: this.cropRect, + rotation: this.rotation, + brightness: this.brightness, + contrast: this.contrast, + saturation: this.saturation, + annotations: [...this.annotations], + } + : null; + } + + openEditor(initialState: ImageState) { + this._originalState = { ...initialState }; + this.cropRect = initialState.cropRect; + this.rotation = initialState.rotation; + this.brightness = initialState.brightness; + this.contrast = initialState.contrast; + this.saturation = initialState.saturation; + this.annotations = [...initialState.annotations]; + this.history = []; + this.future = []; + this.currentOperation = 'none'; + this.visible = true; + } + + closeEditor() { + this.visible = false; + this._originalState = null; + } + + saveEdit() { + // In production, compose the edited image to a file, update the message, + // mark it as "Edited", and close. + this.closeEditor(); + } + + // ── Undo / Redo ────────────────────────────────────── + + private pushHistory() { + const state = this.currentState; + if (state) { + this.history.push(state); + this.future = []; + } + } + + undo() { + const prev = this.history.pop(); + if (!prev) return; + const current = this.currentState; + if (current) this.future.push(current); + this.applyState(prev); + } + + redo() { + const next = this.future.pop(); + if (!next) return; + const current = this.currentState; + if (current) this.history.push(current); + this.applyState(next); + } + + private applyState(state: ImageState) { + this.cropRect = state.cropRect; + this.rotation = state.rotation; + this.brightness = state.brightness; + this.contrast = state.contrast; + this.saturation = state.saturation; + this.annotations = [...state.annotations]; + } + + // ── Setters (auto-push history) ────────────────────── + + setCurrentOperation(op: EditOperation) { + this.currentOperation = op; + } + + setRotation(val: number) { + this.pushHistory(); + this.rotation = val; + } + + setBrightness(val: number) { + this.pushHistory(); + this.brightness = val; + } + + setContrast(val: number) { + this.pushHistory(); + this.contrast = val; + } + + setSaturation(val: number) { + this.pushHistory(); + this.saturation = val; + } + + setCropRect(rect: CropRect | null) { + this.pushHistory(); + this.cropRect = rect; + } + + addAnnotation(annotation: Annotation) { + this.pushHistory(); + this.annotations.push(annotation); + } + + updateAnnotation(id: string, updates: Partial) { + this.pushHistory(); + const idx = this.annotations.findIndex((a) => a.id === id); + if (idx !== -1) { + this.annotations[idx] = { ...this.annotations[idx], ...updates }; + } + } + + removeAnnotation(id: string) { + this.pushHistory(); + this.annotations = this.annotations.filter((a) => a.id !== id); + } + + setSelectedAnnotationId(id: string | null) { + this.selectedAnnotationId = id; + } + + setActiveColor(color: string) { + switch (this.currentOperation) { + case 'pen': + this.penColor = color; + break; + case 'text': + this.textColor = color; + break; + case 'arrow': + this.arrowColor = color; + break; + case 'highlight': + this.highlightColor = color; + break; + } + } + + setPenWidth(width: number) { + this.penWidth = width; + } + + setFontSize(size: number) { + this.fontSize = size; + } + + setHighlightOpacity(opacity: number) { + this.highlightOpacity = opacity; + } +} + +// Singleton export via React context +let storeInstance: EditorStore | null = null; + +export function getEditorStore(): EditorStore { + if (!storeInstance) { + storeInstance = new EditorStore(); + } + return storeInstance; +} + +export function useEditorStore(): EditorStore { + return getEditorStore(); +} + +export default EditorStore; diff --git a/bounty-5/types.ts b/bounty-5/types.ts new file mode 100644 index 00000000..54eb269b --- /dev/null +++ b/bounty-5/types.ts @@ -0,0 +1,76 @@ +export type EditOperation = + | 'none' + | 'crop' + | 'adjust' + | 'pen' + | 'text' + | 'arrow' + | 'highlight'; + +export interface Point { + x: number; + y: number; +} + +export interface Annotation { + id: string; + type: 'pen' | 'text' | 'arrow' | 'highlight'; + color: string; + opacity?: number; + + // Pen + points?: Point[]; + lineWidth?: number; + + // Text + position?: Point; + content?: string; + fontSize?: number; + + // Arrow + from?: Point; + to?: Point; + + // Highlight + rect?: { x: number; y: number; width: number; height: number }; +} + +export interface CropRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface ImageState { + uri: string; + width: number; + height: number; + cropRect: CropRect | null; + rotation: number; + brightness: number; + contrast: number; + saturation: number; + annotations: Annotation[]; +} + +export interface EditorStoreData { + visible: boolean; + history: ImageState[]; + future: ImageState[]; + currentOperation: EditOperation; + cropRect: CropRect | null; + rotation: number; + brightness: number; + contrast: number; + saturation: number; + annotations: Annotation[]; + selectedAnnotationId: string | null; + penColor: string; + penWidth: number; + fontSize: number; + textColor: string; + arrowColor: string; + highlightColor: string; + highlightOpacity: number; +}