Skip to content

Commit ddfb983

Browse files
committed
Add janky pattern resize to preview
1 parent 8a5950d commit ddfb983

4 files changed

Lines changed: 226 additions & 27 deletions

File tree

src/App.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from './functions'
1111
import { useStableFunction } from './hooks'
1212
import { Preview } from './preview'
13-
import { AbsolutePattern, AbsolutePoint, NumberPair, RelativePattern, State } from './types'
13+
import { AbsolutePattern, AbsolutePoint, NumberPair, PatternId, RelativePattern, State } from './types'
1414

1515
const getDraftState = (state: State, draftClick: DraftClick, mousePosition: AbsolutePoint): State => {
1616
const draftPattern = {
@@ -34,7 +34,7 @@ const getDraftState = (state: State, draftClick: DraftClick, mousePosition: Abso
3434
newDraft = getRelativePatternPosition(newDraft, state.patterns[k]!.pattern)
3535
}
3636

37-
return { ...state, patterns: state.patterns.concat({ id: randomId(), pattern: newDraft }) }
37+
return { ...state, patterns: state.patterns.concat({ id: randomId() as PatternId, pattern: newDraft }) }
3838
}
3939

4040
type DraftClick = {
@@ -172,7 +172,21 @@ function App() {
172172
<PreviewButton onClick={() => setPreviewOpen(false)}>x</PreviewButton>
173173
<h2 className='text-amber-300'>preview (wip)</h2>
174174
</div>
175-
<Preview state={state} />
175+
<Preview
176+
state={state}
177+
updatePattern={(id, pattern) => {
178+
const prev = state.patterns.find(p => p.id === id)
179+
180+
if (!prev) {
181+
throw new Error(`Pattern with id ${id} not found`)
182+
}
183+
184+
setState(prevState => ({
185+
...prevState,
186+
patterns: prevState.patterns.map(p => (p.id === id ? { ...p, pattern } : p)),
187+
}))
188+
}}
189+
/>
176190
</div>
177191
) : (
178192
<div className=' border-r-amber-300 border-r-1'>

src/draw/patterns.worker.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AbsolutePattern, RelativePattern, State, ViewportPattern } from '../types'
1+
import { AbsolutePattern, PatternId, RelativePattern, State, ViewportPattern } from '../types'
22
import { streamBatchedDrawablePatterns } from './stream-batched-drawable-patterns'
33

44
type WorkerState =
@@ -77,7 +77,9 @@ self.onmessage = event => {
7777
function generatePatterns() {
7878
for (const { depth, patterns } of streamBatchedDrawablePatterns({
7979
state: {
80-
patterns: [{ id: 'debug', pattern: { anchor: [0, 0], target: [0.5, 0.5] } as RelativePattern }],
80+
patterns: [
81+
{ id: 'debug' as PatternId, pattern: { anchor: [0, 0], target: [0.5, 0.5] } as RelativePattern },
82+
],
8183
screens: [{ anchor: [0.2, 0.2], target: [0.8, 0.8] } as AbsolutePattern],
8284
},
8385
chunkSize: 5,

src/preview.tsx

Lines changed: 202 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React from 'react'
1+
import React, { useMemo, useState } from 'react'
22
import { getBoundariesFromPattern } from './functions'
3-
import { NumberPair, State } from './types'
3+
import { NumberPair, PatternId, RelativePattern, RelativePoint, State } from './types'
44

5-
function getViewBox(state: State): string {
5+
function getViewBox(state: State): { x: number; y: number; width: number; height: number } {
66
let xMin = 0
77
let xMax = 1
88
let yMin = 0
@@ -24,7 +24,15 @@ function getViewBox(state: State): string {
2424
yMin -= 0.1
2525
yMax += 0.1
2626

27-
return `${xMin} ${yMin} ${xMax - xMin} ${yMax - yMin}`
27+
return { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin }
28+
}
29+
30+
function isMirroredX(pattern: RelativePattern): boolean {
31+
return pattern.anchor[0] > pattern.target[0]
32+
}
33+
34+
function isMirroredY(pattern: RelativePattern): boolean {
35+
return pattern.anchor[1] > pattern.target[1]
2836
}
2937

3038
function lerp(a: number, b: number, k: number): number {
@@ -55,10 +63,162 @@ const PartialLine: React.FC<{ anchor: NumberPair; target: NumberPair }> = ({ anc
5563
)
5664
}
5765

58-
export const Preview: React.FC<{ state: State }> = ({ state }) => {
66+
type Corner = 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left'
67+
68+
export const Preview: React.FC<{
69+
state: State
70+
updatePattern: (id: PatternId, pattern: RelativePattern) => void
71+
}> = ({ state: _state, updatePattern }) => {
72+
const [svgEl, setSvgEl] = useState<SVGSVGElement | null>(null)
73+
const [resizeState, setResizeState] = useState<
74+
| {
75+
id: PatternId
76+
initialPattern: RelativePattern
77+
pattern: RelativePattern
78+
corner: Corner
79+
initialMouseViewportX: number
80+
initialMouseViewportY: number
81+
}
82+
| undefined
83+
>(undefined)
84+
85+
const state = useMemo(() => {
86+
if (resizeState === undefined) {
87+
return _state
88+
}
89+
90+
const { id, pattern } = resizeState
91+
92+
const prev = _state.patterns.find(p => p.id === id)
93+
94+
if (!prev) {
95+
throw new Error(`Pattern with id ${id} not found`)
96+
}
97+
98+
return {
99+
..._state,
100+
patterns: _state.patterns.map(p => (p.id === id ? { ...p, pattern } : p)),
101+
}
102+
}, [_state, resizeState])
103+
104+
const viewBox = getViewBox(_state)
105+
59106
return (
60107
<div className='border-b-1 border-amber-300 aspect-square'>
61-
<svg viewBox={getViewBox(state)} xmlns='http://www.w3.org/2000/svg' className='w-full aspect-square'>
108+
<svg
109+
ref={setSvgEl}
110+
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
111+
xmlns='http://www.w3.org/2000/svg'
112+
className='w-full aspect-square'
113+
onPointerMove={e => {
114+
e.preventDefault()
115+
116+
if (svgEl === null || resizeState === undefined) {
117+
return
118+
}
119+
120+
const { initialPattern, initialMouseViewportX, initialMouseViewportY, corner } = resizeState
121+
122+
const { width: svgWidth, height: svgHeight } = svgEl.getBoundingClientRect()
123+
const { clientX: mouseViewportX, clientY: mouseViewportY } = e
124+
125+
// Calculate movement delta in viewport coordinates
126+
const viewportDeltaX = mouseViewportX - initialMouseViewportX
127+
const viewportDeltaY = mouseViewportY - initialMouseViewportY
128+
129+
// Convert viewport delta to SVG coordinate delta
130+
const svgDeltaX = (viewportDeltaX / svgWidth) * viewBox.width
131+
const svgDeltaY = (viewportDeltaY / svgHeight) * viewBox.height
132+
133+
let {
134+
xMin,
135+
xMax,
136+
yMin,
137+
yMax,
138+
}: {
139+
xMin: number
140+
xMax: number
141+
yMin: number
142+
yMax: number
143+
} = getBoundariesFromPattern(initialPattern)
144+
145+
if (corner === 'top-left') {
146+
// Dragging top-left, bottom-right is fixed
147+
xMin += svgDeltaX
148+
yMin += svgDeltaY
149+
} else if (corner === 'top-right') {
150+
// Dragging top-right, bottom-left is fixed
151+
xMax += svgDeltaX
152+
yMin += svgDeltaY
153+
} else if (corner === 'bottom-right') {
154+
// Dragging bottom-right, top-left is fixed
155+
xMax += svgDeltaX
156+
yMax += svgDeltaY
157+
} else {
158+
// Dragging bottom-left, top-right is fixed
159+
xMin += svgDeltaX
160+
yMax += svgDeltaY
161+
}
162+
163+
// Calculate new anchor and target based on their relative positions in the new box
164+
const newPattern: RelativePattern = {
165+
anchor: [xMin, yMin] satisfies NumberPair as RelativePoint,
166+
target: [xMax, yMax] satisfies NumberPair as RelativePoint,
167+
}
168+
169+
// Mirror X if mirrored in the initial pattern
170+
if (isMirroredX(initialPattern)) {
171+
;[newPattern.anchor[0], newPattern.target[0]] = [newPattern.target[0], newPattern.anchor[0]]
172+
}
173+
174+
// Mirror Y if mirrored in the initial pattern
175+
if (isMirroredY(initialPattern)) {
176+
;[newPattern.anchor[1], newPattern.target[1]] = [newPattern.target[1], newPattern.anchor[1]]
177+
}
178+
179+
setResizeState(prev =>
180+
prev === undefined
181+
? undefined
182+
: {
183+
...prev,
184+
pattern: newPattern,
185+
}
186+
)
187+
}}
188+
onPointerUp={e => {
189+
e.preventDefault()
190+
191+
if (resizeState !== undefined) {
192+
const { id, pattern: newPattern } = resizeState
193+
194+
if (
195+
newPattern.anchor[0] === newPattern.target[0] ||
196+
newPattern.anchor[1] === newPattern.target[1]
197+
) {
198+
console.error('Anchor and target cannot be the same')
199+
} else {
200+
updatePattern(id, newPattern)
201+
}
202+
}
203+
204+
setResizeState(undefined)
205+
}}
206+
onPointerLeave={e => {
207+
e.preventDefault()
208+
209+
setResizeState(undefined)
210+
}}
211+
onPointerCancel={e => {
212+
e.preventDefault()
213+
214+
setResizeState(undefined)
215+
}}
216+
// onPointerOut={e => {
217+
// e.preventDefault()
218+
219+
// setResizeState(undefined)
220+
// }}
221+
>
62222
<rect
63223
className='stroke-amber-100'
64224
vectorEffect='non-scaling-stroke'
@@ -71,14 +231,14 @@ export const Preview: React.FC<{ state: State }> = ({ state }) => {
71231
/>
72232
<PartialLine anchor={[0, 0]} target={[1, 1]} />
73233

74-
{state.patterns.map(({ pattern }, i) => {
234+
{state.patterns.map(({ id, pattern }) => {
75235
const { xMin, xMax, yMin, yMax } = getBoundariesFromPattern(pattern)
76236

77237
const width = xMax - xMin
78238
const height = yMax - yMin
79239

80240
return (
81-
<React.Fragment key={i}>
241+
<React.Fragment key={id}>
82242
<rect
83243
className='stroke-amber-100'
84244
vectorEffect='non-scaling-stroke'
@@ -93,28 +253,49 @@ export const Preview: React.FC<{ state: State }> = ({ state }) => {
93253
<PartialLine anchor={pattern.anchor} target={pattern.target} />
94254

95255
{/* Click surfaces for rotation and resizing actions */}
96-
{[
97-
// top left
98-
{ x: xMin, y: yMin, rotation: 0 },
99-
// top right
100-
{ x: xMax, y: yMin, rotation: 90 },
101-
// bottom right
102-
{ x: xMax, y: yMax, rotation: 180 },
103-
// bottom left
104-
{ x: xMin, y: yMax, rotation: 270 },
105-
].map(({ x, y, rotation }, j) => {
106-
const r_handle = 0.05 // Radius of the handle
256+
{(
257+
[
258+
// top left
259+
{ x: xMin, y: yMin, rotation: 0, corner: 'top-left' },
260+
// top right
261+
{ x: xMax, y: yMin, rotation: 90, corner: 'top-right' },
262+
// bottom right
263+
{ x: xMax, y: yMax, rotation: 180, corner: 'bottom-right' },
264+
// bottom left
265+
{ x: xMin, y: yMax, rotation: 270, corner: 'bottom-left' },
266+
] as const
267+
).map(({ x, y, rotation, corner }) => {
268+
const r_handle = 0.07 // Radius of the handle
107269

108270
// Define the base handle shape (for top-left corner)
109271
// This creates a quarter-circle arc pointing outward from the corner
110272
const basePathD = `M 0 0 L -${r_handle} 0 A ${r_handle} ${r_handle} 0 0 1 0 -${r_handle} Z`
111273

112274
return (
113275
<g
114-
key={`corner-handle-${i}-${j}`}
276+
key={`corner-handle-${id}-${corner}`}
115277
transform={`translate(${x.toFixed(3)}, ${y.toFixed(3)}) rotate(${rotation})`}
278+
onPointerDown={e => {
279+
e.preventDefault()
280+
281+
setResizeState({
282+
id,
283+
initialPattern: pattern,
284+
pattern,
285+
corner,
286+
initialMouseViewportX: e.clientX,
287+
initialMouseViewportY: e.clientY,
288+
})
289+
}}
116290
>
117-
<path d={basePathD} className='fill-slate-400 hover:fill-blue-600 cursor-grab' />
291+
<path
292+
d={basePathD}
293+
className={
294+
'fill-slate-400 hover:fill-blue-600 cursor-grab' +
295+
// Force hover color while dragging.
296+
(resizeState?.id === id && resizeState?.corner === corner ? ' fill-blue-600!' : '')
297+
}
298+
/>
118299
</g>
119300
)
120301
})}

src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,16 @@ export type Boundaries<N extends PatternNumber> = {
7878
// --- State ---
7979
// ---
8080

81+
export type PatternId = Brand<string, 'PatternId'>
82+
8183
/** This defines what is drawn on the screen. */
8284
export type State = {
8385
/** These are root-level patterns. */
8486
screens: AbsolutePattern[]
8587

8688
/** These are nested patterns. They are applied recursively to root-level patterns.*/
8789
patterns: {
88-
id: string
90+
id: PatternId
8991
pattern: RelativePattern
9092
}[]
9193
}

0 commit comments

Comments
 (0)