Skip to content

Commit 715e63f

Browse files
Ripwordsclaude
andcommitted
feat(expo): annotation store and gesture-based canvas (pen tool v1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 97a0ae9 commit 715e63f

4 files changed

Lines changed: 249 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import React, { useState } from "react"
2+
import { View } from "react-native"
3+
import { GestureDetector, Gesture } from "react-native-gesture-handler"
4+
import Svg, { Path } from "react-native-svg"
5+
import type { AnnotationStore } from "./store"
6+
import type { Shape, Tool, PenPoint } from "@reprojs/sdk-utils"
7+
import { newShapeId } from "@reprojs/sdk-utils"
8+
9+
interface Props {
10+
width: number
11+
height: number
12+
tool: Tool
13+
color: string
14+
strokeWidth: number
15+
store: AnnotationStore
16+
}
17+
18+
export function AnnotationCanvas({ width, height, tool, color, strokeWidth, store }: Props) {
19+
const [draft, setDraft] = useState<PenPoint[]>([])
20+
21+
const pan = Gesture.Pan()
22+
.onStart((e) => {
23+
setDraft([{ x: e.x, y: e.y, p: 1 }])
24+
})
25+
.onUpdate((e) => {
26+
setDraft((d) => [...d, { x: e.x, y: e.y, p: 1 }])
27+
})
28+
.onEnd(() => {
29+
if (draft.length === 0) {
30+
setDraft([])
31+
return
32+
}
33+
const shape = buildShape(tool, draft, color, strokeWidth)
34+
if (shape) store.addShape(shape)
35+
setDraft([])
36+
})
37+
38+
return (
39+
<GestureDetector gesture={pan}>
40+
<View style={{ width, height }}>
41+
<Svg width={width} height={height}>
42+
{store.snapshot().map((s, i) => renderCommitted(s, i))}
43+
{draft.length > 0 && renderDraft(tool, draft, color, strokeWidth)}
44+
</Svg>
45+
</View>
46+
</GestureDetector>
47+
)
48+
}
49+
50+
function buildShape(
51+
tool: Tool,
52+
points: PenPoint[],
53+
color: string,
54+
strokeWidth: number,
55+
): Shape | null {
56+
const first = points[0]
57+
const last = points[points.length - 1]
58+
if (!first || !last) return null
59+
const id = newShapeId()
60+
if (tool === "pen") return { kind: "pen", id, color, strokeWidth, points }
61+
if (tool === "arrow")
62+
return {
63+
kind: "arrow",
64+
id,
65+
color,
66+
strokeWidth,
67+
x1: first.x,
68+
y1: first.y,
69+
x2: last.x,
70+
y2: last.y,
71+
}
72+
if (tool === "rect" || tool === "highlight") {
73+
return {
74+
kind: tool,
75+
id,
76+
color,
77+
strokeWidth,
78+
x: Math.min(first.x, last.x),
79+
y: Math.min(first.y, last.y),
80+
w: Math.abs(last.x - first.x),
81+
h: Math.abs(last.y - first.y),
82+
}
83+
}
84+
if (tool === "text")
85+
return {
86+
kind: "text",
87+
id,
88+
color,
89+
strokeWidth,
90+
x: first.x,
91+
y: first.y,
92+
w: 120,
93+
h: 24,
94+
content: "Tap to edit",
95+
fontSize: 16,
96+
}
97+
return null
98+
}
99+
100+
function renderCommitted(s: Shape, key: number): React.ReactNode {
101+
if (s.kind === "pen") {
102+
const d = s.points.map((pt, i) => `${i === 0 ? "M" : "L"}${pt.x},${pt.y}`).join(" ")
103+
return <Path key={key} d={d} stroke={s.color} strokeWidth={s.strokeWidth} fill="none" />
104+
}
105+
// v1: only pen strokes rendered in-canvas; other shapes appear via the flatten view on submit.
106+
return null
107+
}
108+
109+
function renderDraft(
110+
tool: Tool,
111+
points: PenPoint[],
112+
color: string,
113+
strokeWidth: number,
114+
): React.ReactNode {
115+
if (tool !== "pen") return null
116+
const d = points.map((pt, i) => `${i === 0 ? "M" : "L"}${pt.x},${pt.y}`).join(" ")
117+
return <Path d={d} stroke={color} strokeWidth={strokeWidth} fill="none" />
118+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { test, expect } from "bun:test"
2+
import { createAnnotationStore } from "./store"
3+
import { newShapeId } from "@reprojs/sdk-utils"
4+
import type { Shape } from "@reprojs/sdk-utils"
5+
6+
function rect(color = "#f00", x = 0): Shape {
7+
return { kind: "rect", id: newShapeId(), color, strokeWidth: 2, x, y: 0, w: 1, h: 1 }
8+
}
9+
10+
test("addShape appends and snapshot returns the list", () => {
11+
const s = createAnnotationStore()
12+
s.addShape(rect())
13+
expect(s.snapshot()).toHaveLength(1)
14+
})
15+
16+
test("undo removes last, redo re-adds", () => {
17+
const s = createAnnotationStore()
18+
s.addShape(rect())
19+
s.undo()
20+
expect(s.snapshot()).toHaveLength(0)
21+
s.redo()
22+
expect(s.snapshot()).toHaveLength(1)
23+
})
24+
25+
test("addShape after undo discards redo stack", () => {
26+
const s = createAnnotationStore()
27+
s.addShape(rect("#f00", 0))
28+
s.undo()
29+
s.addShape(rect("#0f0", 1))
30+
expect(s.snapshot()).toHaveLength(1)
31+
s.redo() // no-op
32+
expect(s.snapshot()).toHaveLength(1)
33+
})
34+
35+
test("clear empties and resets stacks", () => {
36+
const s = createAnnotationStore()
37+
s.addShape(rect())
38+
s.clear()
39+
expect(s.snapshot()).toEqual([])
40+
s.redo()
41+
expect(s.snapshot()).toEqual([])
42+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Shape } from "@reprojs/sdk-utils"
2+
3+
export interface AnnotationStore {
4+
addShape: (s: Shape) => void
5+
undo: () => void
6+
redo: () => void
7+
clear: () => void
8+
snapshot: () => Shape[]
9+
subscribe: (fn: () => void) => () => void
10+
}
11+
12+
export function createAnnotationStore(): AnnotationStore {
13+
let shapes: Shape[] = []
14+
let redoStack: Shape[] = []
15+
const listeners = new Set<() => void>()
16+
const notify = () => {
17+
for (const l of listeners) l()
18+
}
19+
return {
20+
addShape(s) {
21+
shapes = [...shapes, s]
22+
redoStack = []
23+
notify()
24+
},
25+
undo() {
26+
if (!shapes.length) return
27+
const popped = shapes[shapes.length - 1]
28+
shapes = shapes.slice(0, -1)
29+
if (popped) redoStack = [...redoStack, popped]
30+
notify()
31+
},
32+
redo() {
33+
if (!redoStack.length) return
34+
const last = redoStack[redoStack.length - 1]
35+
redoStack = redoStack.slice(0, -1)
36+
if (last) shapes = [...shapes, last]
37+
notify()
38+
},
39+
clear() {
40+
shapes = []
41+
redoStack = []
42+
notify()
43+
},
44+
snapshot: () => shapes,
45+
subscribe(fn) {
46+
listeners.add(fn)
47+
return () => {
48+
listeners.delete(fn)
49+
}
50+
},
51+
}
52+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from "react"
2+
import { Pressable, Text, View } from "react-native"
3+
import type { Tool } from "@reprojs/sdk-utils"
4+
import type { AnnotationStore } from "./store"
5+
6+
interface Props {
7+
tool: Tool
8+
onToolChange: (t: Tool) => void
9+
store: AnnotationStore
10+
}
11+
12+
const TOOLS: Tool[] = ["pen", "arrow", "rect", "highlight", "text"]
13+
14+
export function AnnotationToolbar({ tool, onToolChange, store }: Props) {
15+
return (
16+
<View style={{ flexDirection: "row", padding: 8, gap: 8 }}>
17+
{TOOLS.map((t) => (
18+
<Pressable
19+
key={t}
20+
onPress={() => onToolChange(t)}
21+
style={{ padding: 8, backgroundColor: t === tool ? "#6366f1" : "#e5e7eb" }}
22+
>
23+
<Text style={{ color: t === tool ? "white" : "#111" }}>{t}</Text>
24+
</Pressable>
25+
))}
26+
<Pressable onPress={() => store.undo()} style={{ padding: 8, backgroundColor: "#e5e7eb" }}>
27+
<Text>undo</Text>
28+
</Pressable>
29+
<Pressable onPress={() => store.redo()} style={{ padding: 8, backgroundColor: "#e5e7eb" }}>
30+
<Text>redo</Text>
31+
</Pressable>
32+
<Pressable onPress={() => store.clear()} style={{ padding: 8, backgroundColor: "#e5e7eb" }}>
33+
<Text>clear</Text>
34+
</Pressable>
35+
</View>
36+
)
37+
}

0 commit comments

Comments
 (0)