From 5d5f19a61cfe0ba8de25f436f29a577c3a4e2327 Mon Sep 17 00:00:00 2001 From: JackUait Date: Sun, 16 Nov 2025 00:00:30 +0300 Subject: [PATCH] fix: replace deprecated APIs with the modern ones --- .gitignore | 1 + .yarn/install-state.gz | Bin 852563 -> 852421 bytes src/components/modules/rectangleSelection.ts | 63 +++- src/components/modules/tools.ts | 18 +- src/components/modules/ui.ts | 12 +- src/components/polyfills.ts | 122 +------ src/components/ui/toolbox.ts | 6 - src/components/utils.ts | 295 +++------------ src/components/utils/scroll-locker.ts | 1 + .../modules/rectangleSelection.test.ts | 338 +++++++++++++++++- .../components/utils/scroll-locker.test.ts | 100 ++++++ test/unit/polyfills.test.ts | 190 ---------- test/unit/tools/inline.test.ts | 18 + test/unit/ui/toolbox.test.ts | 10 +- test/unit/utils/utils.test.ts | 185 +--------- 15 files changed, 605 insertions(+), 754 deletions(-) create mode 100644 test/unit/components/utils/scroll-locker.test.ts diff --git a/.gitignore b/.gitignore index 0ca7788c..e5c4b039 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules/* npm-debug.log yarn-error.log +.yarn/install-state.gz test-results diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 4f563a9342afb496033ba529a320d50250398ef1..c62d4fb741f10274b1d79574521939476d65f211 100644 GIT binary patch delta 15396 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 O{re9_+h*>#ktYDFWOTd$ delta 15537 zcmV;iJWj*KgEZ5GG=PKwgaU*Egam{Iga(8Mgb0KQgbIWUv<&dYf4Z;hx@XqxHEYJT z*0hvQjUptN7ziXHASxCuv{0b5QaQA?3bjQBEXYci_`}zNW_jULkw#V4rfx|DjR(|FO*UB4Tv0pr)zvr{O;Y$P)dA^#^ ze-f4OyIXu66ns!~E6pjj591rt zF=u#oCT}JC?7F5N&Z)Fc&{$9xHNG%f;R!NlFx9|`X2eoGe=~;7G7yF)OChq|CQoWI zHymKCt4`4g-{&Mcjd^li+)Y|TNRuN%uo@LI>k&UW^o8t{pZ)=SKZ*zMK6&)~4sze~ zG2S$C)0II1eIn$;doMPB+b_I@Ny=}%U0TZ5-UKb>>t2?Y^7YrW6!Aka6dFoM3>*u@ zNKunxj2pW~fAl5;vE)=N+5SCjLL@Q8Y_^0eko)}j%o|Bs*4h% z3fo&pB#b=I+Iew_kr0{`kRp{I)-T z;7{^FJTAXuQgYwNdczpnu4vKilb}VvP;GeMf4K2q?_0o)|7O1=Zv3`=Z7$v1NA~m} zYK4gU;D(ONh{y7%#Q3hYPBm>Ajdj3+8m)KhDQCmYT&q-hOVyI*E&;D$(p9>$uf&F2 zH-v*7N=LJ)>SIiuo_7}S@Gpk?_LRWbb2;n>)S(t4hSRj*JfN1_QL+RUH8sL;;cA)8 zf1#`Kj8gBRv(>a9Ut2J!J4m{U1>@DU&1bamRe2`D?e|v{qT3(E<5O}Z-O24TV9qO^flM)pr!;H zja_s~Gf$kgQr!nN#5SAOT8%H(D}qTbY>&!xwK?Xw#2?XWVVUWdyv3q8)@lq|r*wA) znpoC}Sz~5j9flt^kt76Tf4%DpIFQORbRFq?@9B90SuR~@_YJpr1TWPd)_TJH(zC^a zOFZXB-h|LmWHZ3AH+t=pL$+0Cre5K+CWTt#>205tm=BzTWI5Q9%omyv(?>6nfA8IT z`ly~g@MrH-Jb&;M#M1{)-hFG=Q@9AdTOX4gb-(9)(-#a~ncsXefBWZqhxV_(326TV zZ`=a3|IS}{v&^Ayxe4abx4tZM=-aQEL#2HUt4qcwY*)|FJlG2E-uiR|77E4)m*tz~ zygV6a4?~O{^U)sFv#&ZZLGSx0OFwDF%QiJc-+D&EAAxwxnbW4iqP3gCGvV4360Sld zqUt5FMWtuf6#3{e)LQ0K|Xr+&SU?|7ZjiOeXchSU+oIMJ{8-f@4sF6`hlB(uOEC_ z`1;{%_@dL-$+fMWF?XwVjH4l_yl)XYaYn=#17FwaB#m2*!!dW}mafS5RyFDct4eV0 zj?FA-dpU1R3@LfRsZ|VBwq)Z=+-JGS-GQv?fGY2QJZScR&oM)^qyXA!C zd0W##tYL(XHjurXY$n9pTIK_JZbA6jTd=_P`n}$1SMA|Em#-DP6Z`Yim(0``3OGMp zfxrFP$Npli&3&KdO@r2_)D7!*-NZ=tci%+c{yT0$u=x$QJCglfH!+g^-7hc162ZMleup#lE<@J!f?h`{=vwh}-;dynu91J`gKOlSr*FUhw4ObC^yT#+-hKA` z?YHo&ck1c$M|Vr%`##*8zE$KSVR7&M{3$4ffB)j`mcoDeCQ9KSdD&9<|F|xN%iVbO zHC*=TD@w;@nW3XCS+znq!9Pgv!t<{>P_q-WT0*+IJn1= ze{gG;__f~PbiSdsSirj_`+&r-h-;+}f%$*sQKZom0wjCQ!EL`#bpCTcz_6zU^AZ5q z#oF)LS3Y@um!xpthkC=<)vl(H?Gu_p-g`y-dv2m4{>GcAh=1QrRK(wOyA|;tyorkV zTVA#z{Bt`3Hq)9Hj9a8IjP17Ax(}x-SQ0JsbpRa}1y^AS-^mZw_f8!>~;J^8@W$@p*E`xdORPz=f;Z&y% zF<;BMkU(d<(K(hu);sJ!t6#`Z&=j@A&BzCv;@RXJ*H-4#TjlmK=Oy&Tj;s#4lp(WV z*4WJvq-UDcE!!RMC6_&E9yje+f3w%vc8GdHfW0l|w9}DfN=@{TuI7!WwM}COUYJ?3 zxX+AQi2GiU5A4HJo2$#&WZY@Zkpq!~wvDud%XJQPQZEFq55<(vp4U6?RKB;y`fxIQ zw~4>!W4&qgYFGQL_KEDX?wzgs-`+$a{CjVr5dMzawPE_sn;=tt*UOTrfBxt-nd;0s zk+8krj5t{F$x+4~7p<)~%3wo~JO?>;L-g|(Qr)Qk0 zqtfutBUg|-6lO!xeC1%@^O`4{UHX8Q9Qln15|}9>u=7`?Wu_zX#14*y$yhU9EE3B` z?>V%R_E75S9g*@4jckZsf4*xTY3Q*U&E9tf;B~_6pEiL;H5PJimmKele~4s{7%LS=q&%8U zn)~5b{eoK<3;%)JH5d7on=lvo)|WLG`Sxpb5o>!yGbS+mM+CMe5bF0m!O?F@2r;x6 z?Tl=*&EkS+N_CG;i#pjtP^PVU)*w%U?WdxA#rlpYwQy??t03iKYtM9*x^ZR3mqF zjc8I3kVWKFkG0@Em%t|6{i4;+3)Kc6*2B-AK6?G_CvU%1&!0Yu7uUo0eXKW)p`X%v z_?vH{9{%rcq8|S4+pUMc=O*gmKl!rt@b_KU!=a|$#4q0%f8fuGmb7C>G+0X|*msGS zC`~i0!kR61HzaM36kT(%tm0j$&@|5MHP4z7+6^(cu}+J1C#;}t zWh|+(>$s5Slk|wMb)e%tZS--*irC=M(@u9JNIU-p}zhm ztf7AA%UVNy!?iWkqP65K&sHqEc1ngFVFHhnPvpYRbM)4cLmfdq+CDNGWM&b=m|LD& z-3TY$v^yIqFB?o5>Lv8-GJqoq&eq`o&|x2TkA$iI&;w)Au;i^)^a^y zxT&4i&hj}r?=?+(^?uYmy))(liyNe0oU;l8#04MXyfazhwISLak!y^uniivmLIYl= z1-^*v+cRL^(1p}$YziiU^8uAvlMcIUQj6>{VjBz?@oKm^fB+GMQ>xEq%Mh>F(xhky z4`R8xW4{z7_SV@X3WV_O7Q5+;-gKYDdgq+fK9SF{ zFx5Jl2Nvjk_ZA8tS3jqBh97={_E)H3Np09-wIv+NQ0$tRu6~YQcLI1bT0b7rw;>S( z`weg-PE~C?o5nl__1v-UZ3>Sum>f1VO|~;s1R%VZ;At-nfOT^9OEs%KK+; zV#@nNFFWP^^Vd^e00lFh8KHPMS6jVhPWaV>an<3i71p+|-L|5c#g-yrHqiQsl1AY= zg39#lO$~#5-izC$f37^V)1f}bgxfXp^fpgh#it+DHm_L^D$&&YkrUYVhnz7vWL>5p z&|F}?IdCM$%S4dHnx|&BM&hX2c#$YX&>)eMz!?|Djf<)e9f)+=yOImat$7rWOWU-T z??L+tumAb?)Mo(2XCDYR;*EOi&V={AkMV|)dtM>;{3MY3moV835`Wyvh*w7S!!c=w zoF={7g8fo7W*Vy+#*Rcy4G)c6oL(`ET?6}xp~k~HZHAymX2Vydh?6FtI>E+lY>h)U z2_tZNv>Gdy-DOAh#v6*LbQgTPebL1f-~tBTH|{Q*ys#`{=EGPoI!zje`%$g!xm$}& z@Yma7K_jXdxwXx;Ab$|sJ*+j8Wx3#3czQCDIf|}u;*}4UK>qys(?|Q==k*}`Sv`34 z{KGIUlk)pua>oe0A`8&S91mhkw?kwJcFF@G4ABck^a# zrD3q;+;v+b$|ocl-u-j)H9vov45vUAnu1_MF1q-z3G_oYXv1kq4)?tqYf|AYyAav) zaBEw73l4bg`#@pM{lZ~SmK{I@8j3N!cxN48=z&Ndkg{?R`4w(`$p^R z^V!`nMCq&TBY#Tz_kR8qDCz&>+l8oKbrTTvt6vtPe(g0x)mm|KC&cP9ruQ>7N0hG< zVESpQZfSbFTS_*sXi%)QGxl5^JEmIMF&}c-h(uG8j)mpUV|^u;8hrFQq}tl5Qx9S` z#0aa*Yd4V7xM0aSIhZhkl5C528)<=M_u%R>56`k~SbzBqJ<;J(JcQ549yS)w=z{Cf zI}Dt*)~p$^M^@{DZ$6Vc^^GaZGI6-lg_q7H zymwCSv3~~?&}K5usBJQlwMe5Q#Kii>S`d=#fo0yPBGWa9W20B%)~pXnGt++DCZ1$- z-YkQ~sIL1`YOSnS>YlB#XFu(txhdo|F^uizD6n4p9-9N}1_t@Z;cDm9wunx0s5Cbx zjiqDE7#F6nhU=xt>ro3CyM3G;?Glbc!cB5tZ+}I9_D8|sx+wPSZS1^ozd-opXHJK-T{jJsx?w6GEgc3wXZq^}~gE-cqCe-Q>3NGsq ziD$6xY4flwsskj+IS@=Bh55+2Zix$vS1)StD<(f77T!2ZLwZB1fsTVY>u;h#{tYkNApfSj4RXw0M?t6TAg(%O z%2Q8hGMvjW4%0=(h%$*Iv}=q_HLB5`-1eJ#wPH~z^k(6f#oD+XYZNTWI62F0!iRaw@6MeZ4G05 z@1q+KrL zedkRW%YE0&8q59BYhyWOpFV?>74tpmHv3fY_|y>Mxw$uj7{^w=WoI)4urxjJ(j z2UVqJTSa~Af+AtM1E>8g<~1)V^Qs!Nb-eG&+_4`47v);TSfxUU;OOSPR*Z*kHD6ExZn5-Znhcv6F1Qeeb37_L*IKhcc#bCX_xQE zR1o-;VUQ-}*z$axb@*;H62+WL;;pxt6fCkmBW2@D&OE zE~PV)#C8$4L8q<T*KP``LUy1xaSWb^dY{ut|MK0?yt_!igd8?RC zpV=g9oi3Jrz<-(EW_K7x#A-xQv%^NE95R8mJx;C0mDD=EMy}rV>@gbW)H^9z)hl9( zQAX-0z660-oE#xeZSo}3VL3~}^jK9oURpe@G;QzQBitPZaEh&G?1(d(UfybUTZ?I* zn3UzTXYDw_@EG(F*L%tjd2#vnyZdoHc<1eBm$&D^>wix@AJvoRcM}2b`8;nJw)BeR zp`Q@Rb+WB#$4s36jSTzAVY(hwt#>*2$jCiibO^pT=05jgcyCbcb6QPq1mP0FtojTqa41-$M_~$%vG`URqGxxpXk$g2 zEL}ULXN<$=OmDHKHX;Zoza%4(m{;O(2OYD0)n_m{Ge*ZO6O*V^VN5^U;ix_)v=)) zKYSBL8Q<};Mj79AZIpkZh^|R#ZiE@LFOACwZdl)BDzbgYu6QF98Y(F(xgFG6` zPXwEZ7&jbFKYizvP9tp0*2NGOU~{7Yv9)7tOq)|$j9S-*I=WX_j$uT|46+q?lgzX> z>8@OJ#29Dk^3zL2JD+oQ@7aSW`pl-&)I#AN?tCH~i!MTzgeMhTZt?h1ai zf$J!x@DTLHjf(euk(yUo8%OwF`c|aY5y?h_R(AZdObj{!UwwV zJ45YzKFAwJiqDs2;=PZPzx8%IpKrT~&ga`-w)6Qz*PVY)B5rUcmQn#u*KP>29}Uat zTBE512NWo`Z(HSl`$A-i_Q_#3h%pmr(B&VskR|LWt~KF zt;kNT(=UJPtJ!jL4vK~lSzR_jNRHkw{l-qcrcwX&5Ao{BqvwzO@ij=@_vzg*Nd1#l z&V27plr!J=vgOS8-zjIJ@Z88;JkFjAY5|gQ4lmhh*edtY7m`NzQN)tASO+BMY1=ay zZo51wec`F=PP)`wPwa^Zq}6qZ^psrIWhYmYqn&?9j!?)oJTN!+%Eb|DATzJ6oiTBX z!+a)d^4Xc5IaXoWpS#zlVvg+1ZFtX} zx+b4f=S72&$G%@&$lUW0-Y`bktNal^!1sQB`9dc4N!};Ovkr0`OQzt=V>lBMbD0l} z6@7mfT${CD7#|1E#HKS%irQOr>^Oda_nj)>VtaV}FC-;uUoKp+R zSl6Ufg6@nRYUhh7w2UN56j|_Q5KNu5MlRFYbAsz6V(2h+_rUvqOf4Hnuc`#^i8dRZ z1V;KOK7%KX!A;1|Zfk*&MPvD?m2sUN_F-xH`0dvp#of~KzEAIluZ{jHe`KEi-p_xZ zg8uhAZ@2&bBRA3i{?9Ml|NgN%T+}()V1lSSwedNusJ5n!(=b8`1Ra$jYEaE4^2QV^ zuGPDr&V@uc>1@M%R8LSqP0Gv>HrS;F1Wu+r5Gy#;T5ro}xVPj{)vu@et#!=K*4i|b zqY@#NXor%>zn?`Q%bH)FQxx2z6 z=OnGZ0CF{%VCLH7nv41&NIj`%&mTQ`{Z4Ir&!>08lB?EUmCr?~FMj_QpMqqQ@4Sg* zlfQJk+|wVq3GV4%d0Fo1U%Td>F6(Wc5{$a?_#uXAv?R?jdTCRsHtjH%Nn3y1Drog# z%jZO1X(6NoFE^bw&ycLcwteLBRLAas$!bh!Hm%#(qSQHf@`#$1*E9*w^nDbHLn}^V zXI}~x=^$yFjv>$;-*aJljJJ05?Jo`2ljr{W9RlioALI=ecYH;b;7>JK;)iZx zZurA5J2(6bcXOjf@GfR-JAsy~Jce>GP+hYs3euC#m&mhz5c-jD&Wo85)h*k$;{zd) zg!x9$);jQNZt3u^hVG9&6io9zWDZ+I-MEhDwWNKPRvg+RIYY3o6r+DGDWV8*rJ24) zvop^g=Zp=*RGA~yGXjG@kF~NxyccZ|tzzeG2ihOCZQ7?rEzDM`@$|{C@|R9t;|yCb zM2au3r|0q8U-97C8*e{-{)RuvyD8H5dyqGb6rYz3!!5Ld-+jAn;P>1_8~7(*whjEg zI|R_Ba(Jzgx^TdwQMKVz!l&_h6)&ix;mvQJ8hjQ;T#-l9zQmx!8ZYBW-*&@_y|${m*`Y zSMPwev7eTI_O7Jneh=`5aZ0W z7qUXUFT?jt6JJDMhbdBL#%#2QYe%$U_}oAZaob#UEoS*JZ|I%J9(Nt6Ma-YM-6G}(Z=#6#voBl3{JA?3-3)^+XPrGZ@~VHS4$al42A<0~C~XIGt&rtw z6f|u|tVFfRDolh~CX>=EAhVhis2fL!6Gao~?Vn7rn!K_O0NRHm?op#kD0SwHn?B zoCF$yw*`OJ#d4;M#Y&~HJ&|^qUA7)4S~tO05^#&z!I=2UCLgstrIxa8JrcaZsaUOx zH0Q!1x)*;rLOCo`4C3PJrt9Z^2vMKo0Q10~eB}eYSa%Y;@A)im9I`%@65+peyO8xG zHvw6Hq;_B?vE^22mO{QyiT=)I6=MVKpI zo?ZAl@L2abAz>XRM>!Wv%a1qnYow80@GVFg&N_Ffb-XoBKIlXFFo5=U7Hitc<07h`A4LlOr;1>guqA zw`=Re4Q@@kyc;9Dv!;qy{LuLI`50(GcO|)Qt`(<0OzkA(+b<@*l zoq~VNUYARwRRy0?9+ksbxpeI{z#F$3#+L*GGfR-ZYr&Fx9(BmfJQWFnX$q}dsSZ`v zgNES@>w0-bxDS`I_nyq_T|FkMqm3@`i-(G)z(`$dadbIKaPqc&YVq#tz4el<6OKp; zE}NLwCNwp+gc_HJGJAUW#Z<;?ANPvcoX&rlLhFzSx!SbSA@fCK)DH|&k)wF0thwhS zykU&^)cOhhjoU42{?<*DHGlhM%bLG?UDm)@0~x3)ur%heyTamohOXJVxq_o}mSp%a z&^KzTi!qIDhtt7gZ+i(>veF_?WVoP_3fU*x+8EqOF~~q5`OF9toI$Qdcljd4#q3oh+_hid zNJ!jLYa>8ttc|qY19o#PgND+&%C3=3dxRq0Y^+w%lGcc*mbUtEUhDi)&MQ<-s~CjQ3tT=g_m7U{YIUW3-7`&QX6jAH{U? zq%q)w4*!m`cjmyhhrDZr+8myPTUia2xrL;S%TT$ci=2TFdhN#ybizg<*YocCmUO%b z!oKv!5(VEo$-DU~_j`^X4__B|t5uaV+FWBFBAdHv&7+EBmfjuoOT#>h*}xIdmf8ak zGDCg6at2bfm-n@~Z-HV;V~l@^Ok5S7D{}9kZZ;;tgknXrV)5|zD3Y)CkH~-C`}yVJ zYfTWl=Hwz12x;C!>Et$jO+@M7>L<(y!?#K8)u-hlXUc|*sNP9r?YWPwKK=9#61}Pv z$g6pl9Pf*WWRDmt6-BA?Xf|o?hu`oEaBoe)m~gZfdqC53$*kPFBy)eB*?6ro%Rwe* zv7O*C)nJQ5+2)y3%{>m+s1;{2RW5-dA1<@K!!HBj!Gim3!t{c5x>`S!8~JSy|I3>Y zA^qn!Awv3xZ@ftUbMFWAn?HLC2lQXzQ$+1t8tO?rlub^Qv*f_!$&w7}2#0e%WT2Qv*^;;~YlOKNdXD^Y4 z5{3*$u8g@?dY_I+S6ISe3^$j&7nv2w9Dxgy#{{1%DoWQ7L(;ah@su7)sP=RW!<`Ir z=sF^OEJN$Cwl`{xM$V%xy3U8c<+FHLejJzUzxw{=`oZ1si+_Lddt?dR2M_%5>u*1O z^!$yto<01U&wh9hfAk-H{`P0<$)jIdPapo)&wl2MKAMqf$NO#y($`Tvt)=X-&OUv0 z>oV1NGn{$AV(W1_O8I20Y8?uta6>4!Tf}xD$YZfqInyF1FKW$|;bPs2W%bP7!^L2L zx}(drit3(G?L~k0@K3tj!~Qt#1Z3Xx`F;HTd*4;T`}Z$@gqZT)&!2$&_P!zYX6~F9 z2o&!9fWG(5TR5Qad-E3VoImyEE!;VO`pwVNo$~{4{$uZ)AAIxb&LO%?&RUDY6e4J5 z0tsbdyOgK(xfw2UxhS)|t&>~Iqu_o`%Ekjz){-?Wi=uzdDqVd@;-Q$dTxMT0@Lz3< zHTGqVezLJ1y1sMnJDKlAcg|0K_xYp8k3P=k?!FK3<1d|=1d|C5k7eQ$!Sh}^m+;;> zxyK%B=rEIUFg=kO!zYd2waEIGvm?35SkrV=VbnET;1b-Myjcc| zQGL+#eRO{n*FM#&xUap5$ilCC*~r4Lzn%_t5X73Fbm?srCYGqE@c2PfI$0ta@7@6g zZSrC1)%i3wVUZ(KE+n#rNhpk&y2J^iSW4YmKv;1&8Z0bLg(ytx%yf;FZr2EL-zju2 zLWrLe{tTnU2ao+L>*)jh?ge9_`##W*SJK{hy8M5Kh|%P8m9+2u;!{wl-b#l0%@(S^ z>?VdI|KUvxM}GND3`c&&=W96fEC2Dsk^ks=I5Nq%Y-;J9!BS@!gC|A?qC}Q8d3Q>| zo`{#Xm3DIW%4`P_T-}j|6+6zv@BOIH@FoViNg1IWz*bPgnPW(<^q48)%xn2I<5lb& zIf#GGy)YJFQ#QL_sI0VTZfQt|V678V;AFme^wmLV$Rk^lQIhF8dz+=LqU3|*8>bqW z#K%lpfn*6=*~ie~*mW>%8gwW3@QY49x^vOo_mO>kX*y0%*$sy`<^Z9NsJu_MZEcI4 zHQf3-SccWoR-)!(tlfRK8s;{Q-Pa5h?>K+tb=p~Td($y8xff6u%yCTVOTS_1E&8PyJgBWrHF%o)vjL!UmDwsr|an&(*CNWvW3 z_dZ>wHLBrL3hd!Y^I(HLm$60XCb7-b*G2)U6F%ORb3go^FT0HME*$J&Umf8_%AtQd zD7JX(A)3TnAW83SEM=4m*cLgRK2-W~pFJyajtFbg3Me_UyNuSyi8VU4UFYyAnO&*a zkyAitOwHpD|KykbI7`!?{LZ6z`z?PSZ#?|IFMH)nKPXf`{NAY~gMJ9Mz8gpR!cYId(Q9P-;FIcW?n{!!~ zXvB(*kOJwmSC~1iYbo+>wxD6@OR|D?1f2Z(mX&KSA5|l0jIr8qVhSe81{mnH#`1y> zK(V6}@S_`wWD}>=P}RY`3GeBJyb7hx>PE73Gr2{hYS-BgKk@l9bg_3j*!zDzwU4hy zCpVLZ#%s%BX)@CIFdXc~;V?50Y$F|0US2c}mFmj0Xa<@-Ab2yN zEChZf*2>jJp?TQTT8zvv8oXA1z`9r;advtC7cXCrvZ+XNYd&k|9EH#fZ&M6zIRbCI zIoiQ4ReD!e$yxY6-A1tgpR<3;sOP-gESCWEVrv0#}tq!5+5CE7z_nig;pQqk!1bWzT}Pv1ng+c4xEjHR}Hb~;8&ayAHQS#u*@pw5z7&8?P6 zo`*kr69@DUe$kH;H2<0RVe;~?@$}I<&mVr(zjMu5i$y&hX)ubMPi};vm`?}ELNs<6 zZURgx3?$Ns;l^yC8+Ctu@Tk^TkZD`z9AY>));Ynl%*E4=ualQG2WJsR!hi$Uyaoh&;=!k%w; z^J8$2b}QJSY|w||G=yigJ!HkEY;j#nefgv3cXW;SePkaGlG=X-wl=0IynE|HqY->6 zqBM~5GX{aQG%pQDiFPMhRbVhNi;smk4fOYi2mZ{_ye|iZMo-%lHw|dXCz5!Losbfa zHP>;mvh}{TtG>#gBW(QQ_kZyTM6cGC4yzk(wdL48Jeb2d+JIwr%MeAyJ;&DBnRj!V z-Mq?N=Vs7ahM#|xaww-BZ3(Tp3-K%&1Cd1F3nren4m$VhwMIX&cTqh2Pd|g6RHqNH zvQ&Cd!fg~O<&}v9mdz!H3#1yPEv&JqyjgHUjvpJY2rhE*2tF2h7;cIREQHA)X_ zM+e4KvrBHwwZ??h-&I8({`$|{!esF`T+RY~>N6!P*;{{advrUYysfP^b9i>^xhgQr zI^~nfq=w0c7r=4ju}h0BCH0W<*1f#i+;ZF-Q41T}?5Os^bF_9`75*lY3wZb&Kl37X z2b|eWmCakQVvtp1U7GII&%r#6Hc}-PbF`X!G|9Er8Q^_o^Dy@8CvEj~^^H`n)u^wA zT#23|d%u5VxlbB!Spk1N*QN%)1!8*mr(eUp-eS<6BX-V)Z7j?V)I+k2Y{%NM$d6HG zHp3^Lz6N@eO{v3UHr!=Wqh*qoh00Xsi+L$?Z>l{>^2Ati>DOIW3z>VP2&aeNk54g& z2_2h(2}SMjbw-h>Y$RjsMR*DjuG9B~N@48Pu)%*ApeP1-s%C8jZ0og?pBz3rS8dwT zmT0%GhT-5P~wGS^wsVjhW85C054MIN$)xxz4EbB0jBZAlv{Yk{RT`Yxk2EtJee zBpj8VdgC)kO~PUVf-1(sqX@&DeHIL_(s~B|YPc25#QQmGbwmqog#@1c;Sb~qdP=h zF^i^OlH5>T&>4`RFcW58HkAa6rIEXWXh?sg;m+VTXFIS{+)Wvr)WpT|@Xub(BHA&0 zN7f5Ph?k?~t}thOD`mCtsYVoL8r6^WIdk9-01XZk0wqNLf?v=PeOrxM&l#gP>FL%b zg3dYUxnXNrKF_SaDAySef9UdDL=v*b#g1bP1xMaaVr|>*1O?0_IUOgD$X@iZixhv9 z+z4tBV|g1cXS)wU@XMj)XC12IXI=}mb^wXsrgcZU zmN}hjjh*m(3%*JdrSWR#IP9LlC7DnO4%D00g>qvz#7-+@>Qt19-Sz+x zm?TOn)@j<#X}kJ*_~-B`PH!uZb^pF`lFvDZsL}Dzxk+1}g2m^0_~$Rr1r*k}J;2(T#VQ@| zZQotjXdHfE{kaOl?uNNbi)NOSI(|1Wu$f^O`+z7!_O(!CDQ`V3CXG!aHtv6^*3}n* z6py@*k{o?()`x!qpQ5cO44O9CnI#DUVp=nFg2b=oxvMw>T9stp%4{D4BD}3 zqCtMBACHjUJ3N#%sP_sCFXps<>X4LZ*<3l`SCPbLKm3dM6qBGNt!HaWdDErZ6JlRJ$rU3^in7gmo+E-3Mvu>m}jg}{}NkR81Fg?R4`JzD| zUcs_8VlPu8I@HFfXfQZCyhmS173gaqEQ!ZneLVcjuU!V=)Yn*0gVKLiT;nPeB;r22 zEcV_x!lj0eLrWI53Yd~2xq8BpE|zOHl1*-6x<$k_*O+hRc6EJv1h7@UL__f))q zEq$$rKl0jVe%{BlKd=0x^Q^wSo;-i{@UOo1nV;~YUg)2GzZXL3`S7p5_R1GMu4m64 z{-3YC@>3r_di{;(Pac0gc^xay|Le6^zUY}hd-m{ey!OhM;NLu`w{}57@bGWG_R1GO zd;XP=-+lJ*Z@u=)Pki?7>;ClFvxk5CwO79Qd0p7_f9JIi|Cr1F`v3jfD?j6dpFj5x z`MW>%&t3kk|L3(=e&*-={9}LQ-+S$qFZtj%btrTu^41N`WZ{X_46j(>oY zx-@woG%)+olb?@&_v)L^KJydavuVe@vg!a+bZO6ucIH^;B7i2_9u&NJwM0mk8!gAB zn0l)9p}jzCnAEe+qX{P}2BmKf>k z-xmuNyk;R6jShb@6;BgE^(cYJlO=eM;=oL-boR5CTT^MJ$yQT3K61ganNyNGb6cAv zGnZ5fsaGGalvYZZ?4lsC!vEr4`g!jA?!A2d>hXo{^9$YQ$9A7TY!%763SN00N_zxJ>HQ?GpKv*&L=eg7B#?sI=$ zmvuDjWRriH%$tYneM?Rb}VD`ZKzXMG}Kt>d~wfY^qyQDYanN##bz2rQg7jd^F?O4ojsWlx1WEhs z)_4(G7T%hjt7{p9*ikMzm6ry{(^w)dXa!{LBA0*G9X+dzJho+mOwaIdv91l|V=`D1 zAz`=ZNS86GnkiRHclPOpJj(3K(PK|P#yY$Z@i)&JYK(pS(Rt#SmHQ-RVPTh3!&K5X zoEKND*1>RLGM$V6-;U1YNRSW)g5QZ)XLNv$@kRpS#5liE{b>XEAkkTs=wm?7&vleW zuj+r)Joss!*Q;8LaDNt@nXC$)+jb1scQ@kH?ki6gUC;S-8*pm@;VO+(B8r%31Cd;} z1H%O)gwoafT~Eu}pSgJ7*9RpMx4O(pcdc6pYzX^T9qAA7%QII7Fa-t3o7h+0BUeyt zgyKjLwW_k*@M2)Bi(oZRl=5UZnQzne{?dD ze;kppK5>qQrsoO0a0?5{VZ4BX1tg-m5ZH8eJZ`*Q!=>_0Z1>)_l|6zHoM+jSw>}#& zf-kGwt_1h-n5XB??haL05(;rk0P{s)+pi{2Ad0&)qrx{nEG^i zN`#@Ll-I>YCd+%o!h`b8eQ86g{mK2FgUyWa@){=c>{ { this.scrollVertical(speed); }, 0); @@ -413,10 +415,13 @@ export default class RectangleSelection extends Module { return; } - this.overlayRectangle.style.left = `${this.startX - window.pageXOffset}px`; - this.overlayRectangle.style.top = `${this.startY - window.pageYOffset}px`; - this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px)`; - this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px)`; + const scrollLeft = this.getScrollLeft(); + const scrollTop = this.getScrollTop(); + + this.overlayRectangle.style.left = `${this.startX - scrollLeft}px`; + this.overlayRectangle.style.top = `${this.startY - scrollTop}px`; + this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - scrollTop}px)`; + this.overlayRectangle.style.right = `calc(100% - ${this.startX - scrollLeft}px)`; } /** @@ -456,22 +461,25 @@ export default class RectangleSelection extends Module { return; } + const scrollLeft = this.getScrollLeft(); + const scrollTop = this.getScrollTop(); + // Depending on the position of the mouse relative to the starting point, // change this.e distance from the desired edge of the screen*/ if (this.mouseY >= this.startY) { - this.overlayRectangle.style.top = `${this.startY - window.pageYOffset}px`; - this.overlayRectangle.style.bottom = `calc(100% - ${this.mouseY - window.pageYOffset}px)`; + this.overlayRectangle.style.top = `${this.startY - scrollTop}px`; + this.overlayRectangle.style.bottom = `calc(100% - ${this.mouseY - scrollTop}px)`; } else { - this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px)`; - this.overlayRectangle.style.top = `${this.mouseY - window.pageYOffset}px`; + this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - scrollTop}px)`; + this.overlayRectangle.style.top = `${this.mouseY - scrollTop}px`; } if (this.mouseX >= this.startX) { - this.overlayRectangle.style.left = `${this.startX - window.pageXOffset}px`; - this.overlayRectangle.style.right = `calc(100% - ${this.mouseX - window.pageXOffset}px)`; + this.overlayRectangle.style.left = `${this.startX - scrollLeft}px`; + this.overlayRectangle.style.right = `calc(100% - ${this.mouseX - scrollLeft}px)`; } else { - this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px)`; - this.overlayRectangle.style.left = `${this.mouseX - window.pageXOffset}px`; + this.overlayRectangle.style.right = `calc(100% - ${this.startX - scrollLeft}px)`; + this.overlayRectangle.style.left = `${this.mouseX - scrollLeft}px`; } } @@ -483,7 +491,8 @@ export default class RectangleSelection extends Module { private genInfoForMouseSelection(): {index: number | undefined; leftPos: number; rightPos: number} { const widthOfRedactor = document.body.offsetWidth; const centerOfRedactor = widthOfRedactor / 2; - const y = this.mouseY - window.pageYOffset; + const scrollTop = this.getScrollTop(); + const y = this.mouseY - scrollTop; const elementUnderMouse = document.elementFromPoint(centerOfRedactor, y); const lastBlockHolder = this.Editor.BlockManager.lastBlock?.holder; const contentElement = lastBlockHolder?.querySelector('.' + Block.CSS.content); @@ -512,6 +521,28 @@ export default class RectangleSelection extends Module { }; } + /** + * Normalized vertical scroll value that does not rely on deprecated APIs. + */ + private getScrollTop(): number { + if (typeof window.scrollY === 'number') { + return window.scrollY; + } + + return document.documentElement?.scrollTop ?? document.body?.scrollTop ?? 0; + } + + /** + * Normalized horizontal scroll value that does not rely on deprecated APIs. + */ + private getScrollLeft(): number { + if (typeof window.scrollX === 'number') { + return window.scrollX; + } + + return document.documentElement?.scrollLeft ?? document.body?.scrollLeft ?? 0; + } + /** * Select block with index index * diff --git a/src/components/modules/tools.ts b/src/components/modules/tools.ts index ec21eb6d..cff744e2 100644 --- a/src/components/modules/tools.ts +++ b/src/components/modules/tools.ts @@ -2,6 +2,7 @@ import Paragraph from '@editorjs/paragraph'; import Module from '../__module'; import * as _ from '../utils'; import type { ChainData } from '../utils'; +import PromiseQueue from '../utils/promise-queue'; import type { SanitizerConfig, ToolConfig, ToolConstructable, ToolSettings } from '../../../types'; import BoldInlineTool from '../inline-tools/inline-tool-bold'; import ItalicInlineTool from '../inline-tools/inline-tool-italic'; @@ -186,7 +187,22 @@ export default class Tools extends Module { this.toolPrepareMethodFallback({ toolName: data.toolName }); }; - await _.sequence(sequenceData, handlePrepareSuccess, handlePrepareFallback); + const queue = new PromiseQueue(); + + sequenceData.forEach(chainData => { + void queue.add(async () => { + const callbackData = !_.isUndefined(chainData.data) ? chainData.data : {}; + + try { + await chainData.function(chainData.data); + handlePrepareSuccess(callbackData); + } catch (error) { + handlePrepareFallback(callbackData); + } + }); + }); + + await queue.completed; this.prepareBlockTools(); } diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index a1b5b7a7..221c06ab 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -526,17 +526,19 @@ export default class UI extends Module { * @param {KeyboardEvent} event - keyboard event */ private documentKeydown(event: KeyboardEvent): void { - switch (event.keyCode) { - case _.keyCodes.ENTER: + const key = event.key ?? ''; + + switch (key) { + case 'Enter': this.enterPressed(event); break; - case _.keyCodes.BACKSPACE: - case _.keyCodes.DELETE: + case 'Backspace': + case 'Delete': this.backspacePressed(event); break; - case _.keyCodes.ESC: + case 'Escape': this.escapePressed(event); break; diff --git a/src/components/polyfills.ts b/src/components/polyfills.ts index e6b06807..f07badb3 100644 --- a/src/components/polyfills.ts +++ b/src/components/polyfills.ts @@ -1,116 +1,14 @@ 'use strict'; -/** - * Extend Element interface to include prefixed and experimental properties - */ -interface Element { - matchesSelector: (selector: string) => boolean; - mozMatchesSelector: (selector: string) => boolean; - msMatchesSelector: (selector: string) => boolean; - oMatchesSelector: (selector: string) => boolean; - - prepend: (...nodes: Array) => void; - append: (...nodes: Array) => void; -} - -/** - * The Element.matches() method returns true if the element - * would be selected by the specified selector string; - * otherwise, returns false. - * - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/matches#Polyfill} - * @param {string} s - selector - */ -if (typeof Element.prototype.matches === 'undefined') { - const proto = Element.prototype as Element & { - matchesSelector?: (selector: string) => boolean; - mozMatchesSelector?: (selector: string) => boolean; - msMatchesSelector?: (selector: string) => boolean; - oMatchesSelector?: (selector: string) => boolean; - webkitMatchesSelector?: (selector: string) => boolean; - }; - - Element.prototype.matches = proto.matchesSelector ?? - proto.mozMatchesSelector ?? - proto.msMatchesSelector ?? - proto.oMatchesSelector ?? - proto.webkitMatchesSelector ?? - function (this: Element, s: string): boolean { - const doc = this.ownerDocument; - const matches = doc.querySelectorAll(s); - const index = Array.from(matches).findIndex(match => match === this); - - return index !== -1; - }; -} - -/** - * The Element.closest() method returns the closest ancestor - * of the current element (or the current element itself) which - * matches the selectors given in parameter. - * If there isn't such an ancestor, it returns null. - * - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill} - * @param {string} s - selector - */ -if (typeof Element.prototype.closest === 'undefined') { - Element.prototype.closest = function (this: Element, s: string): Element | null { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const startEl: Element = this; - - if (!document.documentElement.contains(startEl)) { - return null; - } - - const findClosest = (el: Element | null): Element | null => { - if (el === null) { - return null; - } - - if (el.matches(s)) { - return el; - } - - const parent: ParentNode | null = el.parentElement || el.parentNode; - - return findClosest(parent instanceof Element ? parent : null); - }; - - return findClosest(startEl); - }; -} - -/** - * The ParentNode.prepend method inserts a set of Node objects - * or DOMString objects before the first child of the ParentNode. - * DOMString objects are inserted as equivalent Text nodes. - * - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/prepend#Polyfill} - * @param {Node | Node[] | string | string[]} nodes - nodes to prepend - */ -if (typeof Element.prototype.prepend === 'undefined') { - Element.prototype.prepend = function prepend(nodes: Array | Node | string): void { - const docFrag = document.createDocumentFragment(); - - const nodesArray = Array.isArray(nodes) ? nodes : [ nodes ]; - - nodesArray.forEach((node: Node | string) => { - const isNode = node instanceof Node; - - docFrag.appendChild(isNode ? node as Node : document.createTextNode(node as string)); - }); - - this.insertBefore(docFrag, this.firstChild); - }; -} - -interface Element { - /** - * Scrolls the current element into the visible area of the browser window - * - * @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor. - */ - scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void; +declare global { + interface Element { + /** + * Scrolls the current element into the visible area of the browser window + * + * @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor. + */ + scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void; + } } /** @@ -214,3 +112,5 @@ if (typeof window.cancelIdleCallback === 'undefined') { globalThis.clearTimeout(id); }; } + +export {}; diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index ecd81a9f..4e236906 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -1,5 +1,4 @@ import * as _ from '../utils'; -import { BlockToolAPI } from '../block'; import Shortcuts from '../utils/shortcuts'; import type BlockToolAdapter from '../tools/block'; import type ToolsCollection from '../tools/collection'; @@ -436,11 +435,6 @@ export default class Toolbox extends EventsDispatcher { currentBlock.isEmpty ); - /** - * Apply callback before inserting html - */ - newBlock.call(BlockToolAPI.APPEND_CALLBACK); - this.api.caret.setToBlock(index); this.emit(ToolboxEvent.BlockAdded, { diff --git a/src/components/utils.ts b/src/components/utils.ts index 842e63e8..3f4f896d 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -3,6 +3,18 @@ */ import { nanoid } from 'nanoid'; +import lodashDelay from 'lodash/delay'; +import lodashIsBoolean from 'lodash/isBoolean'; +import lodashIsEmpty from 'lodash/isEmpty'; +import lodashIsEqual from 'lodash/isEqual'; +import lodashIsFunction from 'lodash/isFunction'; +import lodashIsNumber from 'lodash/isNumber'; +import lodashIsPlainObject from 'lodash/isPlainObject'; +import lodashIsString from 'lodash/isString'; +import lodashIsUndefined from 'lodash/isUndefined'; +import lodashMergeWith from 'lodash/mergeWith'; +import lodashThrottle from 'lodash/throttle'; +import lodashToArray from 'lodash/toArray'; /** * Possible log levels @@ -141,17 +153,6 @@ const getGlobalWindow = (): Window | undefined => { return undefined; }; -/** - * Returns globally available document object if it exists. - */ -const getGlobalDocument = (): Document | undefined => { - if (globalScope?.document) { - return globalScope.document; - } - - return undefined; -}; - /** * Returns globally available navigator object if it exists. */ @@ -312,18 +313,6 @@ export const logLabeled = ( _log(true, msg, type, args, style); }; -/** - * Return string representation of the object type - * - * @param {*} object - object to get type - * @returns {string} - */ -export const typeOf = (object: unknown): string => { - const match = Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/); - - return match ? match[1].toLowerCase() : 'unknown'; -}; - /** * Check if passed variable is a function * @@ -331,7 +320,7 @@ export const typeOf = (object: unknown): string => { * @returns {boolean} */ export const isFunction = (fn: unknown): fn is (...args: unknown[]) => unknown => { - return typeOf(fn) === 'function' || typeOf(fn) === 'asyncfunction'; + return lodashIsFunction(fn); }; /** @@ -341,7 +330,7 @@ export const isFunction = (fn: unknown): fn is (...args: unknown[]) => unknown = * @returns {boolean} */ export const isObject = (v: unknown): v is object => { - return typeOf(v) === 'object'; + return lodashIsPlainObject(v); }; /** @@ -351,7 +340,7 @@ export const isObject = (v: unknown): v is object => { * @returns {boolean} */ export const isString = (v: unknown): v is string => { - return typeOf(v) === 'string'; + return lodashIsString(v); }; /** @@ -361,7 +350,7 @@ export const isString = (v: unknown): v is string => { * @returns {boolean} */ export const isBoolean = (v: unknown): v is boolean => { - return typeOf(v) === 'boolean'; + return lodashIsBoolean(v); }; /** @@ -371,7 +360,7 @@ export const isBoolean = (v: unknown): v is boolean => { * @returns {boolean} */ export const isNumber = (v: unknown): v is number => { - return typeOf(v) === 'number'; + return lodashIsNumber(v); }; /** @@ -381,17 +370,7 @@ export const isNumber = (v: unknown): v is number => { * @returns {boolean} */ export const isUndefined = function (v: unknown): v is undefined { - return typeOf(v) === 'undefined'; -}; - -/** - * Check if passed function is a class - * - * @param {Function} fn - function to check - * @returns {boolean} - */ -export const isClass = (fn: unknown): boolean => { - return isFunction(fn) && /^\s*class\s+/.test(fn.toString()); + return lodashIsUndefined(v); }; /** @@ -401,21 +380,7 @@ export const isClass = (fn: unknown): boolean => { * @returns {boolean} */ export const isEmpty = (object: object | null | undefined): boolean => { - if (!object) { - return true; - } - - return Object.keys(object).length === 0 && object.constructor === Object; -}; - -/** - * Check if passed object is a Promise - * - * @param {*} object - object to check - * @returns {boolean} - */ -export const isPromise = (object: unknown): object is Promise => { - return Promise.resolve(object) === object; + return lodashIsEmpty(object); }; /** @@ -491,7 +456,7 @@ export const sequence = async ( */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const array = (collection: ArrayLike): any[] => { - return Array.prototype.slice.call(collection); + return lodashToArray(collection); }; /** @@ -502,7 +467,7 @@ export const array = (collection: ArrayLike): any[] => { */ export const delay = (method: (...args: unknown[]) => unknown, timeout: number) => { return function (this: unknown, ...args: unknown[]): void { - setTimeout(() => method.apply(this, args), timeout); + void lodashDelay(() => method.apply(this, args), timeout); }; }; @@ -572,130 +537,12 @@ export const debounce = (func: (...args: unknown[]) => void, wait?: number, imme * but if you'd like to disable the execution on the leading edge, pass * `{leading: false}`. To disable execution on the trailing edge, ditto. */ -export const throttle = (func: (...args: unknown[]) => unknown, wait: number, options?: {leading?: boolean; trailing?: boolean}): (...args: unknown[]) => unknown => { - const state: { - args: unknown[] | null; - result: unknown; - timeoutId: ReturnType | null; - previous: number; - boundFunc: ((...boundArgs: unknown[]) => unknown) | null; - } = { - args: null, - result: undefined, - timeoutId: null, - previous: 0, - boundFunc: null, - }; - - const opts = options || {}; - - const later = function (): void { - state.previous = opts.leading === false ? 0 : Date.now(); - state.timeoutId = null; - if (state.args !== null && state.boundFunc !== null) { - state.result = state.boundFunc(...state.args); - } - - state.boundFunc = null; - state.args = null; - }; - - return function (this: unknown, ...restArgs: unknown[]): unknown { - const now = Date.now(); - - if (!state.previous && opts.leading === false) { - state.previous = now; - } - - const remaining = wait - (now - state.previous); - - state.boundFunc = func.bind(this); - state.args = restArgs; - - const shouldInvokeNow = remaining <= 0 || remaining > wait; - - if (!shouldInvokeNow && state.timeoutId === null && opts.trailing !== false) { - state.timeoutId = setTimeout(later, remaining); - } - - if (!shouldInvokeNow) { - return state.result; - } - - if (state.timeoutId !== null) { - clearTimeout(state.timeoutId); - state.timeoutId = null; - } - - state.previous = now; - - if (state.args !== null && state.boundFunc !== null) { - state.result = state.boundFunc(...state.args); - } - - state.boundFunc = null; - state.args = null; - - return state.result; - }; -}; - -/** - * Legacy fallback method for copying text to clipboard - * - * @param text - text to copy - */ -const fallbackCopyTextToClipboard = (text: string): void => { - const win = getGlobalWindow(); - const doc = getGlobalDocument(); - - if (!win || !doc || !doc.body) { - return; - } - - const el = doc.createElement('div'); - - el.className = 'codex-editor-clipboard'; - el.innerHTML = text; - - doc.body.appendChild(el); - - const selection = win.getSelection(); - const range = doc.createRange(); - - range.selectNode(el); - - win.getSelection()?.removeAllRanges(); - selection?.addRange(range); - - if (typeof doc.execCommand === 'function') { - doc.execCommand('copy'); - } - - doc.body.removeChild(el); -}; - -/** - * Copies passed text to the clipboard - * - * @param text - text to copy - */ -export const copyTextToClipboard = (text: string): void => { - const win = getGlobalWindow(); - const navigatorRef = getGlobalNavigator(); - - // Use modern Clipboard API if available - if (win?.isSecureContext && navigatorRef?.clipboard) { - navigatorRef.clipboard.writeText(text).catch(() => { - // Fallback to legacy method if Clipboard API fails - fallbackCopyTextToClipboard(text); - }); - - return; - } - - // Fallback to legacy method for older browsers - fallbackCopyTextToClipboard(text); +export const throttle = ( + func: (...args: unknown[]) => unknown, + wait: number, + options?: {leading?: boolean; trailing?: boolean} +): ((...args: unknown[]) => unknown) => { + return lodashThrottle(func, wait, options); }; /** @@ -710,7 +557,7 @@ export const getUserOS = (): {[key: string]: boolean} => { }; const navigatorRef = getGlobalNavigator(); - const userAgent = navigatorRef?.appVersion?.toLowerCase() ?? ''; + const userAgent = navigatorRef?.userAgent?.toLowerCase() ?? ''; const userOS = userAgent ? Object.keys(OS).find((os: string) => userAgent.indexOf(os) !== -1) : undefined; if (userOS !== undefined) { @@ -729,73 +576,42 @@ export const getUserOS = (): {[key: string]: boolean} => { * @returns {string} */ export const capitalize = (text: string): string => { - return text[0].toUpperCase() + text.slice(1); + if (!text) { + return text; + } + + return text.slice(0, 1).toUpperCase() + text.slice(1); }; /** - * Merge to objects recursively + * Customizer function for deep merge that overwrites arrays * - * @param {object} target - merge target - * @param {object[]} sources - merge sources - * @returns {object} + * @param {unknown} objValue - object value + * @param {unknown} srcValue - source value + * @returns {unknown} */ +const overwriteArrayMerge = (objValue: unknown, srcValue: unknown): unknown => { + if (Array.isArray(srcValue)) { + return srcValue; + } + + return undefined; +}; + export const deepMerge = (target: T, ...sources: Partial[]): T => { - if (sources.length === 0) { + if (!isObject(target) || sources.length === 0) { return target; } - const [source, ...rest] = sources; - - if (!isObject(target) || !isObject(source)) { - return deepMerge(target, ...rest); - } - - const targetRecord = target as Record; - - Object.entries(source).forEach(([key, value]) => { - if (value === null || value === undefined) { - targetRecord[key] = value as unknown; - - return; + return sources.reduce((acc: T, source) => { + if (!isObject(source)) { + return acc; } - if (typeof value !== 'object') { - targetRecord[key] = value; - - return; - } - - if (Array.isArray(value)) { - targetRecord[key] = value; - - return; - } - - if (!isObject(targetRecord[key])) { - targetRecord[key] = {}; - } - - deepMerge(targetRecord[key] as object, value as object); - }); - - return deepMerge(target, ...rest); + return lodashMergeWith(acc, source, overwriteArrayMerge) as T; + }, target); }; -/** - * Return true if current device supports touch events - * - * Note! This is a simple solution, it can give false-positive results. - * To detect touch devices more carefully, use 'touchstart' event listener - * - * @see http://www.stucox.com/blog/you-cant-detect-a-touchscreen/ - * @returns {boolean} - */ -export const isTouchSupported: boolean = (() => { - const doc = getGlobalDocument(); - - return Boolean(doc?.documentElement && 'ontouchstart' in doc.documentElement); -})(); - /** * Make shortcut command more human-readable * @@ -1168,12 +984,5 @@ export const isIosDevice = (() => { * @returns {boolean} true if they are equal */ export const equals = (var1: unknown, var2: unknown): boolean => { - const isVar1NonPrimitive = Array.isArray(var1) || isObject(var1); - const isVar2NonPrimitive = Array.isArray(var2) || isObject(var2); - - if (isVar1NonPrimitive || isVar2NonPrimitive) { - return JSON.stringify(var1) === JSON.stringify(var2); - } - - return var1 === var2; + return lodashIsEqual(var1, var2); }; diff --git a/src/components/utils/scroll-locker.ts b/src/components/utils/scroll-locker.ts index cc97775c..b20bedc9 100644 --- a/src/components/utils/scroll-locker.ts +++ b/src/components/utils/scroll-locker.ts @@ -43,6 +43,7 @@ export default class ScrollLocker { * Locks scroll in a hard way (via setting fixed position to body element) */ private lockHard(): void { + // eslint-disable-next-line deprecation/deprecation this.scrollPosition = window.pageYOffset; document.documentElement.style.setProperty( '--window-scroll-offset', diff --git a/test/unit/components/modules/rectangleSelection.test.ts b/test/unit/components/modules/rectangleSelection.test.ts index 6895a079..bbb0d525 100644 --- a/test/unit/components/modules/rectangleSelection.test.ts +++ b/test/unit/components/modules/rectangleSelection.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type Mock } import RectangleSelection from '../../../../src/components/modules/rectangleSelection'; import Block from '../../../../src/components/block'; +import SelectionUtils from '../../../../src/components/selection'; import EventsDispatcher from '../../../../src/components/utils/events'; import type { EditorEventMap } from '../../../../src/components/events'; import type { EditorModules } from '../../../../src/types-internal/editor-modules'; @@ -257,6 +258,58 @@ describe('RectangleSelection', () => { elementFromPointSpy.mockRestore(); }); + it('ignores selection attempts outside of selectable area', () => { + const { + rectangleSelection, + editorWrapper, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { mousedown: boolean }; + + const outsideNode = document.createElement('div'); + + document.body.appendChild(outsideNode); + + const elementFromPointSpy = vi.spyOn(document, 'elementFromPoint').mockReturnValue(outsideNode); + + rectangleSelection.startSelection(10, 15); + + expect(internal.mousedown).toBe(false); + + const blockContent = document.createElement('div'); + + blockContent.className = Block.CSS.content; + editorWrapper.appendChild(blockContent); + elementFromPointSpy.mockReturnValue(blockContent); + + rectangleSelection.startSelection(20, 25); + + expect(internal.mousedown).toBe(false); + + elementFromPointSpy.mockRestore(); + }); + + it('clears selection activation flag when clearSelection is called', () => { + const { rectangleSelection } = createRectangleSelection(); + const internal = rectangleSelection as unknown as { isRectSelectionActivated: boolean }; + + internal.isRectSelectionActivated = true; + rectangleSelection.clearSelection(); + + expect(internal.isRectSelectionActivated).toBe(false); + }); + + it('reports whether rectangle selection is active', () => { + const { rectangleSelection } = createRectangleSelection(); + const internal = rectangleSelection as unknown as { isRectSelectionActivated: boolean }; + + internal.isRectSelectionActivated = false; + expect(rectangleSelection.isRectActivated()).toBe(false); + + internal.isRectSelectionActivated = true; + expect(rectangleSelection.isRectActivated()).toBe(true); + }); + it('resets selection parameters on endSelection', () => { const { rectangleSelection, @@ -346,6 +399,45 @@ describe('RectangleSelection', () => { expect(scrollSpy).toHaveBeenCalledWith(320); }); + it('updates rectangle on scroll events', () => { + const { + rectangleSelection, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { + processScroll: (event: MouseEvent) => void; + changingRectangle: (event: MouseEvent) => void; + }; + + const changeSpy = vi.spyOn(internal, 'changingRectangle'); + const scrollEvent = { pageX: 50, + pageY: 75 } as unknown as MouseEvent; + + internal.processScroll(scrollEvent); + + expect(changeSpy).toHaveBeenCalledWith(scrollEvent); + }); + + it('stops scrolling when cursor leaves scroll zones', () => { + const { rectangleSelection } = createRectangleSelection(); + const internal = rectangleSelection as unknown as { + scrollByZones: (clientY: number) => void; + isScrolling: boolean; + inScrollZone: number | null; + }; + + Object.defineProperty(document.documentElement, 'clientHeight', { + configurable: true, + value: 1000, + }); + + internal.isScrolling = true; + internal.scrollByZones(200); + + expect(internal.isScrolling).toBe(false); + expect(internal.inScrollZone).toBeNull(); + }); + it('triggers vertical scrolling when mouse enters scroll zones', () => { const { rectangleSelection, @@ -374,6 +466,75 @@ describe('RectangleSelection', () => { expect(scrollSpy).toHaveBeenCalledWith(3); }); + it('scrolls vertically while mouse button is pressed in a scroll zone', () => { + const { rectangleSelection } = createRectangleSelection(); + const internal = rectangleSelection as unknown as { + scrollVertical: (speed: number) => void; + inScrollZone: number | null; + mousedown: boolean; + mouseY: number; + }; + + vi.useFakeTimers(); + + internal.inScrollZone = 1; + internal.mousedown = true; + internal.mouseY = 100; + + let yOffset = 0; + + const scrollYSpy = vi.spyOn(window, 'scrollY', 'get').mockImplementation(() => yOffset); + + const scrollBySpy = vi.spyOn(window, 'scrollBy').mockImplementation((_x, y) => { + yOffset += y; + }); + + internal.scrollVertical(5); + + internal.inScrollZone = null; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + + expect(scrollBySpy).toHaveBeenCalledWith(0, 5); + expect(internal.mouseY).toBe(105); + + scrollBySpy.mockRestore(); + scrollYSpy.mockRestore(); + }); + + it('shrinks overlay rectangle to the starting point', () => { + const { + rectangleSelection, + editorWrapper, + } = createRectangleSelection(); + + rectangleSelection.prepare(); + + const internal = rectangleSelection as unknown as { + shrinkRectangleToPoint: () => void; + overlayRectangle: HTMLDivElement; + startX: number; + startY: number; + }; + + internal.overlayRectangle = editorWrapper.querySelector(`.${RectangleSelection.CSS.rect}`) as HTMLDivElement; + internal.startX = 150; + internal.startY = 260; + + const scrollXSpy = vi.spyOn(window, 'scrollX', 'get').mockReturnValue(10); + const scrollYSpy = vi.spyOn(window, 'scrollY', 'get').mockReturnValue(20); + + internal.shrinkRectangleToPoint(); + + expect(internal.overlayRectangle.style.left).toBe('140px'); + expect(internal.overlayRectangle.style.top).toBe('240px'); + expect(internal.overlayRectangle.style.bottom).toBe('calc(100% - 240px)'); + expect(internal.overlayRectangle.style.right).toBe('calc(100% - 140px)'); + + scrollXSpy.mockRestore(); + scrollYSpy.mockRestore(); + }); + it('selects or unselects blocks based on rectangle overlap', () => { const { rectangleSelection, @@ -411,6 +572,32 @@ describe('RectangleSelection', () => { expect(blockSelection.selectBlockByIndex).not.toHaveBeenCalled(); }); + it('adds blocks to selection stack via addBlockInSelection', () => { + const { + rectangleSelection, + blockSelection, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { + rectCrossesBlocks: boolean; + stackOfSelected: number[]; + addBlockInSelection: (index: number) => void; + }; + + internal.rectCrossesBlocks = true; + internal.addBlockInSelection(2); + + expect(blockSelection.selectBlockByIndex).toHaveBeenCalledWith(2); + expect(internal.stackOfSelected).toEqual([ 2 ]); + + blockSelection.selectBlockByIndex.mockClear(); + internal.rectCrossesBlocks = false; + internal.addBlockInSelection(3); + + expect(blockSelection.selectBlockByIndex).not.toHaveBeenCalled(); + expect(internal.stackOfSelected).toEqual([2, 3]); + }); + it('updates rectangle size based on cursor position', () => { const { rectangleSelection, @@ -487,6 +674,110 @@ describe('RectangleSelection', () => { elementFromPointSpy.mockRestore(); }); + it('activates rectangle selection and updates state when cursor moves with pressed mouse', () => { + const { + rectangleSelection, + toolbar, + editorWrapper, + } = createRectangleSelection(); + + rectangleSelection.prepare(); + + const internal = rectangleSelection as unknown as { + changingRectangle: (event: MouseEvent) => void; + mousedown: boolean; + isRectSelectionActivated: boolean; + overlayRectangle: HTMLDivElement; + }; + + internal.mousedown = true; + internal.isRectSelectionActivated = false; + internal.overlayRectangle = editorWrapper.querySelector(`.${RectangleSelection.CSS.rect}`) as HTMLDivElement; + + const genInfoSpy = vi.spyOn( + rectangleSelection as unknown as { genInfoForMouseSelection: () => { rightPos: number; leftPos: number; index: number } }, + 'genInfoForMouseSelection' + ).mockReturnValue({ + leftPos: 0, + rightPos: 500, + index: 1, + }); + const trySelectSpy = vi.spyOn( + rectangleSelection as unknown as { trySelectNextBlock: (index: number) => void }, + 'trySelectNextBlock' + ); + const inverseSpy = vi.spyOn( + rectangleSelection as unknown as { inverseSelection: () => void }, + 'inverseSelection' + ); + const selectionRemove = vi.fn(); + const selectionSpy = vi.spyOn(SelectionUtils, 'get').mockReturnValue({ + removeAllRanges: selectionRemove, + } as unknown as Selection); + + internal.changingRectangle({ + pageX: 200, + pageY: 220, + } as MouseEvent); + + expect(internal.isRectSelectionActivated).toBe(true); + expect(internal.overlayRectangle.style.display).toBe('block'); + expect(toolbar.close).toHaveBeenCalled(); + expect(trySelectSpy).toHaveBeenCalledWith(1); + expect(inverseSpy).toHaveBeenCalled(); + expect(selectionRemove).toHaveBeenCalled(); + + genInfoSpy.mockRestore(); + selectionSpy.mockRestore(); + }); + + it('does not attempt block selection when no block is detected under cursor', () => { + const { + rectangleSelection, + toolbar, + editorWrapper, + } = createRectangleSelection(); + + rectangleSelection.prepare(); + + const internal = rectangleSelection as unknown as { + changingRectangle: (event: MouseEvent) => void; + mousedown: boolean; + isRectSelectionActivated: boolean; + overlayRectangle: HTMLDivElement; + }; + + internal.mousedown = true; + internal.isRectSelectionActivated = true; + internal.overlayRectangle = editorWrapper.querySelector(`.${RectangleSelection.CSS.rect}`) as HTMLDivElement; + + const genInfoSpy = vi.spyOn( + rectangleSelection as unknown as { genInfoForMouseSelection: () => { rightPos: number; leftPos: number; index: number | undefined } }, + 'genInfoForMouseSelection' + ).mockReturnValue({ + leftPos: 0, + rightPos: 500, + index: undefined, + }); + const trySelectSpy = vi.spyOn( + rectangleSelection as unknown as { trySelectNextBlock: (index: number) => void }, + 'trySelectNextBlock' + ); + const selectionSpy = vi.spyOn(SelectionUtils, 'get'); + + internal.changingRectangle({ + pageX: 120, + pageY: 140, + } as MouseEvent); + + expect(toolbar.close).toHaveBeenCalled(); + expect(trySelectSpy).not.toHaveBeenCalled(); + expect(selectionSpy).not.toHaveBeenCalled(); + + genInfoSpy.mockRestore(); + selectionSpy.mockRestore(); + }); + it('clears selection state on mouse leave and mouse up events', () => { const { rectangleSelection, @@ -510,6 +801,49 @@ describe('RectangleSelection', () => { expect(clearSpy).toHaveBeenCalledTimes(2); expect(endSpy).toHaveBeenCalledTimes(2); }); + + it('extends selection to skipped blocks in downward direction', () => { + const { + rectangleSelection, + blockSelection, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { + stackOfSelected: number[]; + rectCrossesBlocks: boolean; + trySelectNextBlock: (index: number) => void; + }; + + internal.stackOfSelected.push(0, 1); + internal.rectCrossesBlocks = true; + + internal.trySelectNextBlock(4); + + expect(internal.stackOfSelected).toEqual([0, 1, 2, 3, 4]); + expect(blockSelection.selectBlockByIndex).toHaveBeenCalledWith(2); + expect(blockSelection.selectBlockByIndex).toHaveBeenCalledWith(3); + expect(blockSelection.selectBlockByIndex).toHaveBeenCalledWith(4); + }); + + it('shrinks selection stack when cursor moves backwards', () => { + const { + rectangleSelection, + blockSelection, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { + stackOfSelected: number[]; + rectCrossesBlocks: boolean; + trySelectNextBlock: (index: number) => void; + }; + + internal.stackOfSelected.push(0, 1, 2, 3); + internal.rectCrossesBlocks = true; + + internal.trySelectNextBlock(1); + + expect(internal.stackOfSelected).toEqual([0, 1]); + expect(blockSelection.unSelectBlockByIndex).toHaveBeenCalledWith(3); + expect(blockSelection.unSelectBlockByIndex).toHaveBeenCalledWith(2); + }); }); - - diff --git a/test/unit/components/utils/scroll-locker.test.ts b/test/unit/components/utils/scroll-locker.test.ts new file mode 100644 index 00000000..f766d511 --- /dev/null +++ b/test/unit/components/utils/scroll-locker.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ScrollLocker from '../../../../src/components/utils/scroll-locker'; + +const { getIsIosDeviceValue, setIsIosDeviceValue } = vi.hoisted(() => { + let value = false; + + return { + getIsIosDeviceValue: () => value, + setIsIosDeviceValue: (nextValue: boolean) => { + value = nextValue; + }, + }; +}); + +vi.mock('../../../../src/components/utils', async () => { + const actual = await vi.importActual('../../../../src/components/utils'); + + return { + ...actual, + get isIosDevice() { + return getIsIosDeviceValue(); + }, + }; +}); + +const originalScrollTo = window.scrollTo; + +describe('ScrollLocker', () => { + beforeEach(() => { + document.body.className = ''; + document.body.innerHTML = ''; + document.documentElement?.style.removeProperty('--window-scroll-offset'); + setIsIosDeviceValue(false); + delete (window as { pageYOffset?: number }).pageYOffset; + }); + + afterEach(() => { + if (originalScrollTo) { + window.scrollTo = originalScrollTo; + } else { + delete (window as { scrollTo?: typeof window.scrollTo }).scrollTo; + } + document.body.className = ''; + document.documentElement?.style.removeProperty('--window-scroll-offset'); + setIsIosDeviceValue(false); + delete (window as { pageYOffset?: number }).pageYOffset; + }); + + it('adds and removes body class on non-iOS devices', () => { + setIsIosDeviceValue(false); + const locker = new ScrollLocker(); + + locker.lock(); + + expect(document.body.classList.contains('ce-scroll-locked')).toBe(true); + + locker.unlock(); + + expect(document.body.classList.contains('ce-scroll-locked')).toBe(false); + }); + + it('performs hard lock on iOS devices and restores scroll position', () => { + const storedScroll = 160; + + setIsIosDeviceValue(true); + Object.defineProperty(window, 'pageYOffset', { + configurable: true, + value: storedScroll, + }); + + const scrollTo = vi.fn(); + + window.scrollTo = scrollTo; + + const locker = new ScrollLocker(); + + locker.lock(); + + expect(document.body.classList.contains('ce-scroll-locked--hard')).toBe(true); + expect(document.documentElement?.style.getPropertyValue('--window-scroll-offset')).toBe(`${storedScroll}px`); + + locker.unlock(); + + expect(document.body.classList.contains('ce-scroll-locked--hard')).toBe(false); + expect(scrollTo).toHaveBeenCalledWith(0, storedScroll); + }); + + it('does not restore scroll when hard lock was never applied', () => { + setIsIosDeviceValue(true); + const scrollTo = vi.fn(); + + window.scrollTo = scrollTo; + + const locker = new ScrollLocker(); + + locker.unlock(); + + expect(scrollTo).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/polyfills.test.ts b/test/unit/polyfills.test.ts index beec92dd..f3a0f35b 100644 --- a/test/unit/polyfills.test.ts +++ b/test/unit/polyfills.test.ts @@ -7,197 +7,7 @@ const importPolyfills = async (): Promise => { await import(POLYFILLS_PATH); }; -type VendorMatchesKey = - | 'matchesSelector' - | 'mozMatchesSelector' - | 'msMatchesSelector' - | 'oMatchesSelector' - | 'webkitMatchesSelector'; - describe('polyfills', () => { - describe('Element.matches', () => { - type VendorImplementation = (selector: string) => boolean; - type MutableMatchesPrototype = Omit< - Element, - | 'matches' - | 'matchesSelector' - | 'mozMatchesSelector' - | 'msMatchesSelector' - | 'oMatchesSelector' - | 'webkitMatchesSelector' - > & { - matches: VendorImplementation | undefined; - } & Partial>; - - const prototype = Element.prototype as MutableMatchesPrototype; - const vendorKeys: VendorMatchesKey[] = [ - 'matchesSelector', - 'mozMatchesSelector', - 'msMatchesSelector', - 'oMatchesSelector', - 'webkitMatchesSelector', - ]; - - let originalMatches: ((selector: string) => boolean) | undefined; - const originalVendors: Partial> = {}; - - beforeEach(() => { - originalMatches = prototype.matches; - prototype.matches = undefined; - - vendorKeys.forEach((key) => { - originalVendors[key] = prototype[key]; - delete prototype[key]; - }); - }); - - afterEach(() => { - prototype.matches = originalMatches; - - vendorKeys.forEach((key) => { - const storedVendor = originalVendors[key]; - - if (typeof storedVendor === 'undefined') { - delete prototype[key]; - - return; - } - - prototype[key] = storedVendor; - }); - }); - - it('delegates to vendor-specific implementation when available', async () => { - const vendorImplementation = vi.fn((_selector: string) => true); - - prototype.matchesSelector = (selector: string): boolean => vendorImplementation(selector); - - await importPolyfills(); - - const element = document.createElement('div'); - - expect(element.matches('div')).toBe(true); - expect(vendorImplementation).toHaveBeenCalledTimes(1); - expect(vendorImplementation).toHaveBeenCalledWith('div'); - }); - - it('falls back to querySelectorAll when no vendor implementations exist', async () => { - await importPolyfills(); - - const container = document.createElement('div'); - const child = document.createElement('span'); - const other = document.createElement('p'); - - container.appendChild(child); - container.appendChild(other); - document.body.appendChild(container); - - expect(child.matches('span')).toBe(true); - expect(child.matches('button')).toBe(false); - - container.remove(); - }); - }); - - describe('Element.closest', () => { - type MutableClosestPrototype = Omit & { - closest: Element['closest'] | undefined; - }; - - const prototype = Element.prototype as MutableClosestPrototype; - let originalClosest: ((selector: string) => Element | null) | undefined; - - beforeEach(() => { - originalClosest = prototype.closest; - prototype.closest = undefined; - }); - - afterEach(() => { - prototype.closest = originalClosest; - }); - - it('returns the nearest ancestor that matches the selector', async () => { - await importPolyfills(); - - const wrapper = document.createElement('section'); - const parent = document.createElement('div'); - const target = document.createElement('button'); - - parent.className = 'parent'; - target.className = 'child'; - - parent.appendChild(target); - wrapper.appendChild(parent); - document.body.appendChild(wrapper); - - expect(target.closest('.parent')).toBe(parent); - expect(target.closest('section')).toBe(wrapper); - - wrapper.remove(); - }); - - it('returns null when no ancestor matches the selector', async () => { - await importPolyfills(); - - const parent = document.createElement('div'); - const target = document.createElement('button'); - - parent.appendChild(target); - document.body.appendChild(parent); - - expect(target.closest('.missing')).toBeNull(); - - parent.remove(); - }); - }); - - describe('Element.prepend', () => { - type MutablePrependPrototype = Omit & { - prepend: ((nodes: Array | Node | string) => void) | undefined; - }; - - const prototype = Element.prototype as MutablePrependPrototype; - let originalPrepend: ((nodes: Array | Node | string) => void) | undefined; - - beforeEach(() => { - originalPrepend = prototype.prepend; - prototype.prepend = undefined; - }); - - afterEach(() => { - prototype.prepend = originalPrepend; - }); - - it('inserts nodes and strings before the first child', async () => { - await importPolyfills(); - - const element = document.createElement('div'); - const existing = document.createElement('p'); - const newSpan = document.createElement('span'); - - existing.textContent = 'existing'; - newSpan.textContent = 'new'; - - element.appendChild(existing); - - const elementWithPolyfill = element as unknown as MutablePrependPrototype; - const prepend = elementWithPolyfill.prepend; - - if (typeof prepend !== 'function') { - throw new Error('Expected element.prepend to be defined after applying polyfills'); - } - - prepend.call(elementWithPolyfill, ['text', newSpan]); - - const childNodes = Array.from(element.childNodes); - - expect(childNodes).toHaveLength(3); - expect(childNodes[0].textContent).toBe('text'); - expect(childNodes[1]).toBe(newSpan); - expect(childNodes[2]).toBe(existing); - }); - }); - describe('Element.scrollIntoViewIfNeeded', () => { type MutableScrollPrototype = Omit & { scrollIntoViewIfNeeded: ((centerIfNeeded?: boolean) => void) | undefined; diff --git a/test/unit/tools/inline.test.ts b/test/unit/tools/inline.test.ts index 666cf8a7..35ba1703 100644 --- a/test/unit/tools/inline.test.ts +++ b/test/unit/tools/inline.test.ts @@ -86,6 +86,24 @@ describe('InlineToolAdapter', () => { expect(tool.title).toBe(constructable.title); }); + + it('returns empty string when constructable is undefined', () => { + const tool = new InlineToolAdapter({ + ...createInlineToolOptions(), + constructable: undefined as unknown as InlineToolAdapterOptions['constructable'], + }); + + expect(tool.title).toBe(''); + }); + + it('returns empty string when constructable title is undefined', () => { + const tool = new InlineToolAdapter({ + ...createInlineToolOptions(), + constructable: {} as InlineToolAdapterOptions['constructable'], + }); + + expect(tool.title).toBe(''); + }); }); describe('.isInternal', () => { diff --git a/test/unit/ui/toolbox.test.ts b/test/unit/ui/toolbox.test.ts index dc7419ca..ba2a59c3 100644 --- a/test/unit/ui/toolbox.test.ts +++ b/test/unit/ui/toolbox.test.ts @@ -7,7 +7,6 @@ import type { Popover } from '../../../src/components/utils/popover'; import { PopoverEvent } from '@/types/utils/popover/popover-event'; import { EditorMobileLayoutToggled } from '../../../src/components/events'; import Shortcuts from '../../../src/components/utils/shortcuts'; -import { BlockToolAPI } from '../../../src/components/block'; // Mock dependencies at module level const mockPopoverInstance = { @@ -86,13 +85,7 @@ describe('Toolbox', () => { const blockAPI = { id: 'test-block-id', isEmpty: true, - call: vi.fn((methodName: string) => { - if (methodName === BlockToolAPI.APPEND_CALLBACK) { - return undefined; - } - - return undefined; - }), + call: vi.fn(), } as unknown as BlockAPI; // Mock BlockToolAdapter @@ -760,4 +753,3 @@ describe('Toolbox', () => { }); }); }); - diff --git a/test/unit/utils/utils.test.ts b/test/unit/utils/utils.test.ts index 565c316e..1b62f0c8 100644 --- a/test/unit/utils/utils.test.ts +++ b/test/unit/utils/utils.test.ts @@ -2,16 +2,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { ChainData } from '../../../src/components/utils'; import { - typeOf, isFunction, isObject, isString, isBoolean, isNumber, isUndefined, - isClass, isEmpty, - isPromise, isPrintableKey, keyCodes, mouseButtons, @@ -30,7 +27,6 @@ import { capitalize, deepMerge, getUserOS, - isTouchSupported, beautifyShortcut, getValidUrl, generateBlockId, @@ -41,8 +37,7 @@ import { mobileScreenBreakpoint, isMobileScreen, isIosDevice, - equals, - copyTextToClipboard + equals } from '../../../src/components/utils'; // Mock VERSION global variable @@ -90,44 +85,6 @@ describe('utils', () => { }); }); - describe('typeOf', () => { - it('should return correct type for string', () => { - expect(typeOf('test')).toBe('string'); - }); - - it('should return correct type for number', () => { - expect(typeOf(123)).toBe('number'); - }); - - it('should return correct type for boolean', () => { - expect(typeOf(true)).toBe('boolean'); - }); - - it('should return correct type for object', () => { - expect(typeOf({})).toBe('object'); - }); - - it('should return correct type for array', () => { - expect(typeOf([])).toBe('array'); - }); - - it('should return correct type for function', () => { - expect(typeOf(() => {})).toBe('function'); - }); - - it('should return correct type for null', () => { - expect(typeOf(null)).toBe('null'); - }); - - it('should return correct type for undefined', () => { - expect(typeOf(undefined)).toBe('undefined'); - }); - - it('should return correct type for date', () => { - expect(typeOf(new Date())).toBe('date'); - }); - }); - describe('isFunction', () => { it('should return true for regular function', () => { const fn = function (): void {}; @@ -282,32 +239,6 @@ describe('utils', () => { }); }); - describe('isClass', () => { - it('should return true for class', () => { - /** - * - */ - class TestClass {} - expect(isClass(TestClass)).toBe(true); - }); - - it('should return false for regular function', () => { - const fn = function (): void {}; - - expect(isClass(fn)).toBe(false); - }); - - it('should return false for arrow function', () => { - const fn = (): void => {}; - - expect(isClass(fn)).toBe(false); - }); - - it('should return false for object', () => { - expect(isClass({})).toBe(false); - }); - }); - describe('isEmpty', () => { it('should return true for empty object', () => { expect(isEmpty({})).toBe(true); @@ -325,28 +256,11 @@ describe('utils', () => { expect(isEmpty(undefined)).toBe(true); }); - it('should return false for object created with Object.create (no constructor)', () => { + it('should return true for object created with Object.create (no constructor)', () => { const obj = Object.create(null); - expect(isEmpty(obj)).toBe(false); - }); - }); - - describe('isPromise', () => { - it('should return true for Promise', () => { - const promise = Promise.resolve(123); - - expect(isPromise(promise)).toBe(true); - }); - - it('should return false for non-Promise', () => { - expect(isPromise({})).toBe(false); - }); - - it('should return false for thenable object', () => { - const thenable = { then: () => {} }; - - expect(isPromise(thenable)).toBe(false); + // lodash isEmpty returns true for objects with no enumerable properties + expect(isEmpty(obj)).toBe(true); }); }); @@ -733,8 +647,8 @@ describe('utils', () => { expect(capitalize('Hello')).toBe('Hello'); }); - it('should throw when called with empty string', () => { - expect(() => capitalize('')).toThrow(TypeError); + it('should return empty string when called with empty string', () => { + expect(capitalize('')).toBe(''); }); }); @@ -803,13 +717,14 @@ describe('utils', () => { expect(result).toEqual({ value: null }); }); - it('should handle undefined values', () => { + it('should skip undefined values', () => { const target = { value: 'old' }; const source = { value: undefined }; const result = deepMerge(target, source); - expect(result).toEqual({ value: undefined }); + // lodash mergeWith skips undefined values (treats them as "not provided") + expect(result).toEqual({ value: 'old' }); }); }); @@ -818,7 +733,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: '' }, + value: { userAgent: '' }, configurable: true, }); @@ -839,7 +754,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: 'Windows' }, + value: { userAgent: 'Windows' }, configurable: true, }); @@ -857,7 +772,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: 'Mac' }, + value: { userAgent: 'Mac' }, configurable: true, }); @@ -872,12 +787,6 @@ describe('utils', () => { }); }); - describe('isTouchSupported', () => { - it('should be a boolean value', () => { - expect(typeof isTouchSupported).toBe('boolean'); - }); - }); - describe('beautifyShortcut', () => { it('should replace shift with ⇧', () => { expect(beautifyShortcut('Shift+B')).toContain('⇧'); @@ -887,7 +796,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: 'Mac' }, + value: { userAgent: 'Mac' }, configurable: true, }); @@ -905,7 +814,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: 'Windows' }, + value: { userAgent: 'Windows' }, configurable: true, }); @@ -1233,70 +1142,4 @@ describe('utils', () => { expect(equals(arr1, arr2)).toBe(false); }); }); - - describe('copyTextToClipboard', () => { - beforeEach(() => { - // Mock clipboard API - Object.defineProperty(navigator, 'clipboard', { - value: { - writeText: vi.fn().mockResolvedValue(undefined), - }, - configurable: true, - }); - - Object.defineProperty(window, 'isSecureContext', { - value: true, - configurable: true, - }); - }); - - it('should use Clipboard API when available', async () => { - const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText'); - - copyTextToClipboard('test text'); - - // Wait for promise to resolve - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - - expect(writeTextSpy).toHaveBeenCalledWith('test text'); - }); - - it('should fallback to legacy method when Clipboard API fails', async () => { - const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('Clipboard error')); - - // Mock execCommand since it doesn't exist in jsdom - const execCommandSpy = vi.fn().mockReturnValue(true); - - Object.defineProperty(document, 'execCommand', { - value: execCommandSpy, - configurable: true, - writable: true, - }); - - // Mock getSelection and createRange for fallback method - const mockRange = { - selectNode: vi.fn(), - }; - const mockSelection = { - removeAllRanges: vi.fn(), - addRange: vi.fn(), - }; - - vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection as unknown as Selection); - vi.spyOn(document, 'createRange').mockReturnValue(mockRange as unknown as Range); - - copyTextToClipboard('test text'); - - // Wait for promise to reject and fallback to execute - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - expect(execCommandSpy).toHaveBeenCalledWith('copy'); - - writeTextSpy.mockRestore(); - }); - }); });