From bdde03cdfa4320ae7ce18a13733468b091306c0b Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 3 Jul 2026 00:03:16 +0200 Subject: [PATCH] feat: floor plan selection handles, dbl-click edit, bottom z-index - Resize handles only render when the plan is selected (click to select, click outside to deselect); previously always shown when unlocked. - Double-clicking an unlocked plan opens the canvas edit modal (via a floorMapEditNonce signal the Sidebar watches). - Floor plan always sits at the bottom of the canvas (z-index -1), behind nodes and edges, locked or not. - Remount the edit modal on every open (key bump) so it re-seeds from the current floor plan; fixes Save clobbering a canvas-side resize/move with stale modal dimensions. - Drop the no-op history snapshot on floor-plan edits (floorMap isn't part of undo history). --- .../e65c837dca6746fd97b54dade0c9a880.jpg | Bin 0 -> 31431 bytes .../src/components/canvas/FloorMapLayer.tsx | 37 ++++++-- .../canvas/__tests__/FloorMapLayer.test.tsx | 83 ++++++++++++++++++ .../modals/__tests__/DesignModal.test.tsx | 37 ++++++++ frontend/src/components/panels/Sidebar.tsx | 35 ++++++-- .../src/stores/__tests__/canvasStore.test.ts | 7 ++ frontend/src/stores/canvasStore.ts | 7 ++ 7 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg create mode 100644 frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx diff --git a/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg b/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg new file mode 100644 index 0000000000000000000000000000000000000000..792150e4b407c14f1560bb1aca8bf119cd213da3 GIT binary patch literal 31431 zcmbrlWq2I1(k?h=$IQ&k%*@Qp%*+fiGsPY=#LUdh%xuSY%uWn(%nUm@XYYRd(**mlYQ`R#8)yl2wrWj{-c| z!Q9auk_7;8bn)3UohYgU>C1H~fu%)&J>{EB(~H5qqnujMGghTa zN^|9HBQ;gKF{nvRH`hh(V;sj>$^;roJ=5Wk#$4u2>$G=gUmC&iCQ9Z%&bIPg8W$l+ zz_45H7(DrSzY+N&mt4K|DC>({7YYvX1`*BMBnabJH;N#iPda$33(~l~(0Nr{M9 zP}ujI0*SHr5LNGKAP;tmcy|YXcL=W!FJ>WrenlblgvVQ7^U7OOuJ7y^c|jcz8OF&- zExPBNZ&Iw$fmDfIngmaROXZBzPDQDjxpBN`A)5N=GH*7yu(|8ONC&qqv8U%mSiWeP zj#z5UPjT#6m2+@A7k+1;Czfz$aAzAfZ{YG0PBaz~j~MilAMgAgLgbWPryt=&kFKkL26diwYH*#)(~J&zmQM{QDd z8&yUBy^k*s7WWW3vx_%16sKdUyI%gK^5G^7C4Z+R-*seju^m#%Wm=jxEjn76{+a$y zr$vPD8a^lA=KWszKjxjabW}(OW3g)-bM@R#_VjS?6qtSWC)po$_~lxivvFVd8sc?D zXZ>|H!xM@qYg}|o?N&CsT89&Bet0OyN70b;_yToe3>KjqpWl+=-&oVC)8MnkG z{i%@`WlE{BwZCl1k2qdN(U4%sz=o)5UpBit2V}`651p@XB}A<^oh5z7>O&ssBw)-Yj%jx6M~uC}yO zOO@7YD#t~?s_LvQstAfQA0~BVvdH#$$y(du$gi&7gcNr20YIEn6Wt=svD0~R$|GoC z2@zaZCj8ex1_CVAbzH8)lbUJA=T24yUB4?Xvnf$|v7ST@}@{Rp8L(u+yY~!sr zE)M0WN{@@DXufpzIIpx3?vary`i8B?qbs29Z7emv-CopDMoWK_twM~kzL~qr+|1KJ zJ7!uJjL+7VBBm{wdmnmUpJ6j{Ju6{t-o{>2-Vq0Iqfd^S_S(=`Vszh2c`kcWpE3!V zxS5xdyrMn@gkJgMG5P#X-DJ&ve!I^5^m2Vl<8aUdRq}L8UIMI{MJ{{D^!3P3oSsLn z>4u}HaPsu?4a6w4vW03$7;rMOo7>_p8lYmAI{aZ7&>2b@pTe>MD+T2C43>ZoY5jio z?OjHm$j_|WT}U=MIuJHDnghXV(}Mv?dy1xG#5#H1k2?R>Uy!UDS>K&gbK6&SCKeLc z$V{H#DrWN!mE0jUW@h)#wsYkEKHVbi(=lO>BAi8PyUSPny8RzI7v%d3l$2&s-@JJ7 zd7Q}C2NW;;JtNcYHJLC?LcboK(3G~^vobG4_Ber)kVvp>*rH=p@ay!3ge%g8!REhLU* zZ*#J%U>3YGx%z ztCyB*BgtOrWGNlmw^ki>n&BMNrsoH#9eTc}F=ozk@dXxDr%dC*`>1pPd5TuvAA+P0O&+{qkkUk!pvD>hSN`mwLHQA5ws$s?Ed#_YQD^cPD_&vr=9j24?jZb9y=BTjEah~wpRi1yo; z12@sx7L^=@Ys%H*p7HX9VV5tO<^DJsEJtA{(yi>HE=q|c^bX;tz4N=&nH%-lXz4IHT}1(m4Ibpxh%4i_nKSyl>~$l}ST4WvIFgrUTV*OPd9FMrvRM7SmsLqCiX`e9ggT!z|8g&>`eIZ*|B&|ypi}hydOA->A1fAI7L!dBEu?Q6h?d&(_+^XE9NYT09MpdLWm5WEiTN=Kf0Nw4ML}5z-1?zkP zPACm+H^1_TC05$ghHsQ&`iytjL@x4)nnb8xj0xF9)edJ%rt9!8^JJU|x#$fMbXNy^ ziyW$JqCBf6^rl*>s02I3IJRVbazx4=`}|hVsfGQgkz|?-@ykv;*r*;G&q^S>25v8Y z){{^E6`Z9Bh+0YcIn~aW8YMF;3@r zO?Qe}fu|(J&YmkUe|c(&eMugy1~bFj*phT@)z1NgmYmn4xT2@@RXCAj0e-k6gIQ`q z4nY}W9PxYM= zb767uoiI1^LPBxp`IW#)+yaYxw6eS9BVC9#1aXua)^PLcNz15O!Ix;ycX(GlonvQ8 zb+zr4!1hsMO3!lPVv1pTyxHxP;sP{;CTKDPAXaGk0fa4^j-Lej*ma=YOa^3qq4wsB zF+BMJZq+>Z(&6=8&BN-$+)mgX!q40^d}QXn1cdP)zt-tTxhRUM2qEoh*|4J&q-6|j zB3#XNpV83go$dH|Ta4lks1M%Bs@9rMgXGwtn2~OCNqwCT%uko3TQPZOzsW*Ui5|Tz zuLw*dUGmUA2`zf@C@XHwwFt=i_sJQIwr5wPoy0A=JYa|Nmrt+@1wvV776}z(G`_8{ zHrN?MY9;dL)F-4>EP?0Ca75Awvzwn}B)4nCEKm1V)h>~PVqj(z05~`}7$_`;1_uYl zyr6g&8~_1{iUx&(PRa^RhCxEk#xBajfys$QAw~%bjA23XG8hziaN?ODI{b&2by>7L`fHUnA5eMxTV(OzjJ2}V3FFEP*%3A7@vO;}vFP(pLE*rj^gZu=HvF^W^ zlD=3L2XY2Dmp4lU#bh_!%@hhB-i*9K8@y2|@T|kynes&Qe*&1er2o84$pKBxTAsMi z-gKiL)uVq38|T5$F5D*a|1t1Te=^v;YN8j95sgCX*lClS<@wZKi+t@Gz@==wL_si`n_*pdGB^=%_yN_!%AYW61}1gG*&BgXh+jxK8j*IBZf4wCYKt@I6n(ZjjT+s zIjJ&EDIoCQB9Y$hKl;B1*9+B-X6%`B;$oim@|hpHB$NtlNpCMET@9;mIH_J{#qs` z(ZHB_BW3%n8+{p?V?a6;doW)DQq$D_fz-$)MVVFfl3fwrdeL8h*1qwqfB9S9AJzt~ zZ+g6WI5{?5d0mG*7C=G}r&FcaO@0Zt9T9gHtP_w!PXF9xcKPtsSQ}$rXjE9Kta41- zoXf7hvJkaWW$i!nq6=I7=J@Ql{_eaSeEuNb`r^*5UdPSQ`fNJt2&3W`(=Z|U6|U>r z(eKp$Wk(dw;;wb+`;kvYqN{{GpMSU<#9>!V@54kx%qM_sYC0Fs#Gnm-GN^Ld_DzWD z6EJSP9ZaqB38;JVfAZG~ys>B|8}>{?UYiJPO$R9V4Ig}0FsyOI`2-kJQtP}Hz}jW^ zF%oEsKE<=i^s zQwi=cC*_utXS}QY;{D|RHZ^|snt`p4QyDh}^_mOCXpN?9%IE#EIr#~wmo8CGm_6f6 ziDCJo(LxNNQo@8wO;i)B=>9#S=VZti;TF+1-8Vz#IEr`H91metRWti=M)WCx{zvMy z7?Y%xCq8O}ANp@!Gu^%wf4|6tsGVV#spybZhoc%fB)V=S4lNJRFHM+V6@zN$5Suhh zR?;O45r3+rg4PlGl82n|U4SBTirJRM%|&vVOSXyHIKyw;qY!(v19+K}o6U6;0Gcr`$V@`8L~ zd1{#xK3Cu;yx}U&gqCSSGYV$p?rvIwk}A(_QTY+dP)@k=rQgjVPKr69G=GHDnGBGS zjXN$4oB}Ufw+uZs=Ji)X_-gaL#IXgVg_S$L``EIC*e@d|7A}5qN!OHQUu#yTLOrJ+ zU(iwPggVZ5inY?%#{eVOYoljmTNs08DYIZ^k^lF7T%@;4}-4Mnfcan=D1Unv*q7(&C+j5v}=pC%JdUwh2dQo1 zSN*n&S30JB9UqU5;4V?ez}x7Q+uM3G`zI5wqSQVC`*z3_g}g#!Y=?Ud=?7aA#j9u^ zYDwmP1_3TDcxXaoEN_5<5FeI0lmkOVte9#ocn2sZbsB+gJHHIGTj8i_nf^H4xrsGU zzm_Zyp|Z@)M5SNHe$AeDo1@^^IMOWKAZeht?whf?dn)ta<-A(U!PcCOK*lr>Jwd&o z@R_I64pn_LYIAV6J!YS=&T#3>x7ui#r^%PdXjSN;$){Ouh;%y;81h|$SDCgi+USU; z3fxvZY=#+J>V!SxUA5j;f1JHwG4X8ZC(k4;#x;8xYQEg9zE~M)|8vOHJdRG0{yh?# zgsiJywT#lw*rc@gX^sjEhLo2o}tn~p0x=}Gt91h@2~DblWxKgqaNRN znP$($U~!JYn^tjGG(nYf*AHGgvrA4P&S15{3aRozz^P76y@MuKxy}9V!?U)hpJlIQ zsjlsJ2oW$xM>yO#HG#G_Z|xOGqOFj4q#^k5)&aREy6OtDmE83k8>bhvO}8tj(v(g~ z#=ut7R-0Je_U{a#LxpY(zDGz*4+ih`mjcvl2UG`Cu{B+Zs%^EJUxhO+#UB@ZC0@s|B$yf0+2Yx%J3a3s1PMR!EyFxteduPDv;P{vkv4`so8 zZHmniv(7MLx7h54v%T_Y+q{B-PLM+&Q|)FNZru?3XwzDDLcy$r=trjXq@o#A98pPw z#a*51wHs}|HTpFoy+V9yNN7v^MXKix+J`7I);;V>tA}(~()GftM(v)=DzvM{K4%3^ zO*xqx;Y}U7(9NG^mn~*WdJRRd@|}ow4zB7)uBTF~+vP6ZCOo@}HBP=Zv`yon%x&it z6vWZ5;s!i-nJm{2Ralnwmcu3KZyUB-TdFmFruD6XUwIAy`5uIIJt0SKRa)p0qZ}RjrowW^SbN%4TBqGBbQ6C_l<)MY(qrFep#D2WlkQ zdni0I*m6a2_zH_$lN@#zTzfzGlZlrYpKWvH^4fcow5*E0M3tq&{RHRvKWRG^o3aM= zl!U&O=^a_^V_W?)SF0*hqv+(qs?6<^N|MUlJYg)2J&z^_&-jVqs0vyItby!_V$FYR z4t-t6=bg*9Cgo|-5iROs=M@Xml?0EHmHY*J8|U4>9!L+kSS-D=39l~ zHB(&9P-#TmnN}u(q>(}jCG~5H#`ezbuA|g)+hjFkY=%lSjhIglFTpS&ucPf_5lE)= zmlKG8<{2do|3ccK`veT1*l+~+Ojk%)MjE#{XkaYg;|O2xZixW$cxN}51kDTDliKe^ zx9`=o|Fj?XGQzFCFH_BtQ_V@E6Kt7Q`mmw+3~?fKrE;TYDav0`Dp*IyR6|O!5RR;E zNa?-;gVtWEi9Nkfy`Qh5hUvO`6&j0U5{w0Q-Z#%rP7n7UUcBZsI)H?Pj<7$zU)Rrg zG1#w)6;4+6FwC@(Wi-V`*iNhDiw>e^<>Z&%RgbGhrUA4>LMM+T7UG7 zhnSu)nc*eOl6hw;<6H^u4E3?x5`GzdG9PdJ(*StHGIWL;mHA7~v_5u~M8m4uT zqJxUXpwrvBsRsca-`87*|HlFS%&#>~Iw&GI17SW*FpEpkRjTrw57*fS#Tc6e#;IMZ z@sIv8mId;1R8zgZ!ye%@Cl`i;zvQG~)@87_MjJf#*q`2IZ<-X6P*~(6b?8hUPNSde zXL66>{_n!x2(7Qx_4}_#@FC1~g^_J&(HKxfVN3$gkaN=XwaDG->zH1&AAgi_ZpB$QajZx4J279~ zV_aL!icpnH1@UfNm-ajE)b+rQLAp3VK2k zM_inEtgQve=oxU8CiucFy9aKJu?Q4RhNW!0VDA#{XI@FHsPC$86NVF&sl3w}r*p|& zPp}(>pCVUx`8=k+r38k+jOqMjdS%Tto+>vrNxEKV*k5D)i%|HB2H}`!fLy<+mX0kl zb3M$k4J*9`-v$VVu4?ji^ezA7G|$%RSToGn{?mXHwrL)G6ixRJWHIEpPeA-o zAS;j=bL26pvvc)U8y;=k-l2Ai6B`=r7th1c=mc6LnkF4zymfR-Riz?bD&VH7m=jVR z#&jE&-@?}6JPwW9^7*tKo^@ZRAFtr;2xqr5eoRS`&-OU%nz7ZszS^2A$k=YXux=;x z>f1%JiUe@#*zNllxi29i(57qlL&|9nMRp+H4OopkyH_}Yx|5e**Gu{5}Dy_J12}9&0o7$BC6K@>%TutK;L;W_U0E3n-ZHr7P2S4l)k{fzw+aANj8 zWk^r;Ec*aM$C$+~j|#b?{aUn=@< ze69*?@ec;g=tc=};&`NTaYF>OU0ux6$f6d)kz+`3JJ0OVOq>D-PgN6CG`cyc)H3s3 zBbg_iAU1}_6~`OP1J$|Fryke(>Qz3(LFd*Mc4N~h5~1TKw(z6hjap<9Iz=?G{FZw3 zmFp>|hL)$mjr5X<54(Asa&r|NVUpND2=@N9GoGIaCHHPIdJn%_pWQErEBad$s(Dsd z*m$%>w)Peeiiv-bxZwhmh_d5$D=T@iFV=QF!xZhNLTn`iz!@~6Q`BgpQ}q(gtdHOw zEfX$He(8qZQa`8DF2V$cgl!YEyZT$Y*}Ww42ntgZ($QWre^+>M3|b+m$yqtmew}Qd zca|H!)wlOAc{OL>Rx17eP@rsTQSqYONe?+idx&aji)qFHj~NK7Tvwa1SHccmws#OB zT2GErqnq)%2HS|Vj~eLp{MC!F>$U(|GPh>zp{)Vky=47xkf^9-qCNK4QMw&Uv!>p` zA$qgQ!bE#?uzS^J_r@G+AgBMare5r~t|AI}Osn$8PN3gfmUo-v@_a5Q8IECRSY&)Om;ETkpHanfc8d(!QL=8 ze>KI@6t&b3-96gzn{jU@j2}(IUDu}T4i4(gaUOR52Ifx_;UY2G#J#yB)CI{h)@(;m zOmma1WonZxxhOoSGH3*AKBX+r21|K5pZ}mpXyJ;imt!5biCfJQ#kYQU_;thTvYVSBD4viHy-6Hx#OCZJ1GCQ`- zf8uAtHM3&H2FJZbk4=l-0Yf4OQ$EF&HU)0FZExAcN0J@+P?E*Qq8=w&#zj%2>@qnA ztx=;EXNx;2`PmcmgPgzSw^X-n^Vw^%i>7gkgyS?d8hKf<*%Dszm`0DayX!bhX(|Qt)rToiAnevQtkawK)}u60gg3N*Pp7c}Cm}0Vs2Q zG84@ed31tg!DjKF05>EKIevHG(gQ7V^$@9kthPyrHzO4`u3R~? zV)PQPuG0>wShA{}c?MjT-_``WgcnUd6!J5Y2#mP|SqFU=u}f2@uUg_)fQ3RA>}q2P zXEpaSD`~Asslkxumuz;WdQuv+_;IK-8&Bj36Q{(BMS;Co@}nOJJ}XIT!`+A7S?Jxic?ZTY44 zs?t|lcg)sH)OFLdT=^)jQD|2&eS3t=)7k0^>*>PXUy8*3%16pJA=KsLhhuAP#9dE& z*e#UwPD+PJ@p4a6RI`iU!8=miqxsD_PKf=Dnfskt)^7&<*bf#R5|XKZ90~A9@yV*2 zDNeD=L1UKs-U}6@o#zdKVz1J6`f2}6_SMw!_@UZqZRz$XkE1v^Zs={H_g!h z`@W3wFrDoY%WPt|pJ={u@*~mmXYs+q4lx~;1hv1*8B0^>FU(hsRy31W_;MTnLvqs$m_`->5h9uL*uur7n`{2=O(0EKr#Q#xcK-JP<$l0Q8MRzM_Z;79!u|B zbaBApsaGxN!E@fUX-z5@k@hU}?VDcQGC`t6i5oe^SS(MnG={6hb*?JSM~ogXZ50oD zd_2!~x5e=&oSeQmfs~+56GuRWFF_YtX^Z9fvtB?Pd2+17CxD`Wbm_X7tX4ZbFpkk< zf?r?y##MV0Y0kn)DJ6~=(t1`Y<+k~kk*;PvO)*&|j2p$Wye*$pkMk`5Sq-pjJuD zp7ZWC?kO&LJd1`V)DmmsJX3E}WH%{?_N65)p2aV%f9Kg=&LR8pi}_x1b;C+GqIx>% zi(Hmc#WL7@e*D$ko5>qj@fhvIU=&nl^xLi_h+sp{ePzz5 zh+L1*Y0)K_1#?`m)_6}LC@kp?Bxj%nt@bcZCi$8jb>XogB9F7Z#7DV_t?6c(%1pwu z$^jA2)C&7yrBX7U&Wz%-gLr!p28r8BPH_(Bv_>x+zP)0}F6@Sr`h$8k9|`~r0vrPR zA7cpeKkOn9*9Mi874$A5A!8F&HDz%PNr6NcQ)724Y{VcJG5ZF^5xOW|wEur^KR^qE zZzPu(mkswXUn4zYefjPcLcz$Z`n-A7C4Y-&{ZpSaXv4s-}> z4t3EwZAS<^#}7hd5sx(xI+bysoacUDoOOu+)3*0I>l~_#cq%A5e8$e z9V5G*|If$kwK~bu^|I6dSTzlw7K`5vBkmSAmq4!Zj;3)P7k!6?R&oj^j2LZ6cViPl zfm#T$a9V|OkRAI;YsOiW{>ni3q{B6MR`qH4L#_ZlRuCw}xO>nZMB~@!^d^ORr@r(?#Xne1ew!*=@@WsO1zoIM_y&Q zqdzjmyQPDPAUhZDKzyaegbGIH&rH8+&xYGeq7>U}HxktBjfD`^pLxf6fDg8;a5l~N z^%6pesO;+$?;sS5<2CT5N>tvFl&z0p>ysj_U+Tl%zJEHtsRDP;9zYlxaiXlr3;$a- zUNN~rm1tTXMrwy^Q$9O{#vcx=Fyg$zWmj62ru75aHnhRu7ue0*)!+9ow_kpB5u-pq z5WY3xTr^)|&ko-cAv=U^xkQo+Bcl~Jo?!T3NoA;%(8XU9qrSuU3>1nJ&*hFpkE?*GAXIc`%M%ElE$As+vaMzs}ZMkFif$`ioVowvcz! zNWAh15c>p3d;(@ug&2!p4RXg!IqS$GP;5l?2x7du;;NUKCW5Ar+!C+je+wv*nzc{A z5d15hP6hT78dY$zzqtF*AMKbYP5V0FYP9ECR^xv9VT32wRBWLC# z3Avgv&uz$qW<;Q^iW2D`DC)|Z)pz(DvzNcoyNleac1iWuu+GXnR3xg&cx$8}$3!!$ ze(h)YI~>#~3q~HW3}_nk-HLD8MF~a3tCAdNq zTB_+9s#-O~MiE^zb-vO=IeaxZ{!-$mK5=YTo_$tAZ>1jGQMd=z5Rf*5%>$GMufqh7 zg4KT4#H2K)``TRr9P9W5XiC$44a9T_CGDz;GgPGNle08tCdtk6}4hVh230F}s2ik_f~ZDoomF2~ImOcPhS3EC$u6|O<9Fj~K%rm_La zy3Wn1bWUa6&%y3RO2e&d7vA;QfWFlw?5qQB-@6I1-p>*~MqO?nr9-ZQA1|T#Rt!9w zk;|E~BpgA!oAww}#c|qV&Qh*Yg{WLbOHH2+${qayuiw)YDuDYjF552WnXBB2I`l>Vk6b2Nj z21!V)yF~20MnLqV2Ka--`jA9%j=X(13G>?Ao-8 zt%;G161wHwMp}#Bt~4`yQ00P~1X4q;6LwOfK2xHFp6Z*5D^0l3nq5w-!l~o;I>!i` zqFdDzBVVxs&!aA2#WL@l90FG7#zln}Q+o)nJ*5ovAsEPf((?Q*uHGn|z<)28=?b!D;tqajdZD&JC@ZqtxZU?uqY|CkPNAki(-nMV?-Ez-O&U2?7 z$xzIzc8n34HgL}m>bbGJsO8I~z?-7y!hzh@BQn{2_MTN5B2Dgt^l;jOmMuh2>pG32Wj)K7!+CV5A|dh6i~wUlyAG|0Es~>Fr`6>hecFdd zEO@xBS9EbuasOR%lOhIb@O4n z0%cd(7myY%4}Dd!vHa-x*R_0JYi`LK7fTmNDX`mt(F{OrxpfEP14Y z^eZLUpc)7a{ga+Ac7EKRjbk2b3LSU5L8+uxdb9TN+s=7v=zT4h-&;Mc?MyN+)8{Cx z>xmpuK3m?OLB<2kiqwYMH)|-lpG8SQFk{+V$S!tMr3%yFJ7$UHZNZ4+Ucij%5%z2# zph4|VYI*D{UeGSIYKo`DPeo58Jq`EiNi*Lv($z2go9bq&=jrWN@txHbND<@*2+uT{ z;;4TLT1eh>-1|nUt()Fcv)z{`@2(o63`lc`=jfAN^wJoNDMsXm`PDD#zJfuw>ho9A z(4Po6x#=B*$ZkfI6s@p|w3!Znvwukf>oCvK5#*5a$)ujKEUD!cxQF$mtrhWr1#gc{ zw#{((PL199{n0oU!_<`LQSDdos3h3bSx|~+iBn@c4Tnn{Vy`&$fRS!DoOh1O_k`xy zKrAfkM=2ktV;1+7Rx0kw2=H0G+0aWZVqLu$y30qs(x2smO7Iab5m1_j;_aci+$0N0 zft`99M-Mlgvq>)wns)7iKnVwEcwlLtoc>)8{V{ElYwA3WWJQWgOB}F#$Idg^5XkV| zaudKy_iIyLuqi!LB}`vOQ+ui#ZM%^D0EUh*o;zoR;CqDJ2{Kj8G-fyQkg)8WANe+| zfTVmHXRuqUg;;!Lz-c2{gy|y3<28NY(rG{zw9mW9k>9y)j^ecev18i(>dq4ty;xkS zG~%1VGpWYq?}IW(r)W^{_n^HOo%m$CI*0HF(((|mJr}O2uZ1g-2dL|HZJ5R5PRt1) zoBaRg40$d=6glc$8xy zFv18e?s=gqgli!)lcEU?)4$p$C!eg(+f;Jr9Q6Wwq{i1Ey6So&T{b* zqmCCbJMSMPh@!94PWIwaTNKCsm8`fHW*4fWo+#rSnCz3>Su32iBX|=|Lq@!*T@F%@ z%k;?(Fq^Al<;7#1+!!4b1}eEO+8$vQKRIkHt4i>dS5BHc1>%nigxWJ-n6n6 z|Cu1hOffkt-lCaC2wa^h#-=fCt8>Fqy#L!sTU<5}A#jtIIng;5g5AALM@mJ&pdN1* zOmor?nu5@vfTkQE+7sx6`Y-<<3;>#eh@z;Pg61BDje{hM`&ZX2|BLS=OnNIF=m@!x z(l@CRIJNclt8TD%g{fo7es+YUhm}6$HS@{D`D`?*=6ti~^%{2Ql@ z{szG%IZINc(jUo`c$S>ClBfHb+j{1mB^IfE$o429xp(c9)btI zO;^R~Nq*1$Gj_d?|jDcr+^R>{9?OC<(Ja45DA3?0F-f1gUa z7-?sk=u;O7?6@I6fc&^qpwV>5oqGC-&>O6iQAi_*9BT^o2Nj{?r;jip)GvVL^d|5J zvPOxGFl)%@w^EM^QE9Z);&GqO+9~FrlnaQDq3J4x`=5a68wKSPDCJ|zL1#bFi-u{` zDQ&bmm=mIaN7*C=Exi! zE>&E4Pg?@W60_7wJ$w{h1W&k-;#KtO&p^|#3*QJi0mY>UX-UbLAZM8Hbcwio29{0S z5CS{OjN^+Zh&l0-zxiDQomjH$n{`uc5n@5+jru^*NobW`a0mP68!Km9S`PD9OV`~6MFf5)TTCR&dXM! zihgxOYLf(dZ4p-vDCjc62`*xAo&|z6RfdpK@e6(tWd;s^bO-q+D_&a{3L_F(8#R;@ zcz70IH}MNQ`SgoW(oNzmh>2V$y#kFko3+)m1wQUN3{UE87o(WA&YEJ3c8^S(^9QX_ z$u@sPj-}4LPQK_X_;$g86NBPyY*eQ z(kI|_|3TPolV8|StjwimYRqj()LOA|+5D>Fmr1WpOs>)q9&u*z)~vMM?2Or^y4C^M z9Qp|urOAx=j7}H0Wl%t!=2c0*fNNIl5V&2mJ-G$?Oz(htOofOc7$Mu_A9}qPQFP)b zW66#5Uf~j4f8x$3<8?h+$j|{Rngcbpct|*iYh6nGR4#eTjO?haSqU{{N_bvG;!c~n z{d#nlun#eoKvqu06K$Cb`x8O0c3)5RW*OO-Sh&s@|LkVKmmi#IrKRvW7}surEVkkD zQJE|R_7UF79giF6y4$=?`llZcoGgOk&Hf~G<&%sXyEDWNXb5=V<>CAz-S1+@Kd?Lo z_1~??q97_Gp@}Sib-JS%4p|$00@Np>&=&P^P39Rz9shm{4Z|a2+RMnCYWJ+TSmw^~ z#X=TmMfsgwwa5Ue&SYx&(vg{hTOH;0XDSZ@Y}U)1ZP1im&BQgJ6c2u%Sy&B9sxQ;d z_OB~F%$a7vfiKS|ATgKpD80aC42~d4VA#ihi{-Ro{s7_1XwufYq?t5^*w(fx{YG@O zcwxurz#Cc(Zu7W98|&f8w5;wJk6|&(CxSB4pf1xyf&jhzceMn7)Agoct(GBOE&K#`ga42k<={?%N0yc1* z6HVulrZn5M2TR9;&Y9e!hJ%R5fs%g+H#-AZ;c!+0^qx^77zMbbF%|ftbiLB_}rjLb@vVw?**XTrkGbPc|YF-l)iEpM?sL$nOaxeKvbmcmK! zR*gYJ%+Pk?HMbZpUloa>>qJ!0n~XLfxu_qqJMuyCdp$aG!%&WP+Ts3>Li^yJ1D2=Q zEO;jiy#N^p`R&FjAIq*LEA@;pxJ72lBpDne1W8GOaHUeJ1C>^F1IxI~NGczD-!KMT zqi8bS+mZ4v@AyX7pXSe^&?@24Kq*+~5SGYwOv38ujgX1x-;0CH3-)FvWmyFxWY_+2 zL=3Q&Wy2)p48_v7G~Ps|=nvyh9F(2wnNi2wLFmxIsvJ5m*{Dc6e?oOLtjy{K(8YgC zjgcU%9VZns4>^k2g74A9#IGj{tFys#MMN##(nBD2N%2BJUFx`cfAJUy5XZ9`8NFNm z)_sPBiEjHti`AJeOPB(V{+B&bcB+UgJWEx5kirH zyeW;9do<*Jn@dcG{gB903;Ric3ClYBC}%7Qz{KMW5M=UerG|z8_p6oYxDo<8m~UF< zq_L`vU=c2&!e%0T6ePX#l3TD03vApVx@MC~9FE!2c;(B1>I_WQ&o@%|a9KrE%pU^D z`dRSF#{2A&L&gOj(Sn!s!`eh=~BQ&)Ny^i1E94I-_0RI^1^65oRKWv?$$T`Ll-h)PK+&Ff_9NU?X)pj z5`jU&b_tPVR#P#{PVih#1+~7z;F0%b#Hvb*EDCYz_^3nieDWvMk=_0oreLaxkCzmB zt$WrUjS|UG<>0_z9HYI(?NJ!~m~Z5_wjHn7O>%Zj3mBHm8Du+ZGayevwrag%GE4Z8 zSGHdF%DijzBJ?nXF5)k6u`FCm{}iPKL-mY6s1qzk`>cdR;3Gl#FNuDHIwRvW z)fS~oLCgm+0>uyO+HRK+A%!|EId5*ZG+Ps%9t<}zsKq{#%y@a9A7pJdnS<0#1~rhv z=nU)GzeYwyYF&t|u=JoSe{euA>~vj2eV6Uc&;frKKClZ5J+1;CK9iZ^?h{Bsg0%w!v)EcRFb<+H{^1|h zjfDj=VX>;nWq+v^KRrRpoDtwM(Lvw^xXAvEUn}LbX)b)muROJE}iQ61^%4PTLNU^%hbZ3%(utFY0af~x^;hC(V$)M*5S9?Wpl6S6mi z{~_>L2xuEmEG0Xk|Gpp8TlNr?m;msYrgy?0%{OU!!eQ}})qq+!7vQc6(wkf{po>bEKi_|Je%bZk7t#5GgNbI2{y;sk>e1I)|)K;hk?Pg z2V)>V5THf5>~?-2q(ZD>CNRd9nyHy$h!KyQG4nACB$D3cooJ#&8Y?;;IlAl+6h3Bn zOIhKVp*q05w~#Dcpv zo~j#z#lPUslAtv9BBr4!@I+-{e=3v{-il23sUWRFF?bMzsnFt5mz|R}78Z({CflW;t=hqxQ$ZB%rb{fViqndgP&@V6-pP} zGvQRf1+(%Xi_bc&B$z``0Rk*Y-S-q#-gwK8|G24hrN2zAC%9APO1=>&-3(dnh#s+j zKwm`CBg|kmJ)^&3fwiU^_BX(aHXvOUDG?cAR0uKV*LM<9uI{Ip3T&z9J#d)f@05CR z@EoW|K-F!bfe_TGw>k4$-oAw!;S<*I5u+|egu5ZFX1c#g|5s0E;TA>QuHmH{mhSHE zM!LJZyE~5bzYV2K}x<;(k;jANx3c zQlM)h#Me@8rbwjAW0gSKfZXhG=hDfjRTF_rtcHx8c8Bi2LTS+#EZ0)rD~m8a6F2DU zaLM1Xuimz3o)_)v`&Lyu(E1y{$$+YU>ax8d5el~hsaxSQE|2$~QSSqPC6~~NgQXbs zM)s@mbl~n}UB~q(m}Ke2mGf(mlCNJ%OSQZhpTJTiiaWgtoV-&X z%?wUA=r5nXxs&h){;s00;yDo^6h!ex&Y(U6)dqj7aaLq^I+18PJhrFNI@u01S1Cv8@P#~Zh(0+m_2-zc-48zmP&glR)c!_*?fcU6d^%$rR`0g9*vky&;3YvT^S0IVG^a*c@qK8_- z>vBt1bI@hL{14FX(6~%gG^x_4hV`%hCh~krcZpKY_}--} z{8PMuK86QKW8|)t@piWkVFaw=Lko;k^1zA9Z;t=gzhd`z`O^i;pA{Zj863} zd2e#~x&E8W@M84%2pv@Yu#nafJ|eH&u@ll|Dk=NLvv17AhCW> zKgPZMc?K=Vs~yRIpS;tzNNzg|%_%?USKnizg=yLim^`MRBzrF^Me^DOMLZ@5E|8lC zNF;M67AP{FKzX2=T2LXL3v|w&XGBwp55Tvs=}wk>U9@I_mxT&E=+hRZ%Y3ilG~C-n z%ZBd{yegNTJbI)OJ0O4q?(n|+kqKeFqxIwwrjw|0V3k%CAedjqbEVTvw`G*GJRxaY z#1d8Xq3%mhvW9q}!28#eok`TYf&;s>4nbyFy7L(#B0*Hs)L~P@n#JwEuwfB4JI{~( z`Xa6;^6LxS+JEd##@w&tCFyQ81@dzXr<|zV)uC`Qj|Y+@wNXKN*eG(z0^d4w)8F*Y z%Q8f`ZgT}Lomi1K-47m7q<(b!Pa64#Am76?^reK+ZtF@HH(l&l_!djXys5mNS)Ws( z_2y0;@NIt0|K#)t3jsD~-Sk(k#$>A|z!a)5#0${da28Vg_K=5 zrt!07|6K9bso1teC+|yM!T?F0|u_AIR)JDX99f9cpgYQl;KEk_!AO^6J`8NlS{@B07(G zg75Nh(8&qf6LNmxZ{ghEKBhsB?F*p|^22E|?cFLWcq|&b1$^q0=Z|8h(lp@mW%fJ8 zPOln@E;8j5`RZN>=s0umIdf4t-mRtQVTvPT&L1n8_ATP9dbuN!u$_Wji6f0p z!KUT8T%z~L*d;_%JQc1x&HYaM+wSAW^##KK208$n*lWAcJB{>XdqmbX-|PyqEC|qw zF&n2jB1Eq=wvLlTFLgNyKmbXr=w8HM8u*@Ply__SkFYT(V+W$V6XEm9(b?;hTzPBm zq5SLftyFHnyA&}5F`70js6t42&JTYvH#FTqgUXxyo8jB{c2h>+;F?8%VDVQEH}&9( ze}MNm=;|$j8#LQ%f}%3Nt|({=V{r}^)fOq!3VCdZg}Bp2t|6IDJeOGhbCFzj5=J*9 zmT%Y+g2b&J|DfH^{t&(_mW&uA3hV>_y=X{^SW?lh3o4)rdu}(Y4Qf_1;s`h3`L+;7 zSF3@<_v6nuJQ*@{3~k;oQ`bDF_3PUje~q3M0ZzA5kkl^CTF%ajy4dj|4X)wW9eHDR z!*^lSScQf;`a0e}WZPn|-{waE#YAyKwnWb6V*}@+4(2AYB?i0m_jU-@pj)aVE6ZhL z{S6Ad61Lh1#pYY*s9KEO-Ig-b?Xb(`ifqLK$5=h$sEa>kyz|5vY!cfpExDtvXHjdN ze+Hh*pD)h{Xxk}*xjl!|e-=qx;h|=YyX4_fv;-v_KwpCqCj9>tBK=1Rf9*~BFD3ln zUOtqrv4owXGE^?WJUSfnZITMxFs6=NxL z;ic-@wmu75GRa2w1er}AsZOFA(q`5%;Q)Q4>`gFQLcvwp@>;MsSHQ<>7rC4>15_BA zSN9;L9P{dC@lu}73M~;{N6Xhl5)UjN?=weNki2Cd{4cDEKKuLabD1w(LOpfX0GQnJFgw0X_&lTJbT;wEYb_ zbV0y#d&<4sJohpgIOSMd(7JTN_HJ4zS}Mtv3UHyL%69Vfq&rZ4q$#Eg_PII> z^>?0q*IoR3&02q-YqcGr%JXc)skFJ2Ob7h8EeT@L$2`5`F|Y;<9(;X&_PvppH@(Gn zo3L^u<0?(K@sHRDwWyC`lDz>`&o2MWA4dne#Fsgb$JHdk)5|(Bb?{<6>?P#XxtJga zvIl+<$xCsklyqA-Wy8Jc11%jFyikNge-)EkO7U(N%YK?t^5X)wSv_hgs&^aFLElQZ8);^ehdX1 zs^c0QF^wij#lP*p0)105POya+Gj01E{-ksrCSGjz{T7vEwpw7sB`y7psqZ@4*U2?) zGMDT^O$0FO*3)NE?CAT$@JkcVg#$_*zH?B)TB_vv(!%S=(*1X2UkCC(ZD#*BvcT6c zQ;&}CHO>5gIvBXK@T>K~Ow2O=7esj0M1R#6$srN$U+i*v#_*}J<=p3o3~Rs^k+~_%I{|g5fzt_xByToYhGGIIO0DVqf`m5?PT`NO)FQdh* zp=X0u^5g4*og$9Vw7hd8SosG)PWj<+5_8?Evivf?zu%StmLu_{4md4lE-)9f zLMh#{9n=1!`$0pl-J2;|-Wm7QJtV|GtIKQ&qqXDhXEn0B1ftu-nHTFd&I>M}$RRZ1 zk#`io{VAV1scYgW;VweK7MQe`S8UaPOUVZOs+xL7pPE>mU^vqz+o1S;e#n?Y`k zTm`t3-F;E-UVyrZ)GHhBupjA8*T?nJ?fiZgLj`jeR*I?nFGaA?gGpcl9FMG4YY|8P z06D{Ry~z_g=Mt}kH)SYI`P<38i0ymMdRAxbBGonoL{D&J5!a4nk%s@_llF;K-e0c| z!-aI=CEZMAjL2r5+SKq`olE|ulO?l97U<0w{MrPG<>@=K6H#xOyem&)xob3sXkgxqD!s0yl-W`9f5rfUGHsI$4%BPGb6PTR? zgRmN0xV(u!60_r{oscDU)i(#MV!m{-gg;0;Q!wmlPysTPubuJnF?%?(5BUvBsC`;B zc`VOJFb(rH9*=$S-JbxCnJcn|-UNCaW1lpZ2i~0L5y$&-(yM4iG|}*X0JemY*uiSb z8T`Quu7I-XXNBkJ8%ZhFhj)7^a$c;$ao_On4yb4^!xFmK{{g6`?+5oF&80QZUxn_O z3t0JgRfE(?6cKPmxuZwf{WoRO&X38&(VWJUQtY_6F$5Ds@s@E|>m<#urBzGP)oFTtvTls~0h+_Sx~PBoh`6mW{Z9U`F8}bc$zTP2-mlqc zZ(?E&_|OdB&J}I0^PjVsJHEx-fSNk&yN{^!)_k5v#G|iZMAo2}2e-mX8Gi=N zix=k6&w)1Jy!RUh?tMupA1rRETV!0dWoBRq=QYT#_p#(U}*mUbqtSY`4Xa@Yj4^jr5S|%uaw#EADP+g|U1IXMIS?zC{h-K#c?^It@b$Xg+XnJSa*bl=Hw3Hj26 zvwzc?#s!rgeE;P~CkSIn#XTXn#Cohr@ZnMvMsPoPQewUuh6GY2CG*ss7H*rS&?eVY zgcVu_uMT%@%`oYI85Tum56)623-4KIA%(4Js0 z^A5;-N7mgG1!itL&*1>%JCbCsT$ty@-Pf}VTjdoL#@@jwZ&*;|3)W6o2d5nd@YwtV zh*)KjMZ6+IrOfD_Xmuzo&EGU970iALJ>|`|iMLvhy}_jfwnz|L^z#YIHf)QGc;B@d zMH9Tw`0TJA!bKyq&NV_}I=xO=+4B7(KVu@2SBra8%|=Il_w_KL=|%fXUX4@75O+8Y zWBy`8;!R3vMVreRDz!r$TV5wTEdKjeHA!)z!6$p8cL=i5%o6Z561lIGAaNW#fx*@p zS%!ZABt^=9fL*+~e8z#u*XEk@vY(m17iV^U5GTv&nQ=cDM>38sBK!H2Ov|?(#Ru}S zQ9MYETl=r6wD?zoC>r?tG`=~uB2WxO%*a}n^XR)$7@5Ax5I>D`n)QuBsg>lcg_SD> z%C{BVi$ZFS366aH3-L}qH&N{-2Rn+_Ef`v{ysi7qeT!(+8OVpiH|4z(QW`V~WChav z`3FFy`v*85`jlu>=WtINdv#X{1-<~5s+6vtaMX?bKfZ}1{NsOmhKuR@ROZjlQT!1* ztTEW@7XQ0(mB$%sK6baqx^TN(m9^cehW(X)3}dk~7E+Z%=#jX^vV>~5ONFz(*AwCg zp|dxnYzFwIYs4^O3r1vQRe@TeIG{JNr6@os2B#2v@tuUm1qs(UB+D#AUQlC_9g*bp zmTHN+IpbAEn0&PZPC;v{9yk0&NbGa60S#C6b&)Tm2Xxt=f>JU6? zJHEU2@K(_+1z$F_=S)mS?=c=jK%+ZR*uuyJXCI**T}PP0VqSUBco~hGV2%ed3r&}- zDhmXnDPDl{+6L$uck9DIYsLe$uAg#HxQ^P`z`rA8g*ai0mhgPp_bPCw=FR;EK|%V= zn%~uNH1wT({c+EkgQ@u#LBwVE<-A-EqBzmpEp$4s#xej?{aA1Su~;N)@(SHB&nnC+K^g#VYbbPg>@ki1-7n!tU+7U}~a*ZY*9YF|h!( zBz;Y;9C$N%2)Bn{02$ZQp0wE<$h(dX??XF4Q`OdBH6 z@*-nP%G26FHN3rL40zQigxk@lWpdcriA1TX+BY+7rlarejC;P&VW#ffNAuM8(d{GL zX(@QbTC$n>`d-%*xMSd)Ivz~S+mT4QVB>iAP=qJgP_Nq;d*fHl(}qn8@4=9=?7SJ~ zxIfkn%T?|xXVBUU;|+K{`H@-v)@|}S}J-f*jDX&BARDNF@IC=S%k4cVM*Hp`*x%Zz&$f>g29a)-u!V9KJI zzu0tbbdO_x6;&eg^=L>=)I)$&5>JyV7)mXduQrPVu7Mh%1d+00nv;i$ zCXszGDvwdY5ux9Lk{u;`GJV4*g7fCe;PhGv<(R!FJKW@X{YZvYhjtdN(+qYO&tdO; znEk5xQ!YRD#Fn2KzWZIo8U5KJO=Ob(PW}uesiZP^b!eyzazl{JqO5+YZs_|nB-1e0 zNVegexZ>|0g);alss$^|qr#$R7OAuSwA1$oHcl@g;g=}>f&onF))Ev++GNT>^cOHg zuJ|sT*mj4m6vb=0g3}>S+4`;4`=jwIBtf~aLQpP}U_<8`!K%Yg)sU~C+AXbYg3ORcX6a06VC1R^(w;LY6mVGg#T8qq>(JnQBo6NFT zQ%4S?WZZ~K6n_<`J~oPxj?#XcGXwsNN)}snr;DTR2gu`il)KLN%E4avhBd%3A^ig+ zPG3NM&~cZBMOYq1x#8nc)fVY()l6fNtbuuW1T z<0;L^5MqDKgm`X;$=(s8CE(+CXvDbRHpxeC;ty|VoDCdNlDga96nRKUOCSd<-hcgcERkE{4uqDGa;I&N(B)AieV)^;wc{$@eZcR~kj~EG1KXIo%f;)^2*%q8r zd{Y^SduzDTCzF14{wnzt%tIy3Y4R39>T<9;sQm6{k~T2?Harg(H(~%UnWS=*7CaJH zM=$KgxTr_Idj-!|H>;R8Tkf+slXS~|Iy7Yw)%z9kpa-^mwj zsij9vV9KTpQHB{{@^yY5qgRS??ps=QW7KS7K^@x8zJjA8`*%LG=vfU!q|*U@p_KB? zI;d19R1yk^#oFREzm%#N?k2+)9WWKGD1W+A&$&ZVtxy%>A za3@_M&>0;E0r3+FQV3T;KZ#*;o$X3O?0LO2CxmbKOBzL+ZiOzcRQ9}C*e=R&g7w%1 z(f6^rwU7Rv2&buoHPjUz3vJAIkdg|5>AOfl7^tS&`?W9GDS6s|W6Mj%-bHcIcfk~Lb ziPP)ej5YL;?p(3q`lAH^ec1;EJ|yf!0~^x~=KU&DCuM{#3{7qolkG?ykV#g)G&s0o zTbw|P?7md)V6v|qp(xn?*7SfAYgpGR3gIpyH;IZnr8^Id7)b%yvFtB$l*a7lcgBMcH3?yn%=_;ZkFB zQ!{j8pQf%{VFK^PIHW9vK7!FIPH2Bt+wC4D+Wr)`NF??TK(;J3wBL$tHCZJ3oW^S+ zoNaj~vaY}K(jW|HS;C)x{P^&l=;wW+OP=*@;_lU3jI=In%l|<#{ zQHU?qgtnYoH06z=pdU2&lIZW8AR$EYk}U%Le22!;HPx;f- zzALosC?C=J+u?N}S4dO9jC7qNlq-;mId_xix|Z>fAZ~9U`hSz0V8CVuj{(LqcLDFZ zCt3QM_hHBDN^WlY&!v&dVC+nujmOOT^>@B?c|;9WnQsJ+0p!grB*sJg7nuIXQOvVe zaeR@?_|w#Z6hxlvT;zEC)4qPyR3gQk*fk&G;uQgS$aW(l!Aefb8nGM^ z_b+6n7#9e3Bxjs-k#g%PH?Lr3vOC&t)+7GYs`78fkrl3-b@H``Zx=wv(q*0}LbMJ8 z*Quvt_1EML9oRW}>wism5p|Tr`jd(e}D_e>PoRsNG}(BLkVsB_qLc(1uA5`6S@8`K;f#1 zab~-}$_sQ z_|a+2AEX`5y}q~brUY1_+(<698<*DWZL`kD&H(&(EOUi1^*z3EP^Hf5Lu9$e`Sx_( z(5=o;uQ;m>S9r_uQ)IcZ!wb|%lcW7AF{=A?8?}chFGx*9(k3xEvW*&3Fp;t+)^YrL z1(WS(7Lxedr@Tr803vtVNKQ`uZ>W{*^CQ<~x$GwZEa&*VSLLnKKTb*izMtJ>C_Gol zmpadOZ`s8u-Dm~#@dlL+Bn&dyxWC7ZfR|R$C6UdnBK1tzV~mM6~3$ctf{|}4mmXX1+mW&ViS!M`8DsUrZzFg zhX&aBGtU(|j^A?D{oP@-Sr8Me`GyO<)4Ob4or0Y!Je5-tgtL2>AB$Dg@6^aWKCA+( zB>E^=l`g&KQPoDs6?>E1|0Jf7a6*Q}xh^2}Gi^?wWj;9nN5%XI#&)qCIA2$>E!liX zkW-^78%8#<%oEG3OE5bAt;aQY1oOi?K`dLppdm>H?>qp(pHp4 zPPBgajgfGKQOSh23^6L3CeA~SctqoR7% zQ5*bN^l42+B6(au9j>=8%!^Vf9wlQr8VJ}fC+2;h)Q7j;K1Z~nYg0019Sa*^8}Ci= zRTZboMOb#`QNf!;mX-XQVx*L|zUkls#YezIn|zTIs2rfF#pdrx8epZO?Q#L#qRh?u z^|%>uiP|QVCmg1>r;5(rC8KF`ARqh@)ip9Q6fUh92^M7#W}BP!6272pNEg|r>sTO@ zrR(K75Y`v4a%RWLwCc~4+N-S3vEA*bq zz^zL^OVZ=F1pct88)A~izg$xwuDYrBQ|y%0(pdbynr{CQITPr12dV4&8r^+n73V75 zMx;yWZjY`JIj6(sD0>-#xoiRa1zTraC#~Cz^;@2rUvNBzF}u53vZ^qu4+7<3x)kVF z3Oicz1E%qjvE=CyeK-^#qeTRG@3|r#f9nd`=Pw_3fr=*dD?alY`oG1_aWP87KFG=` zjE;XWB*z$u)-`mHgJR3i=zgq=hxBL7qZdy{DLf?EU=zMdC(%ugoFv6kD%6=@6`Au- ziW095*u$smbKOa4(Oi5RX6T1w@I*%BmZW#Js3_Z(io%mzE@ZO$yJMVvL1&bvGwkb> zZ$|__Kkq%HBSRZUEg^hHh4}31cDsA2dB1#1Rn|vC#B5_nTSqJzi{lS%}#c_RyC;qIb&||I(-zOR?mYA|aV;3c*5v7l5ruT}1nVZ>rC=n^ z@{@x+=EVCp!70WW;xXBe!7!`c{oLO{ux8uY(2f*y%I(IeT+5oU2Q3YnevOK~I4$tb zJUp)z@^{c*-lT$9j%rsTmEH7_(n$m&dQs#nIbiJ4(YOwu-a z=u+B-zY0if%n4YKK4P3uajY-NT4hH=UYA{A6XGvY=I9(9)k+b5bytl{T`6fYO=;Li zqcG&QGAxX46g_w*=E}ul>rh$1_n8-Vh*WS5R2p>3(juFdT`ZNKF(`e#xZ$y@^Nt$s zjl*RfXrzME>r*qv%^$HZE(n$J7K@)0#$9r?v~PI7BLx~D_h#iY4hd?W$!wh`CWZj> zvMWG}*L4d+QUORQx^W>|6__^kkl$6A?8&aYakWA{J~&4-jMg?ioD2IY(hL)AxrGn` zJS8A?Fu{KsY;2t?H;87dta!lO6%*G`&zFs4&2W&zG(rTJoQb6A+2Q8jCcOb>_?ZG`bY1($%w z{~zFyTOLQ`h>2ooGy7%l`y=9lrmmC5dR4M{ZO9J8XPisE7u%8G&GNT@t-h#-Uu)`H z41L1S|KxT(Gd4ndh8b&@l9_gGI_dIqIg8|XF`YrRhMFwE zbR7&ZSDFJp9OCI|!O_qRrOjqk_NtvcRmsFPvjmD#cUHt$v*zqBglmH{u_NYI8-=d2 zrgl)1aYN>dgRAYL)>}qPYpNyS-7cPi9}oI2C&6BQiZ!B649&0Uw$Ewn4aWqAcx4IhEtG5j(&}B z!QSf5pB5^C)SVkkY2FN`fY5-5!;$csl`1qxuXVdjW$X})@~1H*;|_v&;BTkMN>;V~ z>Sy^TgozikYF?E4Z#=hwexQe}SMs$dUBFXKBW0%qM&}EiU9`hpbc+`^rz&5Ja>8ZL z1|}38*IFB*N-JC;SlIF(2?3?R67bo;g|N=aM^IR;RS1gdP%SAaol}x=*zfy_z_fdw z{;9pNOa2kQC1#15>y<8f%;fkZNY1lt9U=6MGTG3@lQ4p5!W?%_p@Y`90ogdFCDD5j zQYxgWIy^mTbma>4>a9iY_q>bOF;IeVC$$yi;$kTmnSEp5q{fopfxvpCja#tKUF1 zK}FIRb<)h>_%@?z@#7~sb6!^yUdbn|nDP*Jotjb1-1JOUr}QR`jYpQ7Ocg#gkatNo z2(6+Y7tyN21Cpv~O^HA@W8N~4NE+#z{X5?U_{R;Q$IVGI4+7}YPXa@7tc5i?fRsBFz}}O1wpPthe(xH^ z^XoijYu z9?x&J{Gogf6T_WClb|N?HCaQdSU`_dHbo*6dkbp$do`;C07`iAKv}^`dUfqPMX~80$LG^ z7qB1LzssNaFoSg_EJ$8Cn%UVwA(06+a<@>%{pttIo^1CtdAF0oWYJMy#DFwh5U)bb zp%*-zg!h+$*UTZ>BQ79#N#mgoqa>qbDHlGT^FWCg3i(zu=eB4+aCWo}R5?IwfN{1W zyAlenwsCjLp&;J;O{WTqgfBO++kr*T$DYeVv)SX{5HYz*{n}(~tVBo*j~n;@q%89C zwvbLbDc0P0>ba?WAv+|v^8z717!_bMBFtm_fzSA@WzM6b0QEaB9#D@Nf*I8RE^m^D zUBH#ZQU3`RP(Wv{Nty&1F6sM#BAJ2m(c$4eNJFa9mNY8|j|piM@#Vs}g1Dp3bFyJJ zXsKv1{rR=3hBptL6bg3qtRH25*{?kBW=qE?QnsW>vw*v6m&;y9Wuw<-b?~0)`WO6w z>%-mK(Xkl=J#9#o<4`ZSTJ&y5^HR>CSzMFUK@Y+anquKc%oh%+RguXtk<##t&&oD$PCTjMOz5|(z$ zk47r;uk?%{%vR{%;1<$Zb!=4xtt=XMQQxp^Fxt_@gARn*lH_+eQuBmRt~i&SoS)`! zuiMg0*gU6P?`0jHz!i64vz-;3>(Y!KI1c#48Md#LBS-;sS6I;^o;`kP{+22UG;66OtJ zY!)SGbF-WOq1ot?wkJeEzam2yIy*02O&Zisqq!bY#rIVZ zLH;|krCetO)Fk*x{%gAl3{dcWYI#^tFczCX!J%YOchgkiXCZqXnlDegSSvuxwePBa zcQGwHG0pRWhzqnaMNV)|YkFrGzV2I*W!wf?(Y8-R%j8NGt*D5l+aqMbLUFd%h)2bc zgBi352kCM29JsXQ@xujNr0K95&M*&Ey-ik-bmUy%R&YKG2Y&ON6=!K>G6l3|_p%a| zTxDJ6#i7tNJO%b|raY~Cw_K{7ZRs10*>&X#a`E)8JqkFoBv;5LIxB*ze0}yiT*Dnk zT)twZTl2luJ?^>hPpRwU1J!ABP`C^E-Qmalf+T4{^P64sk7KhmvrMz* zLA1MVK;rK$N4D%ms#knKB2ge9Z)rU_{(e|@)c2s_?tq}__?X{BO1a4&_Nkm0J?0A( z?I0}%#cBpnVAP!w${`YWMr=#UVfDO?V=X1eZCc3a;=5wzrkderhI(5T;GS;hg7YLO z@Rq9K0^w~84oMWf5rFD1e^M`<$@|3O{Fvp>6t%Jyr?Col4uMA&dE@5dIxyRPd@by( zMbu!rTPnkTJj9(76Hg>YjP&StD#=k6(fY3usJec)pUMifsE|no@!h@877+sm_RE)XqLsU++?dRQsC3u*sJ*EbgiLac%!pgkJ zKftva_V1A?jyJFaCjc$Jg+w8RJtCWG`-&;f07KW52;K-C_^srebbCGgt5y%Bp4+zL z@FW3UHdjg$TV1MiP}N9y5+MKPgM8Ks)g+sbqHCYJ7?G-A1KbrG>t`?QOHJKx%126p zHd)sf=xtt_W^-`?;|;3ElA!L!hCO`~70U{U>q?0zVpUmfL_I<=0Ix{Bb0=@4pU%ma z{x&6zGh`&<&mCeZvmd5+w68q@pAJqIgrQq4-Jhr%`7PIg3LmZsOe&`uVe2FK6Uh+{ zcKPr46Z)O@eZ%Km3vK-jgkSnGhGOf1ePa{X$G`VJ?lRYE4uxhWmYd5{e9e7S@kQuw z!!}Cm+DSfQPv`sOPFN+Hl0}rlE44n{02YPIuEJ3Zh@+S{P;L;<%Uxn@?fnPjVK;)hPb0?Dw@38!ZTf4no+wo^E)WV>7%W>kmzTEdM z-v~Sy$he(&`ju1c^TNpS#`XgdTr_*|eTL}*QBFUF#wQSzpfkTq=0Z7Qw96yO_{GFc zg?>5rs(}a#%Gm5O$Pb5^#njq`rBWHv74mHyx1v5$Lk=s_7ln2&)!XKYxRaotcCTOxIU;iF^ z)%Le;YJFJwW?iNLa}2v7E>^}?Ilb{A!*(vB&q?z%$pfPPM34BkdGE6P(LvfrU|+6j z$}Nd>9aVCd?@CA%jZUl1Ss=OA-#(SMUQW)0i1D?8{S%d({O*7Zq!Vl9P6m|6s7Cw) RIhZNXzf$iuLg(Mg{{Y#wkQD#` literal 0 HcmV?d00001 diff --git a/frontend/src/components/canvas/FloorMapLayer.tsx b/frontend/src/components/canvas/FloorMapLayer.tsx index 2e925e0..26daf2a 100644 --- a/frontend/src/components/canvas/FloorMapLayer.tsx +++ b/frontend/src/components/canvas/FloorMapLayer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react' import { useCanvasStore } from '@/stores/canvasStore' @@ -17,20 +17,40 @@ interface ResizeState { * ViewportPortal) so it pans and zooms together with the nodes. Position and * size are stored in flow coordinates. * - * When unlocked it sits above the nodes so it can be grabbed/resized; when - * locked it drops behind everything to act as a static background. + * It always sits at the bottom of the canvas (behind nodes and edges). When + * unlocked it can still be grabbed/resized in areas not covered by a node; + * resize handles appear only while it is selected. Double-clicking an unlocked + * plan opens its edit modal. */ export function FloorMapLayer() { const floorMap = useCanvasStore((s) => s.floorMap) const updateFloorMap = useCanvasStore((s) => s.updateFloorMap) + const requestFloorMapEdit = useCanvasStore((s) => s.requestFloorMapEdit) const { screenToFlowPosition } = useReactFlow() const zoom = useStore((s) => s.transform[2]) const resizeRef = useRef(null) + const wrapperRef = useRef(null) + const [selected, setSelected] = useState(false) + + const locked = floorMap?.locked ?? false + + // While selected (and unlocked), deselect on any click outside the plan. + // A locked plan can't be selected, and handles/edit are gated on !locked, so + // a residual selection is harmless. + useEffect(() => { + if (locked || !selected) return + const onDocDown = (ev: MouseEvent) => { + if (!wrapperRef.current?.contains(ev.target as Node)) setSelected(false) + } + document.addEventListener('mousedown', onDocDown) + return () => document.removeEventListener('mousedown', onDocDown) + }, [selected, locked]) const onDragStart = useCallback((e: React.MouseEvent) => { if (!floorMap) return e.stopPropagation() + setSelected(true) const startX = e.clientX const startY = e.clientY const origPosX = floorMap.posX @@ -96,7 +116,7 @@ export function FloorMapLayer() { if (!floorMap || !floorMap.enabled) return null - const { imageData, posX, posY, width, height, opacity, locked } = floorMap + const { imageData, posX, posY, width, height, opacity } = floorMap // Handles live in flow space, so counter-scale by zoom to keep a ~constant // on-screen size regardless of the current zoom level. @@ -115,6 +135,7 @@ export function FloorMapLayer() { return (
{ e.stopPropagation(); requestFloorMapEdit() }} > - {!locked && ( + {!locked && selected && ( <>
onResizeStart(e, new Set(['n','w']))} />
onResizeStart(e, new Set(['n']))} /> diff --git a/frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx b/frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx new file mode 100644 index 0000000..cce0973 --- /dev/null +++ b/frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { FloorMapLayer } from '../FloorMapLayer' +import { useCanvasStore } from '@/stores/canvasStore' +import type { FloorMapConfig } from '@/types' + +// Stub React Flow: render the portal inline, and give the layer a 1x zoom and +// an identity screen→flow projection so it can mount without a provider. +vi.mock('@xyflow/react', () => ({ + ViewportPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, + useReactFlow: () => ({ screenToFlowPosition: (p: { x: number; y: number }) => p }), + useStore: (sel: (s: { transform: number[] }) => unknown) => sel({ transform: [0, 0, 1] }), +})) + +const BASE: FloorMapConfig = { + imageData: '/api/v1/media/abc.png', + posX: 0, posY: 0, width: 800, height: 600, + opacity: 0.8, locked: false, enabled: true, +} + +function setFloorMap(patch: Partial = {}) { + useCanvasStore.setState({ floorMap: { ...BASE, ...patch }, floorMapEditNonce: 0 }) +} + +function wrapper() { + return screen.getByAltText('Floor plan').parentElement as HTMLElement +} + +function handleCount(root: HTMLElement) { + return Array.from(root.querySelectorAll('div')).filter((d) => + (d.getAttribute('style') ?? '').includes('resize'), + ).length +} + +describe('FloorMapLayer', () => { + beforeEach(() => useCanvasStore.setState({ floorMap: null, floorMapEditNonce: 0 })) + + it('renders nothing when there is no plan or it is disabled', () => { + const { container, rerender } = render() + expect(container.querySelector('img')).toBeNull() + setFloorMap({ enabled: false }) + rerender() + expect(container.querySelector('img')).toBeNull() + }) + + it('hides resize handles until the plan is selected, then shows them (unlocked)', () => { + setFloorMap() + render() + expect(handleCount(wrapper())).toBe(0) + + fireEvent.mouseDown(wrapper()) + expect(handleCount(wrapper())).toBe(8) + }) + + it('never shows handles and is non-interactive when locked', () => { + setFloorMap({ locked: true }) + render() + const w = wrapper() + fireEvent.mouseDown(w) + expect(handleCount(w)).toBe(0) + expect(w.style.pointerEvents).toBe('none') + }) + + it('double-click on an unlocked plan requests the edit modal', () => { + setFloorMap() + render() + fireEvent.doubleClick(wrapper()) + expect(useCanvasStore.getState().floorMapEditNonce).toBe(1) + }) + + it('locked plan ignores double-click', () => { + setFloorMap({ locked: true }) + render() + fireEvent.doubleClick(wrapper()) + expect(useCanvasStore.getState().floorMapEditNonce).toBe(0) + }) + + it('sits at the bottom of the canvas (negative z-index)', () => { + setFloorMap() + render() + expect(wrapper().style.zIndex).toBe('-1') + }) +}) diff --git a/frontend/src/components/modals/__tests__/DesignModal.test.tsx b/frontend/src/components/modals/__tests__/DesignModal.test.tsx index 27c0976..688f092 100644 --- a/frontend/src/components/modals/__tests__/DesignModal.test.tsx +++ b/frontend/src/components/modals/__tests__/DesignModal.test.tsx @@ -157,6 +157,43 @@ describe('DesignModal', () => { expect(screen.queryByAltText('Floor plan preview')).toBeNull() }) + // Regression: reopening the edit modal after a canvas-side resize must not + // save stale dimensions. Sidebar bumps the modal `key` on every open so it + // remounts and re-seeds from the current floor plan. + it('re-seeds width/height when remounted with a new key (reopen after resize)', () => { + const onSubmit = vi.fn() + const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON } + const { rerender } = render( + , + ) + // Canvas-side resize happened; reopen with a fresh key + larger dims. + const resized = { ...fm, width: 1200, height: 900 } + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 1200, height: 900 }) + }) + + it('keeps stale dimensions when reopened without remount (why the key bump matters)', () => { + const onSubmit = vi.fn() + const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON } + const { rerender } = render( + , + ) + const resized = { ...fm, width: 1200, height: 900 } + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + // Same key → no remount → local state still the original 800×600. + expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 800, height: 600 }) + }) + it('submits floorMap: null when shown but no image was chosen', () => { const { onSubmit } = renderModal({ showFloorMap: true, diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index da5765b..758ec67 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -39,7 +39,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null) - const { nodes, hasUnsavedChanges, floorMap, setFloorMap, snapshotHistory } = useCanvasStore() + // Bumped on every open so the modal remounts and re-seeds its local state from + // the current floor plan — otherwise a reopen keeps stale width/height/lock + // and Save would clobber canvas-side resize/move. + const [openSeq, setOpenSeq] = useState(0) + const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore() + const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce) + + const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => { + setOpenSeq((s) => s + 1) + setDesignModal(m) + }, []) const handleDesignSubmit = useCallback(async (data: DesignFormData) => { if (!designModal) return @@ -57,16 +67,16 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee } // Floor plan is canvas data attached to the active design. `undefined` // means the section wasn't shown → leave it untouched. Applied to the - // store and persisted on the next explicit canvas Save. + // store and persisted on the next explicit canvas Save. Not pushed to + // undo history (floorMap isn't part of HistoryEntry). if (data.floorMap !== undefined) { - snapshotHistory() setFloorMap(data.floorMap) } setDesignModal(null) } catch { toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas') } - }, [designModal, addDesign, updateDesign, setFloorMap, snapshotHistory]) + }, [designModal, addDesign, updateDesign, setFloorMap]) const handleDesignDelete = useCallback(async (d: Design) => { if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return } @@ -96,6 +106,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee const isActiveEdit = designModal?.mode === 'edit' && designModal.design?.id === activeDesignId + // Double-click on the floor plan (canvas) asks to edit the active canvas. + useEffect(() => { + if (floorMapEditNonce === 0) return + const active = designs.find((d) => d.id === activeDesignId) + if (active) openDesignModal({ mode: 'edit', design: active }) + // Only react to the nonce bump, not to design/active changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [floorMapEditNonce]) + const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length @@ -160,7 +179,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee