^!u!v#?Y!v!w#@[!x!y#AW~#)mP#i#j#)p~#)sP#]#^#)v~#)yP#`#a#)|~#*PP#h#i#*S~#*VP}!O#*Y~#*]P!k!l#*`~#*cP#b#c#*f~#*iPpq#*l~#*oP!i!j#*r~#*uP#T#U#*x~#*{P#a#b#+O~#+RP#X#Y#+U~#+XPpq#+[~#+_P!o!p#+b~#+eP#c#d#+h~#+kP#W#X#+n~#+qP#X#Y#+t~#+wPpq#+z~#+}T!c!d#,^!e!f#-S!o!p#-r!t!u#.[!u!v#/d~#,aP#b#c#,d~#,gP#b#c#,j~#,mP#c#d#,p~#,sP#i#j#,v~#,yP#b#c#,|~#-PP#V#W5x~#-VP#c#d#-Y~#-]P#a#b#-`~#-cP#d#e#-f~#-iP#`#a#-l~#-oP#X#Y!)O~#-uP#i#j#-x~#-{P#g#h#.O~#.RP#]#^#.U~#.XP#V#W1O~#._P#X#Y#.b~#.eP#g#h#.h~#.kP#d#e#.n~#.qP#T#U#.t~#.wP#k#l#.z~#.}P#b#c#/Q~#/TP#]#^#/W~#/ZP#b#c#/^~#/aP#Z#[1O~#/gP#V#W#/j~#/mP#c#d#/p~#/sP#f#g#/Q~#/yP#X#Y#/|~#0PP#T#U#0S~#0VP#h#i#0Y~#0]P#[#]#0`~#0cPpq#0f~#0iP!u!v#0l~#0oP#d#e#0r~#0uP#X#Y#0x~#0{P#V#W#1O~#1RP#h#i#1U~#1XP#T#U#1[~#1_P#h#i#1b~#1eP#X#Y#1h~#1kPpq#1n~#1qQ!c!d#1w!v!w#2T~#1zP#`#a#1}~#2QP#`#aNw~#2WP#T#U#2Z~#2^P#f#g#2a~#2dP#Z#[#2g~#2jP#X#Y#2m~#2pP#h#i#2s~#2vPpq#2y~#2|P!j!k#3P~#3SP!w!x#3V~#3YP!f!g1O~#3`P#T#U#3c~#3fP#a#b#3i~#3lP#X#Y#3o~#3rPpq#3u~#3xP!o!p#3{~#4OP#c#d#4R~#4UP#W#X#4X~#4[P#X#Y#4_~#4bPpq#4e~#4hQ!j!k#3P!k!l#4n~#4qP#b#c#4t~#4wP}!O#4z~#4}P!y!z#5Q~#5TP#c#d#5W~#5ZP#f#g#5^~#5aP#`#a#5d~#5gP#W#X#5j~#5mPpq#5p~#5sP!w!x#5v~#5yP!k!l1O~#6PP#X#Y#6S~#6VP#f#g#6Y~#6]P#c#d#2s~#6cP#b#c#6f~#6iP#g#h#6l~#6oP#d#e#6r~#6uP#X#Y#6x~#6{P#V#W#7O~#7RP#h#i#7U~#7XP#c#d#7[~#7_P#f#g#7b~#7ePpq#7h~#7kP!t!u#7n~#7qP#X#Y#7t~#7wP#V#W#7z~#7}P#c#d#8Q~#8TP#f#g#8W~#8ZP#W#X#/Q~#8aP#]#^#8d~#8gP#`#a#8j~#8mP#`#a#8p~#8sPpq#8v~#8yP!h!i#8|~#9PP#X#Y#9S~#9VP#X#Y#9Y~#9]P#W#X1O~#9cQ#X#Y#9i#c#d#:X~#9lP#g#h#9o~#9rP#g#h#9u~#9xP#T#U#9{~#:OP#Z#[#:R~#:UP#X#Y! s~#:[P#j#k#:_~#:bP#X#Y#:e~#:hP#a#b#:k~#:nP#X#Y#:q~#:tP#b#c#:w~#:zP#h#i#:}~#;QPpq#;T~#;WP!e!f#;Z~#;^P#c#d#;a~#;dP#`#a#;g~#;jP#`#a#;m~#;pP#]#^#;s~#;vP#g#h#;y~#;|P#]#^#Q~#>TP#X#Y#>W~#>ZP#b#c!:u~#>aP#T#U#>d~#>gP#a#b#>j~#>mP#X#Y#>p~#>sP#d#e#>v~#>yP#`#a#>|~#?PP#T#U#?S~#?VP#h#i#:R~#?]P#V#W#?`~#?cP#c#d#?f~#?iP#f#g#?l~#?oP#X#Y#?r~#?uP#U#V#?x~#?{P#c#d#@O~#@RP#T#U#@U~#@XP#f#g#9Y~#@_P#X#Y#@b~#@eP#l#m#@h~#@kP#h#i#@n~#@qPpq#@t~#@wP!e!f#@z~#@}P#[#]#AQ~#ATP#T#U!:u~#AZP#c#d#A^~#AaP#]#^#Ad~#AgP#V#W#Aj~#AmP#X#Y#@n~#AsP#`#a#Av~#AyP#c#d#A|~#BPP#k#l#BS~#BVPpq?[~#B]P#T#U#B`~#BcP#b#c#Bf~#BiP#V#W#Bl~#BoP#X#Y#Br~#BuPpq#Bx~#B{P!d!e#CO~#CRP#X#Y#CU~#CXP#h#i#C[~#C_P#k#l#Cb~#CeP#X#Y#Ch~#CkP#X#Y*u~#CqP#]#^!Cx~#CwQ#h#i!?s#k#l*u~#DQT#`#a#Da#a#b#Ec#b#c#Eu#j#k$ _#m#n$)o~#DdP#g#h#Dg~#DjP#X#Y#Dm~#DpVpq#EVxy1eyz1e|}1e!]!^1e#o#p1e#q#r1e~#EYP!k!l#E]~#E`P#Y#Z1O~#EfP#d#e#Ei~#ElP#h#i#Eo~#ErP#m#nEq~#ExR#T#U#FR#W#X1O#h#i#NS~#FUP#U#V#FX~#F[P#`#a#F_~#FbP#X#Y#Fe~#FhQpq#Fn#W#X#Lt~#FqZ!d!e#)j!f!g#/v!i!j#Gd!j!k#5|!k!l#6`!m!n#8^!o!p#Hr!p!q#>^!u!v#?Y!v!w#@[!x!y#AW~#GgP#T#U#Gj~#GmP#a#b#Gp~#GsP#X#Y#Gv~#GyPpq#G|~#HPP!o!p#HS~#HVP#c#d#HY~#H]P#W#X#H`~#HcP#X#Y#Hf~#HiPpq#Hl~#HoP!k!l#4n~#HuQ#X#Y#9i#c#d#H{~#IOP#j#k#IR~#IUP#X#Y#IX~#I[P#a#b#I_~#IbP#X#Y#Ie~#IhP#b#c#Ik~#InP#h#i#Iq~#ItPpq#Iw~#IzP!e!f#I}~#JQP#c#d#JT~#JWP#`#a#JZ~#J^P#`#a#Ja~#JdP#]#^#Jg~#JjP#g#h#Jm~#JpP#]#^#Js~#JvP#c#d#Jy~#J|P#b#c#KP~#KSPpq#KV~#KYP!y!z#K]~#K`P#]#^#Kc~#KfP#h#i#Ki~#KlP#[#]#Ko~#KrPpq#Ku~#KxQ!g!h#LO!r!s! T~#LRP#b#c#LU~#LXP#j#k#L[~#L_P#]#^#Lb~#LeP#f#g#Lh~#LkP#c#d#Ln~#LqP#b#c#=z~#LwPpq#Lz~#L}P!i!j#MQ~#MTP#T#U#MW~#MZP#a#b#M^~#MaP#X#Y#Md~#MgPpq#Mj~#MmP!o!p#Mp~#MsP#c#d#Mv~#MyP#W#X#M|~#NPP#X#Y#2s~#NVP#]#^#NY~#N]P#h#i#N`~#NcP#m#n#Nf~#NiPpq#Nl~#NoQ!e!fMr!g!h#Nu~#NxP#l#m#N{~$ OP#]#^$ R~$ UP#g#h$ X~$ [P#h#i8f~$ bQ#T#U$ h#X#Y$!d~$ kP#`#a$ n~$ qP#i#j$ t~$ wP#T#U$ z~$ }P#h#i$!Q~$!TP#X#Y$!W~$!ZPpq$!^~$!aP!q!rDl~$!gP#b#c$!j~$!mP#h#i$!p~$!sPpq$!v~$!yT!c!d$#Y!f!g$#x!j!k$$w!r!s$%Z!y!z$%m~$#]P#U#V$#`~$#cP#]#^$#f~$#iP#`#a$#l~$#oP#]#^$#r~$#uP#h#iFa~$#{Q#T#U$$R#]#^$$X~$$UP#a#b!6m~$$[P#f#g$$_~$$bP#X#Y$$e~$$hP#V#W$$k~$$nP#h#i$$q~$$tP#]#^!$|~$$zP#X#Y$$}~$%QP#T#U$%T~$%WP#`#a+w~$%^P#`#a$%a~$%dP#T#U$%g~$%jP#m#n! y~$%pP#T#U$%s~$%vP#g#h$%y~$%|Ppq$&P~$&SR!e!f$&]!g!h$'k!j!k$(a~$&`P#f#g$&c~$&fP#]#^$&i~$&lP#h#i$&o~$&rP#]#^$&u~$&xP#V#W$&{~$'OP#T#U$'R~$'UP#`#a$'X~$'[Ppq$'_~$'bP!j!k$'e~$'hP#]#^NU~$'nP#b#c$'q~$'tP#j#k$'w~$'zP#]#^$'}~$(QP#f#g$(T~$(WP#c#d$(Z~$(^P#b#c!Bv~$(dP#X#Y$(g~$(jP#T#U$(m~$(pP#`#a$(s~$(vP#h#i$(y~$(|P#[#]$)P~$)SPpq$)V~$)YP!r!s$)]~$)`P#T#U$)c~$)fP#V#W$)i~$)lP#_#`)k~$)rP#X#Y$)u~$)xPpq$){~$*OP!r!s$*R~$*UP#c#d$*X~$*[P#g#h$*_~$*bP#]#^$$k~$*hS#T#U$*t#]#^$/X#`#a$0W#c#d$0d~$*wR#V#W$+Q#`#a$,r#f#g$-T~$+TP#]#^$+W~$+ZP#b#c$+^~$+aP#Z#[$+d~$+gPpq$+j~$+mP!f!g$+p~$+sP#]#^$+v~$+yP#f#g$+|~$,PP#X#Y$,S~$,VP#V#W$,Y~$,]P#h#i$,`~$,cP#]#^$,f~$,iP#c#d$,l~$,oP#b#c@v~$,uP#g#h$,x~$,{P#X#Y$-O~$-TOW~~$-WP#h#i$-Z~$-^P#[#]$-a~$-dP#X#Y$-g~$-jP#g#h$-m~$-pP#h#i$-s~$-vPpq$-y~$-|P!r!s$.P~$.SP#`#a$.V~$.YP#T#U$.]~$.`P#m#n$.c~$.fP#X#Y$.i~$.lP#f#g$.o~$.rPpq$.u~$.xP!h!i$.{~$/OP#f#g$/R~$/UP#c#d!7P~$/[Q#`#a$/b#f#g$0Q~$/eP#h#i$/h~$/kP#X#Y$/n~$/qP#f#g$/t~$/wP#X#Y$/z~$/}P#W#XEq~$0TP#g#h!8_~$0ZP#T#U$0^~$0aP#Z#[$)u~$0gP#f#g$0j~$0mQpq$0s#k#l!!x~$0vQ!i!j$0|!r!s$2n~$1PP#`#a$1S~$1VP#c#d$1Y~$1]P#U#V$1`~$1cP#T#U$1f~$1iP#`#a$1l~$1oPpq$1r~$1uP!x!y$1x~$1{P#T#U$2O~$2RP#f#g$2U~$2XP#]#^$2[~$2_P#T#U$2b~$2eP#U#V$2h~$2kP#`#a0x~$2qP#`#a$2t~$2wP#T#U$2z~$2}P#m#n$3Q~$3TP#X#Y$3W~$3ZP#f#g$1l~$3aR#T#U!CY#`#a$3j#c#d$5h~$3mP#c#d$3p~$3sP#U#V$3v~$3yP#T#U$3|~$4PP#`#a$4S~$4VVpq$4lxy*Qyz*Q|}*Q!]!^*Q#o#p*Q#q#r*Q~$4oP!x!y$4r~$4uP#T#U$4x~$4{P#f#g$5O~$5RP#]#^$5U~$5XP#T#U$5[~$5_P#U#V$5b~$5eP#`#a)e~$5kPpq$5n~$5qP!v!w$5t~$5wP#c#d$5z~$5}Ppq$6Q~$6TP!c!d$6W~$6ZP#g#h$6^~$6aP#g#h$6d~$6gP#X#Y$6j~$6mP#a#b$6p~$6sP#U#V$6v~$6yP#`#a$6|~$7PP#X#Y$7S~$7VPpq$7Y~$7]P!j!k$7`~$7cP#X#Y$7f~$7iP#f#g$7l~$7oP#c#d#:R~$7uR#T#U$8O#X#Y$9g#c#d$?d~$8RP#g#h$8U~$8XPpq$8[~$8_P!u!v$8b~$8eQ#d#e$8k#h#i$9T~$8nP#T#U$8q~$8tP#k#l$8w~$8zP#b#c$8}~$9QP#X#Y!#U~$9WP#T#U$9Z~$9^P#h#i$9a~$9dP#i#j8f~$9jQ#T#U$9p#f#g$O~$>RP#b#c$>U~$>XP#Z#[$>[~$>_Ppq$>b~$>eP!f!g$>h~$>kP#i#j$>n~$>qP#d#e$>t~$>wP#`#a$>z~$>}P#]#^$?Q~$?TP#V#W$?W~$?ZP#T#U$?^~$?aP#h#i$8}~$?gQ#f#g$?m#g#h$Db~$?pP#]#^$?s~$?vP#n#o$?y~$?|P#c#d$@P~$@SP#b#c$@V~$@YP#h#i$@]~$@`P#T#U$@c~$@fP#`#a$@i~$@lPpq$@o~$@rR!c!d$@{!h!i$Bj!u!v$Cx~$AOP#b#c$AR~$AUP#Z#[$AX~$A[P#`#a$A_~$AbP#X#Y$Ae~$AhPpq$Ak~$AnQ!h!i$At!v!w#'`~$AwP#f#g$Az~$A}P#c#d$BQ~$BTP#a#b$BW~$BZPpq$B^~$BaP!f!g$Bd~$BgP#]#^$$X~$BmP#T#U$Bp~$BsP#V#W$Bv~$ByP#]#^$B|~$CPP#b#c$CS~$CVP#Z#[$CY~$C]Ppq$C`~$CcP!c!d$Cf~$CiP#b#c$Cl~$CoP#Z#[$Cr~$CuP#`#a@p~$C{P#d#e$DO~$DRP#X#Y$DU~$DXP#X#Y$D[~$D_P#W#X@v~$DeP#h#i$Dh~$DkPpq$Dn~$DqP!r!s$%Z~$DwS#V#W+R#Y#Z$ET#b#c$Fo#g#h$Jw~$EWVxy1eyz1e|}1e}!O$Em!]!^1e#o#p1e#q#r1e~$EpP!v!w$Es~$EvP#[#]$Ey~$E|P#X#Y$FP~$FSP#b#c$FV~$FYP}!O$F]~$F`P!g!h$Fc~$FfP#`#a$Fi~$FlP#g#h)e~$FrQ#W#X$Fx#d#e$Ii~$F{P#X#Y$GO~$GRP#l#m$GU~$GXPpq$G[~$G_P!q!r$Gb~$GeP#Y#Z$Gh~$GkPpq$Gn~$GqQ!c!d$Gw!u!v$Ha~$GzP#f#g$G}~$HQP#f#g$HT~$HWP#T#U$HZ~$H^P#m#n2f~$HdP#h#i$Hg~$HjP#f#g$Hm~$HpP#]#^$Hs~$HvP#b#c$Hy~$H|P#Z#[$IP~$ISPpq$IV~$IYP!e!f$I]~$I`P#[#]$Ic~$IfP#T#U!!P~$IlP#i#j$Io~$IrP#h#i$Iu~$IxPpq$I{~$JOP!d!e$JR~$JUP#]#^$JX~$J[P#b#c$J_~$JbP#W#X$Je~$JhP#]#^$Jk~$JnP#b#c$Jq~$JtP#Z#[+_~$JzPpq$J}~$KQa!c!d$LV!d!e$Mb!e!f% {!f!g%,|!h!i%.t!i!j%3i!j!k%5a!k!l%7X!l!m%=O!o!p%=b!q!r%?f!r!s%Ay!t!u%Ck!u!v%DZ!v!w%Dm!w!x%Gp!y!z%J^~$LYQ#`#a=g#g#h$L`~$LcP#g#h$Lf~$LiP#X#Y$Ll~$LoP#a#b$Lr~$LuP#U#V$Lx~$L{P#`#a$MO~$MRP#]#^$MU~$MXP#b#c$M[~$M_P#Z#[7p~$MeQ#X#Y$Mk#i#j$Ny~$MnP#h#i$Mq~$MtP#k#l$Mw~$MzP#X#Y$M}~$NQP#X#Y$NT~$NWP#b#c$NZ~$N^Ppq$Na~$NdP!t!u$Ng~$NjP#c#d$Nm~$NpP#i#j$Ns~$NvP#b#c#'x~$N|P#h#i% P~% SP#h#i% V~% YP#c#d% ]~% `P#b#c% c~% fPpq% i~% lP!j!k% o~% rP#X#Y% u~% xP#`#a!#U~%!OR!v!w%!X#c#d%%R#f#g%,d~%![P!h!i%!_~%!bPpq%!e~%!hP!o!p%!k~%!nP#c#d%!q~%!tP#W#X%!w~%!zP#X#Y%!}~%#QPpq%#T~%#WP!k!l%#Z~%#^P#b#c%#a~%#dPpq%#g~%#jP!u!v%#m~%#pP#i#j%#s~%#vP#W#X%#y~%#|P#W#X%$P~%$SP#X#Y%$V~%$YP#b#c%$]~%$`Ppq%$c~%$fP!f!g%$i~%$lP#X#Y%$o~%$rP#T#U%$u~%$xP#h#i%${~%%OP#[#])k~%%UQ#a#b%%[#b#c%)v~%%_P#a#b%%b~%%eP#i#j%%h~%%kP#b#c%%n~%%qP#]#^%%t~%%wP#V#W%%z~%%}P#T#U%&Q~%&TP#h#i%&W~%&ZP#]#^%&^~%&aP#b#c%&d~%&gP#Z#[%&j~%&mVpq%'Sxy*Qyz*Q|}*Q!]!^*Q#o#p*Q#q#r*Q~%'VP!c!d%'Y~%']P#b#c%'`~%'cP#m#n%'f~%'iVpq%(Oxy*Qyz*Q|}*Q!]!^*Q#o#p*Q#q#r*Q~%(RR!g!h%([!u!v%(n!x!y%(t~%(_P#a#b%(b~%(eP#c#d%(h~%(kP#h#i)e~%(qP#d#eFT~%(wP#c#d%(z~%(}P#]#^%)Q~%)TP#V#W%)W~%)ZP#X#Y%)^~%)aPpq%)d~%)gP#`#a%)j~%)mP#]#^%)p~%)sP#b#c)e~%)yP#h#i%)|~%*PP#f#g%*S~%*VP#c#d%*Y~%*]P#`#a%*`~%*cPpq%*f~%*iP!o!p%*l~%*oP#c#d%*r~%*uP#W#X%*x~%*{P#X#Y%+O~%+RPpq%+U~%+XP!r!s%+[~%+_P#c#d%+b~%+eP#]#^%+h~%+kP#b#c%+n~%+qP#h#i%+t~%+wPpq%+z~%+}P!n!o%,Q~%,TP#c#d%,W~%,ZP#V#W%,^~%,aP#_#`$8}~%,gP#c#d%,j~%,mP#i#j%,p~%,sP#V#W%,v~%,yP#[#]+w~%-PQ#X#Y%-V#i#j%-]~%-YP#T#U!#U~%-`Q#a#b%-f#d#e%.U~%-iP#a#b%-l~%-oP#m#n%-r~%-uPpq%-x~%-{P!d!e%.O~%.RP#c#dNU~%.XP#`#a%.[~%._P#]#^%.b~%.eP#V#W%.h~%.kP#T#U%.n~%.qP#h#i+w~%.wQ#]#^%.}#`#a%1U~%/QP#f#g%/T~%/WP#]#^%/Z~%/^P#b#c%/a~%/dP#Z#[%/g~%/jPpq%/m~%/pQ!r!s%/v!u!v%0f~%/yP#f#g%/|~%0PP#]#^%0S~%0VP#a#b%0Y~%0]P#T#U%0`~%0cP#f#gFa~%0iP#X#Y%0l~%0oP#V#W%0r~%0uP#c#d%0x~%0{P#b#c%1O~%1RP#W#X%0Y~%1XP#T#U%1[~%1_P#Z#[%1b~%1ePpq%1h~%1kQ!c!d%1q!d!e%2Z~%1tP#h#i%1w~%1zPpq%1}~%2QP!d!e%2T~%2WP#T#U$Fi~%2^P#X#Y%2a~%2dP#]#^%2g~%2jP#b#c%2m~%2pP#Z#[%2s~%2vPpq%2y~%2|P!e!f%3P~%3SP#T#U%3V~%3YP#f#g%3]~%3`P#f#g%3c~%3fP#]#^$8}~%3lP#T#U%3o~%3rP#a#b%3u~%3xP#X#Y%3{~%4OPpq%4R~%4UP!k!l%4X~%4[P#b#c%4_~%4bPpq%4e~%4hP!r!s%4k~%4nP#f#g%4q~%4tP#c#d%4w~%4zP#Z#[%4}~%5QP#f#g%5T~%5WP#X#Y%5Z~%5^P#g#h8f~%5dP#X#Y%5g~%5jP#f#g%5m~%5pP#c#d%5s~%5vPpq%5y~%5|P!d!e%6P~%6SP#X#Y%6V~%6YP#]#^%6]~%6`P#b#c%6c~%6fP#Z#[%6i~%6lPpq%6o~%6rP!r!s%6u~%6xP#`#a%6{~%7OP#T#U%7R~%7UP#m#n$8}~%7[P#b#c%7_~%7bPpq%7e~%7hS!c!d%7t!n!o%9c!u!v%:w!x!y%|#c#d%?`~%=qP#h#i%=t~%=wP#V#W%=z~%=}P#[#]%>Q~%>TPpq%>W~%>ZP!e!f%>^~%>aP#c#d%>d~%>gP#a#b%>j~%>mP#d#e%>p~%>sP#`#a%>v~%>yP#X#Y%(h~%?PP#`#a%?S~%?VP#X#Y%?Y~%?]P#X#Y+w~%?cP#j#k+w~%?iQ#U#V%?o#b#c%@k~%?rP#^#_%?u~%?xP#X#Y%?{~%@OP#V#W%@R~%@UP#h#i%@X~%@[P#]#^%@_~%@bP#j#k%@e~%@hP#X#Y%>Q~%@nPpq%@q~%@tR!i!j%@}!q!ru!j!k7|!n!o9t!r!s&?}!u!v&A]~&=TP#X#Y&=W~&=ZP#T#U&=^~&=aQ#W#X8x#h#i&=g~&=jP#[#]8f~&=pP#`#a&=s~&=vP#]#^&=y~&=|P#a#b&>P~&>SP#]#^&>V~&>YP#b#c&>]~&>`P#T#U&>c~&>fP#h#i&>i~&>lP#]#^&>o~&>rP#c#dJV~&>xP#]#^&>{~&?OP#b#c&?R~&?UP#T#U&?X~&?[P#`#a&?_~&?bPpq&?e~&?hP!d!e&?k~&?nP#`#a&?q~&?tP#c#d&?w~&?zP#k#l8f~&@QP#`#a&@T~&@WP#T#U&@Z~&@^P#m#n&@a~&@dP#X#Y&@g~&@jP#f#g&@m~&@pP#g#h&@s~&@vVpq<_xy*Qyz*Q|}*Q!]!^*Q#o#p*Q#q#r*Q~&A`P#`#a&Ac~&AfP#c#d$ X~&AlR#U#V&Au#d#e&Cj#f#g)k~&AxP#^#_&A{~&BOP#X#Y&BR~&BUP#V#W&BX~&B[P#h#i&B_~&BbP#]#^&Be~&BhP#j#k&Bk~&BnP#X#Y&Bq~&BtPpq&Bw~&BzQ!k!l&CQ!r!s$*R~&CTP#b#c&CW~&CZP#W#X&C^~&CaP#X#Y&Cd~&CgP#l#m)k~&CmP#d#e&Cp~&CsP#c#d&Cv~&CyP#g#h&C|~&DPP#]#^&DS~&DVP#h#i&DY~&D]P#X#Y&D`~&DcPpq&Df~&DiP!v!w&Dl~&DoP#X#Y&Dr~&DuP#T#U&Dx~&D{P#a#b@v~&ERS#T#U&E_#`#a&Hk#c#d'#[#f#g'$y~&EbQ#i#j&Eh#m#n&Fj~&EkP#g#h&En~&EqP#X#Y&Et~&EwPpq&Ez~&E}P!o!p&FQ~&FTP#T#U&FW~&FZP#h#i&F^~&FaP#V#W&Fd~&FgP#[#]!-g~&FmP#`#a&Fp~&FsP#c#d&Fv~&FyP#T#U&F|~&GPP#W#X&GS~&GVPpq&GY~&G]P!r!s&G`~&GcQ#c#d$*X#f#g&Gi~&GlP#c#d&Go~&GrP#Z#[&Gu~&GxP#f#g&G{~&HOP#X#Y&HR~&HUP#g#h&HX~&H[P#g#h&H_~&HbPpq&He~&HhP!r!s!5w~&HnP#T#U&Hq~&HtP#m#n&Hw~&HzQpq!:V#X#Y&IQ~&ITP#f#g&IW~&IZQpq&Ia#g#h&M{~&IdS!e!f&Ip!j!k&MP!u!v&Mo!x!y$4r~&IsQ#T#U&Iy#`#a&KX~&I|P#f#g&JP~&JSP#f#g&JV~&JYP#m#n&J]~&J`P#]#^&Jc~&JfP#b#c&Ji~&JlP#Z#[&Jo~&JrPpq&Ju~&JxP!h!i&J{~&KOP#`#a&KR~&KUP#T#U,T~&K[P#c#d&K_~&KbP#g#h&Ke~&KhP#X#Y&Kk~&KnP#g#h&Kq~&KtP#h#i&Kw~&KzPpq&K}~&LQP!v!w<~&LWP#c#d&LZ~&L^Ppq&La~&LdP!t!u&Lg~&LjP#X#Y&Lm~&LpP#h#i&Ls~&LvP#]#^&Ly~&L|P#V#W$5b~&MSP#X#Y&MV~&MYP#f#g&M]~&M`P#c#d&Mc~&MfPpq&Mi~&MlP!u!v&Mo~&MrP#h#i&Mu~&MxP#T#UNU~&NOPpq&NR~&NUS!k!l&Nb!q!r&Nz!y!z' j#]#^'!x~&NeP#b#c&Nh~&NkPpq&Nn~&NqP!u!v&Nt~&NwP#`#a%.O~&N}P#b#c' Q~' TPpq' W~' ZP!j!k' ^~' aP#X#Y' d~' gP#f#gA`~' mP#]#^' p~' sP#h#i' v~' yP#[#]' |~'!PP#]#^'!S~'!VP#b#c'!Y~'!]Ppq'!`~'!cP!t!u'!f~'!iP#T#U'!l~'!oP#W#X'!r~'!uP#]#^$9a~'!{P#b#c'#O~'#RPpq'#U~'#XP!x!y%v!j!k(?r!o!p(AT!t!u(CX!u!v(C_!v!w(D|~(3uQ#V#W(3{#g#h(4w~(4OP#V#W(4R~(4UP#X#Y(4X~(4[P#`#a(4_~(4bP#X#Y(4e~(4hP#f#g(4k~(4nP#T#U(4q~(4tP#h#i#/Q~(4zP#g#h(4}~(5QP#]#^(5T~(5WP#g#h!:u~(5^P#T#U(5a~(5dP#a#b(5g~(5jP#X#Y(5m~(5pP#f#g(5s~(5vP#T#U1O~(5|P#T#U(6P~(6SP#a#b(6V~(6YP#T#U(6]~(6`P#Z#[(6c~(6fP#X#Y(6i~(6lPpq(6o~(6rQ!o!p(6x!q!r!-T~(6{P#c#d(7O~(7RP#W#X(7U~(7XP#]#^(7[~(7_P#Y#Z(7b~(7eP#]#^(7h~(7kP#V#W(7n~(7qP#T#U!)O~(7wQ#T#U'Hp#c#d(7}~(8QP#f#g(8T~(8WP#V#W(8Z~(8^P#]#^(8a~(8dP#b#c(8g~(8jP#Z#[(8m~(8pPpq(8s~(8vS!f!g(9S!r!s(:h!u!v(=U!v!w(>W~(9VP#i#j(9Y~(9]P#a#b(9`~(9cP#a#b(9f~(9iP#m#n(9l~(9oPpq(9r~(9uP!d!e(9x~(9{P#c#d(:O~(:RP#h#i(:U~(:XPpq(:[~(:_P!p!q(:b~(:eP#T#U!-y~(:kP#`#a(:n~(:qP#T#U(:t~(:wP#m#n(:z~(:}P#X#Y(;Q~(;TP#f#g(;W~(;ZPpq(;^~(;aR!q!r(;j!r!s(Q~(>TP#c#d&6h~(>ZP#[#](>^~(>aP#f#g(>d~(>gP#c#d(>j~(>mP#h#i(>p~(>sP#h#i$2h~(>yP#T#U(>|~(?PP#a#b(?S~(?VP#X#Y(?Y~(?]Ppq(?`~(?cP!o!p(?f~(?iP#c#d(?l~(?oP#W#X0x~(?uQ#X#Y(?{#c#d(@e~(@OP#T#U(@R~(@UP#`#a(@X~(@[Ppq(@_~(@bP!q!r!-T~(@hP#`#a(@k~(@nP#W#X(@q~(@tP#]#^(@w~(@zP#b#c(@}~(AQP#Z#[#BS~(AWP#c#d(AZ~(A^P#W#X(Aa~(AdP#]#^(Ag~(AjP#Y#Z(Am~(ApP#m#n(As~(AvP#]#^(Ay~(A|P#b#c(BP~(BSP#Z#[(BV~(BYPpq(B]~(B`P!x!y(Bc~(BfP#c#d(Bi~(BlP#]#^(Bo~(BrP#V#W(Bu~(BxP#X#Y(B{~(COPpq(CR~(CUP!n!o(;|~(C[P#i#j$2h~(CbP#V#W(Ce~(ChP#T#U(Ck~(CnP#`#a(Cq~(CtP#]#^(Cw~(CzP#b#c(C}~(DQP#Z#[(DT~(DWPpq(DZ~(D^Q!d!e(Dd!r!s5f~(DgP#T#U(Dj~(DmP#f#g(Dp~(DsP#f#g(Dv~(DyP#]#^! g~(EPQ#[#](EV#f#g(F}~(EYP#f#g(E]~(E`P#c#d(Ec~(EfP#h#i(Ei~(ElP#h#i(Eo~(ErP#`#a(Eu~(ExP#X#Y(E{~(FOPpq(FR~(FUP!k!l(FX~(F[P#b#c(F_~(FbPpq(Fe~(FhP!f!g(Fk~(FnP#]#^(Fq~(FtP#f#g(Fw~(FzP#X#Y!(x~(GQP#T#U(GT~(GWP#b#c(GZ~(G^P#g#h(Ga~(GdP#Y#Z(Gg~(GjP#c#d(Gm~(GpP#f#g(Gs~(GvP#a#b(Gy~(G|P#]#^(HP~(HSP#b#c(HV~(HYP#Z#[(H]~(H`Ppq(Hc~(HfP!v!w(>W~(HlP#d#e(Ho~(HrPpq(Hu~(HxW!c!d(Ib!e!f(M|!f!g(5y!h!i(7t!j!k(N{!o!p(AT!u!v(C_!v!w(D|~(IeR#V#W(3{#`#a(In#g#h(4w~(IqP#`#a(It~(IwPpq(Iz~(I}R!c!d(JW!f!g(Jp!j!k(Lw~(JZP#g#h(J^~(JaP#g#h(Jd~(JgP#]#^(Jj~(JmP#g#h##d~(JsP#T#U(Jv~(JyP#a#b(J|~(KPP#T#U(KS~(KVP#Z#[(KY~(K]P#X#Y(K`~(KcPpq(Kf~(KiQ!o!p(Ko!q!r!-T~(KrP#c#d(Ku~(KxP#W#X(K{~(LOP#]#^(LR~(LUP#Y#Z(LX~(L[P#]#^(L_~(LbP#V#W(Le~(LhP#T#U(Lk~(LnP#h#i(Lq~(LtP#]#^#$]~(LzP#X#Y(L}~(MQP#T#U(MT~(MWP#`#a(MZ~(M^Qpq(@_#]#^(Md~(MgP#b#c(Mj~(MmP#Z#[(Mp~(MsPpq(Mv~(MyP!o!p(Ko~(NPQ#T#U(5a#[#](NV~(NYP#T#U(N]~(N`P#g#h(Nc~(NfP#]#^(Ni~(NlP#b#c(No~(NrP#Z#[(Nu~(NxPpq$0s~) OQ#X#Y) U#c#d(@e~) XP#T#U) [~) _P#`#a) b~) eQpq(@_#]#^) k~) nP#b#c) q~) tP#Z#[) w~) zPpq) }~)!QP!o!p(6x~)!WP#]#^)!Z~)!^P#b#c)!a~)!dP#Z#[)!g~)!jVpq)#Pxy*Qyz*Q|}*Q!]!^*Q#o#p*Q#q#r*Q~)#SS!e!fLQ!n!o)#`!t!u)#r!u!v)$[~)#cP#X#Y)#f~)#iP#b#c)#l~)#oP#Z#[%$u~)#uP#X#Y)#x~)#{P#d#e)$O~)$RP#`#a)$U~)$XP#T#U,y~)$_Q#`#aLv#d#e)$e~)$hP#`#a$'e~)$nP#U#V)$q~)$tP#h#i)$w~)$zP#f#g)$}~)%QP#T#U!@i~)%WT#T#U)%g#X#Y)&V#[#])'n#c#d)(W#f#g)*O~)%jP#b#c)%m~)%pP#Z#[)%s~)%vP#X#Y)%y~)%|P#b#c)&P~)&SP#h#i!7i~)&YR#T#U)&c#`#a)'U#l#mM`~)&fP#a#b)&i~)&lPpq)&o~)&rQ!q!rAS!u!v)&x~)&{P#V#W)'O~)'RP#c#d!2}~)'XP#X#Y)'[~)'_P#d#e)'b~)'eP#c#d)'h~)'kP#f#g!:u~)'qP#f#g)'t~)'wP#c#d)'z~)'}P#h#i)(Q~)(TP#h#i$Cr~)(ZP#h#i)(^~)(aP#T#U)(d~)(gP#`#a)(j~)(mPpq)(p~)(sP!v!w)(v~)(yP#]#^)(|~))PP#a#b))S~))VP#X#Y))Y~))]Ppq))`~))cP!g!h))f~))iP#`#a))l~))oP#T#U))r~))uP#d#e))x~)){P#g#h$8}~)*RP#i#j$,x~)*XR#`#a)*b#b#c),r#d#e)-U~)*eP#h#i)*h~)*kP#]#^)*n~)*qP#a#b)*t~)*wP#T#U)*z~)*}P#h#i)+Q~)+TP#X#Y)+W~)+ZPpq)+^~)+aP!e!f)+d~)+gP#[#])+j~)+mP#T#U)+p~)+sP#f#g)+v~)+yP#Z#[)+|~),PP#X#Y),S~),VPpq),Y~),]P!r!s),`~),cP#X#Y),f~),iP#f#g),l~),oP#V#W!B|~),uP#d#e),x~),{P#T#U)-O~)-RP#i#j&Eh~)-XVxy*Qyz*Q|}*Q!]!^*Q#W#X)-n#o#p*Q#q#r*Q~)-qP#T#U)-t~)-wP#h#i)-z~)-}P#X#Y).Q~).TPpq).W~).ZP!g!h).^~).aP#j#k).d~).gP#X#Y).j~).mP#f#g).p~).sP#m#n).v~).yPpq).|~)/PP!h!i)/S~)/VP#f#g)/Y~)/]P#T#U&,f~)/cR#T#U')z#X#Y)/l#]#^)1|~)/oR#V#W)/x#`#a)0z#f#g)1j~)/{P#h#i)0O~)0RP#c#d)0U~)0XP#f#g)0[~)0_Vpq)0txy*Qyz*Q|}*Q!]!^*Q#o#p*Q#q#r*Q~)0wP!v!w#'`~)0}P#c#d)1Q~)1TP#V#W)1W~)1ZP#]#^)1^~)1aP#h#i)1d~)1gP#m#n@v~)1mP#h#i)1p~)1sP#]#^)1v~)1yP#V#W$@]~)2PP#V#W)2S~)2VP#h#i)2Y~)2]P#]#^!7P~)2cS#T#U)2o#X#Y)3}#[#])4Z#c#d)4a~)2rP#]#^)2u~)2xP#h#i)2{~)3OVpq)3exy1eyz1e|}1e!]!^1e#o#p1e#q#r1e~)3hP!w!x)3k~)3nP#b#c)3q~)3tP#h#i)3w~)3zP#]#^%Kx~)4QP#T#U)4T~)4WP#d#e!$|~)4^P#]#^$2h~)4dP#f#g)4g~)4jQ#_#`)4p#`#a)7d~)4sP#g#h)4v~)4yP#[#])4|~)5PP#c#d)5S~)5VP#d#e)5Y~)5]Ppq)5`~)5cP!u!v)5f~)5iP#X#Y)5l~)5oP#h#i)5r~)5uP#h#i)5x~)5{P#]#^)6O~)6RP#b#c)6U~)6XP#Z#[)6[~)6_Ppq)6b~)6eT!e!f)6t!j!k' ^!k!l')O!t!u')h!v!w)7W~)6wP#c#d)6z~)6}P#a#b)7Q~)7TP#U#VA`~)7ZP#c#d)7^~)7aP#Z#[%OP#`#a)>R~)>UP#c#d)>X~)>[P#U#V)>_~)>bP#T#U)>e~)>hP#`#a);f~)>nP#`#a)>q~)>tP#T#U)>w~)>zP#m#n)>}~)?QP#X#Y)?T~)?WP#f#g);f~)?^P#i#j)?a~)?dP#`#a)?g~)?jP#X#Y);f~)?pQ#X#Y)?v#i#j)@f~)?yP#h#i)?|~)@PP#h#i)@S~)@VP#]#^)@Y~)@]P#b#c)@`~)@cP#Z#[);`~)@iP#U#V)@l~)@oP#f#g)@r~)@uP#c#d)@x~)@{P#i#j)AO~)ARP#h#i)AU~)AXP#]#^)A[~)A_P#b#c)Ab~)AeP#X#Y);`~)AkP#T#U)An~)AqP#f#g)At~)AwP#]#^)Az~)A}P#T#U)BQ~)BTP#U#V)BW~)BZP#`#a)Ab",
- tokenizers: [0],
- topRules: {"Program":[0, 2]},
- tokenPrec: 0
-})
diff --git a/app/javascript/src/lib/lang.terms.js b/app/javascript/src/lib/lang.terms.js
deleted file mode 100644
index 0713bf14b..000000000
--- a/app/javascript/src/lib/lang.terms.js
+++ /dev/null
@@ -1,12 +0,0 @@
-// This file was generated by lezer-generator. You probably shouldn't edit it.
-export const
- LineComment = 1,
- Program = 2,
- Keyword = 3,
- Action = 4,
- Value = 5,
- Variable = 6,
- String = 7,
- Boolean = 8,
- Number = 9,
- Punctuation = 10
diff --git a/app/javascript/src/lib/languageOptions.js b/app/javascript/src/lib/languageOptions.ts
similarity index 100%
rename from app/javascript/src/lib/languageOptions.js
rename to app/javascript/src/lib/languageOptions.ts
diff --git a/app/javascript/src/lib/ows.grammar b/app/javascript/src/lib/ows.grammar
deleted file mode 100644
index da40efd68..000000000
--- a/app/javascript/src/lib/ows.grammar
+++ /dev/null
@@ -1,62 +0,0 @@
-@top Program { expression }
-
-@skip { space | LineComment }
-
-expression {
- Keyword |
- Action |
- Value |
- Variable |
- String |
- Boolean |
- Number |
- Punctuation
-}
-
-@tokens {
- separators { "(" | ")" | ";" | "," | "{" | "}" }
-
- space { $[ \t\n\r]+ }
-
- Variable { "<<>>" }
-
- keywords { "variables" | "subroutines" | "disabled" | "event" | "rule" | "actions" | "conditions" | "global" | "player" | "settings" }
- Keyword { keywords $[(\n \t{] }
-
- /*
- * Regenerate this with (requires [yq](https://github.com/kislyuk/yq)):
- * yq -jc 'map(.["en-US"]) | sort' config/arrays/wiki/actions.yml | sed 's/^\[\(.*\)\]$/\1/g' | sed 's/","/" | "/g' | xclip
- */
- actions { "Abort" | "Abort If" | "Abort If Condition Is False" | "Abort If Condition Is True" | "Add Health Pool To Player" | "Allow Button" | "Apply Impulse" | "Attach Players" | "Big Message" | "Break" | "Call Subroutine" | "Cancel Primary Action" | "Chase Global Variable At Rate" | "Chase Global Variable Over Time" | "Chase Player Variable At Rate" | "Chase Player Variable Over Time" | "Clear Status" | "Communicate" | "Continue" | "Create Beam Effect" | "Create Dummy Bot" | "Create Effect" | "Create HUD Text" | "Create Homing Projectile" | "Create Icon" | "Create In-World Text" | "Create Progress Bar HUD Text" | "Create Progress Bar In-World Text" | "Create Projectile" | "Create Projectile Effect" | "Damage" | "Declare Match Draw" | "Declare Player Victory" | "Declare Round Draw" | "Declare Round Victory" | "Declare Team Victory" | "Destroy All Dummy Bots" | "Destroy All Effects" | "Destroy All HUD Text" | "Destroy All Icons" | "Destroy All In-World Text" | "Destroy All Progress Bar HUD Text" | "Destroy All Progress Bar In-World Text" | "Destroy Dummy Bot" | "Destroy Effect" | "Destroy HUD Text" | "Destroy Icon" | "Destroy In-World Text" | "Destroy Progress Bar HUD Text" | "Destroy Progress Bar In-World Text" | "Detach Players" | "Disable Built-In Game Mode Announcer" | "Disable Built-In Game Mode Completion" | "Disable Built-In Game Mode Music" | "Disable Built-In Game Mode Respawning" | "Disable Built-In Game Mode Scoring" | "Disable Death Spectate All Players" | "Disable Death Spectate Target HUD" | "Disable Game Mode HUD" | "Disable Game Mode In-World UI" | "Disable Hero HUD" | "Disable Inspector Recording" | "Disable Kill Feed" | "Disable Messages" | "Disable Movement Collision With Environment" | "Disable Movement Collision With Players" | "Disable Nameplates" | "Disable Scoreboard" | "Disable Text Chat" | "Disable Voice Chat" | "Disallow Button" | "Else" | "Else If" | "Enable Built-In Game Mode Announcer" | "Enable Built-In Game Mode Completion" | "Enable Built-In Game Mode Music" | "Enable Built-In Game Mode Respawning" | "Enable Built-In Game Mode Scoring" | "Enable Death Spectate All Players" | "Enable Death Spectate Target HUD" | "Enable Game Mode HUD" | "Enable Game Mode In-World UI" | "Enable Hero HUD" | "Enable Inspector Recording" | "Enable Kill Feed" | "Enable Messages" | "Enable Movement Collision With Environment" | "Enable Movement Collision With Players" | "Enable Nameplates" | "Enable Scoreboard" | "Enable Text Chat" | "Enable Voice Chat" | "End" | "For Global Variable" | "For Player Variable" | "Go To Assemble Heroes" | "Heal" | "If" | "Kill" | "Log To Inspector" | "Loop" | "Loop If" | "Loop If Condition Is False" | "Loop If Condition Is True" | "Modify Global Variable" | "Modify Global Variable At Index" | "Modify Player Score" | "Modify Player Variable" | "Modify Player Variable At Index" | "Modify Team Score" | "Move Player To Team" | "Pause Match Time" | "Play Effect" | "Preload Hero" | "Press Button" | "Remove All Health Pools From Player" | "Remove Health Pool From Player" | "Remove Player" | "Reset Player Hero Availability" | "Respawn" | "Restart Match" | "Resurrect" | "Return To Lobby" | "Set Ability 1 Enabled" | "Set Ability 2 Enabled" | "Set Ability Charge" | "Set Ability Cooldown" | "Set Ability Resource" | "Set Aim Speed" | "Set Ammo" | "Set Crouch Enabled" | "Set Damage Dealt" | "Set Damage Received" | "Set Environment Credit Player" | "Set Facing" | "Set Global Variable" | "Set Global Variable At Index" | "Set Gravity" | "Set Healing Dealt" | "Set Healing Received" | "Set Invisible" | "Set Jump Enabled" | "Set Jump Vertical Speed" | "Set Knockback Dealt" | "Set Knockback Received" | "Set Match Time" | "Set Max Ammo" | "Set Max Health" | "Set Melee Enabled" | "Set Move Speed" | "Set Objective Description" | "Set Player Allowed Heroes" | "Set Player Health" | "Set Player Score" | "Set Player Variable" | "Set Player Variable At Index" | "Set Primary Fire Enabled" | "Set Projectile Gravity" | "Set Projectile Speed" | "Set Reload Enabled" | "Set Respawn Max Time" | "Set Secondary Fire Enabled" | "Set Slow Motion" | "Set Status" | "Set Team Score" | "Set Ultimate Ability Enabled" | "Set Ultimate Charge" | "Set Weapon" | "Skip" | "Skip If" | "Small Message" | "Start Accelerating" | "Start Assist" | "Start Camera" | "Start Damage Modification" | "Start Damage Over Time" | "Start Facing" | "Start Forcing Dummy Bot Name" | "Start Forcing Player Outlines" | "Start Forcing Player Position" | "Start Forcing Player To Be Hero" | "Start Forcing Spawn Room" | "Start Forcing Throttle" | "Start Game Mode" | "Start Heal Over Time" | "Start Healing Modification" | "Start Holding Button" | "Start Modifying Hero Voice Lines" | "Start Rule" | "Start Scaling Barriers" | "Start Scaling Player" | "Start Throttle In Direction" | "Start Transforming Throttle" | "Stop Accelerating" | "Stop All Assists" | "Stop All Damage Modifications" | "Stop All Damage Over Time" | "Stop All Heal Over Time" | "Stop All Healing Modifications" | "Stop Assist" | "Stop Camera" | "Stop Chasing Global Variable" | "Stop Chasing Player Variable" | "Stop Damage Modification" | "Stop Damage Over Time" | "Stop Facing" | "Stop Forcing Dummy Bot Name" | "Stop Forcing Player Outlines" | "Stop Forcing Player Position" | "Stop Forcing Player To Be Hero" | "Stop Forcing Spawn Room" | "Stop Forcing Throttle" | "Stop Heal Over Time" | "Stop Healing Modification" | "Stop Holding Button" | "Stop Modifying Hero Voice Lines" | "Stop Scaling Barriers" | "Stop Scaling Player" | "Stop Throttle In Direction" | "Stop Transforming Throttle" | "Teleport" | "Unpause Match Time" | "Wait" | "Wait Until" | "While" }
- Action { actions separators }
-
- /*
- * Regenerate this with (requires [yq](https://github.com/kislyuk/yq)):
- * yq -jc 'map(.["en-US"]) | sort' config/arrays/wiki/values.yml | sed 's/^\[\(.*\)\]$/\1/g' | sed 's/","/" | "/g' | xclip
- */
- values { "Ability Charge" | "Ability Cooldown" | "Ability Icon String" | "Ability Resource" | "Absolute Value" | "Add" | "All Damage Heroes" | "All Dead Players" | "All Heroes" | "All Living Players" | "All Players" | "All Players Not On Objective" | "All Players On Objective" | "All Support Heroes" | "All Tank Heroes" | "Allowed Heroes" | "Altitude Of" | "Ammo" | "And" | "Angle Between Vectors" | "Angle Difference" | "Append To Array" | "Arccosine In Degrees" | "Arccosine In Radians" | "Arcsine In Degrees" | "Arcsine In Radians" | "Arctangent In Degrees" | "Arctangent In Radians" | "Array" | "Array Contains" | "Array Slice" | "Assist Count" | "Attacker" | "Backward" | "Button" | "Char In String" | "Closest Player To" | "Color" | "Compare" | "Control Mode Scoring Percentage" | "Control Mode Scoring Team" | "Cosine From Degrees" | "Cosine From Radians" | "Count Of" | "Cross Product" | "Current Array Element" | "Current Array Index" | "Current Game Mode" | "Current Map" | "Custom Color" | "Custom String" | "Damage Modification Count" | "Damage Over Time Count" | "Direction From Angles" | "Direction Towards" | "Distance Between" | "Divide" | "Dot Product" | "Down" | "Empty Array" | "Entity Count" | "Entity Exists" | "Evaluate Once" | "Event Ability" | "Event Damage" | "Event Direction" | "Event Healing" | "Event Player" | "Event Was Critical Hit" | "Event Was Environment" | "Event Was Health Pack" | "Eye Position" | "Facing Direction Of" | "False" | "Farthest Player From" | "Filtered Array" | "First Of" | "Flag Position" | "Forward" | "Game Mode" | "Global" | "Global Variable" | "Has Spawned" | "Has Status" | "Heal Over Time Count" | "Healee" | "Healer" | "Healing Modification Count" | "Health" | "Health of Type" | "Hero" | "Hero Being Duplicated" | "Hero Icon String" | "Hero Of" | "Horizontal Angle From Direction" | "Horizontal Angle Towards" | "Horizontal Facing Angle Of" | "Horizontal Speed Of" | "Host Player" | "Icon String" | "If-Then-Else" | "Index Of Array Value" | "Index Of String Char" | "Input Binding String" | "Is Alive" | "Is Assembling Heroes" | "Is Between Rounds" | "Is Button Held" | "Is CTF Mode In Sudden Death" | "Is Communicating" | "Is Communicating Any" | "Is Communicating Any Emote" | "Is Communicating Any Spray" | "Is Communicating Any Voice line" | "Is Control Mode Point Locked" | "Is Crouching" | "Is Dead" | "Is Dummy Bot" | "Is Duplicating" | "Is Firing Primary" | "Is Firing Secondary" | "Is Flag At Base" | "Is Flag Being Carried" | "Is Game In Progress" | "Is Hero Being Played" | "Is In Air" | "Is In Alternate Form" | "Is In Line of Sight" | "Is In Setup" | "Is In Spawn Room" | "Is In View Angle" | "Is Jumping" | "Is Match Complete" | "Is Meleeing" | "Is Moving" | "Is Objective Complete" | "Is On Ground" | "Is On Objective" | "Is On Wall" | "Is Portrait On Fire" | "Is Reloading" | "Is Standing" | "Is Team On Defense" | "Is Team On Offense" | "Is True For All" | "Is True For Any" | "Is Using Ability 1" | "Is Using Ability 2" | "Is Using Ultimate" | "Is Waiting For Players" | "Last Assist ID" | "Last Created Entity" | "Last Created Health Pool" | "Last Damage Modification ID" | "Last Damage Over Time ID" | "Last Heal Over Time ID" | "Last Healing Modification ID" | "Last Of" | "Last Text ID" | "Left" | "Local Player" | "Local Vector Of" | "Magnitude Of" | "Map" | "Mapped Array" | "Match Round" | "Match Time" | "Max" | "Max Ammo" | "Max Health" | "Max Health of Type" | "Min" | "Modulo" | "Multiply" | "Nearest Walkable Position" | "Normalize" | "Normalized Health" | "Not" | "Null" | "Number of Dead Players" | "Number of Deaths" | "Number of Eliminations" | "Number of Final Blows" | "Number of Heroes" | "Number of Living Players" | "Number of Players" | "Number of Players On Objective" | "Number of Slots" | "Objective Index" | "Objective Position" | "Opposite Team Of" | "Or" | "Payload Position" | "Payload Progress Percentage" | "Player Carrying Flag" | "Player Closest To Reticle" | "Player Hero Stat" | "Player Stat" | "Player Variable" | "Players In Slot" | "Players On Hero" | "Players Within Radius" | "Players in View Angle" | "Point Capture Percentage" | "Position Of" | "Raise To Power" | "Random Integer" | "Random Real" | "Random Value In Array" | "Randomized Array" | "Ray Cast Hit Normal" | "Ray Cast Hit Player" | "Ray Cast Hit Position" | "Remove From Array" | "Right" | "Round To Integer" | "Score Of" | "Server Load" | "Server Load Average" | "Server Load Peak" | "Sine From Degrees" | "Sine From Radians" | "Slot Of" | "Sorted Array" | "Spawn Points" | "Speed Of" | "Speed Of In Direction" | "Square Root" | "String" | "String Contains" | "String Length" | "String Replace" | "String Slice" | "String Split" | "Subtract" | "Tangent From Degrees" | "Tangent From Radians" | "Team Of" | "Team Score" | "Text Count" | "Throttle Of" | "Total Time Elapsed" | "True" | "Ultimate Charge Percent" | "Up" | "Update Every Frame" | "Value In Array" | "Vector" | "Vector Towards" | "Velocity Of" | "Vertical Angle From Direction" | "Vertical Angle Towards" | "Vertical Facing Angle Of" | "Vertical Speed Of" | "Victim" | "Weapon" | "Workshop Setting Combo" | "Workshop Setting Hero" | "Workshop Setting Integer" | "Workshop Setting Real" | "Workshop Setting Toggle" | "World Vector Of" | "X Component Of" | "Y Component Of" | "Z Component Of" }
- Value { values separators }
-
- String { '"' (!["\\] | "\\" _)* '"' }
-
- Boolean { "True" | "False" }
-
- LineComment { "\\" ![\n]* | "//" ![\n]* }
-
- Number { @digit+ $[.%] }
-
- Punctuation { ";" | "(" | ")" | "[" | "]" | "{" | "}" | "." | "," | "!" | "?" | "=" | ">" | "<" }
-
- @precedence {
- Punctuation
- Keyword
- Value
- Action
- Boolean
- Variable
- Number
- space
- }
-}
-
-@detectDelim
diff --git a/app/javascript/src/lib/parameterTooltip.js b/app/javascript/src/lib/parameterTooltip.ts
similarity index 66%
rename from app/javascript/src/lib/parameterTooltip.js
rename to app/javascript/src/lib/parameterTooltip.ts
index 738b55a90..cc8564715 100644
--- a/app/javascript/src/lib/parameterTooltip.js
+++ b/app/javascript/src/lib/parameterTooltip.ts
@@ -1,9 +1,10 @@
+import type { Extension } from "@codemirror/state"
import { hoverTooltip } from "@codemirror/view"
-import { completionsMap } from "@stores/editor"
+import { completionsMap, settings } from "@stores/editor"
import { getPhraseFromPosition } from "@utils/parse"
import { get } from "svelte/store"
-export function parameterTooltip() {
+export function parameterTooltip(): Extension {
return hoverTooltip((view, position) => {
const line = view.state.doc.lineAt(position)
@@ -11,7 +12,7 @@ export function parameterTooltip() {
const phrase = getPhraseFromPosition(line, position)
- const possibleValues = get(completionsMap).filter(v => v.args_length)
+ const possibleValues = get(completionsMap).filter(v => v.info)
const validValue = possibleValues.find(v => v.label == phrase.text)
if (!validValue) return null
@@ -22,9 +23,9 @@ export function parameterTooltip() {
above: true,
create: () => {
const dom = document.createElement("div")
- dom.textContent = validValue.detail_full
+ dom.innerHTML = validValue.detail_full
return { dom }
}
}
- }, { hoverTime: 100 })
+ }, { hoverTime: get(settings)["tooltip-hover-delay"] })
}
diff --git a/app/javascript/src/lib/templates.js b/app/javascript/src/lib/templates.ts
similarity index 100%
rename from app/javascript/src/lib/templates.js
rename to app/javascript/src/lib/templates.ts
diff --git a/app/javascript/src/linked-input.js b/app/javascript/src/linked-input.js
deleted file mode 100644
index 717d79243..000000000
--- a/app/javascript/src/linked-input.js
+++ /dev/null
@@ -1,19 +0,0 @@
-export function bind() {
- const elements = document.querySelectorAll("[data-role~='linked-input']")
-
- if (!elements?.length) return
-
- elements.forEach(element => {
- element.removeAndAddEventListener("blur", updateLinkedInputs)
- })
-}
-
-function updateLinkedInputs(event) {
- const key = event.target.dataset.key
-
- const elements = document.querySelectorAll(`[data-key="${key}"]`)
-
- elements.forEach(element => {
- element.value = event.target.value
- })
-}
diff --git a/app/javascript/src/linked-input.ts b/app/javascript/src/linked-input.ts
new file mode 100644
index 000000000..6bd67a9bb
--- /dev/null
+++ b/app/javascript/src/linked-input.ts
@@ -0,0 +1,20 @@
+export function bind(): void {
+ const elements = document.querySelectorAll("[data-role~='linked-input']")
+
+ if (!elements?.length) return
+
+ elements.forEach(element => {
+ element.removeAndAddEventListener("blur", updateLinkedInputs)
+ })
+}
+
+function updateLinkedInputs(event: Event): void {
+ const currentTarget = event.currentTarget as HTMLInputElement
+ const key = currentTarget.dataset.key
+
+ const elements = Array.from(document.querySelectorAll(`[data-key="${key}"]`)) as HTMLInputElement[]
+
+ elements.forEach(element => {
+ element.value = currentTarget.value
+ })
+}
diff --git a/app/javascript/src/microlight.js b/app/javascript/src/microlight.ts
similarity index 95%
rename from app/javascript/src/microlight.js
rename to app/javascript/src/microlight.ts
index 7a45e4813..e6f5c17a5 100644
--- a/app/javascript/src/microlight.js
+++ b/app/javascript/src/microlight.ts
@@ -11,7 +11,7 @@
* Code structure aims at minimizing the compressed library size
*/
-export async function reset(cls) {
+export async function reset(className = ""): Promise {
// for better compression
var _window = window,
_document = document,
@@ -25,7 +25,7 @@ export async function reset(cls) {
el; // current microlighted element to run through
// nodes to highlight
- microlighted = _document.getElementsByClassName(cls || 'microlight');
+ microlighted = _document.getElementsByClassName(className || 'microlight');
for (i = 0; el = microlighted[i++];) {
const fullText = el.textContent
@@ -90,7 +90,7 @@ export async function reset(cls) {
if (!chr || // end of content
// types 9-10 (single-line comments) end with a
// newline
- (tokenType > 8 && chr == '\n') ||
+ (tokenType > 5 && chr == '\n') ||
[ // finalize conditions for other token types
// 0: whitespaces
/\S/[test](chr), // merged together
@@ -165,6 +165,7 @@ export async function reset(cls) {
chr === '@' || /[$\w]/[test](chr),// 3: (key)word
/^\d+$/[test](chr), // 4: number
chr == '"', // 5: string with "
+ (chr == '/' && next1 == '/') // 6: multilnes comment
][--tokenType]);
}
diff --git a/app/javascript/src/modal.js b/app/javascript/src/modal.ts
similarity index 63%
rename from app/javascript/src/modal.js
rename to app/javascript/src/modal.ts
index 8268ce957..ec4c3c496 100644
--- a/app/javascript/src/modal.js
+++ b/app/javascript/src/modal.ts
@@ -1,4 +1,4 @@
-export function bind() {
+export function bind(): void {
const elements = document.querySelectorAll("[data-action='toggle-modal']")
elements.forEach(element => element.removeAndAddEventListener("click", closeModal))
@@ -11,8 +11,8 @@ export function bind() {
document.body.removeAndAddEventListener("keydown", closeModalOnKeyDown)
}
-function showModal() {
- const modal = document.querySelector(`[data-modal="${this.dataset.target}"]`)
+function showModal({ currentTarget }: { currentTarget: HTMLElement }): void {
+ const modal = document.querySelector(`[data-modal="${currentTarget.dataset.target}"]`) as HTMLElement
if (!modal) return
@@ -20,29 +20,35 @@ function showModal() {
document.body.style.borderRight = `${getScrollbarWidth()}px solid transparent`
document.body.style.overflowY = "hidden"
+
+ focusModal(modal)
}
-export function closeModal() {
- const activeModal = document.querySelector(".modal:not([style*='none'])")
+export function closeModal(): void {
+ const activeModal = document.querySelector(".modal:not([style*='none'])") as HTMLElement
if (!activeModal) return
-
if (activeModal.dataset.ignore != undefined) return
if (activeModal.dataset.hideOnClose != undefined) {
activeModal.style.display = "none"
- document.body.style.borderRight = 0
+ document.body.style.borderRight = "0"
document.body.style.overflowY = "auto"
} else {
activeModal.remove()
}
}
-function closeModalOnKeyDown(event) {
+function closeModalOnKeyDown(event: KeyboardEvent): void {
if (event.code === "Escape") closeModal()
}
-function getScrollbarWidth() {
+function getScrollbarWidth(): number {
return window.innerWidth - document.body.offsetWidth
}
+
+export function focusModal(modal: HTMLElement): void {
+ const firstFocusableElement = modal.querySelector("button:not(disabled), input:not([type='hidden']), select, [tabindex='0'], img") as HTMLFormElement
+ if (firstFocusableElement) firstFocusableElement.focus()
+}
diff --git a/app/javascript/src/navigate-on-change.ts b/app/javascript/src/navigate-on-change.ts
new file mode 100644
index 000000000..ff5613bff
--- /dev/null
+++ b/app/javascript/src/navigate-on-change.ts
@@ -0,0 +1,10 @@
+export function bind(): void {
+ const elements = document.querySelectorAll("[data-action~='navigate-on-change']")
+
+ elements.forEach((element) => element.removeAndAddEventListener("change", navigateOnChange))
+}
+
+function navigateOnChange(event: InputEvent): void {
+ const target = event.target as HTMLSelectElement
+ window.location.href = target.value
+}
diff --git a/app/javascript/src/navigation.js b/app/javascript/src/navigation.ts
similarity index 50%
rename from app/javascript/src/navigation.js
rename to app/javascript/src/navigation.ts
index c5744a814..594f40d20 100644
--- a/app/javascript/src/navigation.js
+++ b/app/javascript/src/navigation.ts
@@ -1,24 +1,25 @@
-export function bind() {
+export function bind(): void {
const elements = document.querySelectorAll("[data-action='toggle-navigation']")
elements.forEach((element) => element.removeAndAddEventListener("click", toggleNavigation))
const searchElement = document.querySelector("[data-action='toggle-search']")
- searchElement.removeAndAddEventListener("click", toggleSearch)
+ if (searchElement) searchElement.removeAndAddEventListener("click", toggleSearch)
}
-function toggleNavigation(event) {
+function toggleNavigation(event: Event): void {
event.preventDefault()
const navigationElement = document.querySelector("[data-role='navigation']")
- navigationElement.classList.toggle("header__content--is-active")
+ navigationElement?.classList.toggle("header__content--is-active")
}
-function toggleSearch(event) {
+function toggleSearch(event: Event): void {
event.preventDefault()
const searchElement = document.querySelector("[data-role~='search-popout']")
- searchElement.classList.toggle("header__search--is-active")
+ searchElement?.classList.toggle("header__search--is-active")
- searchElement.querySelector("input[name='query']").focus()
+ const inputElement = searchElement?.querySelector("input[name='query']") as HTMLFormElement
+ if (inputElement) inputElement.focus()
}
diff --git a/app/javascript/src/num-players-slider.js b/app/javascript/src/num-players-slider.js
deleted file mode 100644
index dd007a79f..000000000
--- a/app/javascript/src/num-players-slider.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import noUiSlider from "nouislider/distribute/nouislider.min"
-
-export function render() {
- const elements = document.querySelectorAll("[data-role='num-player-slider']")
-
- elements.forEach(element => setSlider(element))
-}
-
-export function setSlider(element) {
- if (!element) return
-
- if (element.dataset.initialised === "true") destroySlider(element)
-
- let startMin = 1
- let startMax = 12
-
- if (element.dataset.minPlayers) startMin = element.dataset.minPlayers
- if (element.dataset.maxPlayers) startMax = element.dataset.maxPlayers
-
- if (element.dataset.type == "post" && !(element.dataset.minPlayers || element.dataset.maxPlayers)) {
- startMin = 0
- startMax = 0
- }
-
- create(element, startMin, startMax)
-
- if (element.dataset.type == "post") element.noUiSlider.on("set", postOnSliderUpdate)
- if (element.dataset.type == "filter") element.noUiSlider.on("set", filterOnSliderUpdate)
-
- element.dataset.initialised = true
-}
-
-export function create(element, startMin, startMax) {
- noUiSlider.create(element, {
- start: [startMin, startMax],
- connect: true,
- orientation: "horizontal",
- range: {
- "min": 1,
- "max": 12
- },
- step: 1,
- pips: {
- mode: "steps"
- },
- behaviour: "tap-drag"
- })
-}
-
-export function destroy() {
- const elements = document.querySelectorAll("[data-role='num-player-slider']")
-
- elements.forEach(element => {
- if (!element.noUiSlider) return
-
- destroySlider(element)
- })
-}
-
-function postOnSliderUpdate(values, handle) {
- let element
-
- if (handle == 0) {
- element = document.getElementById("post_min_players")
-
- if (document.getElementById("post_max_players").value == 0) {
- document.getElementById("post_max_players").value = 1
- }
- }
-
- if (handle == 1) {
- element = document.getElementById("post_max_players")
-
- if (document.getElementById("post_min_players").value == 0) {
- document.getElementById("post_min_players").value = 1
- }
- }
-
- element.value = Math.round(values[handle])
-
- const warningElem = document.querySelector("[data-role='vanish-on-slider-update']")
-
- if (warningElem && !warningElem.dataset.hidden) {
- warningElem.style.display = "none"
- warningElem.dataset.hidden = true
- }
-}
-
-function filterOnSliderUpdate(values) {
- values = values.map(v => Math.round(v))
-
- const element = document.querySelector("[data-filter-type='players']")
-
- if (values[0] == 1 && values[1] == 12) {
- element.dataset.value = ""
- return
- }
-
- element.dataset.value = `${values[0]}-${values[1]}`
-}
-
-function destroySlider(element) {
- element.noUiSlider.destroy()
- element.dataset.initialised = false
-}
diff --git a/app/javascript/src/num-players-slider.ts b/app/javascript/src/num-players-slider.ts
new file mode 100644
index 000000000..ed3cec1d0
--- /dev/null
+++ b/app/javascript/src/num-players-slider.ts
@@ -0,0 +1,103 @@
+export function render(): void {
+ const elements = Array.from(document.querySelectorAll("[data-role='num-player-slider']")) as noUiSlider.Instance[]
+
+ elements.forEach(element => setSlider(element))
+}
+
+export async function setSlider(element: noUiSlider.Instance): Promise {
+ if (!element) return
+
+ if (element.dataset.initialised === "true") destroySlider(element)
+
+ let startMin = 1
+ let startMax = 12
+
+ if (element.dataset.minPlayers) startMin = parseInt(element.dataset.minPlayers)
+ if (element.dataset.maxPlayers) startMax = parseInt(element.dataset.maxPlayers)
+
+ if (element.dataset.type == "post" && !(element.dataset.minPlayers || element.dataset.maxPlayers)) {
+ startMin = 0
+ startMax = 0
+ }
+
+ await create(element, startMin, startMax)
+
+ if (element.dataset.type == "post") element.noUiSlider.on("set", postOnSliderUpdate)
+ if (element.dataset.type == "filter") element.noUiSlider.on("set", filterOnSliderUpdate)
+
+ element.dataset.initialised = "true"
+}
+
+export async function create(element: noUiSlider.Instance, startMin: number, startMax: number): Promise {
+ const noUiSlider = await import("nouislider")
+
+ noUiSlider.create(element, {
+ start: [startMin, startMax],
+ connect: true,
+ orientation: "horizontal",
+ range: {
+ "min": 1,
+ "max": 12
+ },
+ step: 1,
+ pips: {
+ mode: "steps"
+ },
+ behaviour: "tap-drag"
+ })
+}
+
+export function destroy(): void {
+ const elements = Array.from(document.querySelectorAll("[data-role='num-player-slider']")) as noUiSlider.Instance[]
+
+ elements.forEach(element => {
+ if (!element.noUiSlider) return
+
+ destroySlider(element)
+ })
+}
+
+function postOnSliderUpdate(values: any[], handle: number): void {
+ let element
+
+ if (handle == 0) {
+ element = document.getElementById("post_min_players") as HTMLFormElement
+ const maxElement = document.getElementById("post_max_players") as HTMLFormElement
+
+ if (maxElement.value == 0) maxElement.value = 1
+ }
+
+ if (handle == 1) {
+ element = document.getElementById("post_max_players") as HTMLFormElement
+ const minElement = document.getElementById("post_min_players") as HTMLFormElement
+
+ if (minElement.value == 0) minElement.value = 1
+ }
+
+ element!.value = Math.round(values[handle])
+
+ const warningElement = document.querySelector("[data-role='vanish-on-slider-update']") as HTMLElement
+
+ if (warningElement && !warningElement.dataset.hidden) {
+ warningElement.style.display = "none"
+ warningElement.dataset.hidden = "true"
+ }
+}
+
+function filterOnSliderUpdate(values: number[]): void {
+ values = values.map(v => Math.round(v))
+
+ const element = document.querySelector("[data-filter-type='players']") as HTMLElement
+
+ if (values[0] == 1 && values[1] == 12) {
+ element.dataset.value = ""
+ return
+ }
+
+ element.dataset.value = `${values[0]}-${values[1]}`
+}
+
+function destroySlider(element: noUiSlider.Instance): void {
+ element.noUiSlider.destroy()
+ element.dataset.initialised = "false"
+}
diff --git a/app/javascript/src/ollie-form.js b/app/javascript/src/ollie-form.js
deleted file mode 100644
index 1030f32bb..000000000
--- a/app/javascript/src/ollie-form.js
+++ /dev/null
@@ -1,79 +0,0 @@
-export function bind() {
- const element = document.querySelector("[data-role='ollie-form']")
-
- if (!element) return
-
- loadImagesSimultaniously()
-
- const usernameInput = element.querySelector("input[name='username']")
- usernameInput.removeAndAddEventListener("input", event => setPupilPosition(event, element))
- usernameInput.removeAndAddEventListener("focus", event => setPupilPosition(event, element))
- usernameInput.removeAndAddEventListener("focus", () => setOllieBody(element, "base"))
- usernameInput.removeAndAddEventListener("blur", () => resetPupilPosition(element))
-
- const passwordInput = element.querySelector("input[type='password']")
- passwordInput.removeAndAddEventListener("focus", () => setOllieBody(element, "eyes-closed"))
- passwordInput.removeAndAddEventListener("blur", () => setOllieBody(element, "base"))
-
- const rememberMeInput = element.querySelector("input[type='checkbox'][name='remember_me']")
- rememberMeInput.removeAndAddEventListener("input", () => setOllieBody(element, "base"))
-}
-
-function setPupilPosition({ target }, element) {
- const max = 32
- const valueLength = Math.min(max, Math.max(0, target.value.length))
- const xOffset = -6 + (valueLength / (max / 6)) * 2
-
- const pupils = getOlliePart(element, "pupils")
- pupils.style.transform = `translateX(${xOffset}%) translateY(8%)`
-}
-
-function resetPupilPosition(element) {
- const pupils = getOlliePart(element, "pupils")
- pupils.style.transform = "none"
-}
-
-function getOlliePart(element, part) {
- return element.querySelector(`[data-ollie="${part}"]`)
-}
-
-function setOllieBody(element, variant = "base") {
- const base = getOlliePart(element, "body-base")
- const happy = getOlliePart(element, "body-happy")
- const suspicious = getOlliePart(element, "body-suspicious")
- const eyesClosed = getOlliePart(element, "body-eyes-closed")
- const eyesClosedHappy = getOlliePart(element, "body-eyes-closed-happy")
- const pupils = getOlliePart(element, "pupils")
- const armLeft = getOlliePart(element, "arm-left")
- const armRight = getOlliePart(element, "arm-right")
-
- const rememberMe = element.querySelector("input[type='checkbox'][name='remember_me']").checked
-
- base.classList.toggle("hidden", !(variant === "base" && !rememberMe))
- happy.classList.toggle("hidden", !(variant === "base" && rememberMe))
- suspicious.classList.add("hidden")
- eyesClosed.classList.toggle("hidden", !(variant === "eyes-closed" && !rememberMe))
- eyesClosedHappy.classList.toggle("hidden", !(variant === "eyes-closed" && rememberMe))
- pupils.classList.toggle("hidden", variant === "eyes-closed")
- armLeft.classList.toggle("out-of-view", variant !== "eyes-closed")
- armRight.classList.toggle("out-of-view", variant !== "eyes-closed")
-}
-
-function loadImagesSimultaniously() {
- const element = document.querySelector("[data-role='ollie-image-holder']")
- const images = element.querySelectorAll("img")
-
- let imagesLoaded = 0
- images.forEach(image => {
- if (image.complete) {
- imagesLoaded++
- if (imagesLoaded === images.length) element.classList.add("ollie-login__images--loaded")
- return
- }
-
- image.removeAndAddEventListener("load", () => {
- imagesLoaded++
- if (imagesLoaded === images.length) element.classList.add("ollie-login__images--loaded")
- })
- })
-}
diff --git a/app/javascript/src/ollie-form.ts b/app/javascript/src/ollie-form.ts
new file mode 100644
index 000000000..d35f98af3
--- /dev/null
+++ b/app/javascript/src/ollie-form.ts
@@ -0,0 +1,81 @@
+export function bind(): void {
+ const element = document.querySelector("[data-role='ollie-form']") as HTMLElement
+
+ if (!element) return
+
+ loadImagesSimultaniously()
+
+ const usernameInput = element.querySelector("input[name='username']")
+ usernameInput?.removeAndAddEventListener("input", (event: Event) => setPupilPosition(event, element))
+ usernameInput?.removeAndAddEventListener("focus", (event: Event) => setPupilPosition(event, element))
+ usernameInput?.removeAndAddEventListener("focus", () => setOllieBody(element, "base"))
+ usernameInput?.removeAndAddEventListener("blur", () => resetPupilPosition(element))
+
+ const passwordInput = element.querySelector("input[type='password']")
+ passwordInput?.removeAndAddEventListener("focus", () => setOllieBody(element, "eyes-closed"))
+ passwordInput?.removeAndAddEventListener("blur", () => setOllieBody(element, "base"))
+
+ const rememberMeInput = element.querySelector("input[type='checkbox'][name='remember_me']")
+ rememberMeInput?.removeAndAddEventListener("input", () => setOllieBody(element, "base"))
+}
+
+function setPupilPosition(event: Event, element: HTMLElement): void {
+ const target = event.target as HTMLFormElement
+ const max = 32
+ const valueLength = Math.min(max, Math.max(0, target.value.length))
+ const xOffset = -6 + (valueLength / (max / 6)) * 2
+ const pupils = getOlliePart(element, "pupils")
+
+ pupils.style.transform = `translateX(${xOffset}%) translateY(8%)`
+}
+
+function resetPupilPosition(element: HTMLElement): void {
+ const pupils = getOlliePart(element, "pupils")
+ pupils.style.transform = "none"
+}
+
+function getOlliePart(element: HTMLElement, part: string): HTMLElement {
+ return element.querySelector(`[data-ollie="${part}"]`) as HTMLElement
+}
+
+function setOllieBody(element: HTMLElement, variant = "base"): void {
+ const base = getOlliePart(element, "body-base")
+ const happy = getOlliePart(element, "body-happy")
+ const suspicious = getOlliePart(element, "body-suspicious")
+ const eyesClosed = getOlliePart(element, "body-eyes-closed")
+ const eyesClosedHappy = getOlliePart(element, "body-eyes-closed-happy")
+ const pupils = getOlliePart(element, "pupils")
+ const armLeft = getOlliePart(element, "arm-left")
+ const armRight = getOlliePart(element, "arm-right")
+
+ const rememberMeinput = element.querySelector("input[type='checkbox'][name='remember_me']") as HTMLFormElement
+ const rememberMe = rememberMeinput.checked
+
+ base?.classList.toggle("hidden", !(variant === "base" && !rememberMe))
+ happy?.classList.toggle("hidden", !(variant === "base" && rememberMe))
+ suspicious?.classList.add("hidden")
+ eyesClosed?.classList.toggle("hidden", !(variant === "eyes-closed" && !rememberMe))
+ eyesClosedHappy?.classList.toggle("hidden", !(variant === "eyes-closed" && rememberMe))
+ pupils?.classList.toggle("hidden", variant === "eyes-closed")
+ armLeft?.classList.toggle("out-of-view", variant !== "eyes-closed")
+ armRight?.classList.toggle("out-of-view", variant !== "eyes-closed")
+}
+
+function loadImagesSimultaniously(): void {
+ const element = document.querySelector("[data-role='ollie-image-holder']") as HTMLElement
+ const images = element.querySelectorAll("img")
+
+ let imagesLoaded = 0
+ images.forEach(image => {
+ if (image.complete) {
+ imagesLoaded++
+ if (imagesLoaded === images.length) element.classList.add("ollie-login__images--loaded")
+ return
+ }
+
+ image.removeAndAddEventListener("load", () => {
+ imagesLoaded++
+ if (imagesLoaded === images.length) element.classList.add("ollie-login__images--loaded")
+ })
+ })
+}
diff --git a/app/javascript/src/remove-and-add-event-listener.js b/app/javascript/src/remove-and-add-event-listener.js
deleted file mode 100644
index 50ef4ddfa..000000000
--- a/app/javascript/src/remove-and-add-event-listener.js
+++ /dev/null
@@ -1,9 +0,0 @@
-Element.prototype.removeAndAddEventListener = function(event, funct) {
- this.removeEventListener(event, funct)
- this.addEventListener(event, funct)
-}
-
-Document.prototype.removeAndAddEventListener = function(event, funct) {
- this.removeEventListener(event, funct)
- this.addEventListener(event, funct)
-}
diff --git a/app/javascript/src/remove-and-add-event-listener.ts b/app/javascript/src/remove-and-add-event-listener.ts
new file mode 100644
index 000000000..f1eff5b3f
--- /dev/null
+++ b/app/javascript/src/remove-and-add-event-listener.ts
@@ -0,0 +1,23 @@
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+interface Element {
+ removeAndAddEventListener(event: string, fun: Function): void
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+interface Document {
+ removeAndAddEventListener(event: string, fun: Function): void
+}
+
+Element.prototype.removeAndAddEventListener = function(event, fun): void {
+ // @ts-ignore
+ this.removeEventListener(event, fun)
+ // @ts-ignore
+ this.addEventListener(event, fun)
+}
+
+Document.prototype.removeAndAddEventListener = function(event, fun): void {
+ // @ts-ignore
+ this.removeEventListener(event, fun)
+ // @ts-ignore
+ this.addEventListener(event, fun)
+}
diff --git a/app/javascript/src/reveal-by-checkbox.js b/app/javascript/src/reveal-by-checkbox.js
deleted file mode 100644
index c8f021b61..000000000
--- a/app/javascript/src/reveal-by-checkbox.js
+++ /dev/null
@@ -1,15 +0,0 @@
-export function bind() {
- const elements = document.querySelectorAll("[data-action='reveal-by-checkbox']")
-
- elements.forEach((element) => element.removeAndAddEventListener("click", toggleRevealByCheckbox))
-}
-
-function toggleRevealByCheckbox() {
- const state = this.checked
- const parent = this.closest("[data-reveal-by-checkbox]")
- const target = parent.dataset.revealByCheckbox
-
- const elements = parent.querySelectorAll(target == "" ? "[data-role='hidden-by-checkbox']" : `[data-role="hidden-by-checkbox"][data-target="${target}"]`)
-
- elements.forEach(element => element.style.display = state ? (element.dataset.initialDisplay || "initial") : "none")
-}
diff --git a/app/javascript/src/reveal-by-checkbox.ts b/app/javascript/src/reveal-by-checkbox.ts
new file mode 100644
index 000000000..51f994d79
--- /dev/null
+++ b/app/javascript/src/reveal-by-checkbox.ts
@@ -0,0 +1,16 @@
+export function bind(): void {
+ const elements = document.querySelectorAll("[data-action='reveal-by-checkbox']")
+
+ elements.forEach((element) => element.removeAndAddEventListener("click", toggleRevealByCheckbox))
+}
+
+function toggleRevealByCheckbox({ currentTarget }: { currentTarget: HTMLFormElement }): void {
+ const state = currentTarget.checked
+ const parent = currentTarget.closest("[data-reveal-by-checkbox]") as HTMLElement
+ const target = parent?.dataset.revealByCheckbox
+
+ const selector = target == "" ? "[data-role='hidden-by-checkbox']" : `[data-role="hidden-by-checkbox"][data-target="${target}"]`
+ const elements = Array.from(parent.querySelectorAll(selector)) as HTMLElement[]
+
+ elements.forEach((element: HTMLElement) => element.style.display = state ? (element.dataset.initialDisplay || "initial") : "none")
+}
diff --git a/app/javascript/src/reveal-by-select.js b/app/javascript/src/reveal-by-select.js
deleted file mode 100644
index 43dd30605..000000000
--- a/app/javascript/src/reveal-by-select.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export function bind() {
- const elements = document.querySelectorAll("[data-action~='reveal-by-select']")
-
- elements.forEach((element) => element.removeAndAddEventListener("input", toggleRevealBySelect))
-}
-
-function toggleRevealBySelect() {
- const parent = this.closest("[data-reveal-by-select-parent]")
- const target = parent.querySelector(`[data-reveal-by-select-target="${this.value}"]`)
- const elements = parent.querySelectorAll("[data-reveal-by-select-target]")
-
- elements.forEach(element => element.classList.add("visibility-hidden"))
- if (target) target.classList.remove("visibility-hidden")
-}
diff --git a/app/javascript/src/reveal-by-select.ts b/app/javascript/src/reveal-by-select.ts
new file mode 100644
index 000000000..4c295ae3f
--- /dev/null
+++ b/app/javascript/src/reveal-by-select.ts
@@ -0,0 +1,14 @@
+export function bind(): void {
+ const elements = document.querySelectorAll("[data-action~='reveal-by-select']")
+
+ elements.forEach((element) => element.removeAndAddEventListener("input", toggleRevealBySelect))
+}
+
+function toggleRevealBySelect({ currentTarget }: { currentTarget: HTMLFormElement }): void {
+ const parent = currentTarget.closest("[data-reveal-by-select-parent]") as HTMLElement
+ const target = parent?.querySelector(`[data-reveal-by-select-target="${currentTarget.value}"]`)
+ const elements = Array.from(parent.querySelectorAll("[data-reveal-by-select-target]")) as HTMLElement[]
+
+ elements.forEach(element => element.classList.add("visibility-hidden"))
+ if (target) target.classList.remove("visibility-hidden")
+}
diff --git a/app/javascript/src/reveal-on-difference.js b/app/javascript/src/reveal-on-difference.ts
similarity index 51%
rename from app/javascript/src/reveal-on-difference.js
rename to app/javascript/src/reveal-on-difference.ts
index 04b0f30b7..0d1d7c671 100644
--- a/app/javascript/src/reveal-on-difference.js
+++ b/app/javascript/src/reveal-on-difference.ts
@@ -1,15 +1,15 @@
-export function bind() {
+export function bind(): void {
const elements = document.querySelectorAll("[data-action='reveal-on-difference']")
elements.forEach((element) => element.removeAndAddEventListener("input", toggleRevealOnDifference))
}
-function toggleRevealOnDifference() {
- const value = this.value
- const original = this.dataset.original
+function toggleRevealOnDifference({ currentTarget }: { currentTarget: HTMLFormElement }): void {
+ const value = currentTarget.value
+ const original = currentTarget.dataset.original
const different = value !== original && original !== undefined
- const elements = document.querySelectorAll("[data-role='reveal-on-difference']")
+ const elements = Array.from(document.querySelectorAll("[data-role='reveal-on-difference']")) as HTMLElement[]
elements.forEach(element => element.style.display = different ? "block" : "none")
}
diff --git a/app/javascript/src/safe-stringify.js b/app/javascript/src/safe-stringify.js
deleted file mode 100644
index b6d434c52..000000000
--- a/app/javascript/src/safe-stringify.js
+++ /dev/null
@@ -1,16 +0,0 @@
-// https://stackoverflow.com/questions/11616630/how-can-i-print-a-circular-structure-in-a-json-like-format
-JSON.safeStringify = (obj, indent = 2) => {
- let cache = []
- const retVal = JSON.stringify(
- obj,
- (key, value) =>
- typeof value === "object" && value !== null
- ? cache.includes(value)
- ? undefined // Duplicate reference found, discard key
- : cache.push(value) && value // Store value in our collection
- : value,
- indent
- )
- cache = null
- return retVal
-}
diff --git a/app/javascript/src/scroll-indicator.js b/app/javascript/src/scroll-indicator.ts
similarity index 74%
rename from app/javascript/src/scroll-indicator.js
rename to app/javascript/src/scroll-indicator.ts
index 10bf9be6d..a8e4b6151 100644
--- a/app/javascript/src/scroll-indicator.js
+++ b/app/javascript/src/scroll-indicator.ts
@@ -1,6 +1,6 @@
import debounce from "@src/debounce"
-export function bind() {
+export function bind(): void {
const element = document.querySelector("[data-role~='scroll-indicator']")
if (!element) return
@@ -12,9 +12,9 @@ export function bind() {
const scrollIndicator = debounce(element => {
const scrollableDistanceFromRight = element.scrollWidth - element.clientWidth - element.scrollLeft
const indicatorRight = document.querySelector("[data-role='scroll-indicator-right']")
- indicatorRight.classList.toggle("scroll-indicator--active", scrollableDistanceFromRight > 50)
+ indicatorRight!.classList.toggle("scroll-indicator--active", scrollableDistanceFromRight > 50)
const scrolledFromLeft = element.scrollLeft
const indicatorLeft = document.querySelector("[data-role='scroll-indicator-left']")
- indicatorLeft.classList.toggle("scroll-indicator--active", scrolledFromLeft > 50)
+ indicatorLeft!.classList.toggle("scroll-indicator--active", scrolledFromLeft > 50)
}, 10)
diff --git a/app/javascript/src/scroll-into-view-on-load.js b/app/javascript/src/scroll-into-view-on-load.js
deleted file mode 100644
index 56f960363..000000000
--- a/app/javascript/src/scroll-into-view-on-load.js
+++ /dev/null
@@ -1,9 +0,0 @@
-export function initialize() {
- const elements = document.querySelectorAll("[data-role~='scroll-into-view-on-load']")
-
- elements.forEach(element => {
- const parent = element.closest("[data-scroll-container]")
-
- parent.scrollTop = Math.max(element.offsetTop - 55, 0)
- })
-}
diff --git a/app/javascript/src/scroll-into-view-on-load.ts b/app/javascript/src/scroll-into-view-on-load.ts
new file mode 100644
index 000000000..7b649d549
--- /dev/null
+++ b/app/javascript/src/scroll-into-view-on-load.ts
@@ -0,0 +1,9 @@
+export function initialize(): void {
+ const elements = Array.from(document.querySelectorAll("[data-role~='scroll-into-view-on-load']")) as HTMLElement[]
+
+ elements.forEach(element => {
+ const parent = element.closest("[data-scroll-container]")
+
+ if (parent) parent.scrollTop = Math.max(element.offsetTop - 55, 0)
+ })
+}
diff --git a/app/javascript/src/set-css-variable.js b/app/javascript/src/set-css-variable.js
deleted file mode 100644
index e71223035..000000000
--- a/app/javascript/src/set-css-variable.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export function bind() {
- const elements = document.querySelectorAll("[data-action~='set-css-variable']")
-
- elements.forEach(element => {
- element.removeAndAddEventListener("input", setCssVariable)
- })
-}
-
-export default function setCssVariable(event) {
- event.preventDefault()
- const targetElement = document.querySelector(`[data-css-variable="${this.dataset.target}"]`)
-
- targetElement.style.setProperty(`--${this.dataset.variable}`, this.value + "px")
-}
diff --git a/app/javascript/src/set-css-variable.ts b/app/javascript/src/set-css-variable.ts
new file mode 100644
index 000000000..2b7dafdbd
--- /dev/null
+++ b/app/javascript/src/set-css-variable.ts
@@ -0,0 +1,16 @@
+export function bind(): void {
+ const elements = document.querySelectorAll("[data-action~='set-css-variable']")
+
+ elements.forEach(element => {
+ element.removeAndAddEventListener("input", setCssVariable)
+ })
+}
+
+export default function setCssVariable(event: InputEvent): void {
+ event.preventDefault()
+
+ const currentTarget = event.currentTarget as HTMLFormElement
+ const targetElement = document.querySelector(`[data-css-variable="${currentTarget.dataset.target}"]`) as HTMLElement
+
+ if (targetElement) targetElement.style.setProperty(`--${currentTarget.dataset.variable}`, currentTarget.value + "px")
+}
diff --git a/app/javascript/src/sticky.js b/app/javascript/src/sticky.ts
similarity index 74%
rename from app/javascript/src/sticky.js
rename to app/javascript/src/sticky.ts
index b3f4e4caf..e22e0db74 100644
--- a/app/javascript/src/sticky.js
+++ b/app/javascript/src/sticky.ts
@@ -1,14 +1,14 @@
-export function bind() {
+export function bind(): void {
window.addEventListener("scroll", stickyScroll, { passive: true })
stickyScroll()
}
-export function destroy() {
- window.removeEventListener("scroll", stickyScroll, { passive: true })
+export function destroy(): void {
+ window.removeEventListener("scroll", stickyScroll)
}
-function stickyScroll() {
- const elements = document.querySelectorAll("[data-role~='sticky']")
+function stickyScroll(): void {
+ const elements = Array.from(document.querySelectorAll("[data-role~='sticky']")) as HTMLElement[]
elements.forEach(element => {
if ((element.dataset.stickyDesktopOnly == "true" && window.innerWidth < 640) ||
@@ -17,18 +17,18 @@ function stickyScroll() {
return
}
- const scrollElement = element.dataset.sticky == "true" ? document.querySelector("[data-role='sticky-placeholder']") : element
+ const scrollElement = element.dataset.sticky == "true" ? (document.querySelector("[data-role='sticky-placeholder']") as HTMLElement) : element
const topOffset = scrollElement.getBoundingClientRect().top
const scrollPosition = window.scrollY
const documentOffset = topOffset + scrollPosition
- const stickyOffset = parseInt(element.dataset.stickyOffset || 0)
+ const stickyOffset = parseInt(element.dataset.stickyOffset || "0")
if (documentOffset - scrollPosition - stickyOffset <= 0) setSticky(element, stickyOffset)
else setNotSticky(element)
})
}
-function setSticky(element, offset) {
+function setSticky(element: HTMLElement, offset: number): void {
if (element.dataset.sticky == "true") return
element.dataset.sticky = "true"
@@ -46,12 +46,12 @@ function setSticky(element, offset) {
element.classList.add("is-sticky")
}
-function setNotSticky(element) {
+function setNotSticky(element: HTMLElement): void {
if (element.dataset.sticky != "true") return
element.dataset.sticky = "false"
const placeholderElement = document.querySelector("[data-role='sticky-placeholder']")
- placeholderElement.remove()
+ if (placeholderElement) placeholderElement.remove()
element.style.removeProperty("width")
element.style.removeProperty("position")
diff --git a/app/javascript/src/stores/editor.js b/app/javascript/src/stores/editor.ts
similarity index 58%
rename from app/javascript/src/stores/editor.js
rename to app/javascript/src/stores/editor.ts
index c2403b704..b76e101f5 100644
--- a/app/javascript/src/stores/editor.js
+++ b/app/javascript/src/stores/editor.ts
@@ -4,6 +4,7 @@ import { isAnyParentHidden } from "@utils/editor"
import { getMixins } from "@utils/compiler/mixins"
import { getSubroutines } from "@utils/compiler/subroutines"
import { debounced } from "@utils/debounceStore"
+import type { EditorStates, ExtendedCompletion, Item, Project, RecoveredProject, WorkshopConstant } from "@src/types/editor"
// Preferably keep below the debounce time for the linter, so it
// has access to the most up-to-date information from the store.
@@ -13,11 +14,11 @@ export const screenWidth = writable(0)
export const isMobile = derived(screenWidth, $screenWidth => $screenWidth && $screenWidth < 1000)
export const modal = (() => {
- const { subscribe, set } = writable(null)
+ const { subscribe, set } = writable<{ [key: string]: string } | null>(null)
return {
subscribe,
- show: (key, options = {}) => {
+ show: (key: string, options = {}) => {
set({ key, ...options })
},
close: () => {
@@ -26,24 +27,24 @@ export const modal = (() => {
}
})()
-export const editorStates = writable({})
+export const editorStates = writable({})
export const editorScrollPositions = writable({})
-export const projects = writable(null)
-export const currentProjectUUID = writable(null)
+export const projects = writable([])
+export const currentProjectUUID = writable(null)
export const currentProject = derived([projects, currentProjectUUID], ([$projects, $currentProjectUUID]) => {
if (!$projects?.length) return null
return $projects.filter(p => p.uuid == $currentProjectUUID)?.[0]
})
-export const recoveredProject = writable(null)
+export const recoveredProject = writable(null)
-export const items = writable([])
-export const currentItem = writable({})
+export const items = writable- ([])
+export const currentItem = writable
- (null)
export const sortedItems = derived(items, $items => {
- const cleanedItems = $items.map(item => {
- item.parent = $items.some(i => item.parent == i.id) ? item.parent : null
+ const cleanedItems = $items.map((item) => {
+ item.parent = $items.some(i => item.parent == i.id) ? item.parent : ""
return item
})
@@ -56,35 +57,36 @@ export const flatItems = derived(sortedItems, $sortedItems => {
.map(i => i.content).join("\n\n")
})
-export const openFolders = writable([])
+export const openFolders = writable([])
export const isSignedIn = writable(false)
-export const completionsMap = writable([])
-export const variablesMap = derived(flatItems, debounced($flatItems => {
+export const completionsMap = writable([])
+export const variablesMap = derived(flatItems, debounced(($flatItems: string) => {
const { globalVariables, playerVariables } = getVariables($flatItems)
return [
- ...globalVariables.map(v => ({ detail: "Global Variable", label: v, type: "variable" })),
- ...playerVariables.map(v => ({ detail: "Player Variable", label: v, type: "variable" }))
+ ...globalVariables.map((v: string) => ({ detail: "Global Variable", label: v, type: "variable" })),
+ ...playerVariables.map((v: string) => ({ detail: "Player Variable", label: v, type: "variable" }))
]
}, VARIABLE_EXTRACTION_DEBOUNCE_MS))
-export const subroutinesMap = derived(flatItems, debounced($flatItems => {
+export const subroutinesMap = derived(flatItems, debounced(($flatItems: string) => {
const subroutines = getSubroutines($flatItems)
return [
- ...subroutines.map(v => ({ detail: "Subroutine", label: v, type: "variable" }))
+ ...subroutines.map((v: string) => ({ detail: "Subroutine", label: v, type: "variable" }))
]
}, VARIABLE_EXTRACTION_DEBOUNCE_MS))
-export const mixinsMap = derived(flatItems, debounced($flatItems => {
+export const mixinsMap = derived(flatItems, debounced(($flatItems: string) => {
const mixins = getMixins($flatItems)
- return mixins.map(v => ({ detail: "Mixin", label: `@include ${v}()`, type: "variable" }))
+ return mixins.map((v: string) => ({ detail: "Mixin", label: `@include ${v}()`, type: "variable" }))
}, VARIABLE_EXTRACTION_DEBOUNCE_MS))
-export const workshopConstants = writable({})
+/* Example: { "Color": { "AQUA": { "en-US": "Aqua" }, { "BLUE": { "en-US": "Blue" } } } */
+export const workshopConstants = writable>({})
export const settings = writable({
"editor-font": "Consolas",
@@ -109,5 +111,10 @@ export const settings = writable({
"autocomplete-parameter-objects": false,
"autocomplete-min-parameter-size": 2,
"autocomplete-min-parameter-newlines": 2,
- "hide-wiki-sidebar": false
+ "hide-wiki-sidebar": false,
+ "highlight-trailing-whitespace": true,
+ "remove-trailing-whitespace-on-save": true,
+ "tooltip-hover-delay": 50,
+ "rainbow-brackets": false,
+ "context-based-completions": true
})
diff --git a/app/javascript/src/stores/notifications.js b/app/javascript/src/stores/notifications.ts
similarity index 60%
rename from app/javascript/src/stores/notifications.js
rename to app/javascript/src/stores/notifications.ts
index e863f777e..67a46f3a7 100644
--- a/app/javascript/src/stores/notifications.js
+++ b/app/javascript/src/stores/notifications.ts
@@ -1,4 +1,4 @@
import { writable } from "svelte/store"
export const notificationsCount = writable(0)
-export const notifications = writable([])
+export const notifications = writable([])
diff --git a/app/javascript/src/stores/translationKeys.js b/app/javascript/src/stores/translationKeys.ts
similarity index 57%
rename from app/javascript/src/stores/translationKeys.js
rename to app/javascript/src/stores/translationKeys.ts
index 86f2210bf..f05ad1833 100644
--- a/app/javascript/src/stores/translationKeys.js
+++ b/app/javascript/src/stores/translationKeys.ts
@@ -1,14 +1,15 @@
+import type { Language, TranslateKeys } from "@src/types/editor"
import { writable, derived } from "svelte/store"
-export const translationKeys = writable({})
+export const translationKeys = writable({})
export const orderedTranslationKeys = derived(translationKeys, $translationKeys =>
- Object.keys($translationKeys).sort().reduce((result, key) => {
+ Object.keys($translationKeys).sort().reduce((result: TranslateKeys, key: string) => {
result[key] = $translationKeys[key]
return result
}, {})
)
-export const selectedLanguages = writable(["en-US"])
-export const defaultLanguage = writable("en-US")
+export const selectedLanguages = writable(["en-US"])
+export const defaultLanguage = writable("en-US")
export const translationsMap = derived(translationKeys, $translationKeys => {
const translations = Object.keys($translationKeys)
diff --git a/app/javascript/src/svelte-component.js b/app/javascript/src/svelte-component.js
deleted file mode 100644
index 4e257876c..000000000
--- a/app/javascript/src/svelte-component.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export function initializeSvelteComponent(name, Component) {
- const elements = document.querySelectorAll(`[data-svelte-component="${name}"]`)
-
- elements.forEach(element => {
- element.innerHTML = ""
-
- new Component({
- target: element,
- props: JSON.parse(element.dataset.svelteProps)
- })
- })
-}
diff --git a/app/javascript/src/svelte-component.ts b/app/javascript/src/svelte-component.ts
new file mode 100644
index 000000000..4ad90f834
--- /dev/null
+++ b/app/javascript/src/svelte-component.ts
@@ -0,0 +1,12 @@
+export function initializeSvelteComponent(name: string, Component: any): void {
+ const elements = Array.from(document.querySelectorAll(`[data-svelte-component="${name}"]`)) as HTMLElement[]
+
+ elements.forEach(element => {
+ element.innerHTML = ""
+
+ new Component({
+ target: element,
+ props: JSON.parse(element.dataset.svelteProps || "{}")
+ })
+ })
+}
diff --git a/app/javascript/src/tabs.js b/app/javascript/src/tabs.ts
similarity index 56%
rename from app/javascript/src/tabs.js
rename to app/javascript/src/tabs.ts
index 7bc709665..740b947d7 100644
--- a/app/javascript/src/tabs.js
+++ b/app/javascript/src/tabs.ts
@@ -1,33 +1,40 @@
import { carousel, setCarousel } from "@src/carousel"
-export function bind() {
+export function bind(): void {
const elements = document.querySelectorAll("[data-action~='set-tab']")
elements.forEach((element) => element.removeAndAddEventListener("click", setTab))
}
-function setTab(event) {
+function setTab(event: Event): void {
event.preventDefault()
- if (this.classList.contains("tabs__item--active")) return
+ const { currentTarget } = event
+ if (!(currentTarget instanceof HTMLElement)) return
- const target = this.dataset.target
- const parentElement = this.closest("[data-role~='tabs']")
+ if (currentTarget.classList.contains("tabs__item--active")) return
- const tabElement = this.classList.contains("tabs__item") ? this : document.querySelector(`.tabs__item[data-target~='${target}']`)
+ const target = currentTarget.dataset.target
+ const parentElement = currentTarget.closest("[data-role~='tabs']") as HTMLElement
+
+ if (!target) return
+ if (!parentElement) return
+
+ const tabElement = (currentTarget.classList.contains("tabs__item") ? currentTarget : document.querySelector(`.tabs__item[data-target~='${target}']`)) as HTMLAnchorElement
+ if (!tabElement) return
setActiveTab(tabElement, parentElement)
- revealTab(target, parentElement, scroll)
- if (this.dataset.action.includes("scroll")) scrollToElement(parentElement)
+ revealTab(target, parentElement)
+ if (currentTarget.dataset.action?.includes("scroll")) scrollToElement(parentElement)
}
-function revealTab(target, parentElement) {
+function revealTab(target: string, parentElement: Element): void {
const targetElement = document.querySelector(`[data-tab~='${target}']`)
const tabElements = parentElement.querySelectorAll(".tabs-content")
const activeElement = Array.from(tabElements).find(element => {
if (!element.classList.contains("tabs-content--active")) return
- if (element.closest("[data-role~='tabs']").innerHTML != parentElement.innerHTML) return
+ if (element.closest("[data-role~='tabs']")?.innerHTML != parentElement.innerHTML) return
return element
})
@@ -40,6 +47,8 @@ function revealTab(target, parentElement) {
activeElement.classList.remove("tabs-content--transitioning-out")
}
+ if (!targetElement) return
+
targetElement.classList.add("tabs-content--active")
targetElement.classList.add("tabs-content--transitioning-in")
@@ -47,11 +56,11 @@ function revealTab(target, parentElement) {
}, 150)
}
-function setActiveTab(targetElement, parentElement) {
+function setActiveTab(targetElement: HTMLAnchorElement, parentElement: HTMLElement): void {
const tabs = parentElement.querySelectorAll(".tabs__item")
- tabs.forEach(tab => {
- if (tab.closest("[data-role~='tabs']").innerHTML != parentElement.innerHTML) return
+ tabs.forEach((tab: Element) => {
+ if (tab.closest("[data-role~='tabs']")?.innerHTML != parentElement.innerHTML) return
tab.classList.remove("tabs__item--active")
})
@@ -62,15 +71,15 @@ function setActiveTab(targetElement, parentElement) {
}
}
-function resetCarouselInTab(targetElement) {
- const carouselElement = targetElement.querySelector("[data-role='carousel']")
+function resetCarouselInTab(targetElement: Element): void {
+ const carouselElement = targetElement.querySelector("[data-role='carousel']") as HTMLElement
if (!carouselElement || !carousel) return
carousel.destroy(true)
setCarousel(carouselElement)
}
-function scrollToElement(element) {
+function scrollToElement(element: Element): void {
const scrollTop = document.documentElement.scrollTop
const offset = element.getBoundingClientRect().top + scrollTop
if (offset < scrollTop) window.scrollTo({ top: offset - 10 })
diff --git a/app/javascript/src/timeago.js b/app/javascript/src/timeago.ts
similarity index 69%
rename from app/javascript/src/timeago.js
rename to app/javascript/src/timeago.ts
index 2d16cff6a..128751d6d 100644
--- a/app/javascript/src/timeago.js
+++ b/app/javascript/src/timeago.ts
@@ -1,7 +1,7 @@
import * as timeago from "timeago.js"
-export function initialize() {
- const elements = document.querySelectorAll("[data-role~='timeago']")
+export function initialize(): void {
+ const elements = Array.from(document.querySelectorAll("[data-role~='timeago']")) as HTMLElement[]
if (elements.length) timeago.render(elements)
diff --git a/app/javascript/src/toggle-content.js b/app/javascript/src/toggle-content.js
deleted file mode 100644
index ecb2c0d88..000000000
--- a/app/javascript/src/toggle-content.js
+++ /dev/null
@@ -1,54 +0,0 @@
-export function bind() {
- document.body.removeAndAddEventListener("click", toggleContent)
-}
-
-function toggleContent(event) {
- let { target } = event
-
- if (!target) return
-
- if (target.dataset.action == null || !target.dataset.action.includes("toggle-content")) {
- target = target.closest("[data-action~='toggle-content']")
- }
-
- if (target && target.dataset.action != null && target.dataset.action.includes("toggle-content")) {
- event.preventDefault()
-
- const parent = target.closest("[data-toggle-content]")
-
- const element = parent.querySelector("[data-role~='content-to-toggle']")
- const state = window.getComputedStyle(element).display === "none"
- const animationTiming =
- window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 :
- parseInt(target.dataset.animationTiming) > 0 ? parseInt(target.dataset.animationTiming) : 0
-
- if (!state) {
- target.classList.remove("active")
- parent.classList.add("fading-out")
- if (target.dataset.hideWith) target.textContent = target.dataset.hideWith
- } else {
- target.classList.add("active")
- if (animationTiming > 0) element.style.display = "initial"
- parent.classList.add("fading-in")
- if (target.dataset.showWith) target.textContent = target.dataset.showWith
-
- if (parent.dataset.closeOnOutsideClick !== undefined) {
- document.body.removeAndAddEventListener("click", closeOnOutsideClick)
- }
- }
-
- setTimeout(() => {
- element.style.display = state ? "initial" : "none"
- parent.classList.remove("fading-out")
- parent.classList.remove("fading-in")
- }, animationTiming)
- }
-}
-
-function closeOnOutsideClick({ target }) {
- if (target.closest("[data-toggle-content]")) return
- if (target.nodeName === "INPUT") return
-
- toggleContent({ target: document.querySelector("[data-toggle-content] .active"), preventDefault: () => null })
- document.body.removeEventListener("click", closeOnOutsideClick)
-}
diff --git a/app/javascript/src/toggle-content.ts b/app/javascript/src/toggle-content.ts
new file mode 100644
index 000000000..ed18a9237
--- /dev/null
+++ b/app/javascript/src/toggle-content.ts
@@ -0,0 +1,60 @@
+export function bind(): void {
+ document.body.removeAndAddEventListener("click", toggleContent)
+}
+
+function toggleContent(event: MouseEvent): void {
+ let target = event.target as HTMLElement
+
+ if (target.dataset.action === undefined || !target.dataset.action.includes("toggle-content")) {
+ target = target.closest("[data-action~='toggle-content']") as HTMLElement
+ }
+
+ if (!target || target.dataset.action === undefined) return
+ if (!target.dataset.action.includes("toggle-content")) return
+
+ event.preventDefault()
+
+ const parent = target.closest("[data-toggle-content]") as HTMLElement
+ const element = parent.querySelector("[data-role~='content-to-toggle']") as HTMLElement
+ const state = window.getComputedStyle(element).display === "none"
+ const datasetTiming = parseInt(target.dataset.animationTiming || "0")
+ const animationTiming = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : datasetTiming > 0 ? datasetTiming : 0
+
+ if (!state) {
+ target.classList.remove("active")
+ parent.classList.add("fading-out")
+ target.ariaExpanded = "false"
+
+ if (target.dataset.hideWith) target.textContent = target.dataset.hideWith
+ } else {
+ target.classList.add("active")
+ target.ariaExpanded = "true"
+
+ if (animationTiming > 0) element.style.display = "initial"
+
+ parent.classList.add("fading-in")
+
+ if (target.dataset.showWith) target.textContent = target.dataset.showWith
+ if (parent.dataset.closeOnOutsideClick !== undefined) document.body.removeAndAddEventListener("click", closeOnOutsideClick)
+ }
+
+ setTimeout(() => {
+ element.style.display = state ? "initial" : "none"
+ parent.classList.remove("fading-out")
+ parent.classList.remove("fading-in")
+ }, animationTiming)
+}
+
+function closeOnOutsideClick(event: MouseEvent): void {
+ const target = event.target as HTMLElement
+
+ if (target.closest("[data-toggle-content]")) return
+ if (target.nodeName === "INPUT") return
+
+ const activeElement = document.querySelector("[data-toggle-content] .active")
+ if (!activeElement) return
+
+ // @ts-ignore
+ toggleContent({ target: activeElement, preventDefault: () => null })
+ document.body.removeEventListener("click", closeOnOutsideClick)
+}
diff --git a/app/javascript/src/turbolinks-prefetch.js b/app/javascript/src/turbolinks-prefetch.ts
similarity index 71%
rename from app/javascript/src/turbolinks-prefetch.js
rename to app/javascript/src/turbolinks-prefetch.ts
index 0616c5ed8..135c42201 100644
--- a/app/javascript/src/turbolinks-prefetch.js
+++ b/app/javascript/src/turbolinks-prefetch.ts
@@ -3,40 +3,49 @@
// and to automatically exclude links with the data-actions attribute
export default class {
- static start(delay) {
+ static start(delay?: number): void {
if (!window.Turbolinks) {
console.error("window.Turbolinks not found, you must import Turbolinks with global.")
return
}
+ // @ts-ignore
const prefetcher = new Prefetcher(window.Turbolinks.controller)
prefetcher.start(delay)
}
}
class Prefetcher {
- start(delay = 100) {
- this.delay = delay || this.delay
- document.addEventListener("mouseover", (event) => {
- this.mouseover(event)
- })
- }
+ delay?: number
+ fetchers: any
+ doc: Document
+ xhr: XMLHttpRequest
+ controller: any
- constructor(controller) {
+ constructor(controller: any) {
this.delay = 100
this.fetchers = {}
this.doc = document.implementation.createHTMLDocument("prefetch")
this.xhr = new XMLHttpRequest()
this.controller = controller
- this.controller.getActionForLink = (link) => {
+ this.controller.getActionForLink = (link: HTMLElement) => {
return this.getActionForLink(link)
}
}
- mouseover(event) {
- let { target } = event
+ start(delay = 100): void {
+ this.delay = delay || this.delay
+ document.addEventListener("mouseover", (event) => {
+ this.mouseover(event)
+ })
+ }
+
+ mouseover(event: MouseEvent): void {
+ let target = event.target as HTMLElement | null
if (target instanceof HTMLImageElement) target = target.closest("a")
+
if (!target) return
+
if (target.hasAttribute("data-action")) return
if (target.hasAttribute("data-remote")) return
if (target.hasAttribute("data-method")) return
@@ -57,31 +66,33 @@ class Prefetcher {
if (href.includes("://") && !href.startsWith(window.location.origin)) return
if (this.prefetched(href)) return
if (this.prefetching(href)) return
+
this.cleanup(event, href)
- if (event.target) {
- event.target.addEventListener("mouseleave", (event) => this.mouseleave(event, href))
- event.target.addEventListener("mousedown", (event) => this.mouseleave(event, href))
- }
+
+ if (!event.target) return
+
+ event.target.addEventListener("mouseleave", ((event: MouseEvent) => this.mouseleave(event, href)) as EventListener)
+ event.target.addEventListener("mousedown", ((event: MouseEvent) => this.mouseleave(event, href)) as EventListener)
+
this.fetchers[href] = setTimeout(() => this.prefetch(href), this.delay)
}
- mouseleave(event, href) {
+ mouseleave(event: MouseEvent, href: string): void {
this.xhr.abort()
this.cleanup(event, href)
}
- cleanup(event, href) {
- const element = event.target
+ cleanup(event: MouseEvent, href: string): void {
+ const element = event.target as HTMLElement
clearTimeout(this.fetchers[href])
this.fetchers[href] = null
- if (element) {
- element.removeEventListener("mouseleave", (event) => {
- return this.mouseleave(event)
- })
- }
+
+ element.removeEventListener("mouseleave", (event) => {
+ return this.mouseleave(event, href)
+ })
}
- fetchPage(url, success) {
+ fetchPage(url: string, success: Function): void {
const { xhr } = this
xhr.open("GET", url)
xhr.setRequestHeader("Purpose", "prefetch")
@@ -94,39 +105,42 @@ class Prefetcher {
xhr.send()
}
- prefetchTurbolink(url) {
+ prefetchTurbolink(url: string): void {
const { doc } = this
- this.fetchPage(url, (responseText) => {
+ this.fetchPage(url, (responseText: string) => {
doc.open()
doc.write(responseText)
doc.close()
this.fetchers[url] = null
+
+ // @ts-ignore
const snapshot = window.Turbolinks.Snapshot.fromHTMLElement(doc.documentElement)
+
snapshot.isFresh = true
this.controller.cache.put(url, snapshot)
})
}
- prefetch(url) {
+ prefetch(url: string): void {
if (this.prefetched(url)) return
this.prefetchTurbolink(url)
}
- prefetched(url) {
+ prefetched(url: string): boolean {
const hasSnapshot = location.href === url || this.controller.cache.has(url)
const snapshot = this.controller.cache.get(url)
return hasSnapshot && snapshot?.isFresh
}
- prefetching(url) {
+ prefetching(url: string): boolean {
return !!this.fetchers[url]
}
- isAction(action) {
+ isAction(action: string): boolean {
return action == "advance" || action == "replace" || action == "restore"
}
- getActionForLink(link) {
+ getActionForLink(link: HTMLElement): string {
const { controller } = this
const location = controller.getVisitableLocationForLink(link)
const snapshot = controller.cache.get(location)
@@ -137,7 +151,7 @@ class Prefetcher {
return "restore"
}
- const action = link.getAttribute("data-turbolinks-action")
+ const action = link.dataset.turbolinksAction || ""
return this.isAction(action) ? action : "advance"
}
}
diff --git a/app/javascript/src/types/editor.d.ts b/app/javascript/src/types/editor.d.ts
new file mode 100644
index 000000000..8ada4483a
--- /dev/null
+++ b/app/javascript/src/types/editor.d.ts
@@ -0,0 +1,111 @@
+import type { EditorState } from "@codemirror/state"
+import type { languageOptions } from "@src/lib/languageOptions"
+import type { Completion } from "@codemirror/autocomplete"
+
+export type Project = {
+ title: string,
+ content: string,
+ uuid: string,
+ id?: number,
+ user_id?: number
+ created_at?: string
+ updated_at?: string,
+ content_type?: "workshop_codes",
+ is_owner: boolean
+}
+
+export type ProjectBackup = {
+ uuid: string
+ project_uuid: string,
+ title: string,
+ content: string,
+ created_at: string,
+ updated_at: string
+}
+
+export type RecoveredProject = {
+ content: string
+ updated_at: string
+}
+
+export type Item = {
+ name: string,
+ id: string,
+ content: string,
+ type: ItemType,
+ position: number,
+ parent: string,
+ hidden: boolean,
+ forceUpdate?: boolean
+}
+
+export type ItemType = "item" | "folder"
+
+export type Range = [number, number]
+
+export type EditorStates = {
+ [key: string]: EditorState
+}
+
+export type Language = keyof typeof languageOptions
+
+export type TranslationKey = {
+ [locale in Language]: string
+}
+
+export type TranslateKeys = {
+ [key: string]: TranslationKey
+}
+
+export type Mixin = {
+ content: string,
+ full: string,
+ params: { key: string, default: string }[],
+ hasContents: boolean
+}
+
+export type ParameterObject = {
+ start: number,
+ end: number,
+ given: Record,
+ givenKeys: string[],
+ phraseParameters: string[],
+ phraseDefaults: string[],
+ phraseTypes: string[]
+}
+
+export type ExtendedCompletion = Completion & {
+ parameter_keys: string[]
+ parameter_defaults: string[],
+ parameter_types: string[],
+ args_length: number
+ args_min_length: number,
+ args_unlimited: boolean,
+ args_allow_null: boolean,
+ detail_full: string
+}
+
+export type WorkshopConstant = Record>
+
+export type ExpressionTree = {
+ value: string | null,
+ operator: string | null,
+ invalid: boolean,
+ arguments: ExpressionTree[]
+}
+
+export type ComparisonOperator = {
+ type: string,
+ order: number,
+ _regexRegex?: RegExp,
+ eval: Function
+}
+
+export type Variables = {
+ playerVariables: string[],
+ globalVariables: string[]
+}
+
+export type Severity = "hint" | "info" | "warning" | "error"
+
+export type ConfigType = "event" | "conditions" | "actions" | "value"
diff --git a/app/javascript/src/types/main.d.ts b/app/javascript/src/types/main.d.ts
new file mode 100644
index 000000000..0d7c0108d
--- /dev/null
+++ b/app/javascript/src/types/main.d.ts
@@ -0,0 +1,8 @@
+export type Notification = {
+ user_id: number,
+ has_been_read: boolean,
+ content: string,
+ go_to: string,
+ created_at: string,
+ updated_at: string,
+}
diff --git a/app/javascript/src/types/wiki.d.ts b/app/javascript/src/types/wiki.d.ts
new file mode 100644
index 000000000..eb25db785
--- /dev/null
+++ b/app/javascript/src/types/wiki.d.ts
@@ -0,0 +1,23 @@
+export type WikiArticle = {
+ id: number
+ title: string
+ subtitle: string
+ content: string
+ slug: string
+ tags: string
+ category_id: number
+ group_id: string
+ created_at: string
+ updated_at: string
+ category: WikiCategory
+}
+
+export type WikiCategory = {
+ id: number
+ title: string
+ slug: string
+ description: string
+ created_at: string
+ updated_at: string
+ is_documentation: boolean
+}
diff --git a/app/javascript/src/uploader.js b/app/javascript/src/uploader.ts
similarity index 54%
rename from app/javascript/src/uploader.js
rename to app/javascript/src/uploader.ts
index 9e41224a4..6fd2f248d 100644
--- a/app/javascript/src/uploader.js
+++ b/app/javascript/src/uploader.ts
@@ -1,36 +1,42 @@
import { DirectUpload } from "@rails/activestorage"
export default class Uploader {
- constructor(file, input) {
+ file: File
+ input: HTMLInputElement
+ blob: ActiveStorage.Blob | null
+ progress: number
+
+ constructor(file: File, input: HTMLInputElement) {
this.file = file
this.input = input
- this.blob = ""
+ this.blob = null
this.progress = 0
}
- async upload() {
- const directUploadUrl = document.querySelector("[data-direct-upload-url]").dataset.directUploadUrl
+ async upload(): Promise {
+ const element = document.querySelector("[data-direct-upload-url]") as HTMLElement
+ const directUploadUrl = element.dataset.directUploadUrl || ""
const upload = new DirectUpload(this.file, directUploadUrl, this)
upload.create((error, blob) => {
if (error) throw new Error("Something went wrong when uploading the image.")
- this.blob = blob
+ this.blob = blob as ActiveStorage.Blob
const hiddenField = document.createElement("input")
hiddenField.setAttribute("type", "hidden")
hiddenField.setAttribute("value", blob.signed_id)
hiddenField.name = this.input.name
- this.input.closest("form").appendChild(hiddenField)
+ this.input.closest("form")?.appendChild(hiddenField)
})
}
- directUploadWillStoreFileWithXHR(request) {
+ directUploadWillStoreFileWithXHR(request: XMLHttpRequest): void {
request.upload.addEventListener("progress", event => this.directUploadDidProgress(event))
}
- directUploadDidProgress(event) {
+ directUploadDidProgress(event: ProgressEvent): void {
this.progress = Math.round((100 / event.total) * event.loaded)
}
}
diff --git a/app/javascript/src/utils/codemirror/completions.ts b/app/javascript/src/utils/codemirror/completions.ts
new file mode 100644
index 000000000..25f697ed3
--- /dev/null
+++ b/app/javascript/src/utils/codemirror/completions.ts
@@ -0,0 +1,73 @@
+import { completionsMap, mixinsMap, settings, subroutinesMap, variablesMap } from "@src/stores/editor"
+import { translationsMap } from "@src/stores/translationKeys"
+import type { ExtendedCompletion } from "@src/types/editor"
+import { directlyInsideParameterObject } from "@utils/compiler/parameterObjects"
+import { extraCompletions } from "@src/lib/extraCompletions"
+import { inConfigType, isInValue } from "@utils/parse"
+import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete"
+import { get } from "svelte/store"
+
+export function getCompletions(context: CompletionContext): CompletionResult | null {
+ const word = context.matchBefore(/[@a-zA-Z0-9_ .]*/)
+
+ if (!word) return null
+
+ let add = word.text.search(/\S|$/)
+ if (word.from + add == word.to && !context.explicit) return null
+
+ const wordFromPeriod = word.text.trim().slice(0, word.text.trim().indexOf(".") + 1)
+
+ // There's probably a better way of doing this
+ let specialOverwrite = null
+ if (word.text.includes("@i")) {
+ specialOverwrite = get(mixinsMap)
+ } else if (word.text.includes("@t")) {
+ specialOverwrite = get(translationsMap)
+ } else if (word.text.includes("Global.")) {
+ add += wordFromPeriod.length // Start from `Global.`
+ specialOverwrite = get(variablesMap).filter((v: Completion) => v.detail === "Global Variable")
+ } else if (["Local Player.", "Event Player.", "Healee.", "Healer.", "Attacker.", "Victim."].includes(wordFromPeriod)) {
+ add += wordFromPeriod.length // Start from `Match.`
+ specialOverwrite = get(variablesMap).filter((v: Completion) => v.detail === "Player Variable")
+ } else if (get(settings)["context-based-completions"]) {
+ // Limit completions if the cursor is position for a parameter object key, if the parameter object is valid.
+ const insideParameterObject = directlyInsideParameterObject(context.state.doc.toString(), context.pos)
+
+ if (insideParameterObject?.phraseParameters.length) {
+ specialOverwrite = insideParameterObject.phraseParameters.map(label => ({ label, type: "keyword" }))
+ }
+ }
+
+ const totalCompletions: ExtendedCompletion[] = [
+ ...get(completionsMap),
+ ...get(variablesMap),
+ ...get(subroutinesMap),
+ ...extraCompletions
+ ]
+
+ // Limit completions by where in the rule the cursor is. Some types are not allowed certain parts.
+ // For example, actions don't make sense within event or conditions blocks.
+ if (!specialOverwrite && get(settings)["context-based-completions"]) {
+ const text = context.state.doc.toString()
+ const isValue = isInValue(text, context.pos)
+ const configType = isValue ? "value" : inConfigType(text, context.pos)
+
+ if (configType) {
+ const excludeTypes = {
+ event: ["variable", "map", "action", "value", "snippet", "rule"],
+ conditions: ["action", "event", "snippet", "rule"],
+ actions: ["event", "rule"],
+ value: ["action", "event", "snippet", "rule"]
+ }
+
+ specialOverwrite = totalCompletions.filter((c) => !excludeTypes[configType].includes(c.type || ""))
+ }
+ }
+
+ return {
+ from: word.from + add,
+ to: word.to,
+ options: specialOverwrite || totalCompletions,
+ validFor: /^(?:[a-zA-Z0-9]+)$/i
+ }
+}
diff --git a/app/javascript/src/utils/codemirror/indent.js b/app/javascript/src/utils/codemirror/indent.ts
similarity index 59%
rename from app/javascript/src/utils/codemirror/indent.js
rename to app/javascript/src/utils/codemirror/indent.ts
index 65cef333c..ef84d0d1c 100644
--- a/app/javascript/src/utils/codemirror/indent.js
+++ b/app/javascript/src/utils/codemirror/indent.ts
@@ -1,16 +1,13 @@
-import { EditorSelection } from "@codemirror/state"
+import { EditorSelection, type EditorState, type Transaction } from "@codemirror/state"
+import type { Range } from "@src/types/editor"
-/**
- * Indent on using tab with special conditions. Indent is reversed while holding shift
- * @param {Object} view CodeMirror view
- * @param {Object} event Event as fired from CodeMirror
- * @returns {Boolean} Should return true on complete
- */
-export function tabIndent({ state, dispatch }, event) {
- const { shiftKey, target } = event
+/** Indent on using tab with special conditions. Indent is reversed while holding shift */
+export function tabIndent({ state, dispatch }: { state: EditorState, dispatch: Function }, event: KeyboardEvent): boolean {
+ const { shiftKey } = event || {}
+ const target = event.target as HTMLElement
// Do not indent when autocomplete is open
- if (target.closest(".cm-editor").querySelector(".cm-tooltip-autocomplete")) return true
+ if (target.closest(".cm-editor")!.querySelector(".cm-tooltip-autocomplete")) return true
// Insert tabs for each range, each range meaning a cursor position and/or selection
const changes = state.changeByRange(range => {
@@ -67,39 +64,25 @@ export function tabIndent({ state, dispatch }, event) {
return true
}
-/**
- * Find the number of indents of a given line number.
- * @param {Object} state CodeMirror editor state
- * @param {Number} line CodeMirror line number
- * @param {Number} charLimit Max characters given in the text
- * @returns {Number} Number of indents for the given line
- */
-export function getIndentForLine(state, line, charLimit) {
+/** Find the number of indents of a given line number. */
+export function getIndentForLine(state: EditorState, line: number, charLimit?: number): number {
let lineText = state.doc.lineAt(Math.max(line, 0)).text
lineText = charLimit !== undefined ? lineText.slice(0, charLimit) : lineText
return getIndentCountForText(lineText)
}
-/**
- * Get the number of indents for a given text. One tab means one indent, 4 spaces equal one tab.
- * @param {String} text String to check for number of indents
- * @returns {Number} Number of indents
- */
-export function getIndentCountForText(text) {
- const tabs = /^\t*/.exec(text)?.[0].length
- const spaces = /^\s*/.exec(text)?.[0].length - tabs
+/** Get the number of indents for a given text. One tab means one indent, 4 spaces equal one tab. */
+export function getIndentCountForText(text: string): number {
+ const tabs = /^\t*/.exec(text)![0].length
+ const spaces = /^\s*/.exec(text)![0].length
- return Math.floor(spaces / 4) + tabs
+ return Math.floor((spaces - tabs) / 4) + tabs
}
-/**
- * Returns whether or not the next given line should be indented. This is purely based on there
- * being an opening character without a closing character on the previous line.
- * @param {String} text String to check for expected indent level, should be a single line
- * @returns {Boolean}
- */
-export function shouldNextLineBeIndent(text) {
+/** Returns whether or not the next given line should be indented. This is purely based on there
+ * being an opening character without a closing character on the previous line. */
+export function shouldNextLineBeIndent(text: string): boolean {
const isComment = text.includes("//")
const openBracket = !isComment && /[\{\(\[]/gm.exec(text)?.[0].length
const closeBracket = !isComment && /[\}\)\]]/gm.exec(text)?.[0].length
@@ -107,13 +90,9 @@ export function shouldNextLineBeIndent(text) {
return !!(openBracket && !closeBracket)
}
-/**
- * Indent the next line when pressing enter. The number of indents is based on the indents on the
- * previous line as well as there being an opening character on the previous line.
- * @param {Object} view CodeMirror view
- * @returns {Boolean} Should return true on complete
- */
-export function autoIndentOnEnter({ state, dispatch }) {
+/** Indent the next line when pressing enter. The number of indents is based on the indents on the
+ * previous line as well as there being an opening character on the previous line. */
+export function autoIndentOnEnter({ state, dispatch }: { state: EditorState, dispatch: Function }): boolean {
const changes = state.changeByRange(range => {
const { from, to } = range, line = state.doc.lineAt(from)
@@ -131,19 +110,20 @@ export function autoIndentOnEnter({ state, dispatch }) {
return true
}
-/**
- * Add indents on autocomplete with results that contain new lines.
- * @param {Object} view CodeMirror view
- * @param {Object} transaction CodeMirror transaction
- */
-export function indentMultilineInserts({ state, dispatch }, transaction) {
- const [range] = transaction.changedRanges
- const rangeLine = state.doc.lineAt(range.fromB)
- const text = transaction.state.doc.toString().slice(range.fromB, range.toB)
+/** Add indents on autocomplete with results that contain new lines. */
+export function indentMultilineInserts({ state, dispatch }: { state: EditorState, dispatch: Function }, transaction: Transaction): void {
+ const range = getFirstRangeFromTransaction(transaction)
+
+ if (!range) return
+
+ const [from, to] = range
+ const rangeLine = state.doc.lineAt(from)
+ const text = transaction.state.doc.toString().slice(from, to)
const splitText = text.split("\n")
let startIndentCount = 0
let firstIndentCount = 0
+
const mappedText = splitText.map((line, i) => {
if (!i) {
firstIndentCount = getIndentCountForText(line)
@@ -160,24 +140,24 @@ export function indentMultilineInserts({ state, dispatch }, transaction) {
})
const changes = {
- from: range.fromB,
- to: range.toB,
+ from,
+ to,
insert: mappedText.join("\n")
}
dispatch({ changes })
}
-/**
- * Adjust multiline-indents so that the indent on first line matches the rest.
- * @param {Object} view CodeMirror view
- * @param {Object} transaction CodeMirror transaction
- */
-export function pasteIndentAdjustments({ dispatch }, transaction) {
- const [range] = transaction.changedRanges
- const paste = transaction.state.doc.toString().slice(range.fromB, range.toB)
- const line = transaction.state.doc.lineAt(range.fromB)
- const lineText = line.text.slice(0, range.fromB - line.from)
+/** Adjust multiline-indents so that the indent on first line matches the rest. */
+export function pasteIndentAdjustments({ dispatch }: { dispatch: Function }, transaction: Transaction): void {
+ const range = getFirstRangeFromTransaction(transaction)
+
+ if (!range) return
+
+ const [from, to] = range
+ const paste = transaction.state.doc.toString().slice(from, to)
+ const line = transaction.state.doc.lineAt(from)
+ const lineText = line.text.slice(0, from - line.from)
let indentCount = 0
if (paste.includes("\n")) {
@@ -188,6 +168,18 @@ export function pasteIndentAdjustments({ dispatch }, transaction) {
}
dispatch({
- changes: { from: range.fromB, to: range.fromB + indentCount, insert: "" }
+ changes: { from, to: from + indentCount, insert: "" }
})
}
+
+function getFirstRangeFromTransaction(transaction: Transaction): Range | null {
+ let range: Range | null = null
+
+ transaction.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
+ range = [fromB, toB]
+
+ return false
+ })
+
+ return range
+}
diff --git a/app/javascript/src/utils/codemirror/indentedLineWrap.js b/app/javascript/src/utils/codemirror/indentedLineWrap.ts
similarity index 78%
rename from app/javascript/src/utils/codemirror/indentedLineWrap.js
rename to app/javascript/src/utils/codemirror/indentedLineWrap.ts
index b450d7a79..8de1734e7 100644
--- a/app/javascript/src/utils/codemirror/indentedLineWrap.js
+++ b/app/javascript/src/utils/codemirror/indentedLineWrap.ts
@@ -1,28 +1,29 @@
// Adapted from https://github.com/fonsp/Pluto.jl/blob/eb85b0d34b05ee02e61c0316e6f2ea901afe9ab4/frontend/components/CellInput/awesome_line_wrapping.js
import { EditorView, Decoration } from "@codemirror/view"
-import { StateField } from "@codemirror/state"
+import { EditorState, RangeSet, StateField } from "@codemirror/state"
-export const getStartTabs = (line) => /^\t*/.exec(line)?.[0] ?? ""
+export const getStartTabs = (line: string): string => /^\t*/.exec(line)?.[0] ?? ""
-const getDecorations = (state) => {
+const getDecorations = (state: EditorState): RangeSet => {
const decorations = []
for (let i = 0; i < state.doc.lines; i ++) {
const line = state.doc.line(i + 1)
const numberOfTabs = getStartTabs(line.text).length
+
if (numberOfTabs === 0) continue
const offset = numberOfTabs * state.tabSize
- const linerwapper = Decoration.line({
+ const lineWrapper = Decoration.line({
attributes: {
style: `--indented: ${offset}ch;`,
class: "indented-wrapped-line"
}
})
- decorations.push(linerwapper.range(line.from, line.from))
+ decorations.push(lineWrapper.range(line.from, line.from))
}
return Decoration.set(decorations)
diff --git a/app/javascript/src/utils/codemirror/removeTrailingWhitespace.ts b/app/javascript/src/utils/codemirror/removeTrailingWhitespace.ts
new file mode 100644
index 000000000..0d954a4dd
--- /dev/null
+++ b/app/javascript/src/utils/codemirror/removeTrailingWhitespace.ts
@@ -0,0 +1,27 @@
+import type { ChangeSpec, EditorState } from "@codemirror/state"
+import { settings } from "@stores/editor"
+import { get } from "svelte/store"
+
+export function removeTrailingWhitespace({ state, dispatch }: { state: EditorState, dispatch: Function }): boolean {
+ if (!get(settings)["remove-trailing-whitespace-on-save"]) return true
+
+ const changes: ChangeSpec[] = []
+
+ for (let i = 1; i <= state.doc.lines; i++) {
+ const line = state.doc.line(i)
+ const match = line.text.match(/^(.*?)(\s+)$/)
+
+ if (!match) continue
+
+ const start = line.from + match[1].length
+ const end = line.to
+
+ changes.push({ from: start, to: end })
+ }
+
+ if (changes.length > 0) {
+ dispatch(state.update({ changes }, { userEvent: "input" }))
+ }
+
+ return true
+}
diff --git a/app/javascript/src/utils/compiler/comments.js b/app/javascript/src/utils/compiler/comments.js
deleted file mode 100644
index f0adadb88..000000000
--- a/app/javascript/src/utils/compiler/comments.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export function removeComments(joinedItems) {
- return joinedItems.replaceAll(/\/\*[\s\S]*?\*\/|(?<=^|[^:])\/\/.*$/gm, "")
-}
diff --git a/app/javascript/src/utils/compiler/comments.ts b/app/javascript/src/utils/compiler/comments.ts
new file mode 100644
index 000000000..697d72ee7
--- /dev/null
+++ b/app/javascript/src/utils/compiler/comments.ts
@@ -0,0 +1,5 @@
+export function removeComments(joinedItems: string): string {
+ // Matches /*...*/ or //...
+ const regex = /\/\*[\s\S]*?\*\/|(?<=^|[^:])\/\/.*$/gm
+ return joinedItems.replaceAll(regex, "")
+}
diff --git a/app/javascript/src/utils/compiler/compile.js b/app/javascript/src/utils/compiler/compile.ts
similarity index 96%
rename from app/javascript/src/utils/compiler/compile.js
rename to app/javascript/src/utils/compiler/compile.ts
index ef6c20744..8059e19a8 100644
--- a/app/javascript/src/utils/compiler/compile.js
+++ b/app/javascript/src/utils/compiler/compile.ts
@@ -12,7 +12,7 @@ import { convertTranslations } from "@utils/compiler/translations"
import { compileVariables } from "@utils/compiler/variables"
import { get } from "svelte/store"
-export function compile(overwriteContent = null) {
+export function compile(overwriteContent = ""): string {
let joinedItems = overwriteContent || get(flatItems)
joinedItems = removeComments(joinedItems)
diff --git a/app/javascript/src/utils/compiler/conditionals.js b/app/javascript/src/utils/compiler/conditionals.ts
similarity index 89%
rename from app/javascript/src/utils/compiler/conditionals.js
rename to app/javascript/src/utils/compiler/conditionals.ts
index 84c8b8394..18d514099 100644
--- a/app/javascript/src/utils/compiler/conditionals.js
+++ b/app/javascript/src/utils/compiler/conditionals.ts
@@ -1,7 +1,8 @@
+import type { ExpressionTree } from "@src/types/editor"
import { comparisonOperators, sortedComparisonOperatorsSymbols } from "@utils/operators"
import { getClosingBracket, removeSurroundingParenthesis, replaceBetween } from "@utils/parse"
-export function evaluateConditionals(joinedItems) {
+export function evaluateConditionals(joinedItems: string): string {
const ifStartRegex = /@if[\s\n]*\(/g
const startBracketRegex = /[\s\n]*\{/g
const elseStartRegex = /[\s\n]*@else[\s\n]*\{/g
@@ -101,43 +102,45 @@ export function evaluateConditionals(joinedItems) {
return joinedItems
}
-export function evaluateExpressionTree(node) {
- if (node.invalid) {
- return null
- } else if (node.value != null) {
- return node.value.trim()
- } else {
- const evaluatedArguments = node.arguments.map((argument) => evaluateExpressionTree(argument))
- const result = comparisonOperators[node.operator].eval(... evaluatedArguments)
- return result
- }
+export function evaluateExpressionTree(node: ExpressionTree): boolean | string | null {
+ if (node.invalid) return null
+ if (node.value != null) return node.value.trim()
+
+ const evaluatedArguments = node.arguments!.map((argument) => evaluateExpressionTree(argument))
+ const result = comparisonOperators[node.operator!].eval(... evaluatedArguments)
+ return result
}
-export function getExpressionTree(expression) {
+export function getExpressionTree(expression: string): ExpressionTree {
expression = removeSurroundingParenthesis(expression)
- const result = {}
-
- if (expression.length === 0) {
- result.value = ""
- return result
+ const result: ExpressionTree = {
+ value: null,
+ invalid: false,
+ operator: null,
+ arguments: []
}
+ if (expression.length === 0) return result
+
for (let currentIndex = 0; currentIndex < expression.length; currentIndex++) {
const char = expression[currentIndex]
if (char === "(") {
const closingIndex = getClosingBracket(expression, "(", ")", currentIndex - 1)
+
if (closingIndex < 0) {
// parentheses are open-ended, like "(a == b"
result.invalid = true
break
}
+
currentIndex = closingIndex
} else {
let operatorSymbol = null
let operatorIndex = -1
for (const symbol of sortedComparisonOperatorsSymbols) {
const index = expression.indexOf(symbol, currentIndex)
+
if (index >= 0) {
operatorSymbol = symbol
operatorIndex = index
@@ -157,7 +160,6 @@ export function getExpressionTree(expression) {
const righthand = expression.substring(operatorIndex + operatorSymbol.length)
result.operator = operatorSymbol
- result.arguments = []
if (["binary", "unary-right"].includes(operator.type)) {
if (lefthand.length > 0) {
diff --git a/app/javascript/src/utils/compiler/constants.js b/app/javascript/src/utils/compiler/constants.ts
similarity index 72%
rename from app/javascript/src/utils/compiler/constants.js
rename to app/javascript/src/utils/compiler/constants.ts
index eb8d207fd..c10173ac1 100644
--- a/app/javascript/src/utils/compiler/constants.js
+++ b/app/javascript/src/utils/compiler/constants.ts
@@ -1,4 +1,4 @@
-export const openToClosingArrayBracketsMap = {
+export const openToClosingArrayBracketsMap: Record = {
"(": ")",
"[": "]"
}
diff --git a/app/javascript/src/utils/compiler/each.js b/app/javascript/src/utils/compiler/each.ts
similarity index 81%
rename from app/javascript/src/utils/compiler/each.js
rename to app/javascript/src/utils/compiler/each.ts
index 1f43df1e6..84aef4df7 100644
--- a/app/javascript/src/utils/compiler/each.js
+++ b/app/javascript/src/utils/compiler/each.ts
@@ -1,30 +1,29 @@
import { workshopConstants } from "@stores/editor"
import { defaultLanguage } from "@stores/translationKeys"
-import { getClosingBracket, replaceBetween } from "@utils/parse"
+import { getClosingBracket, replaceBetween, getCommasIndexesOutsideQuotes } from "@utils/parse"
import { openArrayBracketRegex, openToClosingArrayBracketsMap } from "@utils/compiler/constants"
import { get } from "svelte/store"
-import { getCommasIndexesOutsideQuotes } from "../parse"
-export function evaluateEachLoops(joinedItems) {
+export function evaluateEachLoops(joinedItems: string): string {
+ // Matches "@each" loops extracting the item and optional index variables, along with the iterable.
+ // For example:
+ // @each (thing in [a, b, c])
+ // @each (item, index in [1, 2, 3]) {
+ // @each (item in Constant.Button) {
const eachRegex = /@each\s*\((\w+)(?:,\s+(\w+))?\s+in\s+(\[.*?\]|(?:Constant)\.[\w\s]+)\s*\)\s*\{/gs
let match
while ((match = eachRegex.exec(joinedItems)) != null) {
const [_, valueVar, indexVar, iterableStr] = match
- let iterable = []
+ let iterable: string[] = []
+
if (iterableStr[0] === "[" && iterableStr[iterableStr.length - 1] === "]") {
iterable = parseArrayValues(iterableStr.substring(1, iterableStr.length - 1))
} else if (iterableStr.startsWith("Constant.")) {
- const language = get(defaultLanguage)
- const constants = get(workshopConstants)
-
- const usedConstant = constants[iterableStr.substring("Constant.".length)]
+ const usedConstant = get(workshopConstants)[iterableStr.substring("Constant.".length)]
- if (usedConstant != null) {
- iterable = Object.values(usedConstant)
- .map((value) => value[language])
- }
+ if (usedConstant != null) iterable = Object.values(usedConstant).map((value) => value[get(defaultLanguage)])
}
if (iterable == null) continue
@@ -58,7 +57,7 @@ export function evaluateEachLoops(joinedItems) {
return joinedItems
}
-export function parseArrayValues(input) {
+export function parseArrayValues(input: string): string[] {
const commaRegex = /, */g
const result = []
@@ -76,6 +75,7 @@ export function parseArrayValues(input) {
// return ["1", "(2, 3)", "4"], not ["1", "(2", "3)", "4"])
openArrayBracketRegex.lastIndex = nextStartingIndex
const openBracketMatch = openArrayBracketRegex.exec(input)
+
if (openBracketMatch != null && openBracketMatch.index < commaMatch.index) {
const openingBracketChar = openBracketMatch[0]
const closingBracketChar = openToClosingArrayBracketsMap[openingBracketChar]
@@ -101,8 +101,8 @@ export function parseArrayValues(input) {
result.push(lastValue)
}
+ // HACK: line finder inserts [linemarker]s on the input, which may confuse @each
+ // into thinking they are nested arrays.
return result
- // HACK: line finder inserts [linemarker]s on the input, which may confuse @each
- // into thinking they are nested arrays.
.map((item) => item.replace(/\s*\[linemarker\].*?\[\/linemarker\]\s*/g, ""))
}
diff --git a/app/javascript/src/utils/compiler/for.js b/app/javascript/src/utils/compiler/for.ts
similarity index 85%
rename from app/javascript/src/utils/compiler/for.js
rename to app/javascript/src/utils/compiler/for.ts
index 671186b6b..7e086adda 100644
--- a/app/javascript/src/utils/compiler/for.js
+++ b/app/javascript/src/utils/compiler/for.ts
@@ -1,8 +1,10 @@
import { getClosingBracket, replaceBetween } from "@utils/parse"
-export function evaluateForLoops(joinedItems) {
+export function evaluateForLoops(joinedItems: string): string {
+ // Matches "@for ([var] [from] number through|to number [in steps of number]) {" in groups for each param
+ const forRegex = /@for\s+\(\s*((?:(\w+)\s+)?(?:from\s+))?(-?[\d\.]+)\s+(?:(through|to)\s+)?(-?[\d\.]+)(?:\s*in steps of\s+(-?[\d\.]+))?\s*\)\s*\{/g
+
let match
- const forRegex = /@for\s+\(\s*((?:(\w+)\s+)?(?:from\s+))?(-?[\d\.]+)\s+(?:(through|to)\s+)?(-?[\d\.]+)(?:\s*in steps of\s+(-?[\d\.]+))?\s*\)\s*\{/g // Matches "@for ([var] [from] number through|to number [in steps of number]) {" in groups for each param
while ((match = forRegex.exec(joinedItems)) != null) {
const [full, _, variable, startString, clusivityKeyword, endString, stepString = "1"] = match
@@ -18,7 +20,7 @@ export function evaluateForLoops(joinedItems) {
// Replace "For.[variable]" with the current index
let repeatedContent = ""
for(let i = start; i < end + (inclusive ? step : 0); i += step) {
- repeatedContent += content.replaceAll(`For.${variable || "i"}`, i)
+ repeatedContent += content.replaceAll(`For.${variable || "i"}`, i.toString())
}
joinedItems = replaceBetween(joinedItems, repeatedContent, match.index, closingBracketIndex + 1)
diff --git a/app/javascript/src/utils/compiler/mixins.js b/app/javascript/src/utils/compiler/mixins.ts
similarity index 80%
rename from app/javascript/src/utils/compiler/mixins.js
rename to app/javascript/src/utils/compiler/mixins.ts
index 6374c353a..f68e2e38b 100644
--- a/app/javascript/src/utils/compiler/mixins.js
+++ b/app/javascript/src/utils/compiler/mixins.ts
@@ -1,21 +1,18 @@
import { getClosingBracket, replaceBetween, splitArgumentsString } from "@utils/parse"
import { getFirstParameterObject } from "@utils/compiler/parameterObjects"
+import type { Mixin } from "@src/types/editor"
-export function getMixins(joinedItems) {
- let mixins = joinedItems.match(/(?<=@mixin\s)[^\s\(]+/g)
+export function getMixins(joinedItems: string): string[] {
+ let mixins = joinedItems.match(/(?<=@mixin\s)[^\s\(]+/g) as string[]
mixins = [...new Set(mixins)]
return mixins
}
-/**
- * Replace and remove all occurances of `@include` and `@mixin`. `@include` is replaced with the declarations of their
- * corresponding `@mixin`.
- * @param {string} joinedItems Given string of the currently parsed compiled items.
- * @returns {string} joinedItems with mixin includes replaced.
- */
-export function extractAndInsertMixins(joinedItems) {
- const mixins = {}
+/** Replace and remove all occurances of `@include` and `@mixin`. `@include` is replaced with the declarations of their
+ * corresponding `@mixin`. */
+export function extractAndInsertMixins(joinedItems: string): string {
+ const mixins: Record = {}
// Find stated mixins and save their names and params to an object
const mixinRegex = /@mixin/g
@@ -33,9 +30,8 @@ export function extractAndInsertMixins(joinedItems) {
const firstOpenBracket = content.indexOf("{")
const firstOpenParen = content.indexOf("(")
const closingParen = getClosingBracket(content, "(", ")", firstOpenParen - 1)
- if (closingParen < 0) {
- continue
- }
+ if (closingParen < 0) continue
+
const params = splitArgumentsString(content.slice(firstOpenParen + 1, closingParen).replace(/\s/, ""))
const paramsDefaults = params
.map(param => {
@@ -70,7 +66,7 @@ export function extractAndInsertMixins(joinedItems) {
const full = joinedItems.slice(index, closing + 1)
const name = full.match(/(?<=@include\s)(\w+)/)?.[0]
- const mixin = mixins[name]
+ const mixin = mixins[name!]
const parameterObjectGiven = getFirstParameterObject(full)?.given
if (!mixin) throw new Error(`Included a mixin that was not specified: "${name}"`)
@@ -107,15 +103,15 @@ export function extractAndInsertMixins(joinedItems) {
/**
* Replace every `@contents` occurance with their corresponding slot from the mixin include.
- * @param {string} joinedItems - The full given content
- * @param {number} index - The starting index of the include
- * @param {number} closing - The index of the closing parenthesis of the include arguments
- * @param {string} replaceWith - String constructed to far to replace the starting value
- * @returns {Object} An object containing the extracted contents of the mixin, the full mixin string (including the declare),
+ * @param joinedItems - The full given content
+ * @param index - The starting index of the include
+ * @param closing - The index of the closing parenthesis of the include arguments
+ * @param replaceWith - String constructed to far to replace the starting value
+ * @returns An object containing the extracted contents of the mixin, the full mixin string (including the declare),
* and the updated content after slot replacement.
- * @throws {Error} If the mixin includes itself
+ * @throws If the mixin includes itself
*/
-export function replaceContents(joinedItems, index, closing, replaceWith) {
+export function replaceContents(joinedItems: string, index: number, closing: number, replaceWith: string): { contents: string, fullMixin: string, replaceWith: string } {
let contents = ""
let contentsClosing = getClosingBracket(joinedItems, "{", "}", index)
if (contentsClosing == -1) contentsClosing = joinedItems.length
@@ -128,7 +124,7 @@ export function replaceContents(joinedItems, index, closing, replaceWith) {
const slotContents = getSlotContents(contents)
while (replaceWith.indexOf("@contents") != -1) {
- const match = /@contents(?:\("(.+?)"\))?;?/.exec(replaceWith)
+ const match = /@contents(?:\("(.+?)"\))?;?/.exec(replaceWith)!
const slot = match[1] || "default"
const start = match.index
const end = match.index + match[0].length
@@ -142,11 +138,11 @@ export function replaceContents(joinedItems, index, closing, replaceWith) {
/**
* Get all given slots in the mixin include
* @param {string} contents Contents of the mixin include
- * @returns {Object} An object containing the extracted slot with their names as keys
+ * @returns {object} An object containing the extracted slot with their names as keys
* and slot content as value. Includes the default slot.
*/
-export function getSlotContents(contents) {
- const slotContents = {}
+export function getSlotContents(contents: string): Record {
+ const slotContents: Record = {}
const defaultSlotContent = []
const slotsRegex = /@slot\("([^"]+)"\) {/g
@@ -168,16 +164,14 @@ export function getSlotContents(contents) {
return { ...slotContents, default: defaultSlotContent.join("").trim() }
}
-/**
- * Get the opening bracket for mixin includes. If there is none, return -1
- * @param {string} content
- * @returns {index} index of the found opening bracket
- */
-export function getOpeningBracketAt(content) {
+/** Get the opening bracket for mixin includes. If there is none, return -1 */
+export function getOpeningBracketAt(content: string): number {
for (let i = 0; i < content.length; i++) {
const char = content[i]
if (char === "{") return i
if (!char.match(/\s/)) return -1
}
+
+ return -1
}
diff --git a/app/javascript/src/utils/compiler/parameterObjects.js b/app/javascript/src/utils/compiler/parameterObjects.js
deleted file mode 100644
index bc56b4e7c..000000000
--- a/app/javascript/src/utils/compiler/parameterObjects.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import { completionsMap } from "@stores/editor"
-import { getClosingBracket, getPhraseFromIndex, replaceBetween, splitArgumentsString } from "@utils/parse"
-import { get } from "svelte/store"
-
-export function evaluateParameterObjects(joinedItems) {
- let moreAvailableObjects = true
- let safety = 0
- let startFromIndex = 0
-
- while (moreAvailableObjects) {
- const parameterObject = getFirstParameterObject(joinedItems, startFromIndex)
-
- if (!parameterObject || safety > 1000) {
- moreAvailableObjects = false
- continue
- }
-
- if (!parameterObject.phraseParameters.length) {
- startFromIndex = parameterObject.start
- continue
- }
-
- joinedItems = replaceParameterObject(joinedItems, parameterObject)
- safety++
- }
-
- return joinedItems
-}
-
-/**
- * Obtain a JavaScript Object out of the string contents of a parameter object
- *
- * @param {string} innerContent Content inside the parameter object's curly braces
- * @returns {Object}
- */
-export function parseParameterObjectContent(innerContent) {
- const splitParameters = splitArgumentsString(innerContent)
- const result = {}
-
- splitParameters.forEach(item => {
- let [key, value] = item.split(/:(.*)/s)
- key = key.replace(/\[linemarker\].*?\[\/linemarker\]/, "").trim()
- value = (value || "").trim()
- if (!key) return
- result[key] = value
- })
-
- return result
-}
-
-/**
- * Find the first matching parameter object in a given string. Parameter objects are a special format that allow the user to give only specific sets of parameters rather than having to write them all out.
- * @param {string} content Content to search for parameter objects in.
- * @param {*} startFromIndex Skip over previous results. This is used when the regex format was found without matches phrases to skip over previous results.
- * @returns {object|null} Object containing details about the parameter object and matching phrase.
- */
-export function getFirstParameterObject(content, startFromIndex = 0) {
- content = content.slice(startFromIndex)
-
- const regex = /[a-z]\s*\(\s*{/
- const match = content.match(regex)
-
- if (!match) return null
-
- let end = getClosingBracket(content, "{", "}", match.index - 1)
- if (end === -1) end = match.index + match.length
-
- const start = match.index + match[0].indexOf("{")
- const phrase = getPhraseFromIndex(content, match.index)
- const completion = get(completionsMap).find(item => item.args_length && item.label.replace(" ", "") === phrase.replace(" ", ""))
- const innerContent = content.slice(match.index + match[0].length, end).trim()
- const given = parseParameterObjectContent(innerContent)
-
- const result = { start: start + startFromIndex, end: end + startFromIndex, given }
-
- // Return a false object to replace contents of unfound phrase
- if (!completion) return {
- ...result,
- phraseParameters: [],
- phraseDefaults: []
- }
-
- return {
- ...result,
- phraseParameters: completion.parameter_keys,
- phraseDefaults: completion.parameter_defaults
- }
-}
-
-/**
- * Replaces and returns a parameter object in the given string.
- * @param {string} content String the parameter object will be replaced in.
- * @param {object} parameterObject Object containing all data needed to replace the expected string. This object contains a start index, end index, given parameters, parameter keys, and parameter defaults.
- * @returns {string} String with parameter object replaced with Workshop code.
- */
-export function replaceParameterObject(content, parameterObject) {
- const { start, end, given, phraseParameters, phraseDefaults } = parameterObject
-
- if (end > content.length - 1) return content
-
- const parameters = phraseDefaults.map((parameterDefault, i) => given[phraseParameters[i]] || parameterDefault)
- content = replaceBetween(content, parameters.join(", "), start, end + 1)
-
- return content
-}
diff --git a/app/javascript/src/utils/compiler/parameterObjects.ts b/app/javascript/src/utils/compiler/parameterObjects.ts
new file mode 100644
index 000000000..a53f58864
--- /dev/null
+++ b/app/javascript/src/utils/compiler/parameterObjects.ts
@@ -0,0 +1,149 @@
+import type { ParameterObject } from "@src/types/editor"
+import { completionsMap } from "@stores/editor"
+import { getClosingBracket, getPhraseEnd, getPhraseFromIndex, replaceBetween, splitArgumentsString } from "@utils/parse"
+import { get } from "svelte/store"
+
+export function evaluateParameterObjects(joinedItems: string): string {
+ let moreAvailableObjects = true
+ let safety = 0
+ let startFromIndex = 0
+
+ while (moreAvailableObjects) {
+ const parameterObject = getFirstParameterObject(joinedItems, startFromIndex)
+
+ if (!parameterObject || safety > 1000) {
+ moreAvailableObjects = false
+ continue
+ }
+
+ if (!parameterObject.phraseParameters.length) {
+ startFromIndex = parameterObject.start
+ continue
+ }
+
+ joinedItems = replaceParameterObject(joinedItems, parameterObject)
+ safety++
+ }
+
+ return joinedItems
+}
+
+/** Obtain an object out of the string contents of a parameter object */
+export function parseParameterObjectContent(innerContent: string): { result: Record, keys: string[] } {
+ const splitParameters = splitArgumentsString(innerContent)
+ const result: Record = {}
+ const keys: string[] = []
+
+ splitParameters.forEach(item => {
+ let [key, value] = item.split(/:(.*)/s)
+
+ key = key.replace(/\[linemarker\].*?\[\/linemarker\]/, "").trim()
+ value = (value || "").trim()
+
+ if (!key) return
+
+ keys.push(key)
+ result[key] = value
+ })
+
+ return { result, keys }
+}
+
+/**
+ * Find the first matching parameter object in a given string. Parameter objects are a special format that allow the user to give only specific sets of parameters rather than having to write them all out.
+ * @param content Content to search for parameter objects in.
+ * @param startFromIndex Skip over previous results. This is used when the regex format was found without matches phrases to skip over previous results.
+ * @returns Object containing details about the parameter object and matching phrase.
+ */
+export function getFirstParameterObject(content: string, startFromIndex = 0): ParameterObject | null {
+ content = content.slice(startFromIndex)
+
+ const regex = /[a-z]\s*\(\s*{/
+ const match = content.match(regex)
+
+ if (!match?.index) return null
+
+ let end = getClosingBracket(content, "{", "}", match.index - 1)
+ if (end === -1) end = match.index + match.length
+
+ const start = match.index + match[0].indexOf("{")
+ const phrase = getPhraseFromIndex(content, match.index)
+ const completion = get(completionsMap).find(item => item.args_length && item.label.replace(" ", "") === phrase.replace(" ", ""))
+ const innerContent = content.slice(match.index + match[0].length, end).trim()
+ const { result: given, keys: givenKeys } = parseParameterObjectContent(innerContent)
+
+ const result = { start: start + startFromIndex, end: end + startFromIndex, given, givenKeys }
+
+ // Return a false object to replace contents of unfound phrase
+ if (!completion) return {
+ ...result,
+ phraseParameters: [],
+ phraseDefaults: [],
+ phraseTypes: []
+ }
+
+ return {
+ ...result,
+ phraseParameters: completion.parameter_keys,
+ phraseDefaults: completion.parameter_defaults,
+ phraseTypes: completion.parameter_types
+ }
+}
+
+/**
+ * Replaces and returns a parameter object in the given string.
+ * @param content String the parameter object will be replaced in.
+ * @param parameterObject Object containing all data needed to replace the expected string. This object contains a start index, end index, given parameters, parameter keys, and parameter defaults.
+ * @returns String with parameter object replaced with Workshop code.
+ */
+export function replaceParameterObject(content: string, parameterObject: ParameterObject): string {
+ const { start, end, given, phraseParameters, phraseDefaults } = parameterObject
+
+ if (end > content.length - 1) return content
+
+ const parameters = phraseDefaults.map((parameterDefault, i) => given[phraseParameters[i]] || parameterDefault)
+ content = replaceBetween(content, parameters.join(", "), start, end + 1)
+
+ return content
+}
+
+/**
+ * Returns the parameter object that the cursor is directly inside of, if any.
+ */
+export function directlyInsideParameterObject(content: string, startIndex = 0): ParameterObject | null {
+ if (startIndex > content.length) return null
+ if (startIndex < 0) return null
+
+ let bracketCount = 0
+ let index = 0
+ let hasMetComma = false
+ let isNotKey = false
+ for (index = startIndex - 1; index > 0; index--) {
+ if (content[index] === "{") bracketCount--
+ if (content[index] === "}") bracketCount++
+ if (content[index] === ",") hasMetComma = true
+ if ((content[index] === ":" && !hasMetComma) || content[index] === ";") {
+ isNotKey = true
+ break
+ }
+
+ if (bracketCount < 0) {
+ // Only count as parameter object when ( proceeds {, while allowing white space. To prevent things like `actions {` matching as objects.
+ if (/\s/.test(content[index])) continue
+ if (content[index] === "(") break
+ }
+ }
+
+ if (isNotKey) return null
+
+ const phraseStart = getPhraseEnd(content, index - 1, -1)
+
+ if (phraseStart < 0) return null
+
+ const parameterObject = getFirstParameterObject(content.slice(phraseStart, content.length))
+
+ if (!parameterObject) return null
+ if (parameterObject.start > startIndex) return null
+
+ return parameterObject
+}
diff --git a/app/javascript/src/utils/compiler/subroutines.js b/app/javascript/src/utils/compiler/subroutines.ts
similarity index 80%
rename from app/javascript/src/utils/compiler/subroutines.js
rename to app/javascript/src/utils/compiler/subroutines.ts
index b4fb33107..7366d1ce8 100644
--- a/app/javascript/src/utils/compiler/subroutines.js
+++ b/app/javascript/src/utils/compiler/subroutines.ts
@@ -1,4 +1,4 @@
-export function compileSubroutines(joinedItems) {
+export function compileSubroutines(joinedItems: string): string {
const subroutines = getSubroutines(joinedItems)
if (!subroutines.length) return ""
@@ -9,7 +9,7 @@ ${subroutines.map((v, i) => ` ${i}: ${v}`).join("\n")}
}\n\n`
}
-export function getSubroutines(joinedItems) {
+export function getSubroutines(joinedItems: string): string[] {
const declaredSubroutines = Array.from(joinedItems.matchAll(/Subroutine;[\r\n]+([^\r\n;]+)/g))
.map((match) => match[1].trim())
const usedSubroutines = Array.from(joinedItems.matchAll(/(?:Call Subroutine|Start Rule)\s*\(([^,\)]+)/g))
diff --git a/app/javascript/src/utils/compiler/translations.js b/app/javascript/src/utils/compiler/translations.ts
similarity index 92%
rename from app/javascript/src/utils/compiler/translations.js
rename to app/javascript/src/utils/compiler/translations.ts
index 012373dbb..1db3e168d 100644
--- a/app/javascript/src/utils/compiler/translations.js
+++ b/app/javascript/src/utils/compiler/translations.ts
@@ -3,7 +3,7 @@ import { defaultLanguage, selectedLanguages, translationKeys } from "@stores/tra
import { getClosingBracket, replaceBetween, splitArgumentsString } from "@utils/parse"
import { get } from "svelte/store"
-export function convertTranslations(joinedItems) {
+export function convertTranslations(joinedItems: string): string {
if (!get(selectedLanguages)?.length) return joinedItems
if (!Object.keys(get(translationKeys) || {})?.length) return joinedItems
@@ -12,21 +12,18 @@ export function convertTranslations(joinedItems) {
const regex = /@translate/g
while ((match = regex.exec(joinedItems)) != null) {
const closing = getClosingBracket(joinedItems, "(", ")", match.index + 1)
- if (closing < 0) {
- continue
- }
- const full = joinedItems.slice(match.index, closing + 1)
+ if (closing < 0) continue
+ const full = joinedItems.slice(match.index, closing + 1)
const argumentsOpeningParen = full.indexOf("(")
const argumentsClosingParen = getClosingBracket(full, "(", ")", argumentsOpeningParen - 1)
- if (argumentsClosingParen < 0) {
- continue
- }
+ if (argumentsClosingParen < 0) continue
+
const argumentsString = full.slice(argumentsOpeningParen + 1, argumentsClosingParen)
const splitArguments = splitArgumentsString(argumentsString) || []
const key = splitArguments[0].replaceAll("\"", "")
- const eachLanguageStrings = []
+ const eachLanguageStrings: string[] = []
get(selectedLanguages).forEach((language) => {
const translation = get(translationKeys)[key]?.[language] || get(translationKeys)[key]?.[get(defaultLanguage)] || key
eachLanguageStrings.push(`Custom String("${translation}"${splitArguments.length > 1 ? ", " : ""}${splitArguments.slice(1).join(", ")})`)
diff --git a/app/javascript/src/utils/compiler/variables.js b/app/javascript/src/utils/compiler/variables.ts
similarity index 89%
rename from app/javascript/src/utils/compiler/variables.js
rename to app/javascript/src/utils/compiler/variables.ts
index 82577a3d4..7d00eafb4 100644
--- a/app/javascript/src/utils/compiler/variables.js
+++ b/app/javascript/src/utils/compiler/variables.ts
@@ -1,5 +1,6 @@
import { findRangesOfStrings, getClosingBracket, matchAllOutsideRanges, splitArgumentsString } from "@utils/parse"
import { evaluateParameterObjects } from "@utils/compiler/parameterObjects"
+import type { Range, Variables } from "@src/types/editor"
// NOTE: The fact variable names can start with a decimal is intentional.
// We leave it to Overwatch to warn the user that this is not allowed.
@@ -24,7 +25,7 @@ const maxVariableNameLength = 32
const actionsDefiningVariablesRegex = /(?:(?:Set|Modify) (?:Global|Player) Variable(?: At Index)?|For (?:Global|Player) Variable|Chase (?:Global|Player) Variable (?:Over Time|At Rate))\(/g
-export function getDefaultVariableNameIndex(name) {
+export function getDefaultVariableNameIndex(name: string): number {
const singleCharFirstIndexOffset = "A".charCodeAt(0) - 1
const maxSingleCharIndex = "Z".charCodeAt(0) - singleCharFirstIndexOffset
@@ -86,11 +87,8 @@ export function getDefaultVariableNameIndex(name) {
* 51: someNameThatIsNotJustAZ
* 52: BA
* ```
- *
- * @param {string[]} variables A list of variables
- * @returns {string[]} The list of variables without
*/
-export function excludeDefaultVariableNames(variables) {
+export function excludeDefaultVariableNames(variables: string[]): string[] {
let removedCount = 0
return variables.filter((name) => {
const defaultIndex = getDefaultVariableNameIndex(name)
@@ -105,7 +103,7 @@ export function excludeDefaultVariableNames(variables) {
})
}
-export function compileVariables(joinedItems) {
+export function compileVariables(joinedItems: string): string {
let { globalVariables, playerVariables } = getVariables(joinedItems)
globalVariables = excludeDefaultVariableNames(globalVariables)
@@ -123,21 +121,22 @@ ${playerVariables.map((v, i) => ` ${i}: ${v}`).join("\n")}
}\n\n`
}
-function getLiteralPlayerVariables(source, stringRanges) {
+function getLiteralPlayerVariables(source: string, stringRanges: Range[]): string[] {
const literalPlayerVariables = []
for (const match of matchAllOutsideRanges(stringRanges, source, possiblePlayerVariablesRegex)) {
const matchPrefix = source.substring(match.index - maxVariableNameLength, match.index)
- if (invalidVariablePrefixRegex.test(matchPrefix)) {
- continue
- }
- const { variableName } = match.groups
+ if (invalidVariablePrefixRegex.test(matchPrefix)) continue
+
+ // variableName comes from possiblePlayerVariablesRegex
+ const { variableName } = match.groups as { variableName: string }
literalPlayerVariables.push(variableName)
}
+
return literalPlayerVariables
}
-export function getVariables(joinedItems) {
+export function getVariables(joinedItems: string): Variables {
joinedItems = evaluateParameterObjects(joinedItems)
const stringRanges = findRangesOfStrings(joinedItems)
@@ -148,8 +147,8 @@ export function getVariables(joinedItems) {
match.index + match[0].length,
getClosingBracket(joinedItems, "(", ")", match.index - 1)
)
- const args = splitArgumentsString(argsContent)
+ const args = splitArgumentsString(argsContent)
const isPlayer = match[0].includes("Player")
if (isPlayer) {
diff --git a/app/javascript/src/utils/debounceStore.js b/app/javascript/src/utils/debounceStore.ts
similarity index 52%
rename from app/javascript/src/utils/debounceStore.js
rename to app/javascript/src/utils/debounceStore.ts
index f31a117c1..0df9a47e5 100644
--- a/app/javascript/src/utils/debounceStore.js
+++ b/app/javascript/src/utils/debounceStore.ts
@@ -1,8 +1,9 @@
-export function debounced(callback, wait) {
- return (values, set) => {
+export function debounced(callback: Function, wait: number) {
+ return (values: any, set: (store: any) => void) => {
const timeout = setTimeout(() => {
return set(callback(values))
}, wait)
+
return () => clearTimeout(timeout)
}
}
diff --git a/app/javascript/src/utils/editor.js b/app/javascript/src/utils/editor.ts
similarity index 62%
rename from app/javascript/src/utils/editor.js
rename to app/javascript/src/utils/editor.ts
index cac372755..a81a12a6d 100644
--- a/app/javascript/src/utils/editor.js
+++ b/app/javascript/src/utils/editor.ts
@@ -1,49 +1,52 @@
import { currentItem, items, openFolders, editorStates } from "@stores/editor"
import { defaultLanguage, selectedLanguages, translationKeys } from "@stores/translationKeys"
import { get } from "svelte/store"
+import type { Item, ItemType } from "@src/types/editor"
-export function createNewItem(name, content, position = 9999, type = "item") {
- const item = {
+export function createNewItem(name: string, content: string, position = 9999, type: ItemType = "item"): Item {
+ const item: Item = {
name: name,
id: Math.random().toString(16).substring(2, 8),
type: type,
position,
- content: content
+ content: content,
+ parent: "",
+ hidden: false
}
return item
}
-export function destroyItem(id) {
- if (get(currentItem).id == id || get(currentItem).parent == id) currentItem.set({})
+export function destroyItem(id: string): void {
+ if (get(currentItem)?.id == id || get(currentItem)?.parent == id) currentItem.set(null)
items.set(get(items).filter(i => i.id != id && i.parent != id))
}
-export function updateItemName(id, name) {
+export function updateItemName(id: string, name: string): void {
items.set(get(items).map(i => {
if (i.id == id) i.name = name
return i
}))
}
-export function toggleHideItem(id) {
+export function toggleHideItem(id: string): void {
items.set(get(items).map(i => {
if (i.id == id) i.hidden = !i.hidden
return i
}))
}
-export function isAnyParentHidden(item) {
+export function isAnyParentHidden(item: Item): boolean {
while (item.parent) {
- item = get(items).find(i => i.id === item.parent)
+ item = get(items).find(i => i.id === item.parent)!
- if (item.hidden) return true
+ if (item?.hidden) return true
}
return false
}
-export function duplicateItem(item, newParent = null) {
+export function duplicateItem(item: Item, newParent: string = ""): void {
const itemCount = get(items).filter(i => {
if (i.parent != item.parent) return false
return i.name.match(/\(Copy(?: \d+)?\)/g)
@@ -62,11 +65,11 @@ export function duplicateItem(item, newParent = null) {
}
}
-export function getItemById(id) {
+export function getItemById(id: string): Item | null {
return get(items).find(i => i.id == id) || null
}
-export function setCurrentItemById(id) {
+export function setCurrentItemById(id: string): void {
const item = getItemById(id)
if (!item) return
@@ -78,7 +81,7 @@ export function setCurrentItemById(id) {
if (parent) toggleFolderState(parent, true)
}
-export function updateItem(newItem) {
+export function updateItem(newItem: Item): void {
items.set(get(items).map(item => {
if (item.id != newItem.id) return item
return newItem
@@ -87,33 +90,33 @@ export function updateItem(newItem) {
updateStateForId(newItem.id, newItem.content)
}
-export async function updateStateForId(id, insert) {
+export function updateStateForId(id: string, insert: string): void {
const state = get(editorStates)[id]
if (!state) return
- const transaction = state.update({changes: {from: 0, to: state.doc.length, insert}})
+ const transaction = state.update({ changes: { from: 0, to: state.doc.length, insert }})
editorStates.set({
...get(editorStates),
[id]: transaction.state
})
- if (get(currentItem).id == id) currentItem.set({ ...get(currentItem), forceUpdate: true })
+ if (get(currentItem)!.id == id) currentItem.set({ ...get(currentItem)!, forceUpdate: true })
}
-export function toggleFolderState(item, state, set = true) {
+export function toggleFolderState(item: Item, state: boolean, set = true): void {
if (item?.type != "folder") return
- if (set) localStorage.setItem(`folder_expanded_${item.id}`, state)
+ if (set) localStorage.setItem(`folder_expanded_${item.id}`, state.toString())
if (state) openFolders.set([...get(openFolders), item.id])
else openFolders.set([...get(openFolders).filter(f => f != item.id)])
- if (item.parent) toggleFolderState(getItemById(item.parent), true)
+ if (item.parent) toggleFolderState(getItemById(item.parent)!, true)
}
-export function getSaveContent() {
+export function getSaveContent(): string {
return JSON.stringify({
items: get(items),
translations: {
diff --git a/app/javascript/src/utils/files.js b/app/javascript/src/utils/files.ts
similarity index 66%
rename from app/javascript/src/utils/files.js
rename to app/javascript/src/utils/files.ts
index 37aec7209..4c65c4df1 100644
--- a/app/javascript/src/utils/files.js
+++ b/app/javascript/src/utils/files.ts
@@ -1,4 +1,4 @@
-export async function getMostRecentFileFromDirectory(directoryHandle) {
+export async function getMostRecentFileFromDirectory(directoryHandle: FileSystemDirectoryHandle): Promise {
let mostRecentFile = null
let mostRecentDate = new Date(0)
@@ -8,9 +8,11 @@ export async function getMostRecentFileFromDirectory(directoryHandle) {
const fileHandle = await directoryHandle.getFileHandle(entry.name)
const file = await fileHandle.getFile()
- if (file.lastModifiedDate < mostRecentDate) continue
+ const lastModifiedDate = new Date(file.lastModified)
- mostRecentDate = file.lastModifiedDate
+ if (lastModifiedDate < mostRecentDate) continue
+
+ mostRecentDate = lastModifiedDate
mostRecentFile = file
}
diff --git a/app/javascript/src/utils/is-crawler.js b/app/javascript/src/utils/is-crawler.ts
similarity index 97%
rename from app/javascript/src/utils/is-crawler.js
rename to app/javascript/src/utils/is-crawler.ts
index 44bbd3d62..4010b1b87 100644
--- a/app/javascript/src/utils/is-crawler.js
+++ b/app/javascript/src/utils/is-crawler.ts
@@ -1,6 +1,7 @@
-export default function isCrawler() {
+export default function isCrawler(): boolean {
const botPattern = "(googlebot\/|bot|Googlebot-Mobile|Googlebot-Image|Google favicon|Mediapartners-Google|bingbot|slurp|java|wget|curl|Commons-HttpClient|Python-urllib|libwww|httpunit|nutch|phpcrawl|msnbot|jyxobot|FAST-WebCrawler|FAST Enterprise Crawler|biglotron|teoma|convera|seekbot|gigablast|exabot|ngbot|ia_archiver|GingerCrawler|webmon |httrack|webcrawler|grub.org|UsineNouvelleCrawler|antibot|netresearchserver|speedy|fluffy|bibnum.bnf|findlink|msrbot|panscient|yacybot|AISearchBot|IOI|ips-agent|tagoobot|MJ12bot|dotbot|woriobot|yanga|buzzbot|mlbot|yandexbot|purebot|Linguee Bot|Voyager|CyberPatrol|voilabot|baiduspider|citeseerxbot|spbot|twengabot|postrank|turnitinbot|scribdbot|page2rss|sitebot|linkdex|Adidxbot|blekkobot|ezooms|dotbot|Mail.RU_Bot|discobot|heritrix|findthatfile|europarchive.org|NerdByNature.Bot|sistrix crawler|ahrefsbot|Aboundex|domaincrawler|wbsearchbot|summify|ccbot|edisterbot|seznambot|ec2linkfinder|gslfbot|aihitbot|intelium_bot|facebookexternalhit|yeti|RetrevoPageAnalyzer|lb-spider|sogou|lssbot|careerbot|wotbox|wocbot|ichiro|DuckDuckBot|lssrocketcrawler|drupact|webcompanycrawler|acoonbot|openindexspider|gnam gnam spider|web-archive-net.com.bot|backlinkcrawler|coccoc|integromedb|content crawler spider|toplistbot|seokicks-robot|it2media-domain-crawler|ip-web-crawler.com|siteexplorer.info|elisabot|proximic|changedetection|blexbot|arabot|WeSEE:Search|niki-bot|CrystalSemanticsBot|rogerbot|360Spider|psbot|InterfaxScanBot|Lipperhey SEO Service|CC Metadata Scaper|g00g1e.net|GrapeshotCrawler|urlappendbot|brainobot|fr-crawler|binlar|SimpleCrawler|Livelapbot|Twitterbot|cXensebot|smtbot|bnf.fr_bot|A6-Indexer|ADmantX|Facebot|Twitterbot|OrangeBot|memorybot|AdvBot|MegaIndex|SemanticScholarBot|ltx71|nerdybot|xovibot|BUbiNG|Qwantify|archive.org_bot|Applebot|TweetmemeBot|crawler4j|findxbot|SemrushBot|yoozBot|lipperhey|y!j-asr|Domain Re-Animator Bot|AddThis)"
const re = new RegExp(botPattern, "i")
const userAgent = navigator.userAgent
+
return re.test(userAgent)
}
diff --git a/app/javascript/src/utils/operators.js b/app/javascript/src/utils/operators.ts
similarity index 72%
rename from app/javascript/src/utils/operators.js
rename to app/javascript/src/utils/operators.ts
index 691cb8331..8c2137e1d 100644
--- a/app/javascript/src/utils/operators.js
+++ b/app/javascript/src/utils/operators.ts
@@ -1,29 +1,32 @@
-export const comparisonOperators = {
+import type { ComparisonOperator } from "@src/types/editor"
+
+export const comparisonOperators: Record = {
"!": {
type: "unary-left",
order: -1,
- eval: (v) => !v
+ eval: (v: string) => !v
},
"==": {
type: "binary",
order: 0,
- eval: (l, r) => l === r
+ eval: (l: string, r: string) => l === r
},
"!=": {
type: "binary",
order: 0,
- eval: (l, r) => l !== r
+ eval: (l: string, r: string) => l !== r
},
"*=": {
type: "binary",
order: 0,
- eval: (l, r) => l.includes(r)
+ eval: (l: string, r: string) => l.includes(r)
},
"~=": {
type: "binary",
order: 0,
_regexRegex: /^\/(.+)\/(\w*)$/,
- eval(l, r) {
+ eval(l: string, r: string) {
+ // @ts-ignore
const match = r.match(this._regexRegex)
if (!match) {
return false
@@ -32,7 +35,7 @@ export const comparisonOperators = {
let regex
try {
regex = new RegExp(pattern, flags)
- } catch (err) {
+ } catch {
return false
}
return regex.test(l)
@@ -41,12 +44,12 @@ export const comparisonOperators = {
"&&": {
type: "binary",
order: 1,
- eval: (l, r) => l && r
+ eval: (l: string, r: string) => l && r
},
"||": {
type: "binary",
order: 1,
- eval: (l, r) => l || r
+ eval: (l: string, r: string) => l || r
}
}
@@ -54,6 +57,7 @@ export const sortedComparisonOperatorsSymbols = Object.keys(comparisonOperators)
.sort((a, b) => {
// sort descending by order, so higher order operators are matched first
// and the evaluation works with normal logic
+ // @ts-ignore
const orderRank = comparisonOperators[b].order - comparisonOperators[a].order
if (orderRank === 0) {
// sort descending by length, so longer operators are matched first
diff --git a/app/javascript/src/utils/parse.js b/app/javascript/src/utils/parse.ts
similarity index 55%
rename from app/javascript/src/utils/parse.js
rename to app/javascript/src/utils/parse.ts
index b958df98c..4d6415c61 100644
--- a/app/javascript/src/utils/parse.js
+++ b/app/javascript/src/utils/parse.ts
@@ -1,4 +1,7 @@
-export function getClosingBracket(content, characterOpen = "{", characterClose = "}", start = 0) {
+import type { Line } from "@codemirror/state"
+import type { ConfigType, Range } from "@src/types/editor"
+
+export function getClosingBracket(content: string, characterOpen = "{", characterClose = "}", start = 0): number {
let closePos = start
let counter = 1
let initial = true
@@ -17,10 +20,11 @@ export function getClosingBracket(content, characterOpen = "{", characterClose =
return closePos
}
-export function splitArgumentsString(content) {
+export function splitArgumentsString(content: string): string[] {
let ignoredByString = false
let ignoredByBrackets = 0
- const commaIndexes = []
+
+ const commaIndexes: number[] = []
for (let i = 0; i < content.length; i++) {
if (content[i] == "\\")
@@ -47,7 +51,7 @@ export function splitArgumentsString(content) {
return splitArguments
}
-export function getSettings(value) {
+export function getSettings(value: string): number[] {
const regex = new RegExp(/settings/)
const match = regex.exec(value)
if (!match) return []
@@ -58,12 +62,13 @@ export function getSettings(value) {
return [match.index, untilIndex + 1]
}
-export function replaceBetween(origin, replace, startIndex, endIndex) {
+export function replaceBetween(origin: string, replace: string, startIndex: number, endIndex: number): string {
return origin.substring(0, startIndex) + replace + origin.substring(endIndex)
}
-export function getPhraseEnd(text, start, direction = 1) {
+export function getPhraseEnd(text: string, start: number, direction = 1): number {
let lastValidCharacterPosition = start
+
for (let i = 1; i < 100; i++) {
const char = text[start + i * direction]
if (char !== undefined && /[A-Za-z\- ]/.test(char)) lastValidCharacterPosition += direction
@@ -73,7 +78,7 @@ export function getPhraseEnd(text, start, direction = 1) {
return lastValidCharacterPosition
}
-export function getPhraseFromPosition(line, position) {
+export function getPhraseFromPosition(line: Line, position: number): { start: number, end: number, text: string } {
const start = getPhraseEnd(line.text, position - line.from, -1)
const end = getPhraseEnd(line.text, position - line.from, 1)
@@ -84,22 +89,23 @@ export function getPhraseFromPosition(line, position) {
}
}
-export function getPhraseFromIndex(text, index) {
+export function getPhraseFromIndex(text: string, index: number): string {
const start = getPhraseEnd(text, index, -1)
const end = getPhraseEnd(text, index, 1)
return text.slice(start, end + 1).trim()
}
-export function removeSurroundingParenthesis(source) {
+export function removeSurroundingParenthesis(source: string): string {
const openMatch = /^[\s\n]*\(/.exec(source)
const closeMatch = /\)[\s\n]*$/.exec(source)
+
return openMatch != null && closeMatch != null
? removeSurroundingParenthesis(source.substring(openMatch.index + openMatch[0].length, closeMatch.index))
: source
}
-export function findRangesOfStrings(source) {
+export function findRangesOfStrings(source: string): Range[] {
const foundRanges = []
let currentRangeIndex = null
@@ -109,7 +115,7 @@ export function findRangesOfStrings(source) {
if (currentRangeIndex == null) {
currentRangeIndex = i
} else if (source[i - 1] !== "\\") {
- const range = [currentRangeIndex, i + 1]
+ const range: Range = [currentRangeIndex, i + 1]
foundRanges.push(range)
currentRangeIndex = null
@@ -120,19 +126,20 @@ export function findRangesOfStrings(source) {
return foundRanges
}
-export function matchAllOutsideRanges(ranges, content, regex) {
+export function matchAllOutsideRanges(ranges: Range[], content: string, regex: RegExp): RegExpExecArray[] {
const matches = []
for (const match of content.matchAll(regex)) {
- const isInsideRange = ranges.some((range) => match.index >= range[0] && match.index + match[0].length <= range[1])
- if (isInsideRange) {
- continue
- }
+ const isInsideRange = ranges.some((range: Range) => match.index >= range[0] && match.index + match[0].length <= range[1])
+
+ if (isInsideRange) continue
+
matches.push(match)
}
+
return matches
}
-export function getCommasIndexesOutsideQuotes(string) {
+export function getCommasIndexesOutsideQuotes(string: string): number[] {
const commaIndexes = []
const stringRanges = findRangesOfStrings(string)
@@ -145,3 +152,46 @@ export function getCommasIndexesOutsideQuotes(string) {
return commaIndexes
}
+
+/** Returns whether the position is inside event, conditions, actions, or none */
+export function inConfigType(content: string, startIndex = 0): ConfigType | null {
+ if (startIndex > content.length) return null
+ if (startIndex < 0) return null
+
+ let bracketCount = 0
+ let index = 0
+ for (index = startIndex - 1; index >= 0; index--) {
+ if (content[index] === "{") bracketCount--
+ if (content[index] === "}") bracketCount++
+
+ if (bracketCount < 0) {
+ if (/\s/.test(content[index]) || content[index] === "{") continue
+
+ if (content[index] === "(") bracketCount++
+ else break
+ }
+ }
+
+ if (index <= 0) return null
+
+ const phraseStart = getPhraseEnd(content, index, -1)
+ const phraseEnd = getPhraseEnd(content, index, 1)
+ const phrase = content.slice(phraseStart, phraseEnd + 1).trim()
+
+ if (["event", "conditions", "actions"].includes(phrase)) return phrase as ConfigType
+
+ return null
+}
+
+/** Whether or not a position is inside a value, determine by being inside of parenthesis `()` */
+export function isInValue(content: string, startIndex = 0): boolean {
+ let parenthesisCount = 0
+ let index = 0
+ for (index = startIndex - 1; index >= 0; index--) {
+ if (content[index] === "(") parenthesisCount--
+ if (content[index] === ")") parenthesisCount++
+ if (content[index] === ";") break
+ }
+
+ return parenthesisCount < 0
+}
diff --git a/app/javascript/src/utils/project.js b/app/javascript/src/utils/project.ts
similarity index 76%
rename from app/javascript/src/utils/project.js
rename to app/javascript/src/utils/project.ts
index 7fe6f7917..0e3397b2a 100644
--- a/app/javascript/src/utils/project.js
+++ b/app/javascript/src/utils/project.ts
@@ -4,8 +4,9 @@ import { addAlert } from "@lib/alerts"
import { projects, currentProjectUUID, currentProject, recoveredProject, items, currentItem, isSignedIn } from "@stores/editor"
import { translationKeys, defaultLanguage, selectedLanguages } from "@stores/translationKeys"
import { get } from "svelte/store"
+import type { Project, RecoveredProject } from "@src/types/editor"
-export async function createProject(title, content = null) {
+export async function createProject(title: string, content = null): Promise {
if (!get(isSignedIn)) return createDemoProject(title)
return await new FetchRails("/projects", { project: { title, content, content_type: "workshop_codes" } }).post()
@@ -16,7 +17,7 @@ export async function createProject(title, content = null) {
projects.set([parsedData, ...get(projects)])
currentProjectUUID.set(parsedData.uuid)
- currentItem.set({})
+ currentItem.set(null)
items.set([])
return parsedData
@@ -29,23 +30,26 @@ export async function createProject(title, content = null) {
})
}
-export function createDemoProject(title) {
- const newProject = {
+export function createDemoProject(title: string): Project {
+ const newProject: Project = {
uuid: Math.random().toString(16).substring(2, 8),
title,
+ content: "",
is_owner: true
}
projects.set([newProject, ...get(projects)])
currentProjectUUID.set(newProject.uuid)
- currentItem.set({})
+ currentItem.set(null)
items.set([])
+
+ return newProject
}
-export async function fetchProject(uuid) {
+export async function fetchProject(uuid: string): Promise {
const baseUrl = "/projects/"
- const localProject = getProjectFromLocalStorage(uuid)
+ const localProject: RecoveredProject = getProjectFromLocalStorage(uuid)
return await new FetchRails(baseUrl + uuid).get()
.then(data => {
@@ -72,7 +76,7 @@ export async function fetchProject(uuid) {
})
currentProjectUUID.set(parsedData.uuid)
- currentItem.set({})
+ currentItem.set(null)
updateProjectContent(parsedData.content)
@@ -80,13 +84,13 @@ export async function fetchProject(uuid) {
})
.catch(error => {
items.set([])
- currentItem.set({})
+ currentItem.set(null)
console.error(error)
alert(`Something went wrong while loading, please try again. ${error}`)
})
}
-export function updateProjectContent(content) {
+export function updateProjectContent(content: string): void {
const parsedContent = JSON.parse(content)
items.set(parsedContent?.items || parsedContent || [])
@@ -95,16 +99,17 @@ export function updateProjectContent(content) {
defaultLanguage.set(parsedContent?.translations?.defaultLanguage || "en-US")
}
-function getProjectFromLocalStorage(uuid) {
+function getProjectFromLocalStorage(uuid: string): RecoveredProject {
const localContent = JSON.parse(localStorage.getItem("saved-projects") || "{}")
return localContent[uuid]
}
-export function updateProject(uuid, params) {
+export function updateProject(uuid: string, params: object): void {
get(projects).forEach(project => {
if (project.uuid != uuid) return
Object.entries(params).forEach(([key, value]) => {
+ // @ts-ignore
project[key] = value
})
})
@@ -112,16 +117,16 @@ export function updateProject(uuid, params) {
projects.set([...get(projects)])
}
-export async function renameCurrentProject(value) {
- return await new FetchRails(`/projects/${get(currentProjectUUID)}`).request("PATCH", { parameters: { body: JSON.stringify({ project: { title: value } }) } })
+export async function renameCurrentProject(title: string): Promise {
+ return await new FetchRails(`/projects/${get(currentProjectUUID)}`).request("PATCH", { parameters: { body: JSON.stringify({ project: { title } }) } })
.then(data => {
if (!data) throw Error("Project rename failed")
- updateProject(get(currentProjectUUID), {
- title: value
+ updateProject(get(currentProjectUUID)!, {
+ title
})
- addAlert(`Project renamed to "${get(currentProject).title}"`)
+ addAlert(`Project renamed to "${get(currentProject)!.title}"`)
return data
})
@@ -131,14 +136,14 @@ export async function renameCurrentProject(value) {
})
}
-export async function destroyCurrentProject() {
+export async function destroyCurrentProject(): Promise {
return await new FetchRails(`/projects/${get(currentProjectUUID)}`).post({ method: "delete" })
.then(data => {
if (!data) throw Error("Destroying current project failed")
projects.set(get(projects).filter(p => p.uuid != get(currentProjectUUID)))
currentProjectUUID.set(null)
- currentItem.set({})
+ currentItem.set(null)
return data
})
@@ -148,8 +153,8 @@ export async function destroyCurrentProject() {
})
}
-export function setUrl(uuid) {
- const url = new URL(window.location)
+export function setUrl(uuid: string): void {
+ const url = new URL(window.location.toString())
if (uuid) url.searchParams.set("uuid", uuid)
else url.searchParams.delete("uuid")
window.history.replaceState("", "", url)
diff --git a/app/javascript/src/utils/projectBackups.js b/app/javascript/src/utils/projectBackups.ts
similarity index 74%
rename from app/javascript/src/utils/projectBackups.js
rename to app/javascript/src/utils/projectBackups.ts
index a41c712dd..fbfb57303 100644
--- a/app/javascript/src/utils/projectBackups.js
+++ b/app/javascript/src/utils/projectBackups.ts
@@ -1,10 +1,12 @@
import FetchRails from "@src/fetch-rails"
import { addAlert } from "@lib/alerts"
+import type { ProjectBackup } from "@src/types/editor"
-export async function createProjectBackup(uuid) {
+export async function createProjectBackup(uuid: string): Promise {
return await new FetchRails("/project_backups", { project: { uuid } }).post()
.then(data => {
if (!data) throw Error("Creating backup failed")
+
addAlert("Backup successfully created")
})
.catch(error => {
@@ -13,10 +15,10 @@ export async function createProjectBackup(uuid) {
})
}
-export async function fetchBackupsForProject(uuid) {
+export async function fetchBackupsForProject(uuid: string): Promise {
return await new FetchRails(`/project_backups?uuid=${uuid}`).get()
.then(data => {
- return JSON.parse(data)
+ return JSON.parse(data) as ProjectBackup[]
})
.catch(error => {
console.error(error)
@@ -24,10 +26,11 @@ export async function fetchBackupsForProject(uuid) {
})
}
-export async function destroyBackup(uuid) {
+export async function destroyBackup(uuid: string): Promise {
return await new FetchRails(`/project_backups/${uuid}`).post({ method: "delete" })
.then(data => {
if (!data) throw Error("Destroying current project failed")
+
return true
})
.catch(error => {
@@ -37,11 +40,12 @@ export async function destroyBackup(uuid) {
})
}
-export async function fetchBackupContent(uuid) {
+export async function fetchBackupContent(uuid: string): Promise {
return await new FetchRails(`/project_backups/${uuid}`).get()
.then(data => {
if (!data) throw new Error("Fetch contained no data")
- return JSON.parse(data)
+
+ return JSON.parse(data) as ProjectBackup
})
.catch(error => {
console.error(error)
diff --git a/app/javascript/src/utils/setCssVariable.js b/app/javascript/src/utils/setCssVariable.js
deleted file mode 100644
index c3e252f9e..000000000
--- a/app/javascript/src/utils/setCssVariable.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export function setCssVariable(key, value) {
- document.body.style.setProperty(`--${key}`, value)
-}
diff --git a/app/javascript/src/utils/setCssVariable.ts b/app/javascript/src/utils/setCssVariable.ts
new file mode 100644
index 000000000..a4d0d5945
--- /dev/null
+++ b/app/javascript/src/utils/setCssVariable.ts
@@ -0,0 +1,3 @@
+export function setCssVariable(key: string, value: string): void {
+ document.body.style.setProperty(`--${key}`, value)
+}
diff --git a/app/javascript/src/utils/text.js b/app/javascript/src/utils/text.ts
similarity index 63%
rename from app/javascript/src/utils/text.js
rename to app/javascript/src/utils/text.ts
index eede2fb98..e4dcef7af 100644
--- a/app/javascript/src/utils/text.js
+++ b/app/javascript/src/utils/text.ts
@@ -1,3 +1,3 @@
-export function toCapitalize(string) {
+export function toCapitalize(string: string): string {
return string.toLowerCase().replace(/(^\w{1})|(\s+\w{1})/g, letter => letter.toUpperCase())
}
diff --git a/app/javascript/src/wiki/search.js b/app/javascript/src/wiki/search.ts
similarity index 69%
rename from app/javascript/src/wiki/search.js
rename to app/javascript/src/wiki/search.ts
index bef0cbd20..2fdf754cf 100644
--- a/app/javascript/src/wiki/search.js
+++ b/app/javascript/src/wiki/search.ts
@@ -1,7 +1,8 @@
import debounce from "@src/debounce"
import FetchRails from "@src/fetch-rails"
+import type { WikiArticle } from "@src/types/wiki"
-export function bind() {
+export function bind(): void {
const element = document.querySelector("[data-role='wiki-search']")
if (!element) return
@@ -11,30 +12,33 @@ export function bind() {
}
const searchWiki = debounce(() => {
- const element = document.querySelector("[data-role='wiki-search']")
+ const element = document.querySelector("[data-role='wiki-search']") as HTMLFormElement
if (!element.value) return
- const resultsElement = document.querySelector("[data-role='wiki-search-results']")
+ const resultsElement = document.querySelector("[data-role='wiki-search-results']") as HTMLElement
resultsElement.innerHTML = "Searching..."
- const url = resultsElement.dataset.url.replace("query", element.value) + ".json"
+ resultsElement.classList.add("search__results--empty")
+ const url = resultsElement.dataset.url!.replace("query", element.value) + ".json"
new FetchRails(url).get()
.then(data => {
setWikiSearchResults(JSON.parse(data))
})
-}, 500)
+}, 250)
-function setWikiSearchResults(data) {
- const resultsElement = document.querySelector("[data-role='wiki-search-results']")
- resultsElement.innerHTML = ""
+function setWikiSearchResults(data: WikiArticle[]): void {
+ const resultsElement = document.querySelector("[data-role='wiki-search-results']") as HTMLElement
if (!data.length) {
- resultsElement.innerText = "No results found"
+ resultsElement.innerHTML = "No results found"
return
}
- data.forEach(item => {
+ resultsElement.classList.remove("search__results--empty")
+ resultsElement.innerHTML = ""
+
+ data.forEach((item: WikiArticle) => {
const itemElement = document.createElement("a")
itemElement.classList.add("search__item")
itemElement.innerText = item.title
diff --git a/app/javascript/src/youtube-preview.ts b/app/javascript/src/youtube-preview.ts
new file mode 100644
index 000000000..62509656b
--- /dev/null
+++ b/app/javascript/src/youtube-preview.ts
@@ -0,0 +1,45 @@
+export function bind(): void {
+ const youtubePreviewButtons = document.querySelectorAll("[data-action~='youtube-preview']")
+ youtubePreviewButtons.forEach(element => {
+ element.removeAndAddEventListener("click", load)
+ element.removeAndAddEventListener("keydown", keydown)
+ })
+}
+
+function load(event: MouseEvent | KeyboardEvent): void {
+ event.preventDefault()
+
+ const currentTarget = event.currentTarget as HTMLAnchorElement
+
+ if (currentTarget.dataset.loaded === "true") return
+
+ const youtubeId = currentTarget.dataset.id
+ const parent = currentTarget.closest(".video")
+ const images = parent!.querySelectorAll("img")
+ const iframe = createIframe(youtubeId!)
+
+ images.forEach(image => image.remove())
+ parent!.innerHTML = ""
+ parent!.insertAdjacentElement("beforeend", iframe)
+ currentTarget.dataset.loaded = "true"
+}
+
+function createIframe(youtubeId: string): HTMLIFrameElement {
+ const element = document.createElement("iframe")
+
+ element.width = "560"
+ element.height = "315"
+ element.allowFullscreen = true
+ element.src = `https://www.youtube-nocookie.com/embed/${youtubeId}?autoplay=1&playsinline=1&enablejsapi=1`
+ element.frameBorder = "0"
+ element.allow = "autoplay"
+ element.classList.add("video__iframe")
+
+ return element
+}
+
+function keydown(event: KeyboardEvent): void {
+ if (event.key !== "Enter" && event.key !== " ") return
+
+ load(event)
+}
diff --git a/app/javascript/test/favorite.test.js b/app/javascript/test/favorite.test.js
index 48cec6319..13b002096 100644
--- a/app/javascript/test/favorite.test.js
+++ b/app/javascript/test/favorite.test.js
@@ -1,7 +1,9 @@
// @vitest-environment jsdom
-import { toggleFavorite } from "@src/favorite"
+import { bind, toggleFavorite } from "@src/favorite"
import { vi, describe, it, expect } from "vitest"
+import { fireEvent } from "@testing-library/dom"
+import "@src/remove-and-add-event-listener"
vi.mock("@rails/ujs", () => ({
default: {
@@ -9,7 +11,59 @@ vi.mock("@rails/ujs", () => ({
}
}))
-describe("favorite.js", () => {
+describe("favorite.ts", () => {
+ describe("bind", () => {
+ it("Should call toggleFavorite when correct element is clicked", async() => {
+ const element = document.createElement("button")
+ element.dataset.action = "favorite"
+ const imageElement = document.createElement("img")
+ element.insertAdjacentElement("afterbegin", imageElement)
+ document.body.insertAdjacentElement("afterbegin", element)
+
+ bind()
+
+ const button = document.querySelector("button")
+ fireEvent.click(button)
+
+ expect(button.dataset.active).toBe("true")
+ })
+
+ it("Should not call toggleFavorite when body element is clicked", async() => {
+ const element = document.createElement("button")
+ element.dataset.action = "favorite"
+ const imageElement = document.createElement("img")
+ element.insertAdjacentElement("afterbegin", imageElement)
+ document.body.insertAdjacentElement("afterbegin", element)
+
+ bind()
+
+ fireEvent.click(document.body)
+
+ const button = document.querySelector("button")
+ expect(button.dataset.active).not.toBe("true")
+ })
+
+ it("Should call toggleFavorite only on related element", async() => {
+ const element = document.createElement("button")
+ element.dataset.action = "favorite"
+ const imageElement = document.createElement("img")
+ element.insertAdjacentElement("afterbegin", imageElement)
+ const element2 = document.createElement("button")
+ element2.dataset.action = "favorite"
+ element2.insertAdjacentElement("afterbegin", imageElement)
+ document.body.insertAdjacentElement("afterbegin", element)
+ document.body.insertAdjacentElement("afterbegin", element2)
+
+ bind()
+
+ const [button1, button2] = document.querySelectorAll("button")
+ fireEvent.click(button1)
+
+ expect(button1.dataset.active).toBe("true")
+ expect(button2.dataset.active).not.toBe("true")
+ })
+ })
+
describe("toggleFavorite", () => {
it("Should set active state when not active", () => {
const element = document.createElement("div")
@@ -25,6 +79,7 @@ describe("favorite.js", () => {
it("Should set inactive state when active", () => {
const element = document.createElement("div")
const imageElement = document.createElement("img")
+ imageElement.src = "some-src"
element.insertAdjacentElement("afterbegin", imageElement)
element.dataset.active = "true"
diff --git a/app/javascript/test/utils/compiler/parameterObjects.test.js b/app/javascript/test/utils/compiler/parameterObjects.test.js
index 93c9b54ed..b245eff79 100644
--- a/app/javascript/test/utils/compiler/parameterObjects.test.js
+++ b/app/javascript/test/utils/compiler/parameterObjects.test.js
@@ -1,4 +1,4 @@
-import { getFirstParameterObject, replaceParameterObject, evaluateParameterObjects, parseParameterObjectContent } from "@utils/compiler/parameterObjects"
+import { getFirstParameterObject, replaceParameterObject, evaluateParameterObjects, parseParameterObjectContent, directlyInsideParameterObject } from "@utils/compiler/parameterObjects"
import { completionsMap } from "@stores/editor"
import { describe, it, expect, beforeEach } from "vitest"
@@ -29,6 +29,7 @@ describe("parameterObjects.js", () => {
start: 12,
end: 33,
given: { One: "10", Three: "20" },
+ givenKeys: ["One", "Three"],
phraseParameters: ["One", "Two", "Three"],
phraseDefaults: [0, 0, 0]
}
@@ -42,8 +43,10 @@ describe("parameterObjects.js", () => {
start: 18,
end: 39,
given: { One: "10", Three: "20" },
+ givenKeys: ["One", "Three"],
phraseParameters: [],
- phraseDefaults: []
+ phraseDefaults: [],
+ phraseTypes: []
}
expect(getFirstParameterObject(content)).toEqual(expected)
@@ -52,6 +55,7 @@ describe("parameterObjects.js", () => {
it("Should handle white space as expected", () => {
const expected = {
given: { One: "10", Three: "20" },
+ givenKeys: ["One", "Three"],
phraseParameters: ["One", "Two", "Three"],
phraseDefaults: [0, 0, 0]
}
@@ -73,6 +77,7 @@ describe("parameterObjects.js", () => {
start: 49,
end: 58,
given: { Two: "2" },
+ givenKeys: ["Two"],
phraseParameters: ["One", "Two", "Three"],
phraseDefaults: [0, 0, 0]
}
@@ -86,6 +91,21 @@ describe("parameterObjects.js", () => {
start: 45,
end: 168,
given: { One: "10", Three: "20" },
+ givenKeys: ["One", "Three"],
+ phraseParameters: ["One", "Two", "Three"],
+ phraseDefaults: [0, 0, 0]
+ }
+
+ expect(getFirstParameterObject(content)).toEqual(expected)
+ })
+
+ it("Should keep duplicated keys in givenKeys array and keep the last given value", () => {
+ const content = "Some Action({ One: 10, Three: 20, Three: 30 })"
+ const expected = {
+ start: 12,
+ end: 44,
+ given: { One: "10", Three: "30" },
+ givenKeys: ["One", "Three", "Three"],
phraseParameters: ["One", "Two", "Three"],
phraseDefaults: [0, 0, 0]
}
@@ -102,7 +122,7 @@ describe("parameterObjects.js", () => {
Three: "20"
}
- expect(parseParameterObjectContent(content)).toEqual(expected)
+ expect(parseParameterObjectContent(content).result).toEqual(expected)
})
it("Should ignore [linemarker]s", () => {
@@ -112,7 +132,7 @@ describe("parameterObjects.js", () => {
Three: "20"
}
- expect(parseParameterObjectContent(content)).toEqual(expected)
+ expect(parseParameterObjectContent(content).result).toEqual(expected)
})
})
@@ -211,4 +231,65 @@ describe("parameterObjects.js", () => {
expect(evaluateParameterObjects(input)).toBe(expected)
})
})
+
+ describe("directlyInsideParameterObject", () => {
+ it("Should return parameter object only when cursor is inside", () => {
+ const input = "Some Action({ Key: Value })"
+
+ expect(directlyInsideParameterObject(input, 13)).toBeTruthy()
+ expect(directlyInsideParameterObject(input, 17)).toBeTruthy()
+ expect(directlyInsideParameterObject(input, 5)).toBe(null)
+ expect(directlyInsideParameterObject(input, input.length)).toBe(null)
+ })
+
+ it("Should not return parameter object when cursor is in value", () => {
+ const input = "Some Action({ Key: Value })"
+
+ expect(directlyInsideParameterObject(input, 20)).toBe(null)
+ expect(directlyInsideParameterObject(input, 25)).toBe(null)
+ })
+
+ it("Should return correct parameter object when nested", () => {
+ const input = `Some Action({
+ One: Some Second Action({ First: Some Value }),
+ })`
+
+ expect(directlyInsideParameterObject(input, 14).phraseDefaults).toEqual([0, 0, 0])
+ expect(directlyInsideParameterObject(input, 48).phraseDefaults).toEqual(["A", "B", "C", "D"])
+ expect(directlyInsideParameterObject(input, 70).phraseDefaults).toEqual([0, 0, 0])
+ })
+
+ it("Should return null when no parameter object was given", () => {
+ const input = "Some Action()"
+
+ for(let i = 0; i < input.length; i++) {
+ expect(directlyInsideParameterObject(input, i)).toBe(null)
+ }
+ })
+
+ it("Should return null when no content was given", () => {
+ const input = ""
+
+ expect(directlyInsideParameterObject(input, 0)).toBe(null)
+ })
+
+ it("Should return null when index was out of range of string was given", () => {
+ const input = "Some Action({})"
+
+ expect(directlyInsideParameterObject(input, 30)).toBe(null)
+ expect(directlyInsideParameterObject(input, -1)).toBe(null)
+ })
+
+ it("Should return null parameter key was not finished properly and cursor is after object", () => {
+ const input = "Some Action({ One }); Two"
+
+ expect(directlyInsideParameterObject(input, 20)).toBe(null)
+ })
+
+ it("Should ignore objects that use curly brackets but are not parameter objects", () => {
+ const input = "conditions { test; Some Action({ One }); }"
+
+ expect(directlyInsideParameterObject(input, 15)).toBe(null)
+ })
+ })
})
diff --git a/app/javascript/test/utils/editor.test.js b/app/javascript/test/utils/editor.test.js
index 8ab6347c9..e367443e4 100644
--- a/app/javascript/test/utils/editor.test.js
+++ b/app/javascript/test/utils/editor.test.js
@@ -78,11 +78,11 @@ describe("editor.js", () => {
it("Should unset currentItem if currentItem is the removed item or parent", () => {
currentItem.set({ id: 2 })
destroyItem(2)
- expect(get(currentItem)).toEqual({})
+ expect(get(currentItem)).toEqual(null)
currentItem.set({ parent: 1 })
destroyItem(1)
- expect(get(currentItem)).toEqual({})
+ expect(get(currentItem)).toEqual(null)
})
})
diff --git a/app/javascript/test/utils/parse.test.js b/app/javascript/test/utils/parse.test.js
index 50ef5f714..1386d2a68 100644
--- a/app/javascript/test/utils/parse.test.js
+++ b/app/javascript/test/utils/parse.test.js
@@ -1,4 +1,4 @@
-import { getClosingBracket, getPhraseEnd, getPhraseFromPosition, getSettings, removeSurroundingParenthesis, replaceBetween, splitArgumentsString, getCommasIndexesOutsideQuotes } from "@utils/parse"
+import { getClosingBracket, getPhraseEnd, getPhraseFromPosition, getSettings, removeSurroundingParenthesis, replaceBetween, splitArgumentsString, getCommasIndexesOutsideQuotes, inConfigType, isInValue } from "@utils/parse"
import { describe, it, expect } from "vitest"
describe("parse.js", () => {
@@ -160,4 +160,84 @@ describe("parse.js", () => {
expect(getCommasIndexesOutsideQuotes("")).toEqual([])
})
})
+
+ describe("inConfigType", () => {
+ it("Should return relevant config type when position is inside", () => {
+ const input = "event { some event } actions { some action } conditions { some condition }"
+
+ expect(inConfigType(input, 10)).toBe("event")
+ expect(inConfigType(input, 30)).toBe("actions")
+ expect(inConfigType(input, 60)).toBe("conditions")
+ })
+
+ it("Should return relevant config type regardless of white space", () => {
+ const input = `event
+ {
+ some event }
+
+ actions { some action }
+
+ conditions
+ { some condition }`
+
+ expect(inConfigType(input, 25)).toBe("event")
+ expect(inConfigType(input, 55)).toBe("actions")
+ expect(inConfigType(input, 95)).toBe("conditions")
+ })
+
+ it("Should return null when no relevant phrases are given", () => {
+ expect(inConfigType("Some phrase", 5)).toBe(null)
+ expect(inConfigType("Hey", 2)).toBe(null)
+ expect(inConfigType("actions", 3)).toBe(null)
+ })
+
+ it("Should return null when start index is out of range", () => {
+ expect(inConfigType("actions { }", 50)).toBe(null)
+ expect(inConfigType("actions { }", -1)).toBe(null)
+ expect(inConfigType("actions { }", 0)).toBe(null)
+ })
+ })
+
+ describe("isInValue", () => {
+ it("Should return true if position is inside of parenthesis", () => {
+ const input = "Some Action(Some Value) Some Second Action(Some Value)"
+
+ expect(isInValue(input, 15)).toBe(true)
+ expect(isInValue(input, 45)).toBe(true)
+ })
+
+ it("Should return false if position is not inside of parenthesis", () => {
+ const input = "Some Action(Some Value) Some Second Action(Some Value)"
+
+ expect(isInValue(input, 5)).toBe(false)
+ expect(isInValue(input, 35)).toBe(false)
+ })
+
+ it("Should return false if position is outside of range", () => {
+ const input = "Some Action(Some Value) Some Second Action(Some Value)"
+
+ expect(isInValue(input, -5)).toBe(false)
+ expect(isInValue(input, 100)).toBe(false)
+ })
+
+ it("Should return true if inside of theoretical value, even if it's not closed", () => {
+ const input = "Some Action(Some Value"
+
+ expect(isInValue(input, 15)).toBe(true)
+ })
+
+ it("Should return true if entire input is in parenthesis", () => {
+ const input = "(Some Value)"
+
+ expect(isInValue(input, 5)).toBe(true)
+ })
+
+ it("Should handle nested values at any position", () => {
+ const input = "Some Action(Some Action(Some Action(Some Value)))"
+
+ for (let i = 12; i < input.length; i++) {
+ expect(isInValue(input, i)).toBe(true)
+ }
+ })
+ })
})
diff --git a/app/models/collection.rb b/app/models/collection.rb
index 1fe753d1e..64d1117ce 100644
--- a/app/models/collection.rb
+++ b/app/models/collection.rb
@@ -10,6 +10,7 @@ class Collection < ApplicationRecord
attr_accessor :collection_posts
validates :title, presence: true, length: { minimum: 3, maximum: 50 }
+ validates :nice_url, presence: true, uniqueness: true, length: { minimum: 5, maximum: 20 }, format: { with: /\A[a-z0-9-]+\z/, message: "is invalid. Only lowercase letters, numbers, and dashes are allowed." }
validates :description, length: { maximum: 1000 }
validates :cover_image, content_type: ["image/png", "image/jpg", "image/jpeg"],
size: { less_than: 2.megabytes },
diff --git a/app/models/post.rb b/app/models/post.rb
index 33ae50e78..1b2c94be0 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -124,7 +124,7 @@ class Post < ApplicationRecord
validates :locale, presence: true
validates :title, presence: true, length: { minimum: 5, maximum: 75 }
validates :code, presence: true, uniqueness: { case_sensitive: false }, length: { minimum: 5, maximum: 6 }, format: { with: /\A[A-Za-z0-9]+\z/, message: "is invalid. Only letters and numbers are allowed." }
- validates :nice_url, uniqueness: true, allow_blank: true, length: { minimum: 7, maximum: 20 }, format: { with: /\A[a-z0-9-]+\z/, message: "is invalid. Only lowercase letter, numbers, and dashes are allowed." }
+ validates :nice_url, uniqueness: true, allow_blank: true, length: { minimum: 7, maximum: 20 }, format: { with: /\A[a-z0-9-]+\z/, message: "is invalid. Only lowercase letters, numbers, and dashes are allowed." }
validates :description, length: { maximum: POST_DESCRIPTION_LIMIT }
validates :snippet, length: { maximum: POST_SNIPPET_LIMIT }
validates :categories, presence: true, array_length: { maximum: 3 }, array_part_of: { array: categories }
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index 03fe2d5ea..6dd917f91 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -18,7 +18,7 @@ def thumbnail
unless url.present?
random_with_seed = Random.new(object.id).rand(object.maps.length)
- maps_array = YAML.load(File.read(Rails.root.join("config/arrays", "maps.yml")))
+ maps_array = YAML.safe_load(File.read(Rails.root.join("config/arrays", "maps.yml")))
map = maps_array.find { |m| m["name"] == object.maps[random_with_seed - 1] }
diff --git a/app/views/application/_body_bg.html.erb b/app/views/application/_body_bg.html.erb
index 91ce032d4..79a081815 100644
--- a/app/views/application/_body_bg.html.erb
+++ b/app/views/application/_body_bg.html.erb
@@ -17,7 +17,7 @@
<%= image_path "layout/header-bg_lg.jpg" %> 1920w"
type="image/jpg">
- <%= image_tag "layout/header-bg_lg.jpg", alt: "", height: 400, width: 1920 %>
+ <%= image_tag "layout/header-bg_lg.jpg", alt: "", height: 400, width: 1920, fetchpriority: "high" %>
<% end %>
<% if yield(:replace_body_bg) == "user" %>
@@ -28,7 +28,7 @@
<%= rails_public_blob_url(@user.banner_image.variant(quality: 90, resize_to_fill: [1920, 400]).processed) %> 1920w"
type="image/jpg">
- <%= image_tag rails_public_blob_url(@user.banner_image.variant(quality: 90, resize_to_fill: [1920, 400]).processed) %>
+ <%= image_tag rails_public_blob_url(@user.banner_image.variant(quality: 90, resize_to_fill: [1920, 400]).processed), fetchpriority: "high" %>
<% end %>