1- import React from 'react'
1+ import React , { useMemo , useState } from 'react'
22import { 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
3038function 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 } ) }
0 commit comments