From 1d84c34c155d705461e1424d83576729b2c293f5 Mon Sep 17 00:00:00 2001 From: th
I*~tQS za*ZzHR?4rt> 3_zno9J1t@hFgDQYnQ+-6q&n3h7T$`UO zQf^IowJL$~y+j*MrLyDX;kKO2&l=-Bj4)x35M=y4LAxo|cj-VJtsTT}6R>EKWUGKh zZWTZj-DeTCMSxM81rWJj8^YJqwu1&Jz10@u0x0JT?TPX6&XDwKFeVj#iHYyN#H8}i zG4cICF0r&f%18;N14w+^ANV%@-2jXu50RD(Am;rvej+XH$C3CLhnQmWmk)}2_cgvy z4aR#HozP>32|G1b2Rz7cPL0=rGZgb@rU==Q@w#!_zcCnk@H_KQ|L8GwtZv6?iCy=z z R6z8y;Lb;P(x@|ct_Ffor*MAxBwFeH`_ zLD~C3TtewUj4v5L8VGTv8e!bK{=7JzH}B`Myov`v4DanF{UPq%5RAwk4)<@kz^U10 zc#?9OGm~}jEUzg#?D%9+_1>|%S2m6^xBDZRj~2B0Hnt1Tdq1Z3IERv9pQB`y2x2zr zWAaNgs-K#1Dt?a6*FX{)^S)#y<$x-Cif_uNQ(JL-T`!K- 1R$M9?}z)K1baAs4lmt_9sMAlXkkYKW5K<{-IX1F`;XF+SD_6BHEVDSc7)n&Q1| zFeFqA$GEaDIO)5A6ypOa#!1Bki8;@EAbHFHjU*+5KvFUoqVh+8PvGb1vDh9@P7uIJ z^7xaJ1nk5lA=@`bH+R=KqnW#A*#054fsr~U-v)Ksp-%331XFq($GFdzQH+ZqawOFV zea$eL=Ii|YoS&DgXr8E2*kN*tJ&k{q@1;?$m)#zv7mVm06&gEFkWcT|OY>gW>i@Z~ zWZeFsPx{XBH+9{evTo?L;@lb0)#_=IYBuggEsT0zhvWR;;i7w~xb0>l9=Q>U7cM`* zyB<67#r|1z-@q7*-8T@V`+Gz50V9k({1=EjrUlYd+7L_kzQoYlpX4lkFHh-W1m#yz zcWf{=qz5OJIB~IZ%2)CS5%VGB@dL@<2a>-K_o4wPdE1{!3I?#Kq5%+9OzewB;G>WM zczmHX9v&-zqcndyPVA3N5Y}%UWqfbrSj(2~^X&eiJZ6Zt>9@yirn59h4~p;OnCx^K zBZu4az5-DT^e~m`rO8xhQc*3BpCf8mXSBrRtTtToE6VRO9k>{}7fVL>s>qIOv*2w; z*ZubQ{L8O>2kk!dF>mYmZlBAds@$&*RKWOq)ok(;V*XeS pp9KIO|S1w(J0MCQ)%yT`2UR;1t7aSq(Y%`FZZwB$6%}{#f z6E60O0HZGpL2_9KqHh~w^y3x~6VVZ3RXrf~wF^peT$$vJD?}Fdg@^)Ih$!ld5#&oz zg+uV+v;J^=b!*s9b>Ji9?+3;U)!V<(d%gY})5-f*w)@rd`?Yg-X>K%aqsiCmwBF}1 zwbyaXAnx&B&4H+IL>M)i>Q;x0F@@$Y$uuWVPH73L87(k1s}-i^w8xaZ4qTF~9ZGlT zVB%qlZ{q!UlirbE`@D~z>(sH#&eo*-kj-D-m9%X44!Tb)!vUMh;0upR9CEcD2j5WR z;7bgLTm~HJ#c=d(HIBKjhH;P7FfoYXm=K0TpEPje1FLcVqXJm_APaWfk4KN&kKyLc zJLu >auW_w^wrzyzX0ZI~p!14xp4V&tpd7?Ixx z!t EaA1M}caIg-tQlc&eC0Sx!;jD9xAt9|8&6#(c2q}p zKMbk8PGUMSPj&Ku@DZjUp>Z>EEUl;Aw#M{~HjtX$nx$p6g0!4AkoKknrWSO `)9Q>`H&F|NHK;kDIIu<*$vMasR(y+3wonaZaGb}*|$5QM*vmBk> zYq@?W89E tNF3_iSEZA*}XKXZw8P;PeB3xclHVggl(dqWzrIv4Jfh`mqT{2OEJT%n~HAEg(|S zo`q$0h46f549^{icm17l-z)<>NcqdY38IG8!;GIU8Qb!AkEPDUdx_AIdA3}EuN*I8 zMz0f?+3Ot02CsnVaav3=R>Vb3p?i0pw8Zq>_K=>}7SrFf$MnKZm{#0{=H?wa>0a`Z zBO2c7zzsV4eh=@aeY^C1`&rvI<-J?l)%ZHJs;uZ>_rAfw>^(S`yodIk-(jawW!P(Z z1v+o9Bi0O^4iWeL2)*`m*mECZ&pik|NZt1W^w>+!9Yl0FfjHniz>u2^huvp5!jHkI zKx&^d97*4gZ-Q#sq{roKu73gS_l-cW2iGyke*;7X4#w!977!I`0TB{g3`=YaVVT|W zWkx@|a^3+Bx`|=`B%ylKH@eD|BTPCqox^WwU7DLrT`F=`uh#zxQyed2rsHYMay$*0 zP8T5N8z+>E6=L*6T5EID1yx8J$SUeYtUEw@NoPze?SiSLJuv1d)e#R1G46;C3s@)I z|64xyukF)wQ0ZSfbgLN9p=XVMmw^qnon5N2L&q|7uzHW}P2XWV(^72L`W<$4tAy^W ztI=_v8hal_&XJfq?&CDfd+bH*PS>IP0S 6=jE_BD4yL2_GOQQw?a(V*DR9q;g<<5Av~)W1WJ0tiS<@+ zV7dr4jMPnDI?96oNzZTkbN$S8>N1flTPASEyAIp%mGed7eHLH!x&WyI_Cw5QQ;eBJ zUQYG!sHt?1#J!f7`L+wiHgPZQimC6rbJAxmFlL{Si#{Mw`>oaehj=#~^uWpapLP49 zW@yLH)z7<*W)0nkHn5&w*FcxP70|I&88{f1LwmDwXk%E89fnk3rx_K{eQh1|+@pq` z2Z`@d;(d^KA0i(*LLxuvd5lENdwS6K;xuhL2jF-fSPx>~;{sxztB3<{1AOTVIO;il z7sAvqnDqVgVmufY3jra!AS}We!s6PnP-Snp B*dlWsL#?Y;%m>Nf(^0p%? z^Ju**!vQ6prVz15%pR`%V|X_mbC=J{1f4&xo!n)JI;NK!V@|UeIL&3?IJF+SkFLQE zT`RGjWd+yPvJBgrmqEKOWzhc1a_l^>hU>ngfpetT?tO~b9^ h%6V=>e8Fm@KLD=*OH;!ap%;`3IVOxluiUULjPWXK+_F!){LWfRxG)w+zS z5_cb0yP(^IhKxS5)eU{-f!g^8V13pQqaW1Rb7l=Vbgy6z_T}JUU5@RoE4X$o=vs8E zfG%@tu*bGKbUI7%OmW@&9EVQlIdt}<*Iy*&mk^w;AUaWeJ6$7PM|Acg-Zv@cZ lk5+=uN(BMH2{ilx7Fg$**f7M zTq_uWKMJU(*5f?9rVE}!%Ikd{3tW6bHgqMEP7tw}Df$pQotV$n >COyXpB(L*5y59#OZh*EDsu^Lz%*KM=PiG&cT-*mnb<(~^4V zI=BiQT32xGZ7R9;Eh^BVRRy+hT~6#PvGeR28cWsKhxoc&MQ|m#T;-t8b>ho&zRpox z6W^P({Wb?K#M;%H_Ip6B4~MP~$@4=2CMA&+4CloZflt(7c=n cY&)lJPB|3Dd!gif&&|zpT_SjO#T&^ 4o9QF^S{$qsxPZ7U~1)P=&I6kQsPR0bYApgGV zqpQv0wyYcUSMNXS_ZQ2ln?*y}X3^KUSvZ6=nA;@y5{_HWhgZG5A>Z*PyzPAl3i{k< z#jcN8_Lp8D9Y^&}H;QqJ^O#xsD4DH~QS%KjVzGwzKQ+F8e%(5cuItrvnmWLF9yYj; zzc?=ha9T)yypa5QF^!k=)zD>FHFPAO;njioJ9MrB2d8Ri?@|p8Lu**KH4W%`1EB9c zjyO{c-`0p8BlPnj&VC330%$CK!r`E&0AB nZ**`zdrpNt0wVt+BG@Oxet8f@cqV{XtDi{nWtiGpQ#TN^E`#^!S4-~mRVx@ha zLuvmANEvzrV l}OL^U`}Ye0v&YH+#6 zV1Pey4j_?#d>(}OMF_Ee!Qo( }v_NF%;i1^jrjolad+Ee+w|@bse1c+xPCk zwlCW@9oNV0U~T&;J4IvJF5y_*B^-mhd5K2j9^$`8I2w29jKb}Lk$AV&PL}Bype}KG z21Slfps;r!zI6;Be}4q|J%S;t$0LaEalJmUxBK3Y-~V6T?>4?f$7xQTyURMLC&t)= zXHDuc53$=1h+WC+J1t=7(1*rU>vHBWm|}b)@pdELGfBkTVS$>)Qp&?AmInnBPx208 z4PQraFf0mTcnrdbIE0b$9F9n!_?FVPIEIUh02USkY{|%|^FJ;a^-JeIw0Uc{z2b>% zuW$nH6^`fj3di9-;dtENDB#-zf(fusFb)prPGPrgwn9SZyO`Y72Q#`n#H_A kUsk9Qc>|_b;8(morO0bzW4psh>Nm?fC=E?-$TKpV)V&w(IwZofjf>UI^IX z%gV<5adJI4OjkpPxoYhA1A~ss7 gybzjSQFwA#8I5Km$Ig_H4sa1wV=I0+9@|Bz@h z92QOCjtHk 9bRdwe&Y-{~&9XUsVFhPiHK(39f1`yvjy zE#a^$vF^Hx!!GNPb=|0D?R!)~r$uV$w1Uw!0PM06po=>|*B#_3ROcEk<8U;2$M_@; zC&)RNq(X2@ D)r;&$f4wB#u>6~aDdWsg{MbSd;lJE!g6fWdWYR|^wI^PlR@6=la z `tt^Ehi6OL+wVAJIA{3;#ilZ^;CbU$1dxM z^#=M5Y@>a4)A&mDN51|tnPPZ?g2Tzl9J-| $HHWh(FPZt8_@@J|pz(4` zCBW8Nb;HJYr89qiJpAJ^+Fjf{#B M#Q41U zdv;Ou16~#_#;f9`+;!1%yeeAGozYo ujHtvMKQjKw4HME-5l$&pJP4gxsKn}5`SX7m3(J6jk5=6oIOfDM7-Tp zq%;nvXA<8m4!+Aqn3K!FynGIM&CREL@D;+wQU<$f0Cv`@E4LOG_y76v@Q=r8b#^-= z`X0^*=X1Qy3g?qoEC3Q-5Pi?bF WGe1>-v^>&wRz<>>Og7i!eVAVc}bZ9|}2KSi~XU z&dWvIQw6xM7GQr(eb(OW9N{nV9 Ur@ZcAmJN7yiIq(0Byz6N`xTV!T3ZuZov_ zXqU5V6w_X!m3T|Mn!6)jhxf!AxJx?A@r=$7G>$G}J4G}8DfOyfI`+S|?YOJQC!XfZ zSKc$<#O_#Zft#IousMMxIPe5OubsqkAF)2nVejJ{b2`m2=PSgWYPCIf(b@%n9S+m{ z^d!}=&XLX{&L*Fpmq%>haJc9#2a5|3mK7r`FX3=mDdI9}f23USXg$EeI$#GX>*!y) z`sKd~I=$R3i&wBqqGfoA*j^GY<}Oovg&y;4uZvd@-yebE8N9@+fRE|h6w~)a>+ykT zGx~_!@v6=$Jg2=B&S)=F?-S1cxAHza%;%G{W-CYBGT%^k+hRR &cwQpC$hdbsOEK!c$1Gf9;E9)>}lB?oG)mY<6`oQMFku#p;%r{ zoL810uTSp10+#ewlW&W)p?Ir(O~N$gYT1kS{Oi zt~7cwv0jNc#6O~!m^?$g7H^8zz-{q5c2~TfVtOM!5O3xlinqc;@pil}Sc?}Z#?J~? zsP~EH9{Ova^{?1|)xv$)L({DV4=AqhSggnUw%fp`^?r8X_%j?w^F?R!3YQa9A0ZEM zy~sho>l_ P z9Ih|ta3gU)*#L07o;B>Rs9Cb5u>7yyGci@i$?dw{YIa?;3a^WPq%rVE?uO`p;`lbP zCdR;vI5R$`ABx@4PrMEN#XB%iybG^suR%}k72qjY)o@a8$!}ht|0DT(bvdbZ&w9_w zK nMiT5>uY-hH?%zRdBGG ipVyO%pm#xuLg)-5`(f60XsVfj32K$urjRjCp@XZ{1CJU%VL}h_|p0tRYam z6CdmD#;3abi1kW}?-h7X=STG|^BqTj?eqTV=e*l`etO^fu#=zJvCvRUkJ@0nV~k=O zc?GmOhA%qafS}eM;CbpPeiuP?R9e>=aE0o8x2e|T&9Nc=92*f#>z=#FL&!(^oFdM& z-qV-Xc>2;B&xS(4Eft8HD>>XkeBCP%wvx72ld3q}UyFE{VtaSB+W)}&n!ow`+nL(^ zrrgwB&u)s=;Vt2Mo-=nxw1ND2BYKNAp^tbA`s!{4U-FJey4%73BhDe>gBT#(gJ-pt zk$ zU*`z z6M8(xD2F>3-0>2u4}XiZf)R#ZCwbHS+@I#>PdPR*l4Jc&1JL~~;7YOI|1#COX`hjF z4R)51YB< &Y9l*2Z<2g`w4|&I4c&fV}g2e~$x%e==&^?a#b++Ldt>t)Hdl?2BUxasU((AKY zN&abm5@_#f`pn|;+DP-8nei6)nauJYOR%|%33m4|#?A|#S{!D97W>FQPC~M46v}ws z?e0Rr*junNwGO|fI`&8!8%OzZFeZqDi7%-R5(2FEK>$}8Bm0q$47|p{pj(KWs1~xD zxDsF7TgTzvTJjER4^h5%s20`xYM^vqrFzBQ(wblO>z{u&XY2Hz>LuR5ZV5LL@6DXI za0{^}?+}r9h={d#H$D;XhalYpEListdnP&zyl&}i#xu?N9B>5&8(oC57Fn#kWqLzK zi^$_IoxT3*`26$F|67lrbh!12pY1v0XBOAiOU>_QCz<;+$gLhSh3zAzwDkj(oj=O$ z9%6|333zON0G?VNg4DheNO20s#BRj9)m^x_Ee6)6GML~?oc%Z)_mpZNFFBk^b-qEK z04|NJVIZv+e}02 Nq$+BGwvFJlF57U@!Mns(b7!uKC^P$@8W>j$Ibe zcqt&?7Hmdup*#8tw-fW7=r7uhfug;{eh>JHcCtG =~O%SBp2eXV8 zA&}N07+;v5!637v#QHExc8P?nz6p@h>m|xM2V!*lyRhSB5iE~I9RHAFnpjVH$-&H6 z4kiR5b3K8qAI)n9Ugg*zFT{nkR>O~h`*_c&Lz=%J@cG9 $Tez5q@&UkDz}7g0;|#^yi3@#f#N6C}QWj&eU=y`z|6 zA%R-Ew^(DF168)!P;Q&ikZCVD6Yb>t5ApW3J!=zYd2>yo>BCoPW c< R6Z^4WZ1t`Qt-zJU4*kl#jqAfIO5 z GtUF;_YvH-9Ezn{&tzUKXHE6 zkZl$YIdn~PEu%2sDjMHd$HH6NI4HDBU>P>gS%m3D;_ZnqEze`7OFR^Pkpp>sm5|v} zf~t S-+}z*R{_dDQVY|55E$f{Nz3mPkYvMgwtGnC&=HICgeKr?)9sF!I z9{6k)JJfs*oYY=IIUeOHdPgDB L 7`|0-w)3tiK?Ipe^nlB~)-iJ^1u3(Ij56DadAl)<^i!G9&+A@cjzGgMN zEM7w`sfHL=TjWBOMGlr*WaE2_3@EWkS7%wuQlnb<{q+7Hv;DXqVtYgTh2`x*vBnSY zrJ6je$Tf{(Z%m}{);td1TEsztWdamgCSb8u627xiVVPAbzPCvMg{ePAn_dG6Nos!^ zO9tlP`$2_R)Gr&}^ht*7o>E9|8v-w;MWMT*9+#3wETpxtMYOiNf@;`P=w6b~&(S=c z=9fe1ezkQpj&7&CZ!6V9Hd9?>{abvuIv-AL$gXeYp4aeqeNR6=&!+wwgRegEvpn17 zh3WM>NoJ2V=T~Z>!eVm;mYA!c)I1s9o2Np#SvpplXF{cU7O{QBs?4)_VU@*etR$6D z|D9PTmYAhMfq62;W@1B({o}<=pY!wfuK97pXC^nMCz=EX5$D=G(`a?RX)G%+C7&>r z(dQ@8b(2G>rGmY;R6&_#DpXl#VX>tW5{-R8YH|}|O>Tgq?PDk(P{=9<6yy8;Z?V`l z6W%x~S*AlYi*uF0zLyoaI)UOj3UPTX)s<+iak?ME7iZ}{hD#iby^pvdOU)>T)f) 1f5jZ?79A_dAV(xKYw z6;@fl##d%hm|%Dx6O8Xdoat@M?k0t*&)+i=RP-;xcdmI@ 2I$b!0M8Q{Gvo%Oz$UG;~>bJI1t z-}=ZW;dZxHs%(SmGOR-x&pXFTg87!Q^gT|%Lh~dnGM7V UZ50%}7I?_@tSys3;bdXSc4{Jiv) zag;jWND2kU30Oe;7n#c0JN|oQMttdgE6g(JyOc%#^$KdNa-qmV1xn*UkQ@16lF
1u^1{udKr{ z$5MiMG%n>^#Bp!T6Y#CMjN(-Wg~YtjR6(th7k%dy?2Ty> s%^Gl~_ynfLHf>50$RPQ0A0}1)Wnd zyJZqQbkBh;Nes3S>kWw dToBtZcu8XYZywj1 z;`R5oKCO;_5@vV9Bh@ZMoo4e4GOS+WE6YgCwvga!ix|u`lkx(MC|+|+;wg6H;k8K| zdu1YJuZ-g`!#Em}jUq7K;0Z+Q`l3YdKE&$#5@$b 9V$#RvC1?Xi8HG;&rvs+7qDvLoln;##o!61 z8a{zEqbHDI`3y_DW}(`ZCU-8haMGs%)GoD9Pxn~Xx|BgxpHe98`kLjpNWmw|^I%V0 zJ#3dDY$N~p{vqIy6Z9Jes`E{M!s-^rH26#jZWu7{aq+*D^T*f5ukBf5iLLi;x$V;; zr9}uT&4N*7`ixVWgkYla6G$+A%%nzs5T)-8FZ6CuE$|w9p?jT$>)ygBy*m)CcORvC z4=_ CQ87pNBmv#_}Vb%W;JEYte<-2YvDUu%y~PMWUz&d>0a zIzaanKG8di&-6S=7a>H?6Q1i`hUfZM@Pz@@I`xUQfftL^zeQu=eNd8T #e1!)G)Sf&Aann4I; z7=>U#D+Si~q($!Dj0*q6+69O;$%RpTt3lnT2I`zEp|WcsD`=Am0h>#~J%-_C%K28% z@14FmNxuoaiZxRof#h4Cx^Z(KRsXKq-5-4&UUt0P_PN7_&L6+8yY9E6z4ec%19d%U zt>zpTqD#DWFJY+O6&9v{9bX#UfG~p_7-evW@`T6il~I(&hf64iOHESYy$R)td<>gp zAqgr?UO}ZP&F@Wfutp=|Z1$E_o4sL0#wn0x5P|6i&oIL<6w(c!L8fsi6t+x;I{NJa z_6FpfkQTL&eqc$W_y!k>Z)f7|M1_5d`RdLkSnQC^d=Hj^dpO-+6H50fP@QSS5p~0~ z>r67|VZ+pwKL5OY<70Mzs~_6-+`OPR9?L)8@0i(&A>Mk2)lc-!(A@GIQe5M6y-V;y z{|ZP9??95FKV}<8!CMm hZJ5|d;a^N6!)I`Pe ! >6Tx7y53sSPK|T5;L@u~h_wClpwZC}J$H(XUuk7D! zw^RFe=l%b`y2}MycSmpiLk)p?r;!+=% B#d&tcq}%NQyi p7U6(*<0eBkHtnQm}L-&spJ)D6vr8cFNpaw%(IkY zdAm1sBUv5edIBTPz{fP_#Ql$Ya}0YEdy02-WOP$P1Jt&E2YI84+2Pyuu+*Qindcar zc$7uVxWOjwdQ|&2>j(cz&cF6q=PWn&4>7)^rus6xHjly_vuK(p#=;w#Cl;C|LNVpv z?`S^q&McMYmKm&^e7Vvrn>?9nDm3=hm{85i 9ZKnk2ILvtiEj_$?1+pw6K_&);!V9+FFAWES_@Knf|uH(5WJa=KvR9ZZ~`VXmo^)*{{j zbEsilXU4d0#F%H@i`e!e){Y!6pvR5s$ hmPxgdVyg_Sx3561 zjT$%yQYZ3_F67Y~-o&~GFJj+=m^Ze3UlaG(6&dSTuWqobQonYqsNZ|JzG1#ky=29$ z>Zu1$l>SG>bJI2VxAPb&Yw25=V)GQ!tV1xvieEd4#9XSOydmB^YpSC_G1Up*Q*6Jd z8dZ@=3d=HzVk*j!<@z-L( { zT?6E|@y~T2|7gpgu5B$OJ2TxLujBKA>vaiF2j@v7DRY!oRD>CofxF-L16uqxJ1T z;A1?~lAp`Jq; $Cov%+s` zYae*lMQseOpT7NW#lLl)(!}bIYQ61`8b-A6Ivi)~txB@>%TBTiNRgWQg4Fl{N(^pe zjKN)uHMk2>syW9SJb*X@Z@xCdicQj4gINiH8U0AbybQsDemr4W$@2!Z(eS4Dt|#Uc z-z_P&Ta!n$A=d3k?a9O2Q7pG3-Xx^Q(3-L8?sW~)B~`K$7b|D&eNyrt>CeCQy7=0i zFnrwdltr-Z`PMJYt`tR@ctNz$ZHO_t1F ;>>3zJ-4^Mw-a uyPBUM_SPRC6;-#Ud%Huf_lpmXs~(*YRfWETU7wFu0&>2O}PoryPjga z9+6@kTQ)GRrJA8FsO#GU#dDn^b5hyd*zG0%6`Y&c`n5i2AJX=8*yHv`IyG&(X0=W5 z*yOZ-B(23p8Q)+@CV{NnitgdDq&lDFYYp#e%Qxy;%R=H^%o;4;A kL{W&ww(kS3Gl8X+<$^RRDEX#XrS2)3B~0*0sQFYt>-ephhdk zYVGT*-i@vf$emd+K4Dqb-~4xPnvUVWt@UYn((H-F*}L%;z72&|N>*w`^Bt=!D6`Hc z-np#W>J8LdlZRVVd|UB;Qbn;%W2JQ+t7}o)P}#96vDo?jfj0w7J7j-b@TaKr{~4~K zr`d{TVXdxdg|)hB_VeQjX>m>~*vfO9+&U;E$1yy)yXJ|; zzWqo2#uqehZ+t;xzwreh`kKS|(AT`+LtpcP4}HxGn)>`FH1+uxH1+uxH1(T(+_(Q| zJAK+u`+WLQpI-3OcB1q@efy94+8^~l)$~8ii8cKX|L*2M{ rmwH* zYj`!&*7SM4&6<6x>2q88{wIz7Y`)*DvETTD<^20_jW6h~={LUMLtpa(gU0Qe7kudR zSFPEHK7VDKHEZ0b@x>qdp&xC}{;01x><|0=wf@KZhtg}CK5OeQ_cbTbz41Ni|IL~$ zZ|rN{zO1nyN}E4$&>XUck3FZ^UBiv82>((ITYHY*y_tL28QRczhMcCa*`2Ntzdf7Z zg{~d7_FMTrUEk1V%^LshYj>_pvyC=W0R7ab!1}4r#}DnV`E#d!c4Jo)Z<<4C{A!f- zY1~e`b4`8Py{Z3kJU7O9<7@fdo8I63fBKr;KOBQ*_YZx0ewPpXyEpdv_i5anD=VRX p {C` .tar.gz +# cd ruwio- +# npm install +# npm run dev # or: npm run build +# +# Deliberately excluded: node_modules, dist, the render server, +# docker-compose, logo working files, personal notes, screenshots, +# .claude/, .git/. +set -euo pipefail + +cd "$(dirname "$0")/.." + +VERSION=$(node -p "require('./package.json').version") +NAME="ruwio-${VERSION}" +OUT_DIR="dist-archive" +ARCHIVE="${OUT_DIR}/${NAME}.tar.gz" + +mkdir -p "$OUT_DIR" +rm -f "$ARCHIVE" + +# Explicit allowlist — everything else stays out. If you add a new +# top-level file the Vue app depends on (e.g. a separate env file or +# a PostCSS config), extend this list. +tar --transform "s,^,${NAME}/," -czf "$ARCHIVE" \ + index.html \ + package.json \ + package-lock.json \ + tsconfig.json \ + vite.config.ts \ + .gitignore \ + public \ + src + +echo "Created $ARCHIVE" +ls -lh "$ARCHIVE" diff --git a/src/App.vue b/src/App.vue index ca7509b..e594168 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,6 +26,7 @@ import ExportDialog from './components/ExportDialog.vue' import FullscreenPreview from './components/FullscreenPreview.vue' import PropertiesPanel from './components/PropertiesPanel.vue' import Timeline from './components/Timeline.vue' +import TooltipHost from './components/TooltipHost.vue' import WelcomeDialog from './components/WelcomeDialog.vue' import TestOverlay from './devtools/TestOverlay.vue' import ruwioLogo from './assets/logo.png' @@ -418,13 +419,16 @@ onBeforeUnmount(() => { @@ -128,4 +130,11 @@ kbd { color: var(--text-dim-strong); } +.credit { + margin: 6px 0 0; + font-size: 11px; + text-align: center; + color: var(--text-dim); +} + diff --git a/src/components/ActionEditor.vue b/src/components/ActionEditor.vue index 8c71b4e..4542115 100644 --- a/src/components/ActionEditor.vue +++ b/src/components/ActionEditor.vue @@ -106,13 +106,13 @@ function onTypeChange(index: number, newType: Action['type']) { {{ actionTypeLabel(type) }} - -diff --git a/src/components/AboutDialog.vue b/src/components/AboutDialog.vue index e94452f..7d5ae2e 100644 --- a/src/components/AboutDialog.vue +++ b/src/components/AboutDialog.vue @@ -46,6 +46,8 @@ const shortcuts: Shortcut[] = [ + +{{ t('about.credit') }}
+ - + - + diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue index 65f74f0..eaa5f36 100644 --- a/src/components/AssetSidebar.vue +++ b/src/components/AssetSidebar.vue @@ -1,5 +1,5 @@ + + + + + + + diff --git a/src/components/WelcomeDialog.vue b/src/components/WelcomeDialog.vue index 4560a5f..8f3fdbf 100644 --- a/src/components/WelcomeDialog.vue +++ b/src/components/WelcomeDialog.vue @@ -161,7 +161,7 @@ watch(++{{ content.label }}+{{ content.help }}+diff --git a/src/components/ui/IconButton.vue b/src/components/ui/IconButton.vue index 43cf51b..f2abba5 100644 --- a/src/components/ui/IconButton.vue +++ b/src/components/ui/IconButton.vue @@ -1,5 +1,6 @@ @@ -35,7 +45,7 @@ const iconPx: Record = { xs: 12, sm: 14, md: 15 } class="icon-btn" :class="[`size-${size}`, `variant-${variant}`, { active, rounded }]" :disabled="disabled" - :title="title" + v-tooltip="tooltipBinding" > document.removeEventListener('keydown', onKeyDown)) v-if="dismissable" class="close" :disabled="!dismissable" - :title="t('dialog.close')" + v-tooltip="t('dialog.close')" @click="close" > diff --git a/src/composables/useAssetThumbsPref.ts b/src/composables/useAssetThumbsPref.ts new file mode 100644 index 0000000..5d181a6 --- /dev/null +++ b/src/composables/useAssetThumbsPref.ts @@ -0,0 +1,28 @@ +import { ref, watch } from 'vue' + +const STORAGE_KEY = 'animatr.assetThumbs' + +function read(): boolean { + try { + const v = localStorage.getItem(STORAGE_KEY) + return v !== 'false' + } catch { + return true + } +} + +// Module-level shared ref so AssetSidebar and the settings dialog +// both write to the same preference. +export const assetThumbsEnabled = ref (read()) + +watch(assetThumbsEnabled, (v) => { + try { + localStorage.setItem(STORAGE_KEY, String(v)) + } catch { + /* ignore */ + } +}) + +export function useAssetThumbsPref() { + return { enabled: assetThumbsEnabled } +} diff --git a/src/composables/useTooltipPref.ts b/src/composables/useTooltipPref.ts new file mode 100644 index 0000000..7501661 --- /dev/null +++ b/src/composables/useTooltipPref.ts @@ -0,0 +1,25 @@ +import { ref, watch } from 'vue' + +const STORAGE_KEY = 'ruwio:tooltipsEnabled' + +function readInitial(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) !== '0' + } catch { + return true + } +} + +const enabled = ref (readInitial()) + +watch(enabled, (v) => { + try { + localStorage.setItem(STORAGE_KEY, v ? '1' : '0') + } catch { + /* storage disabled — no persistence, behavior stays in-memory */ + } +}) + +export function useTooltipPref() { + return { enabled } +} diff --git a/src/devtools/TestOverlay.vue b/src/devtools/TestOverlay.vue index 373df23..32d6884 100644 --- a/src/devtools/TestOverlay.vue +++ b/src/devtools/TestOverlay.vue @@ -141,7 +141,7 @@ async function run() { startedAt.value = performance.now() const snapshot = JSON.parse(JSON.stringify(store.$state)) try { - results.value = await runAll(registerAllTests, { + await runAll(registerAllTests, { onProgress: (d, t) => { done.value = d total.value = t @@ -150,6 +150,12 @@ async function run() { currentSuite.value = suiteName currentTest.value = testName }, + // Push each result as it arrives so `failedSoFar` updates live + // — that's what drives the progress bar turning red the moment + // any test fails, instead of only at the end of the run. + onResult: (r) => { + results.value.push(r) + }, interTestDelayMs: 80, }) } finally { @@ -230,16 +236,16 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown)) - + - + diff --git a/src/devtools/testRunner.ts b/src/devtools/testRunner.ts index 548081f..a08d567 100644 --- a/src/devtools/testRunner.ts +++ b/src/devtools/testRunner.ts @@ -131,6 +131,7 @@ function cleanStack(stack: string | undefined): string | undefined { export interface RunOptions { onProgress?: (done: number, total: number) => void onTestStart?: (suite: string, name: string) => void + onResult?: (result: TestResult) => void interTestDelayMs?: number } @@ -149,16 +150,17 @@ export async function runAll( opts.onTestStart?.(s.name, t.name) await new Promise((r) => requestAnimationFrame(() => r(null))) const start = performance.now() + let pushed: TestResult try { if (s.beforeEach) await s.beforeEach() await t.fn() if (s.afterEach) await s.afterEach() - results.push({ + pushed = { suite: s.name, name: t.name, ok: true, durationMs: performance.now() - start, - }) + } } catch (err) { try { if (s.afterEach) await s.afterEach() @@ -166,15 +168,21 @@ export async function runAll( /* ignore cleanup errors if test already failed */ } const e = err as Error - results.push({ + pushed = { suite: s.name, name: t.name, ok: false, error: e.message || String(err), stack: cleanStack(e.stack), durationMs: performance.now() - start, - }) + } } + results.push(pushed) + // Per-result callback fires before the progress tick so the UI + // can paint a failing-run indicator (e.g. flip the progress bar + // to red) the moment a test fails instead of only once runAll + // resolves. + opts.onResult?.(pushed) done++ opts.onProgress?.(done, total) if (delay > 0) await new Promise((r) => setTimeout(r, delay)) diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 87c2ebd..051bfdf 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -36,6 +36,7 @@ import IconButton from '@/components/ui/IconButton.vue' import Toast from '@/components/ui/Toast.vue' import PropertiesPanel from '@/components/PropertiesPanel.vue' import ClipartDrawer from '@/components/ClipartDrawer.vue' +import { vTooltip } from '@/directives/tooltip' import { expect, suite, test } from './testRunner' interface MountedHandle { @@ -88,6 +89,11 @@ function mountComponent( return h(component, props, slots) }, }) + // Register the same global directives the real app registers in + // main.ts — without this, anything using `v-tooltip` (IconButton, + // the toolbar buttons, …) no-ops inside the test harness because + // each `mountComponent` spins up an isolated app instance. + app.directive('tooltip', vTooltip) app.mount(host) return { host, @@ -319,6 +325,68 @@ export function registerAllTests(): void { expect(dup.time).toBeGreaterThan(1) }) + test('duplicateKeyframeAt copies props + easing + hold to an explicit time', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'Src', width: 50, height: 50, fill: '#abcdef' }) + const l = s.addLayer(a.id)! + s.upsertKeyframe(l.id, 1, { x: 123, y: 45 }) + const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)! + src.easing = 'easeInOut' + s.setKeyframeHold(l.id, src.id, true) + + const newId = s.duplicateKeyframeAt(l.id, src.id, 3) + expect(newId).toBeTruthy() + const dup = l.keyframes.find((k) => k.id === newId)! + expect(dup.time).toBe(3) + expect(dup.props.x).toBe(123) + expect(dup.props.y).toBe(45) + expect(dup.easing).toBe('easeInOut') + expect(dup.hold).toBe(true) + // props must be a clone — mutating the duplicate must not touch the source. + dup.props.x = 999 + expect(src.props.x).toBe(123) + }) + + test('duplicateKeyframeAt refuses to overwrite an existing keyframe', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'Col', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.upsertKeyframe(l.id, 1, { x: 10 }) + s.upsertKeyframe(l.id, 2, { x: 20 }) + const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)! + const countBefore = l.keyframes.length + const newId = s.duplicateKeyframeAt(l.id, src.id, 2) + expect(newId).toBe(null) + expect(l.keyframes.length).toBe(countBefore) + }) + + test('duplicateKeyframeAt returns null for unknown layer or keyframe ids', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.upsertKeyframe(l.id, 1, { x: 10 }) + const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)! + expect(s.duplicateKeyframeAt('nope-layer', src.id, 2)).toBe(null) + expect(s.duplicateKeyframeAt(l.id, 'nope-kf', 2)).toBe(null) + }) + + test('duplicateKeyframeAt targets the source keyframe\'s own track (position vs main)', () => { + // Separated layers have independent main / position / rotation + // arrays. The duplicate must land in the same array the source + // came from, not accidentally jump to another. + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.setLayerSeparated(l.id, true) + s.upsertKeyframe(l.id, 1, { x: 50, y: 60 }) + const posKf = l.positionKeyframes!.find((k) => Math.abs(k.time - 1) < 1e-6)! + s.duplicateKeyframeAt(l.id, posKf.id, 2) + const dup = l.positionKeyframes!.find((k) => Math.abs(k.time - 2) < 1e-6) + expect(dup).toBeTruthy() + // Nothing should have been added to the main array. + expect(l.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(false) + }) + test('setKeyframeHold toggles hold flag', async () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' }) @@ -1085,11 +1153,15 @@ export function registerAllTests(): void { suite('ui — IconButton', () => { test('renders a button element', async () => { + // IconButton now uses the custom v-tooltip directive instead of + // the native `title` attribute. The directive still sets an + // `aria-label` so screen readers get the tooltip text — that's + // the stable contract this test pins. const handle = mountComponent(IconButton, { title: 'hi' }) await nextTick() const btn = handle.host.querySelector('button') expect(btn).toBeTruthy() - expect(btn!.getAttribute('title')).toBe('hi') + expect(btn!.getAttribute('aria-label')).toBe('hi') handle.unmount() }) @@ -1772,8 +1844,11 @@ export function registerAllTests(): void { }) test('topbar has save & recents buttons', () => { - const saveBtn = document.querySelector('button[title^="Save project"]') - const recentsBtn = document.querySelector('button[title="Recent projects"]') + // Tooltips live in `aria-label` now (custom v-tooltip directive + // replaced the native `title` attribute on every interactive + // element). + const saveBtn = document.querySelector('button[aria-label^="Save project"]') + const recentsBtn = document.querySelector('button[aria-label="Recent projects"]') expect(saveBtn).toBeTruthy() expect(recentsBtn).toBeTruthy() }) @@ -1798,7 +1873,7 @@ export function registerAllTests(): void { }) test('timeline has add-group button', () => { - const btn = document.querySelector('button[title="Create a new track group"]') + const btn = document.querySelector('button[aria-label="Create a new track group"]') expect(btn).toBeTruthy() }) }) @@ -2710,6 +2785,45 @@ export function registerAllTests(): void { }) }) + suite('interface prefs — shared composables', () => { + // Regression: the tooltip + asset-thumbs prefs are shared module- + // level refs so the Settings dialog and the sidebar/timeline + // toolbars stay in lock-step. The same refs also have to persist + // to localStorage. The thumbs pref was originally an inline ref + // inside AssetSidebar.vue — the settings tab couldn't change it + // without this extraction. + + test('useTooltipPref persists to localStorage and shares the ref', async () => { + const { useTooltipPref } = await import('@/composables/useTooltipPref') + const a = useTooltipPref() + const b = useTooltipPref() + expect(a.enabled).toBe(b.enabled) // same ref instance + const initial = a.enabled.value + a.enabled.value = !initial + await nextTick() + expect(localStorage.getItem('ruwio:tooltipsEnabled')).toBe(initial ? '0' : '1') + // Both consumers see the flip because the ref is shared. + expect(b.enabled.value).toBe(!initial) + // Restore so other tests don't inherit a toggled state. + a.enabled.value = initial + await nextTick() + }) + + test('useAssetThumbsPref persists to localStorage and shares the ref', async () => { + const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref') + const a = useAssetThumbsPref() + const b = useAssetThumbsPref() + expect(a.enabled).toBe(b.enabled) + const initial = a.enabled.value + a.enabled.value = !initial + await nextTick() + expect(localStorage.getItem('animatr.assetThumbs')).toBe(String(!initial)) + expect(b.enabled.value).toBe(!initial) + a.enabled.value = initial + await nextTick() + }) + }) + suite('clipart drawer — grid pre-layout', () => { // Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on // clipart.open and given `flex: 1; min-width: 0` — which meant the diff --git a/src/directives/tooltip.ts b/src/directives/tooltip.ts new file mode 100644 index 0000000..aa43e74 --- /dev/null +++ b/src/directives/tooltip.ts @@ -0,0 +1,173 @@ +import { ref, type Directive, type DirectiveBinding } from 'vue' +import { useTooltipPref } from '@/composables/useTooltipPref' + +/** + * Custom tooltip system. `v-tooltip` replaces the native `title` attribute + * with a styled floating panel that's positioned over its target. Accepts + * either a plain string (label only) or an object with both a `label` and + * a longer `help` line for inline documentation. + * + * … + *… + * + * TooltipHost.vue renders the currently-active tooltip from `activeTooltip`. + * When the user disables tooltips in Settings → Interface, the directive + * silently no-ops (show is never called) — but `aria-label` stays set so + * screen readers still announce the label. + */ + +export interface TooltipContent { + label: string + help?: string +} + +export interface ActiveTooltip { + target: HTMLElement + content: TooltipContent +} + +export const activeTooltip = ref(null) + +const SHOW_DELAY_MS = 350 +let showTimer: ReturnType | null = null +let pendingTarget: HTMLElement | null = null + +function cancelShow() { + if (showTimer) { + clearTimeout(showTimer) + showTimer = null + } + pendingTarget = null +} + +function scheduleShow(target: HTMLElement, content: TooltipContent) { + cancelShow() + pendingTarget = target + showTimer = setTimeout(() => { + showTimer = null + pendingTarget = null + activeTooltip.value = { target, content } + }, SHOW_DELAY_MS) +} + +function hideIfFor(target: HTMLElement) { + if (pendingTarget === target) cancelShow() + if (activeTooltip.value?.target === target) activeTooltip.value = null +} + +function hideAll() { + cancelShow() + activeTooltip.value = null +} + +function normalize(value: unknown): TooltipContent | null { + if (value == null) return null + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed ? { label: trimmed } : null + } + if (typeof value === 'object') { + const label = (value as TooltipContent).label + if (typeof label !== 'string' || !label.trim()) return null + const help = (value as TooltipContent).help + return { + label: label.trim(), + help: typeof help === 'string' && help.trim() ? help.trim() : undefined, + } + } + return null +} + +interface AttachedState { + content: TooltipContent | null + onEnter: (e: Event) => void + onLeave: (e: Event) => void + onFocus: (e: Event) => void + onBlur: (e: Event) => void + onPointerDown: (e: Event) => void +} + +const attached = new WeakMap () +const { enabled } = useTooltipPref() + +function attach(el: HTMLElement, content: TooltipContent | null) { + const state: AttachedState = { + content, + onEnter: () => { + if (!enabled.value) return + if (!state.content) return + scheduleShow(el, state.content) + }, + onLeave: () => hideIfFor(el), + onFocus: () => { + if (!enabled.value) return + if (!state.content) return + // Keyboard focus should show the tooltip immediately — the user + // already indicated intent by tabbing to this control. + activeTooltip.value = { target: el, content: state.content } + }, + onBlur: () => hideIfFor(el), + // A pointerdown on the element (click, drag-start, etc.) is a + // clear "I'm using this, stop hovering" signal — dismiss. + onPointerDown: () => hideIfFor(el), + } + el.addEventListener('mouseenter', state.onEnter) + el.addEventListener('mouseleave', state.onLeave) + el.addEventListener('focusin', state.onFocus) + el.addEventListener('focusout', state.onBlur) + el.addEventListener('pointerdown', state.onPointerDown) + attached.set(el, state) + syncAria(el, content) +} + +function syncAria(el: HTMLElement, content: TooltipContent | null) { + if (content) el.setAttribute('aria-label', content.label) + else el.removeAttribute('aria-label') +} + +function update(el: HTMLElement, content: TooltipContent | null) { + const state = attached.get(el) + if (!state) return attach(el, content) + state.content = content + syncAria(el, content) + // If this element is currently showing, update live. + if (activeTooltip.value?.target === el) { + if (content) activeTooltip.value = { target: el, content } + else activeTooltip.value = null + } +} + +function detach(el: HTMLElement) { + const state = attached.get(el) + if (!state) return + el.removeEventListener('mouseenter', state.onEnter) + el.removeEventListener('mouseleave', state.onLeave) + el.removeEventListener('focusin', state.onFocus) + el.removeEventListener('focusout', state.onBlur) + el.removeEventListener('pointerdown', state.onPointerDown) + attached.delete(el) + hideIfFor(el) +} + +export const vTooltip: Directive = { + mounted(el, binding: DirectiveBinding ) { + attach(el, normalize(binding.value)) + }, + updated(el, binding: DirectiveBinding ) { + update(el, normalize(binding.value)) + }, + beforeUnmount(el) { + detach(el) + }, +} + +export function hideActiveTooltip() { + hideAll() +} + +// Dismiss the active tooltip whenever the window loses focus or the page +// scrolls — both can leave a stale floating panel behind. +if (typeof window !== 'undefined') { + window.addEventListener('blur', hideAll) + window.addEventListener('scroll', hideAll, true) +} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 0752a8b..246e5fd 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -22,12 +22,18 @@ export const en = { defaultName: 'Project {date}', }, renameProject: 'Rename project', + renameProjectHelp: + 'Click to rename. Saved projects update in place; new names apply at the next save.', renameProjectDialog: { title: 'Rename project', message: 'Choose a new name for this project.', confirm: 'Rename', }, untitledProject: 'Untitled project', + saveProjectDirtyHelp: + 'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.', + exportHelp: + 'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.', replaceDialog: { title: 'Replace project?', message: @@ -235,6 +241,7 @@ export const en = { renameMarkerRetry: 'Try again', stillFrameTooltip: 'Still frame {time} — holds until the next keyframe', keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options', + addKfFromPrev: 'Fill with nearest keyframe — copies the previous state\'s pose into this slot', trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open', trimStart: 'Start -{value}s', trimEnd: 'End -{value}s', @@ -250,6 +257,8 @@ export const en = { color: 'Color', lock: 'Lock', unlock: 'Unlock', + hideInEditor: 'Hide in editor (still exported)', + showInEditor: 'Show in editor', deleteLayer: 'Delete layer', deleteClipTrack: 'Delete clip track', deleteScriptTrack: 'Delete script track', @@ -290,10 +299,20 @@ export const en = { title: 'Settings', tab: { document: 'Document', - timeline: 'Timeline', clipart: 'Clipart', + interface: 'Interface', theme: 'UI Theme', }, + timing: 'Timing', + tooltipsLabel: 'Show tooltips', + tooltipsDesc: + 'Floating help text when you hover over a button or control. Turn off if you know the app well and find them distracting — screen readers still get the label.', + showLockedLabel: 'Show locked tracks', + showLockedDesc: + 'When off, locked tracks disappear from the timeline and the stage. Use to focus on the layers you\'re still editing. Also togglable from the timeline toolbar.', + assetThumbsLabel: 'Asset thumbnails', + assetThumbsDesc: + 'Render small previews in the Assets / Clips / Buttons panels. Turn off for a compact list when working with lots of items.', bgVideoLabel: 'Video', bgVideoNone: 'No background video', bgVideoPickFile: 'Pick video file…', @@ -346,6 +365,9 @@ export const en = { back: 'Back', labelWebm: 'Rendering WebM (browser, realtime)…', labelMp4: 'Rendering MP4 (server)…', + skipHidden: 'Skip hidden tracks', + skipHiddenDesc: 'Drop layers toggled off in the editor from every format below.', + skipHiddenNone: 'No editor-hidden tracks in the scene.', }, tests: { @@ -389,12 +411,13 @@ export const en = { }, about: { - openTooltip: 'About Animatr', + openTooltip: 'About RUWIO', title: 'About {name}', tagline: 'A timeline-based animation editor that runs in your browser.', versionLabel: 'Version', buildLabel: 'Build', close: 'Close', + credit: 'Made with love by Torsten Hoeltge', shortcutsHeading: 'Shortcuts', shortcut: { undo: 'Ctrl+Z', diff --git a/src/lib/render.ts b/src/lib/render.ts index ba00645..e001f76 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -136,6 +136,12 @@ function drawLayers( for (let i = layers.length - 1; i >= 0; i--) { const layer = layers[i] if (!layer.visible) continue + // Editor-only hide: respected by the mask-preview overlay and + // sidebar thumbnails so toggling a layer off really removes it + // from the in-editor render. The runtime in exportPlayer.ts does + // NOT read this flag, so fullscreen preview and exports keep + // rendering the layer. + if (layer.editorHidden) continue if (!alwaysActive && !isLayerActiveAt(layer, time)) continue const asset = assetMap.get(layer.assetId) if (!asset) continue diff --git a/src/main.ts b/src/main.ts index a73c994..2c57109 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,8 +4,10 @@ import VueKonva from 'vue-konva' import App from './App.vue' import './composables/useTheme' import './styles/global.css' +import { vTooltip } from './directives/tooltip' const app = createApp(App) app.use(createPinia()) app.use(VueKonva) +app.directive('tooltip', vTooltip) app.mount('#app') diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 4793f9c..07fac85 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -641,6 +641,37 @@ export const useSceneStore = defineStore('scene', { shift(layer.keyframes) shift(layer.positionKeyframes) }, + /** Copy the referenced keyframe to a specific time. Carries easing, + * hold, and control-point handles along; refuses if the target + * time is already occupied in the same array. Used by the + * button-editor's + buttons to fill empty state slots with the + * previous (or next) keyframe's values. */ + duplicateKeyframeAt( + layerId: string, + keyframeId: string, + targetTime: number, + ): string | null { + const layer = this.activeLayers.find((l) => l.id === layerId) + if (!layer) return null + const found = findKf(layer, keyframeId) + if (!found) return null + const { kf: source, arr } = found + if (arr.some((k) => Math.abs(k.time - targetTime) < KEYFRAME_EPSILON)) return null + const newKf: Keyframe = { + id: uid(), + time: targetTime, + props: { ...source.props }, + easing: source.easing, + } + if (source.hold) newKf.hold = true + if (source.ctrlX !== undefined && source.ctrlY !== undefined) { + newKf.ctrlX = source.ctrlX + newKf.ctrlY = source.ctrlY + } + arr.push(newKf) + sortKfs(arr) + return newKf.id + }, duplicateKeyframe(layerId: string, keyframeId: string): string | null { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return null @@ -1225,6 +1256,12 @@ export const useSceneStore = defineStore('scene', { else delete group.locked } }, + setLayerEditorHidden(layerId: string, hidden: boolean) { + const layer = this.activeLayers.find((l) => l.id === layerId) + if (!layer) return + if (hidden) layer.editorHidden = true + else delete layer.editorHidden + }, // -------- Markers -------------------------------------------------- diff --git a/src/types/scene.ts b/src/types/scene.ts index 032c5ec..b659079 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -166,6 +166,15 @@ export interface Layer { */ colorOverridden?: boolean locked?: boolean + /** + * Editor-only hide. Skipped by the Konva stage and the in-editor + * mask-preview / thumbnail canvas paths, but NOT by the export + * runtime — a hidden layer still shows up in fullscreen preview, + * exports, and the rendered server output. Lets the user mute a + * track temporarily without touching `visible`, which IS honoured + * by every render path. + */ + editorHidden?: boolean isMask?: boolean maskMode?: MaskMode /** Only meaningful when `assetId` points to a ButtonAsset: fires on click. */ diff --git a/src/version.ts b/src/version.ts index 004d6d9..11c9249 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.8.2' +export const APP_VERSION = '0.8.3' export const APP_NAME = 'RUWIO' diff --git a/todo b/todo index 6ba40ea..53bb42d 100644 --- a/todo +++ b/todo @@ -136,14 +136,26 @@ die inline hilfe texte in den settings sind zu klein. das kann man nicht lesen warum ist eigentlich still frame easing disabled? man muss doch zu einem still-frame hin easen können. verstehe den grund nicht - - - - - - ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen wenn man den leeren teil des content-views zieht (außerhalb der canvas constraints) muss canvas move auch ohne control key gehen -export dateien heißen noch animatr. wir haben in RUWIO umbenannt \ No newline at end of file +export dateien heißen noch animatr. wir haben in RUWIO umbenannt + +die tooltips sind hässlich. bau ein schöneres custom tooltip system ein. nimm dir zeit und such alle tooltips raus die wir haben. wenn es sinnvoll ist eine funktion besser zu erklären kann ein tooltip auch eine inline hilfe sein. das muss man unter settings -> interface abstellen können + + + +---- + + +buttons müssen einen pointer cursor anzeigen + +die track-lanes der nicht aktiven traks zeigen durchsichtige keyframes. das muss genauso aussehen wie bei den aktiven - nur etwas ausgefaded + + +bei den button timelines zeigen wir + icons an auf leeren frames. da wird dann der vorrige hin dupliziert + +element auf gruppe ziehen muss auch in der track label spalte gehen + +der bereich wo man den timeline-bereich größer ziehen kann ist nur noch 1px. da hatten wir 15px vorher. man trifft diesen anfasser jetzt nicht mehr \ No newline at end of file