Merge pull request #2415 from LenAnderson/parser-followup-2 Parser followup 2

11608e0cb80c8bd17b30d6054e4fe8f9428b49fb

Wolfsblvt <wolfsblvt@gmail.com>

Signed
50 files changed, +5939 -498Showing whitespace changes
public/img/step-into.svg+149 -0
@@ -0,0 +1,149 @@
1+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2+<!-- Created with Inkscape (http://www.inkscape.org/) -->
3+
4+<svg
5+ width="48"
6+ height="48"
7+ viewBox="0 0 48 48"
8+ version="1.1"
9+ id="svg2120"
10+ xml:space="preserve"
11+ inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
12+ sodipodi:docname="step-into.svg"
13+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15+ xmlns:xlink="http://www.w3.org/1999/xlink"
16+ xmlns="http://www.w3.org/2000/svg"
17+ xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
18+ id="namedview2122"
19+ pagecolor="#ffffff"
20+ bordercolor="#000000"
21+ borderopacity="0.25"
22+ inkscape:showpageshadow="2"
23+ inkscape:pageopacity="0.0"
24+ inkscape:pagecheckerboard="0"
25+ inkscape:deskcolor="#d1d1d1"
26+ inkscape:document-units="px"
27+ showgrid="false"
28+ inkscape:zoom="11.313708"
29+ inkscape:cx="58.910834"
30+ inkscape:cy="25.323262"
31+ inkscape:window-width="1920"
32+ inkscape:window-height="992"
33+ inkscape:window-x="-8"
34+ inkscape:window-y="-8"
35+ inkscape:window-maximized="1"
36+ inkscape:current-layer="g2714" /><defs
37+ id="defs2117"><inkscape:path-effect
38+ effect="spiro"
39+ id="path-effect2144"
40+ is_visible="true"
41+ lpeversion="1" /><inkscape:path-effect
42+ effect="bspline"
43+ id="path-effect2138"
44+ is_visible="true"
45+ lpeversion="1"
46+ weight="33.333333"
47+ steps="2"
48+ helper_size="0"
49+ apply_no_weight="true"
50+ apply_with_weight="true"
51+ only_selected="false" /></defs><g
52+ inkscape:groupmode="layer"
53+ id="layer4"
54+ inkscape:label="img"
55+ style="display:none"><image
56+ width="305.68866"
57+ height="70.374367"
58+ preserveAspectRatio="none"
59+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARYAAABACAYAAADf7VgRAAAABHNCSVQICAgIfAhkiAAABdxJREFU
60+eJzt3T9vo0gYBvB3by+SsZRiKCI5SCkyJS6dzny3+26UpoSSFJFMOaTyUETaK3zDAQYbzGBgeH7S
61+SutN1kKj8cM7fxj/2u/3fwgAQKO/xr4AADAPggUAtEOwAIB2CBYA0A7BAgDaIVgAQDsECwBoh2AB
62+AO0QLACgHYIFALRDsACAdn+PfQFw5nkeWZZFRERSSvJ9f+Qrmg7XdSnLMorjeOxLgZYQLDBpruuS
63+4zj5a4TLPGAoBJNVDRXOOXHOR7wiaAvBApNUDRUF4TIPCBaYnKZQURAu04dggUm5FSoKwmXaECww
64+GW1DRUG4TNfDV4VUx1mtViSEICKiLMtIStn4f4qdJ8syOh6Pw14kPFxdqAghyLKs0jI8EeWvif7v
65+G1gtmpbBg8V1XbJtm4jKHYKoHBhCCIrjuDZkqr+HYDFLU6gEQUCe55X+3ff90p4fIoTLFA0WLI7j
66+kOu6rX/ftm2ybZuEEBRF0dUKBsxxLVSaIFymT/scC2OMdrtdp1Apsm2bPM8jzvlFhQPmybKs9PpW
67+qCi+71/cfKrvZQrXdYkxNvZldKK1YmGM0cfHR+3PhBAkpaTv728iIjqdTmTbNq1WK7Jtu3aYxBij
68+KIp0XuLoXNelz8/PzhWZZVnkuq5x1ZyqMDjnrUNFKVYuURQZOUTe7XZ5NR+GIaVpOvYltaItWCzL
69+qg0VNXdS1yDFf2OM0Xa7LQWMbdt3Vz5TVOwkQRC0DgjLsvK5BlPD5d5Jed/3yXEcI0OlOj+53W5n
70+Ey5ahkLFjq9IKelwOFAQBK0aIk1T8n3/ooOohp27aifZ7XathnrVtjUtbJU+wWBiqBDRRWWrwqXv
71+sKi40jYULRVLtaP3eTo3iiLKssy4/QlJkpSGfCpcrlUudYFNhAnKpZBSUhAEpZvQvZWLGkoXb9RS
72+SkqSZJD+1Lti4ZxfVBVhGPZ6zziOjfvwpGlKYRhe3IGaKpemUDkcDrMohUEPFS59KhfOOXmed/E5
73+tSwr/5nuCqZ3xfL6+lp63bXjq0naJVDhUpxLqgsXhAoU9alcGGM3q3/VB3WeAdSrYqmO1aSUnTs+
74+Yyyf0Kz+MVFT5XILQmXZ7q1cmlZpqyzL6vQ4xS29KpZqtZIkSa+LWYq6yuUahIo56irRLqr95Vrl
75+0nUksNlstE2EawsWKaVx8yJDahsuCBWzDLEa0xQu6/W68/vo0itYdIzJumyIMs2tcEGoQFt1/We1
76+WvV+j3vhzNuRNYULQsVMfTc2Nn346/qLEKLTtg2d+4FGOzbhdDrlz3aYtIv0HtVwQaiYq0+V33W1
77+UJ0U0LYS0fms1cOCpelQHjzNfKbCRf0dzqSUed9Ych+5ZwuClJLCMGy1MqQevdHl136//6Pt3Rqo
78+Z2SuMfUhMoC++u5runXSngognTe0wSuWup25dd7f3/MnoAHgTMdmyTiOKUmSh27pHzRY1Jbhtr/7
79++vqKJWuA/+jcga022FWP+RzKpFaFlrK1vw5jjNbrdX4W8K1zgJdiye1S9xR734n9R7XdoMFS3Zl7
80+i6nb+K+pOxyLcz7IuHdO0C7necfi8GVOq4W/397e/hnqzdfrNb28vLT+fSklfX19DXU5k8M5p+12
81+W/uzp6enfGl+Lp1JF7TL2c/PD6VpSs/Pz7ML00G/V0h9vUdbSylxidrPPy3t7F+0S5maG5lTqBAN
82+HCzFPQhtLGnitsspcCaeGNcE7WKGwb8Jse3ZrkKI2aVyH13mk+oOGzcV2sUMgweLmmy7Fi5dT2ef
83+O3wY6qFdzPGQ5WZ1ULY6LU4dEHU8HilJkkVVKvda0vxTF2iXaXroPpYlzaFco+ae2t6hl/LhQbuY
84+Y/ChENTrctrekk7mQ7uYAcEykjiOW91xl3YyH9rFDAiWEd1aMRNC9P4qlTlCu8zfQ45NgOscx6HN
85+ZlOa1M6ybPF3ZLTLfCFYAEA7DIUAQDsECwBoh2ABAO0QLACgHYIFALRDsACAdggWANAOwQIA2iFY
86+AEA7BAsAaIdgAQDt/gUoDXNStc/rMQAAAABJRU5ErkJggg==
87+"
88+ id="image2132"
89+ x="-82"
90+ y="-11.9" /></g><g
91+ inkscape:groupmode="layer"
92+ id="layer5"
93+ inkscape:label="dot" /><g
94+ inkscape:label="over"
95+ inkscape:groupmode="layer"
96+ id="layer1"
97+ style="display:none"><ellipse
98+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
99+ id="path2637"
100+ cx="24"
101+ cy="33"
102+ rx="4.3600898"
103+ ry="4.3600893" /><path
104+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
105+ d="M 9,24 C 9.7590836,20.626295 11.695032,17.529367 14.395314,15.369142 17.095595,13.208917 20.541953,12 24,12 c 3.458047,0 6.904405,1.208917 9.604686,3.369142 C 36.304968,17.529367 38.240916,20.626295 39,24"
106+ id="path2142"
107+ inkscape:path-effect="#path-effect2144"
108+ inkscape:original-d="m 9,24 c 4.959859,-3.824406 10.021901,-8.173595 15,-12 4.978099,-3.8264055 10.285024,8.398237 15,12"
109+ sodipodi:nodetypes="csc" /><path
110+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
111+ d="M 26,22 H 39 V 9"
112+ id="path2626"
113+ sodipodi:nodetypes="ccc" /></g><g
114+ inkscape:groupmode="layer"
115+ id="layer6"
116+ inkscape:label="into"
117+ style="display:inline"><ellipse
118+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
119+ id="path2637-3"
120+ cx="24"
121+ cy="38.5"
122+ rx="4.3600898"
123+ ry="4.3600893" /><path
124+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
125+ d="M 24,2 V 24"
126+ id="path2668"
127+ sodipodi:nodetypes="cc" /><path
128+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
129+ d="M 14.807612,16.994936 24,26.187324 33.192388,16.994936"
130+ id="path2626-8"
131+ sodipodi:nodetypes="ccc" /></g><g
132+ inkscape:groupmode="layer"
133+ id="g2714"
134+ inkscape:label="out"
135+ style="display:none"><ellipse
136+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
137+ id="ellipse2708"
138+ cx="24"
139+ cy="38.5"
140+ rx="4.3600898"
141+ ry="4.3600893" /><path
142+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
143+ d="M 24,29.722858 V 7.7228579"
144+ id="path2710"
145+ sodipodi:nodetypes="cc" /><path
146+ style="display:inline;fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
147+ d="M 33.192388,14.727922 24,5.5355339 14.807612,14.727922"
148+ id="path2712"
149+ sodipodi:nodetypes="ccc" /></g></svg>
public/img/step-out.svg+149 -0
@@ -0,0 +1,149 @@
1+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2+<!-- Created with Inkscape (http://www.inkscape.org/) -->
3+
4+<svg
5+ width="48"
6+ height="48"
7+ viewBox="0 0 48 48"
8+ version="1.1"
9+ id="svg2120"
10+ xml:space="preserve"
11+ inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
12+ sodipodi:docname="step-out.svg"
13+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15+ xmlns:xlink="http://www.w3.org/1999/xlink"
16+ xmlns="http://www.w3.org/2000/svg"
17+ xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
18+ id="namedview2122"
19+ pagecolor="#ffffff"
20+ bordercolor="#000000"
21+ borderopacity="0.25"
22+ inkscape:showpageshadow="2"
23+ inkscape:pageopacity="0.0"
24+ inkscape:pagecheckerboard="0"
25+ inkscape:deskcolor="#d1d1d1"
26+ inkscape:document-units="px"
27+ showgrid="false"
28+ inkscape:zoom="11.313708"
29+ inkscape:cx="58.910834"
30+ inkscape:cy="25.323262"
31+ inkscape:window-width="1920"
32+ inkscape:window-height="992"
33+ inkscape:window-x="-8"
34+ inkscape:window-y="-8"
35+ inkscape:window-maximized="1"
36+ inkscape:current-layer="g2714" /><defs
37+ id="defs2117"><inkscape:path-effect
38+ effect="spiro"
39+ id="path-effect2144"
40+ is_visible="true"
41+ lpeversion="1" /><inkscape:path-effect
42+ effect="bspline"
43+ id="path-effect2138"
44+ is_visible="true"
45+ lpeversion="1"
46+ weight="33.333333"
47+ steps="2"
48+ helper_size="0"
49+ apply_no_weight="true"
50+ apply_with_weight="true"
51+ only_selected="false" /></defs><g
52+ inkscape:groupmode="layer"
53+ id="layer4"
54+ inkscape:label="img"
55+ style="display:none"><image
56+ width="305.68866"
57+ height="70.374367"
58+ preserveAspectRatio="none"
59+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARYAAABACAYAAADf7VgRAAAABHNCSVQICAgIfAhkiAAABdxJREFU
60+eJzt3T9vo0gYBvB3by+SsZRiKCI5SCkyJS6dzny3+26UpoSSFJFMOaTyUETaK3zDAQYbzGBgeH7S
61+SutN1kKj8cM7fxj/2u/3fwgAQKO/xr4AADAPggUAtEOwAIB2CBYA0A7BAgDaIVgAQDsECwBoh2AB
62+AO0QLACgHYIFALRDsACAdn+PfQFw5nkeWZZFRERSSvJ9f+Qrmg7XdSnLMorjeOxLgZYQLDBpruuS
63+4zj5a4TLPGAoBJNVDRXOOXHOR7wiaAvBApNUDRUF4TIPCBaYnKZQURAu04dggUm5FSoKwmXaECww
64+GW1DRUG4TNfDV4VUx1mtViSEICKiLMtIStn4f4qdJ8syOh6Pw14kPFxdqAghyLKs0jI8EeWvif7v
65+G1gtmpbBg8V1XbJtm4jKHYKoHBhCCIrjuDZkqr+HYDFLU6gEQUCe55X+3ff90p4fIoTLFA0WLI7j
66+kOu6rX/ftm2ybZuEEBRF0dUKBsxxLVSaIFymT/scC2OMdrtdp1Apsm2bPM8jzvlFhQPmybKs9PpW
67+qCi+71/cfKrvZQrXdYkxNvZldKK1YmGM0cfHR+3PhBAkpaTv728iIjqdTmTbNq1WK7Jtu3aYxBij
68+KIp0XuLoXNelz8/PzhWZZVnkuq5x1ZyqMDjnrUNFKVYuURQZOUTe7XZ5NR+GIaVpOvYltaItWCzL
69+qg0VNXdS1yDFf2OM0Xa7LQWMbdt3Vz5TVOwkQRC0DgjLsvK5BlPD5d5Jed/3yXEcI0OlOj+53W5n
70+Ey5ahkLFjq9IKelwOFAQBK0aIk1T8n3/ooOohp27aifZ7XathnrVtjUtbJU+wWBiqBDRRWWrwqXv
71+sKi40jYULRVLtaP3eTo3iiLKssy4/QlJkpSGfCpcrlUudYFNhAnKpZBSUhAEpZvQvZWLGkoXb9RS
72+SkqSZJD+1Lti4ZxfVBVhGPZ6zziOjfvwpGlKYRhe3IGaKpemUDkcDrMohUEPFS59KhfOOXmed/E5
73+tSwr/5nuCqZ3xfL6+lp63bXjq0naJVDhUpxLqgsXhAoU9alcGGM3q3/VB3WeAdSrYqmO1aSUnTs+
74+Yyyf0Kz+MVFT5XILQmXZ7q1cmlZpqyzL6vQ4xS29KpZqtZIkSa+LWYq6yuUahIo56irRLqr95Vrl
75+0nUksNlstE2EawsWKaVx8yJDahsuCBWzDLEa0xQu6/W68/vo0itYdIzJumyIMs2tcEGoQFt1/We1
76+WvV+j3vhzNuRNYULQsVMfTc2Nn346/qLEKLTtg2d+4FGOzbhdDrlz3aYtIv0HtVwQaiYq0+V33W1
77+UJ0U0LYS0fms1cOCpelQHjzNfKbCRf0dzqSUed9Ych+5ZwuClJLCMGy1MqQevdHl136//6Pt3Rqo
78+Z2SuMfUhMoC++u5runXSngognTe0wSuWup25dd7f3/MnoAHgTMdmyTiOKUmSh27pHzRY1Jbhtr/7
79++vqKJWuA/+jcga022FWP+RzKpFaFlrK1vw5jjNbrdX4W8K1zgJdiye1S9xR734n9R7XdoMFS3Zl7
80+i6nb+K+pOxyLcz7IuHdO0C7necfi8GVOq4W/397e/hnqzdfrNb28vLT+fSklfX19DXU5k8M5p+12
81+W/uzp6enfGl+Lp1JF7TL2c/PD6VpSs/Pz7ML00G/V0h9vUdbSylxidrPPy3t7F+0S5maG5lTqBAN
82+HCzFPQhtLGnitsspcCaeGNcE7WKGwb8Jse3ZrkKI2aVyH13mk+oOGzcV2sUMgweLmmy7Fi5dT2ef
83+O3wY6qFdzPGQ5WZ1ULY6LU4dEHU8HilJkkVVKvda0vxTF2iXaXroPpYlzaFco+ae2t6hl/LhQbuY
84+Y/ChENTrctrekk7mQ7uYAcEykjiOW91xl3YyH9rFDAiWEd1aMRNC9P4qlTlCu8zfQ45NgOscx6HN
85+ZlOa1M6ybPF3ZLTLfCFYAEA7DIUAQDsECwBoh2ABAO0QLACgHYIFALRDsACAdggWANAOwQIA2iFY
86+AEA7BAsAaIdgAQDt/gUoDXNStc/rMQAAAABJRU5ErkJggg==
87+"
88+ id="image2132"
89+ x="-82"
90+ y="-11.9" /></g><g
91+ inkscape:groupmode="layer"
92+ id="layer5"
93+ inkscape:label="dot" /><g
94+ inkscape:label="over"
95+ inkscape:groupmode="layer"
96+ id="layer1"
97+ style="display:none"><ellipse
98+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
99+ id="path2637"
100+ cx="24"
101+ cy="33"
102+ rx="4.3600898"
103+ ry="4.3600893" /><path
104+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
105+ d="M 9,24 C 9.7590836,20.626295 11.695032,17.529367 14.395314,15.369142 17.095595,13.208917 20.541953,12 24,12 c 3.458047,0 6.904405,1.208917 9.604686,3.369142 C 36.304968,17.529367 38.240916,20.626295 39,24"
106+ id="path2142"
107+ inkscape:path-effect="#path-effect2144"
108+ inkscape:original-d="m 9,24 c 4.959859,-3.824406 10.021901,-8.173595 15,-12 4.978099,-3.8264055 10.285024,8.398237 15,12"
109+ sodipodi:nodetypes="csc" /><path
110+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
111+ d="M 26,22 H 39 V 9"
112+ id="path2626"
113+ sodipodi:nodetypes="ccc" /></g><g
114+ inkscape:groupmode="layer"
115+ id="layer6"
116+ inkscape:label="into"
117+ style="display:none"><ellipse
118+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
119+ id="path2637-3"
120+ cx="24"
121+ cy="38.5"
122+ rx="4.3600898"
123+ ry="4.3600893" /><path
124+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
125+ d="M 24,2 V 24"
126+ id="path2668"
127+ sodipodi:nodetypes="cc" /><path
128+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
129+ d="M 14.807612,16.994936 24,26.187324 33.192388,16.994936"
130+ id="path2626-8"
131+ sodipodi:nodetypes="ccc" /></g><g
132+ inkscape:groupmode="layer"
133+ id="g2714"
134+ inkscape:label="out"
135+ style="display:inline"><ellipse
136+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
137+ id="ellipse2708"
138+ cx="24"
139+ cy="38.5"
140+ rx="4.3600898"
141+ ry="4.3600893" /><path
142+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
143+ d="M 24,29.722858 V 7.7228579"
144+ id="path2710"
145+ sodipodi:nodetypes="cc" /><path
146+ style="display:inline;fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
147+ d="M 33.192388,14.727922 24,5.5355339 14.807612,14.727922"
148+ id="path2712"
149+ sodipodi:nodetypes="ccc" /></g></svg>
public/img/step-over.svg+149 -0
@@ -0,0 +1,149 @@
1+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2+<!-- Created with Inkscape (http://www.inkscape.org/) -->
3+
4+<svg
5+ width="48"
6+ height="48"
7+ viewBox="0 0 48 48"
8+ version="1.1"
9+ id="svg2120"
10+ xml:space="preserve"
11+ inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
12+ sodipodi:docname="step-over.svg"
13+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15+ xmlns:xlink="http://www.w3.org/1999/xlink"
16+ xmlns="http://www.w3.org/2000/svg"
17+ xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
18+ id="namedview2122"
19+ pagecolor="#ffffff"
20+ bordercolor="#000000"
21+ borderopacity="0.25"
22+ inkscape:showpageshadow="2"
23+ inkscape:pageopacity="0.0"
24+ inkscape:pagecheckerboard="0"
25+ inkscape:deskcolor="#d1d1d1"
26+ inkscape:document-units="px"
27+ showgrid="false"
28+ inkscape:zoom="11.313708"
29+ inkscape:cx="58.910834"
30+ inkscape:cy="25.323262"
31+ inkscape:window-width="1920"
32+ inkscape:window-height="992"
33+ inkscape:window-x="-8"
34+ inkscape:window-y="-8"
35+ inkscape:window-maximized="1"
36+ inkscape:current-layer="g2714" /><defs
37+ id="defs2117"><inkscape:path-effect
38+ effect="spiro"
39+ id="path-effect2144"
40+ is_visible="true"
41+ lpeversion="1" /><inkscape:path-effect
42+ effect="bspline"
43+ id="path-effect2138"
44+ is_visible="true"
45+ lpeversion="1"
46+ weight="33.333333"
47+ steps="2"
48+ helper_size="0"
49+ apply_no_weight="true"
50+ apply_with_weight="true"
51+ only_selected="false" /></defs><g
52+ inkscape:groupmode="layer"
53+ id="layer4"
54+ inkscape:label="img"
55+ style="display:none"><image
56+ width="305.68866"
57+ height="70.374367"
58+ preserveAspectRatio="none"
59+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARYAAABACAYAAADf7VgRAAAABHNCSVQICAgIfAhkiAAABdxJREFU
60+eJzt3T9vo0gYBvB3by+SsZRiKCI5SCkyJS6dzny3+26UpoSSFJFMOaTyUETaK3zDAQYbzGBgeH7S
61+SutN1kKj8cM7fxj/2u/3fwgAQKO/xr4AADAPggUAtEOwAIB2CBYA0A7BAgDaIVgAQDsECwBoh2AB
62+AO0QLACgHYIFALRDsACAdn+PfQFw5nkeWZZFRERSSvJ9f+Qrmg7XdSnLMorjeOxLgZYQLDBpruuS
63+4zj5a4TLPGAoBJNVDRXOOXHOR7wiaAvBApNUDRUF4TIPCBaYnKZQURAu04dggUm5FSoKwmXaECww
64+GW1DRUG4TNfDV4VUx1mtViSEICKiLMtIStn4f4qdJ8syOh6Pw14kPFxdqAghyLKs0jI8EeWvif7v
65+G1gtmpbBg8V1XbJtm4jKHYKoHBhCCIrjuDZkqr+HYDFLU6gEQUCe55X+3ff90p4fIoTLFA0WLI7j
66+kOu6rX/ftm2ybZuEEBRF0dUKBsxxLVSaIFymT/scC2OMdrtdp1Apsm2bPM8jzvlFhQPmybKs9PpW
67+qCi+71/cfKrvZQrXdYkxNvZldKK1YmGM0cfHR+3PhBAkpaTv728iIjqdTmTbNq1WK7Jtu3aYxBij
68+KIp0XuLoXNelz8/PzhWZZVnkuq5x1ZyqMDjnrUNFKVYuURQZOUTe7XZ5NR+GIaVpOvYltaItWCzL
69+qg0VNXdS1yDFf2OM0Xa7LQWMbdt3Vz5TVOwkQRC0DgjLsvK5BlPD5d5Jed/3yXEcI0OlOj+53W5n
70+Ey5ahkLFjq9IKelwOFAQBK0aIk1T8n3/ooOohp27aifZ7XathnrVtjUtbJU+wWBiqBDRRWWrwqXv
71+sKi40jYULRVLtaP3eTo3iiLKssy4/QlJkpSGfCpcrlUudYFNhAnKpZBSUhAEpZvQvZWLGkoXb9RS
72+SkqSZJD+1Lti4ZxfVBVhGPZ6zziOjfvwpGlKYRhe3IGaKpemUDkcDrMohUEPFS59KhfOOXmed/E5
73+tSwr/5nuCqZ3xfL6+lp63bXjq0naJVDhUpxLqgsXhAoU9alcGGM3q3/VB3WeAdSrYqmO1aSUnTs+
74+Yyyf0Kz+MVFT5XILQmXZ7q1cmlZpqyzL6vQ4xS29KpZqtZIkSa+LWYq6yuUahIo56irRLqr95Vrl
75+0nUksNlstE2EawsWKaVx8yJDahsuCBWzDLEa0xQu6/W68/vo0itYdIzJumyIMs2tcEGoQFt1/We1
76+WvV+j3vhzNuRNYULQsVMfTc2Nn346/qLEKLTtg2d+4FGOzbhdDrlz3aYtIv0HtVwQaiYq0+V33W1
77+UJ0U0LYS0fms1cOCpelQHjzNfKbCRf0dzqSUed9Ych+5ZwuClJLCMGy1MqQevdHl136//6Pt3Rqo
78+Z2SuMfUhMoC++u5runXSngognTe0wSuWup25dd7f3/MnoAHgTMdmyTiOKUmSh27pHzRY1Jbhtr/7
79++vqKJWuA/+jcga022FWP+RzKpFaFlrK1vw5jjNbrdX4W8K1zgJdiye1S9xR734n9R7XdoMFS3Zl7
80+i6nb+K+pOxyLcz7IuHdO0C7necfi8GVOq4W/397e/hnqzdfrNb28vLT+fSklfX19DXU5k8M5p+12
81+W/uzp6enfGl+Lp1JF7TL2c/PD6VpSs/Pz7ML00G/V0h9vUdbSylxidrPPy3t7F+0S5maG5lTqBAN
82+HCzFPQhtLGnitsspcCaeGNcE7WKGwb8Jse3ZrkKI2aVyH13mk+oOGzcV2sUMgweLmmy7Fi5dT2ef
83+O3wY6qFdzPGQ5WZ1ULY6LU4dEHU8HilJkkVVKvda0vxTF2iXaXroPpYlzaFco+ae2t6hl/LhQbuY
84+Y/ChENTrctrekk7mQ7uYAcEykjiOW91xl3YyH9rFDAiWEd1aMRNC9P4qlTlCu8zfQ45NgOscx6HN
85+ZlOa1M6ybPF3ZLTLfCFYAEA7DIUAQDsECwBoh2ABAO0QLACgHYIFALRDsACAdggWANAOwQIA2iFY
86+AEA7BAsAaIdgAQDt/gUoDXNStc/rMQAAAABJRU5ErkJggg==
87+"
88+ id="image2132"
89+ x="-82"
90+ y="-11.9" /></g><g
91+ inkscape:groupmode="layer"
92+ id="layer5"
93+ inkscape:label="dot" /><g
94+ inkscape:label="over"
95+ inkscape:groupmode="layer"
96+ id="layer1"
97+ style="display:inline"><ellipse
98+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
99+ id="path2637"
100+ cx="24"
101+ cy="33"
102+ rx="4.3600898"
103+ ry="4.3600893" /><path
104+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
105+ d="M 9,24 C 9.7590836,20.626295 11.695032,17.529367 14.395314,15.369142 17.095595,13.208917 20.541953,12 24,12 c 3.458047,0 6.904405,1.208917 9.604686,3.369142 C 36.304968,17.529367 38.240916,20.626295 39,24"
106+ id="path2142"
107+ inkscape:path-effect="#path-effect2144"
108+ inkscape:original-d="m 9,24 c 4.959859,-3.824406 10.021901,-8.173595 15,-12 4.978099,-3.8264055 10.285024,8.398237 15,12"
109+ sodipodi:nodetypes="csc" /><path
110+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
111+ d="M 26,22 H 39 V 9"
112+ id="path2626"
113+ sodipodi:nodetypes="ccc" /></g><g
114+ inkscape:groupmode="layer"
115+ id="layer6"
116+ inkscape:label="into"
117+ style="display:none"><ellipse
118+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
119+ id="path2637-3"
120+ cx="24"
121+ cy="38.5"
122+ rx="4.3600898"
123+ ry="4.3600893" /><path
124+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
125+ d="M 24,2 V 24"
126+ id="path2668"
127+ sodipodi:nodetypes="cc" /><path
128+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
129+ d="M 14.807612,16.994936 24,26.187324 33.192388,16.994936"
130+ id="path2626-8"
131+ sodipodi:nodetypes="ccc" /></g><g
132+ inkscape:groupmode="layer"
133+ id="g2714"
134+ inkscape:label="out"
135+ style="display:none"><ellipse
136+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
137+ id="ellipse2708"
138+ cx="24"
139+ cy="38.5"
140+ rx="4.3600898"
141+ ry="4.3600893" /><path
142+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
143+ d="M 24,29.722858 V 7.7228579"
144+ id="path2710"
145+ sodipodi:nodetypes="cc" /><path
146+ style="display:inline;fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
147+ d="M 33.192388,14.727922 24,5.5355339 14.807612,14.727922"
148+ id="path2712"
149+ sodipodi:nodetypes="ccc" /></g></svg>
public/img/step-resume.svg+218 -0
@@ -0,0 +1,218 @@
1+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2+<!-- Created with Inkscape (http://www.inkscape.org/) -->
3+
4+<svg
5+ width="48"
6+ height="48"
7+ viewBox="0 0 48 48"
8+ version="1.1"
9+ id="svg2120"
10+ xml:space="preserve"
11+ inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
12+ sodipodi:docname="step-resume.svg"
13+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15+ xmlns:xlink="http://www.w3.org/1999/xlink"
16+ xmlns="http://www.w3.org/2000/svg"
17+ xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
18+ id="namedview2122"
19+ pagecolor="#ffffff"
20+ bordercolor="#000000"
21+ borderopacity="0.25"
22+ inkscape:showpageshadow="2"
23+ inkscape:pageopacity="0.0"
24+ inkscape:pagecheckerboard="0"
25+ inkscape:deskcolor="#d1d1d1"
26+ inkscape:document-units="px"
27+ showgrid="false"
28+ inkscape:zoom="11.315"
29+ inkscape:cx="4.7282369"
30+ inkscape:cy="24.259832"
31+ inkscape:window-width="1920"
32+ inkscape:window-height="992"
33+ inkscape:window-x="-8"
34+ inkscape:window-y="-8"
35+ inkscape:window-maximized="1"
36+ inkscape:current-layer="layer8" /><defs
37+ id="defs2117"><inkscape:path-effect
38+ effect="spiro"
39+ id="path-effect2144"
40+ is_visible="true"
41+ lpeversion="1" /><inkscape:path-effect
42+ effect="bspline"
43+ id="path-effect2138"
44+ is_visible="true"
45+ lpeversion="1"
46+ weight="33.333333"
47+ steps="2"
48+ helper_size="0"
49+ apply_no_weight="true"
50+ apply_with_weight="true"
51+ only_selected="false" /></defs><g
52+ inkscape:groupmode="layer"
53+ id="layer4"
54+ inkscape:label="img"
55+ style="display:none"><image
56+ width="305.68866"
57+ height="70.374367"
58+ preserveAspectRatio="none"
59+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARYAAABACAYAAADf7VgRAAAABHNCSVQICAgIfAhkiAAABdxJREFU
60+eJzt3T9vo0gYBvB3by+SsZRiKCI5SCkyJS6dzny3+26UpoSSFJFMOaTyUETaK3zDAQYbzGBgeH7S
61+SutN1kKj8cM7fxj/2u/3fwgAQKO/xr4AADAPggUAtEOwAIB2CBYA0A7BAgDaIVgAQDsECwBoh2AB
62+AO0QLACgHYIFALRDsACAdn+PfQFw5nkeWZZFRERSSvJ9f+Qrmg7XdSnLMorjeOxLgZYQLDBpruuS
63+4zj5a4TLPGAoBJNVDRXOOXHOR7wiaAvBApNUDRUF4TIPCBaYnKZQURAu04dggUm5FSoKwmXaECww
64+GW1DRUG4TNfDV4VUx1mtViSEICKiLMtIStn4f4qdJ8syOh6Pw14kPFxdqAghyLKs0jI8EeWvif7v
65+G1gtmpbBg8V1XbJtm4jKHYKoHBhCCIrjuDZkqr+HYDFLU6gEQUCe55X+3ff90p4fIoTLFA0WLI7j
66+kOu6rX/ftm2ybZuEEBRF0dUKBsxxLVSaIFymT/scC2OMdrtdp1Apsm2bPM8jzvlFhQPmybKs9PpW
67+qCi+71/cfKrvZQrXdYkxNvZldKK1YmGM0cfHR+3PhBAkpaTv728iIjqdTmTbNq1WK7Jtu3aYxBij
68+KIp0XuLoXNelz8/PzhWZZVnkuq5x1ZyqMDjnrUNFKVYuURQZOUTe7XZ5NR+GIaVpOvYltaItWCzL
69+qg0VNXdS1yDFf2OM0Xa7LQWMbdt3Vz5TVOwkQRC0DgjLsvK5BlPD5d5Jed/3yXEcI0OlOj+53W5n
70+Ey5ahkLFjq9IKelwOFAQBK0aIk1T8n3/ooOohp27aifZ7XathnrVtjUtbJU+wWBiqBDRRWWrwqXv
71+sKi40jYULRVLtaP3eTo3iiLKssy4/QlJkpSGfCpcrlUudYFNhAnKpZBSUhAEpZvQvZWLGkoXb9RS
72+SkqSZJD+1Lti4ZxfVBVhGPZ6zziOjfvwpGlKYRhe3IGaKpemUDkcDrMohUEPFS59KhfOOXmed/E5
73+tSwr/5nuCqZ3xfL6+lp63bXjq0naJVDhUpxLqgsXhAoU9alcGGM3q3/VB3WeAdSrYqmO1aSUnTs+
74+Yyyf0Kz+MVFT5XILQmXZ7q1cmlZpqyzL6vQ4xS29KpZqtZIkSa+LWYq6yuUahIo56irRLqr95Vrl
75+0nUksNlstE2EawsWKaVx8yJDahsuCBWzDLEa0xQu6/W68/vo0itYdIzJumyIMs2tcEGoQFt1/We1
76+WvV+j3vhzNuRNYULQsVMfTc2Nn346/qLEKLTtg2d+4FGOzbhdDrlz3aYtIv0HtVwQaiYq0+V33W1
77+UJ0U0LYS0fms1cOCpelQHjzNfKbCRf0dzqSUed9Ych+5ZwuClJLCMGy1MqQevdHl136//6Pt3Rqo
78+Z2SuMfUhMoC++u5runXSngognTe0wSuWup25dd7f3/MnoAHgTMdmyTiOKUmSh27pHzRY1Jbhtr/7
79++vqKJWuA/+jcga022FWP+RzKpFaFlrK1vw5jjNbrdX4W8K1zgJdiye1S9xR734n9R7XdoMFS3Zl7
80+i6nb+K+pOxyLcz7IuHdO0C7necfi8GVOq4W/397e/hnqzdfrNb28vLT+fSklfX19DXU5k8M5p+12
81+W/uzp6enfGl+Lp1JF7TL2c/PD6VpSs/Pz7ML00G/V0h9vUdbSylxidrPPy3t7F+0S5maG5lTqBAN
82+HCzFPQhtLGnitsspcCaeGNcE7WKGwb8Jse3ZrkKI2aVyH13mk+oOGzcV2sUMgweLmmy7Fi5dT2ef
83+O3wY6qFdzPGQ5WZ1ULY6LU4dEHU8HilJkkVVKvda0vxTF2iXaXroPpYlzaFco+ae2t6hl/LhQbuY
84+Y/ChENTrctrekk7mQ7uYAcEykjiOW91xl3YyH9rFDAiWEd1aMRNC9P4qlTlCu8zfQ45NgOscx6HN
85+ZlOa1M6ybPF3ZLTLfCFYAEA7DIUAQDsECwBoh2ABAO0QLACgHYIFALRDsACAdggWANAOwQIA2iFY
86+AEA7BAsAaIdgAQDt/gUoDXNStc/rMQAAAABJRU5ErkJggg==
87+"
88+ id="image2132"
89+ x="-82"
90+ y="-11.9" /><image
91+ width="460"
92+ height="71"
93+ preserveAspectRatio="none"
94+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcwAAABHCAYAAACK23cpAAAABHNCSVQICAgIfAhkiAAACuZJREFU
95+eJzt3T9o3FgeB/Cv77aZBHInYZPD+JLF4iDxDl44ZNiAvVfNhC2vCEqxWQJpJn3MwlRXTeP0drNF
96+NtfIKa5cYnVxkQMLDoztbU6GgBkwDiPOEHuOK/aK7FM0Gs3M01gzkp6+HzBknPFEkSV93z/9NLO6
97+uvoLiIiIaKjfZL0BRERERcDAJCIiksDAJCIiksDAJCIiksDAJCIiksDAJCIiksDAJCIiksDAJCIi
98+ksDAJCIiksDAJCIiksDAJCIiksDAJCIikvBZ1htAlIZmswlN0wAAvu+j1WplvEX5YVkWOp0OHMfJ
99+elMoBYuLi8Gfj4+PM9yS8mFgEinMsiyYphm8ZmgSjY9DskSKioZlvV5HrVbLcIuoyEzTDEZxyoqB
100+SaSgaFgKDE0aR6PRCI6pMocmA5NIMYPCUmBoUhKNRgOGYQD4eOyUOTQZmEQKGRWWAkOTZPm+3/O6
101+zKHJwCRShGxYCgxNkmHbNlzX7fleWUNzqqtk5xaWMbuwHLx+d+Tg4vw0eH3txs2e19RPXBA1TQuW
102+lHc6nb5WYFj4ouj7ft/BT8UXF5ae50HX9Z7bbQD0XOTq9ToArp6l4WzbBoC+RWQA4Lru0OuPSqYa
103+mLMLy1i69yh4/f5kvycgv3nyI47evuwL0jKzLCuYPxjWmvM8D47jxIanOLDF+xiYahkUlltbW2g2
104+mz3fb7VaPfesAgxNksPQzOGQ7NK9R/jLgw3MhXqiZWSaJjY2NoJhj1FDH4ZhBCvZyjZMUmbDwnKQ
105+VqsV26ji8CyNUvbh2VwWLrh24ya+frCBs5N9uK+fl6q3aRgGarVa0Ksc5+ebzSZ2dnZK0+ors06n
106+0/N6VFgKcT1NVY8Vy7Lgui48z8t6Uwpr1HVpUE9T0zQ8ffp0rDAVo2F5GhHLXQ8zbG5hGd88+RG3
107+l2q4duNm1pszcaKXGHdQioPHtm3Yto2tra2hoViv15XsbY77f9I0DY1GQ7n94TgOdnZ2AMiHpRDu
108+acb1HFTQaDRgmmbP1AYlI86dUfsvrqfp+z42NzfHaowZhgHLsnI18pHLHmaUef+Z8r1NcVBGibnJ
109+uNZx+Hvi4AofrOJ7qhAnrWEYiU5CTdOCuTzLsmDbtlK9Kcdxxl7M1Wq1YJqmkmEZnf8Xv3v2NJMJ
110+r4GQfW+4IS9Cc9yeZp7m2H9769atv03rH5tbWMbcH78MXkcX94QXBEVdv3ETf/rzXwEAF+en+N9/
111+P0xuQ6csfEEXfN/HixcvgovhKL7vY3d3F7quY35+Pvi+rut97yvixdGyLFSrVQBApVJBtVrFwcEB
112+ut0uAGBtbQ2VSgUA0O12sbu7C6B/34r9U8R9MEy73Y79/qD9IvOzRddut1GtVoP/f6VSgWEYaLfb
113+V2owaZqGSqUSHHvTNu1h9MePH/e89jwPvu/3fIWvM4Zh4PLyEr7vB/uo2+3i4OAA8/PzfT876Cv6
114+mb7vZ36s5raHeXF+ig/np32Lf5buPcLnX9Thvn6Os5P9jLYuXdFe4FWetmHbNjqdTqJWYRG4rgvD
115+MIKLhZgbGdbTjGuIAPloqdLkxfVsxu1pip8LD0v6vo+9vb1SHU+Dhv3j6hYD/T3NJFMG0Tl2y7KC
116+sM5KbucwP5yf4s2r9dhhWLEo6OsHG4Wf24ybSBfLt8cVntdShed5fUOpwxYUDArLra0tDsmVSNwc
117+Wlz4DVOr1dBsNvver2ka6vV634W9jCaxejauMTzusG5acjske3F+indHDv5zdox//+sfuP67P+D3
118+c70HrBimFUO0RRymffjwYTBkBCS/oNdqteDADH/put43HAsUd0gWQDAkYxhGzzBbtVrtOYkqlQrW
119+1tb6fr6MYSkzJKs6MRw4zvCszDoAcQxOa99Oe0g2PFo17PpxeHjYNyUUNzwra9DvLTodM0257WFG
120+ua+f482r9dhFP+b9ZzDvPytcbzN6f6Xv+4kv6GIRTNyXigb1NEcpY1jSJ+P2NOMW4sXRNC1RWUJV
121+pd3THPZ7y6Knmds5zDhnJ/v46YfvcPerb/H5F/WegBS3oBSpUlD0BNvb28toS4pFhKbsScOwVEfc
122+MHsS0eNl2Jxm0kanqquNk0q7IpCYK15ZWQl+f4ZhBPt7mnOahelhhv38z78PXPRTpEpBKysrwZ99
123+3y/V4oGriutpxmFYqkWMyoz7Negz43qaSXswcVMgZZV2T9NxnL4Ohfi8aSpUDzPsQwF6kKOMuxI2
124+LMmqM9WM6mkyLEnWoIVjV/2MMptETzNq2o2UQgbm3a++HXjP5rsjB+7r51PeIsrKoNBkWKrpqsNv
125+g0It7ngRTwOSxeHYfmmFZtziK3HuT1OhAnNuYRl37z2KHW49O9nHz29fKnNv5iDiwPN9P6gjqlLV
126+mnFEQ5Nhqa6rjMokvdVIPPlHtucYretLH101NEXJ0LCkZSDTUpjANO8/w+2l+JqC7uvneHek9vyf
127+uH0kSnYuT3Xh1ibD8pNOp8OGFca7L9f3fdi2LbVSVpSwpHjjhmZcydCkBRDSlPvAvL1Ug3n/Wezf
128+qV5fVhhW+Fg8nUTV4tlJMCj7lXmOW7hKEQvP87CzszO0chYX7MlJGppiMVaYaMRkJbeBef3Xaj5x
129+w68X56dKlcYbRvZRX/V6PfOyUUR5k0bFJ8dx4LouS+OlIGloRhf1ZF08P7eBee3GzdhCBGVa1CNK
130+b8m+1zRNnrxEv0qzPKIYBhTzmWVqmIbncXVdly7mkITMU07ysDYht4EZVZZFPVdhGEZpA1MUZtc0
131+DcfHx8GCjbIr836JK2l31YtuWfZdWKfT6SleP6nbZ4aFpmEYmYclUJDALMOinjhJb8pVtRzeMHEr
132+6IBPcx15OMmywP2CYOW0OC/y0EMpItu2p1b0fFBo5mV9Rm6LrwMfe5VvXq3j/QR6lbOzs7i4uEj9
133+c9Ok63rwDEgZ4pmYZVGr1QYWxq5UKkGDI+n9dEXH/fJRt9uF53mYn5/H9va2MmE57eLr3W4X7XYb
134+mqZhZmam52ERk3CVgu2Tlsse5qQX9czOzuL777/H+vr6RD4/LUlP8DLdByY7v1uv16debzJL3C+9
135+srwFQSWe5020wSHzPM08yF0t2XdHDn764buJhuWTJ08m8tlpE08el1Wm+ctRj1wa971Fx/1CRTSJ
136+52lOwlR7mO9P9nH09mXwOloP9s2r9Yku6hFhOTs7O7F/I23RJ8YPMukWYN4kma8VC1/y1FKdFO4X
137+Kqq0a89OwlQD8+xkf2ggMiz7iUUawx5llVWZqKzkqcWZJ9wvVHR5D81czmGmrahhKXieh1arFRQx
138+0HUdmqbBdV24rluqnuW4sj7R8or7hfImz6GpfGAWPSzDHMcp1TzlIGJuV7ZHVZZQ4H4hVeQ1NHO3
139+6CdNKoUl9Yo+TDat9xYd9wupIo8LgZQNTIal2hzHkWpllq0wNvcLqWRQaGZVpEXJwGRYlsPm5ubQ
140+cMjiAbN5wP1CKomGpud5mVX+mWqln2lIEpZsYRdbt9vF7u4ufN9HpVIJqpC4rouDgwNsb2+Xcp6O
141++0Vt0670kweHh4fQdR2Xl5eZ3hEws7q6+ktm/3rKkvYs817ph4iI8kOZVbKi3B0REdEkKNXDJCIi
142+mhQlF/0QERGljYFJREQkgYFJREQkgYFJREQkgYFJREQkgYFJREQkgYFJREQkgYFJREQkgYFJREQk
143+4bPFxcWst4GIiCj32MMkIiKSMHPnzh3WkiUiIhqBPUwiIiIJDEwiIiIJDEwiIiIJ/weH8+Ed/xCz
144+fAAAAABJRU5ErkJggg==
145+"
146+ id="image2845"
147+ x="-15.489594"
148+ y="-8.2945251" /></g><g
149+ inkscape:groupmode="layer"
150+ id="layer5"
151+ inkscape:label="dot" /><g
152+ inkscape:label="over"
153+ inkscape:groupmode="layer"
154+ id="layer1"
155+ style="display:none"><ellipse
156+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
157+ id="path2637"
158+ cx="24"
159+ cy="33"
160+ rx="4.3600898"
161+ ry="4.3600893" /><path
162+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
163+ d="M 9,24 C 9.7590836,20.626295 11.695032,17.529367 14.395314,15.369142 17.095595,13.208917 20.541953,12 24,12 c 3.458047,0 6.904405,1.208917 9.604686,3.369142 C 36.304968,17.529367 38.240916,20.626295 39,24"
164+ id="path2142"
165+ inkscape:path-effect="#path-effect2144"
166+ inkscape:original-d="m 9,24 c 4.959859,-3.824406 10.021901,-8.173595 15,-12 4.978099,-3.8264055 10.285024,8.398237 15,12"
167+ sodipodi:nodetypes="csc" /><path
168+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
169+ d="M 26,22 H 39 V 9"
170+ id="path2626"
171+ sodipodi:nodetypes="ccc" /></g><g
172+ inkscape:groupmode="layer"
173+ id="layer6"
174+ inkscape:label="into"
175+ style="display:none"><ellipse
176+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
177+ id="path2637-3"
178+ cx="24"
179+ cy="38.5"
180+ rx="4.3600898"
181+ ry="4.3600893" /><path
182+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
183+ d="M 24,2 V 24"
184+ id="path2668"
185+ sodipodi:nodetypes="cc" /><path
186+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
187+ d="M 14.807612,16.994936 24,26.187324 33.192388,16.994936"
188+ id="path2626-8"
189+ sodipodi:nodetypes="ccc" /></g><g
190+ inkscape:groupmode="layer"
191+ id="g2714"
192+ inkscape:label="out"
193+ style="display:none"><ellipse
194+ style="fill:#000000;stroke:#000000;stroke-width:5.27982;stroke-dasharray:none"
195+ id="ellipse2708"
196+ cx="24"
197+ cy="38.5"
198+ rx="4.3600898"
199+ ry="4.3600893" /><path
200+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
201+ d="M 24,29.722858 V 7.7228579"
202+ id="path2710"
203+ sodipodi:nodetypes="cc" /><path
204+ style="display:inline;fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
205+ d="M 33.192388,14.727922 24,5.5355339 14.807612,14.727922"
206+ id="path2712"
207+ sodipodi:nodetypes="ccc" /></g><g
208+ inkscape:groupmode="layer"
209+ id="layer8"
210+ inkscape:label="resume"><path
211+ style="fill:#000000;stroke:#000000;stroke-width:5;stroke-dasharray:none"
212+ d="M 13,12 V 38"
213+ id="path2850"
214+ sodipodi:nodetypes="cc" /><path
215+ style="fill:none;stroke:#000000;stroke-width:5;stroke-dasharray:none"
216+ d="M 21,16 V 34 L 36.5,25 Z"
217+ id="path2852"
218+ sodipodi:nodetypes="cccc" /></g></svg>
public/index.html+10 -0
@@ -4268,6 +4268,16 @@
42684268 </div>
42694269 </div>
42704270 </div>
4271+ <div title="Determines which keys select an item from the AutoComplete suggestions">
4272+ <label data-i18n="Keyboard">
4273+ <small>Keyboard:</small>
4274+ </label>
4275+ <select id="stscript_autocomplete_select">
4276+ <option value="3" data-i18n="Select with Tab or Enter">Select with Tab or Enter</option>
4277+ <option value="1" data-i18n="Select with Tab">Select with Tab</option>
4278+ <option value="2" data-i18n="Select with Enter">Select with Enter</option>
4279+ </select>
4280+ </div>
42714281 <div class="flex-container flexFlowColumn gap0" title="Sets the font size of the autocomplete." data-i18n="[title]Sets the font size of the autocomplete.">
42724282 <label for="stscript_autocomplete_font_scale"><small>Font Scale</small></label>
42734283 <input class="neo-range-slider" type="range" id="stscript_autocomplete_font_scale" min="0.5" max="2" step="0.01">
public/scripts/autocomplete/AutoComplete.js+63 -40
@@ -16,8 +16,15 @@ export const AUTOCOMPLETE_WIDTH = {
1616 'FULL': 2,
1717};
1818
19+/**@readonly*/
20+/**@enum {Number}*/
21+export const AUTOCOMPLETE_SELECT_KEY = {
22+ 'TAB': 1, // 2^0
23+ 'ENTER': 2, // 2^1
24+};
25+
1926export class AutoComplete {
2027 /**@type {HTMLTextAreaElement|HTMLInputElement}*/ textarea;
2128 /**@type {boolean}*/ isFloating = false;
2229 /**@type {()=>boolean}*/ checkIfActivate;
2330 /**@type {(text:string, index:number) => Promise<AutoCompleteNameResult>}*/ getNameAt;
@@ -56,6 +63,8 @@ export class AutoComplete {
5663 /**@type {function}*/ updateDetailsPositionDebounced;
5764 /**@type {function}*/ updateFloatingPositionDebounced;
5865
66+ /**@type {(item:AutoCompleteOption)=>any}*/ onSelect;
67+
5968 get matchType() {
6069 return power_user.stscript.matching ?? 'fuzzy';
6170 }
@@ -68,7 +77,7 @@ export class AutoComplete {
6877
6978
7079 /**
7180 * @param {HTMLTextAreaElement|HTMLInputElement} textarea The textarea to receive autocomplete.
7281 * @param {() => boolean} checkIfActivate Function should return true only if under the current conditions, autocomplete should display (e.g., for slash commands: autoComplete.text[0] == '/')
7382 * @param {(text: string, index: number) => Promise<AutoCompleteNameResult>} getNameAt Function should return (unfiltered, matching against input is done in AutoComplete) information about name options at index in text.
7483 * @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor.
@@ -102,10 +111,15 @@ export class AutoComplete {
102111 this.updateDetailsPositionDebounced = debounce(this.updateDetailsPosition.bind(this), 10);
103112 this.updateFloatingPositionDebounced = debounce(this.updateFloatingPosition.bind(this), 10);
104113
105- textarea.addEventListener('input', ()=>this.text != this.textarea.value && this.show(true, this.wasForced));
114+ textarea.addEventListener('input', ()=>{
115+ this.selectionStart = this.textarea.selectionStart;
116+ if (this.text != this.textarea.value) this.show(true, this.wasForced);
117+ });
106118 textarea.addEventListener('keydown', (evt)=>this.handleKeyDown(evt));
107119 textarea.addEventListener('click', ()=>this.isActive ? this.show() : null);{
108- textarea.addEventListener('selectionchange', ()=>this.show());
120+ this.selectionStart = this.textarea.selectionStart;
121+ if (this.isActive) this.show();
122+ });
109123 textarea.addEventListener('blur', ()=>this.hide());
110124 if (isFloating) {
111125 textarea.addEventListener('scroll', ()=>this.updateFloatingPositionDebounced());
@@ -189,6 +203,11 @@ export class AutoComplete {
189203 * @returns The option.
190204 */
191205 fuzzyScore(option) {
206+ // might have been matched by the options matchProvider function instead
207+ if (!this.fuzzyRegex.test(option.name)) {
208+ option.score = new AutoCompleteFuzzyScore(Number.MAX_SAFE_INTEGER, -1);
209+ return option;
210+ }
192211 const parts = this.fuzzyRegex.exec(option.name).slice(1, -1);
193212 let start = null;
194213 let consecutive = [];
@@ -339,7 +358,7 @@ export class AutoComplete {
339358
340359 this.result = this.effectiveParserResult.optionList
341360 // filter the list of options by the partial name according to the matching type
342361 .filter(it => this.isReplaceable || it.name == '' ? (it.matchProvider ? it.matchProvider(this.name) : matchers[this.matchType](it.name)) : it.name.toLowerCase() == this.name)
343362 // remove aliases
344363 .filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx);
345364
@@ -357,10 +376,11 @@ export class AutoComplete {
357376 // build element
358377 option.dom = this.makeItem(option);
359378 // update replacer and add quotes if necessary
379+ const optionName = option.valueProvider ? option.valueProvider(this.name) : option.name;
360380 if (this.effectiveParserResult.canBeQuoted) {
361381 option.replacer = option.nameoptionName.includes(' ') || this.startQuote || this.endQuote ? `"${optionoptionName.namereplace(/"/g, '\\"')}"` : `${option.nameoptionName}`;
362382 } else {
363383 option.replacer = option.nameoptionName;
364384 }
365385 // calculate fuzzy score if matching is fuzzy
366386 if (this.matchType == 'fuzzy') this.fuzzyScore(option);
@@ -399,7 +419,7 @@ export class AutoComplete {
399419 ,
400420 );
401421 this.result.push(option);
402422 } else if (this.result.length == 1 && this.effectiveParserResult && this.effectiveParserResult != this.secondaryParserResult && this.result[0].name == this.effectiveParserResult.name) {
403423 // only one result that is exactly the current value? just show hint, no autocomplete
404424 this.isReplaceable = false;
405425 this.isShowingDetails = false;
@@ -439,11 +459,14 @@ export class AutoComplete {
439459 } else {
440460 item.dom.classList.remove('selected');
441461 }
462+ if (!item.isSelectable) {
463+ item.dom.classList.add('not-selectable');
464+ }
442465 frag.append(item.dom);
443466 }
444467 this.dom.append(frag);
445468 this.updatePosition();
446469 getTopmostModalLayerthis.getLayer().append(this.domWrap);
447470 } else {
448471 this.domWrap.remove();
449472 }
@@ -458,10 +481,17 @@ export class AutoComplete {
458481 if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove();
459482 this.detailsDom.innerHTML = '';
460483 this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM');
461484 getTopmostModalLayerthis.getLayer().append(this.detailsWrap);
462485 this.updateDetailsPositionDebounced();
463486 }
464487
488+ /**
489+ * @returns {HTMLElement} closest ancestor dialog or body
490+ */
491+ getLayer() {
492+ return this.textarea.closest('dialog, body');
493+ }
494+
465495
466496
467497 /**
@@ -474,7 +504,7 @@ export class AutoComplete {
474504 const rect = {};
475505 rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
476506 rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
477507 rect[AUTOCOMPLETE_WIDTH.FULL] = getTopmostModalLayerthis.getLayer().getBoundingClientRect();
478508 this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
479509 this.dom.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
480510 this.domWrap.style.bottom = `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`;
@@ -501,7 +531,7 @@ export class AutoComplete {
501531 const rect = {};
502532 rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
503533 rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
504534 rect[AUTOCOMPLETE_WIDTH.FULL] = getTopmostModalLayerthis.getLayer().getBoundingClientRect();
505535 if (this.isReplaceable) {
506536 this.detailsWrap.classList.remove('full');
507537 const selRect = this.selectedItem.dom.children[0].getBoundingClientRect();
@@ -527,32 +557,34 @@ export class AutoComplete {
527557 updateFloatingPosition() {
528558 const location = this.getCursorPosition();
529559 const rect = this.textarea.getBoundingClientRect();
560+ const layerRect = this.getLayer().getBoundingClientRect();
530561 // cursor is out of view -> hide
531562 if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
532563 return this.hide();
533564 }
534565 const left = Math.max(rect.left, location.left) - layerRect.left;
535566 this.domWrap.style.setProperty('--targetOffset', `${left}`);
536567 if (location.top <= window.innerHeight / 2) {
537568 // if cursor is in lower half of window, show list above line
538569 this.domWrap.style.top = `${location.bottom - layerRect.top}px`;
539570 this.domWrap.style.bottom = 'auto';
540571 this.domWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
541572 } else {
542573 // if cursor is in upper half of window, show list below line
543574 this.domWrap.style.top = 'auto';
544575 this.domWrap.style.bottom = `calc(100vh${layerRect.height}px - ${location.top - layerRect.top}px)`;
545576 this.domWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
546577 }
547578 }
548579
549580 updateFloatingDetailsPosition(location = null) {
550581 if (!location) location = this.getCursorPosition();
551582 const rect = this.textarea.getBoundingClientRect();
583+ const layerRect = this.getLayer().getBoundingClientRect();
552584 if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
553585 return this.hide();
554586 }
555587 const left = Math.max(rect.left, location.left) - layerRect.left;
556588 this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
557589 if (this.isReplaceable) {
558590 this.detailsWrap.classList.remove('full');
@@ -572,14 +604,14 @@ export class AutoComplete {
572604 }
573605 if (location.top <= window.innerHeight / 2) {
574606 // if cursor is in lower half of window, show list above line
575607 this.detailsWrap.style.top = `${location.bottom - layerRect.top}px`;
576608 this.detailsWrap.style.bottom = 'auto';
577609 this.detailsWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
578610 } else {
579611 // if cursor is in upper half of window, show list below line
580612 this.detailsWrap.style.top = 'auto';
581613 this.detailsWrap.style.bottom = `calc(100vh${layerRect.height}px - ${location.top - layerRect.top}px)`;
582614 this.detailsWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
583615 }
584616 }
585617
@@ -597,7 +629,7 @@ export class AutoComplete {
597629 }
598630 this.clone.style.position = 'fixed';
599631 this.clone.style.visibility = 'hidden';
600632 getTopmostModalLayer()document.body.append(this.clone);
601633 const mo = new MutationObserver(muts=>{
602634 if (muts.find(it=>Array.from(it.removedNodes).includes(this.textarea))) {
603635 this.clone.remove();
@@ -656,6 +688,7 @@ export class AutoComplete {
656688 }
657689 this.wasForced = false;
658690 this.textarea.dispatchEvent(new Event('input', { bubbles:true }));
691+ this.onSelect?.(this.selectedItem);
659692 }
660693
661694
@@ -708,8 +741,10 @@ export class AutoComplete {
708741 }
709742 case 'Enter': {
710743 // pick the selected item to autocomplete
744+ if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.ENTER) != AUTOCOMPLETE_SELECT_KEY.ENTER) break;
711745 if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break;
712746 if (this.selectedItem.name == this.name) break;
747+ if (!this.selectedItem.isSelectable) break;
713748 evt.preventDefault();
714749 evt.stopImmediatePropagation();
715750 this.select();
@@ -717,9 +752,11 @@ export class AutoComplete {
717752 }
718753 case 'Tab': {
719754 // pick the selected item to autocomplete
755+ if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.TAB) != AUTOCOMPLETE_SELECT_KEY.TAB) break;
720756 if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break;
721757 evt.preventDefault();
722758 evt.stopImmediatePropagation();
759+ if (!this.selectedItem.isSelectable) break;
723760 this.select();
724761 return;
725762 }
@@ -772,13 +809,7 @@ export class AutoComplete {
772809 // ignore keydown on modifier keys
773810 return;
774811 }
775- switch (evt.key) {
812+ // await keyup to see if cursor position or text has changed
776- case 'ArrowUp':
777- case 'ArrowDown':
778- case 'ArrowRight':
779- case 'ArrowLeft': {
780- if (this.isActive) {
781- // keyboard navigation, wait for keyup to complete cursor move
782813 const oldText = this.textarea.value;
783814 await new Promise(resolve=>{
784815 window.addEventListener('keyup', resolve, { once:true });
@@ -786,16 +817,8 @@ export class AutoComplete {
786817 if (this.selectionStart != this.textarea.selectionStart) {
787818 this.selectionStart = this.textarea.selectionStart;
788819 this.show(this.isReplaceable || oldText != this.textarea.value);
789- }
820+ } else if (this.isActive) {
790- }
791- break;
792- }
793- default: {
794- if (this.isActive) {
795821 this.text != this.textarea.value && this.show(this.isReplaceable);
796822 }
797- break;
798- }
799- }
800823 }
801824}
public/scripts/autocomplete/AutoCompleteNameResult.js+3 -30
@@ -1,36 +1,9 @@
11import { SlashCommandNamedArgumentAutoCompleteOptionAutoCompleteNameResultBase } from '../slash-commands/SlashCommandNamedArgumentAutoCompleteOptionAutoCompleteNameResultBase.js';
22import { AutoCompleteOptionAutoCompleteSecondaryNameResult } from './AutoCompleteOptionAutoCompleteSecondaryNameResult.js';
3-// import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js';
43
54
65
76export class AutoCompleteNameResult extends AutoCompleteNameResultBase {
8- /**@type {string} */ name;
9- /**@type {number} */ start;
10- /**@type {AutoCompleteOption[]} */ optionList = [];
11- /**@type {boolean} */ canBeQuoted = false;
12- /**@type {()=>string} */ makeNoMatchText = ()=>`No matches found for "${this.name}"`;
13- /**@type {()=>string} */ makeNoOptionsText = ()=>'No options';
14-
15-
16- /**
17- * @param {string} name Name (potentially partial) of the name at the requested index.
18- * @param {number} start Index where the name starts.
19- * @param {AutoCompleteOption[]} optionList A list of autocomplete options found in the current scope.
20- * @param {boolean} canBeQuoted Whether the name can be inside quotes.
21- * @param {()=>string} makeNoMatchText Function that returns text to show when no matches where found.
22- * @param {()=>string} makeNoOptionsText Function that returns text to show when no options are available to match against.
23- */
24- constructor(name, start, optionList = [], canBeQuoted = false, makeNoMatchText = null, makeNoOptionsText = null) {
25- this.name = name;
26- this.start = start;
27- this.optionList = optionList;
28- this.canBeQuoted = canBeQuoted;
29- this.noMatchText = makeNoMatchText ?? this.makeNoMatchText;
30- this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText;
31- }
32-
33-
347 /**
358 *
369 * @param {string} text The whole text
public/scripts/autocomplete/AutoCompleteNameResultBase.js+31 -0
@@ -0,0 +1,31 @@
1+import { SlashCommandNamedArgumentAutoCompleteOption } from '../slash-commands/SlashCommandNamedArgumentAutoCompleteOption.js';
2+import { AutoCompleteOption } from './AutoCompleteOption.js';
3+
4+
5+
6+export class AutoCompleteNameResultBase {
7+ /**@type {string} */ name;
8+ /**@type {number} */ start;
9+ /**@type {AutoCompleteOption[]} */ optionList = [];
10+ /**@type {boolean} */ canBeQuoted = false;
11+ /**@type {()=>string} */ makeNoMatchText = ()=>`No matches found for "${this.name}"`;
12+ /**@type {()=>string} */ makeNoOptionsText = ()=>'No options';
13+
14+
15+ /**
16+ * @param {string} name Name (potentially partial) of the name at the requested index.
17+ * @param {number} start Index where the name starts.
18+ * @param {AutoCompleteOption[]} optionList A list of autocomplete options found in the current scope.
19+ * @param {boolean} canBeQuoted Whether the name can be inside quotes.
20+ * @param {()=>string} makeNoMatchText Function that returns text to show when no matches where found.
21+ * @param {()=>string} makeNoOptionsText Function that returns text to show when no options are available to match against.
22+ */
23+ constructor(name, start, optionList = [], canBeQuoted = false, makeNoMatchText = null, makeNoOptionsText = null) {
24+ this.name = name;
25+ this.start = start;
26+ this.optionList = optionList;
27+ this.canBeQuoted = canBeQuoted;
28+ this.noMatchText = makeNoMatchText ?? this.makeNoMatchText;
29+ this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText;
30+ }
31+}
public/scripts/autocomplete/AutoCompleteOption.js+11 -1
@@ -11,6 +11,9 @@ export class AutoCompleteOption {
1111 /**@type {AutoCompleteFuzzyScore}*/ score;
1212 /**@type {string}*/ replacer;
1313 /**@type {HTMLElement}*/ dom;
14+ /**@type {(input:string)=>boolean}*/ matchProvider;
15+ /**@type {(input:string)=>string}*/ valueProvider;
16+ /**@type {boolean}*/ makeSelectable = false;
1417
1518
1619 /**
@@ -21,14 +24,21 @@ export class AutoCompleteOption {
2124 return this.name;
2225 }
2326
27+ get isSelectable() {
28+ return this.makeSelectable || !this.valueProvider;
29+ }
30+
2431
2532 /**
2633 * @param {string} name
2734 */
2835 constructor(name, typeIcon = ' ', type = '', matchProvider = null, valueProvider = null, makeSelectable = false) {
2936 this.name = name;
3037 this.typeIcon = typeIcon;
3138 this.type = type;
39+ this.matchProvider = matchProvider;
40+ this.valueProvider = valueProvider;
41+ this.makeSelectable = makeSelectable;
3242 }
3343
3444
public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js+2 -2
@@ -1,6 +1,6 @@
11import { AutoCompleteNameResultAutoCompleteNameResultBase } from './AutoCompleteNameResultAutoCompleteNameResultBase.js';
22
33export class AutoCompleteSecondaryNameResult extends AutoCompleteNameResultAutoCompleteNameResultBase {
44 /**@type {boolean}*/ isRequired = false;
55 /**@type {boolean}*/ forceMatch = true;
66}
public/scripts/extensions/quick-reply/api/QuickReplyApi.js+88 -66
@@ -24,9 +24,17 @@ export class QuickReplyApi {
2424
2525
2626 /**
27+ * @param {QuickReply} qr
28+ * @returns {QuickReplySet}
29+ */
30+ getSetByQr(qr) {
31+ return QuickReplySet.list.find(it=>it.qrList.includes(qr));
32+ }
33+
34+ /**
2735 * Finds and returns an existing Quick Reply Set by its name.
2836 *
2937 * @param {Stringstring} name name of the quick reply set
3038 * @returns the quick reply set, or undefined if not found
3139 */
3240 getSetByName(name) {
@@ -36,13 +44,14 @@ export class QuickReplyApi {
3644 /**
3745 * Finds and returns an existing Quick Reply by its set's name and its label.
3846 *
3947 * @param {Stringstring} setName name of the quick reply set
4048 * @param {Stringstring|number} label label or numeric ID of the quick reply
4149 * @returns the quick reply, or undefined if not found
4250 */
4351 getQrByLabel(setName, label) {
4452 const set = this.getSetByName(setName);
4553 if (!set) return;
54+ if (Number.isInteger(label)) return set.qrList.find(it=>it.id == label);
4655 return set.qrList.find(it=>it.label == label);
4756 }
4857
@@ -70,24 +79,25 @@ export class QuickReplyApi {
7079 /**
7180 * Executes an existing quick reply.
7281 *
7382 * @param {Stringstring} setName name of the existing quick reply set
7483 * @param {Stringstring|number} label label of the existing quick reply (text on the button) or its numeric ID
7584 * @param {Objectobject} [args] optional arguments
85+ * @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options] optional execution options
7686 */
7787 async executeQuickReply(setName, label, args = {}, options = {}) {
7888 const qr = this.getQrByLabel(setName, label);
7989 if (!qr) {
8090 throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
8191 }
8292 return await qr.execute(args, false, false, options);
8393 }
8494
8595
8696 /**
8797 * Adds or removes a quick reply set to the list of globally active quick reply sets.
8898 *
8999 * @param {Stringstring} name the name of the set
90100 * @param {Booleanboolean} isVisible whether to show the set's buttons or not
91101 */
92102 toggleGlobalSet(name, isVisible = true) {
93103 const set = this.getSetByName(name);
@@ -104,8 +114,8 @@ export class QuickReplyApi {
104114 /**
105115 * Adds a quick reply set to the list of globally active quick reply sets.
106116 *
107117 * @param {Stringstring} name the name of the set
108118 * @param {Booleanboolean} isVisible whether to show the set's buttons or not
109119 */
110120 addGlobalSet(name, isVisible = true) {
111121 const set = this.getSetByName(name);
@@ -118,7 +128,7 @@ export class QuickReplyApi {
118128 /**
119129 * Removes a quick reply set from the list of globally active quick reply sets.
120130 *
121131 * @param {Stringstring} name the name of the set
122132 */
123133 removeGlobalSet(name) {
124134 const set = this.getSetByName(name);
@@ -132,8 +142,8 @@ export class QuickReplyApi {
132142 /**
133143 * Adds or removes a quick reply set to the list of the current chat's active quick reply sets.
134144 *
135145 * @param {Stringstring} name the name of the set
136146 * @param {Booleanboolean} isVisible whether to show the set's buttons or not
137147 */
138148 toggleChatSet(name, isVisible = true) {
139149 if (!this.settings.chatConfig) return;
@@ -151,8 +161,8 @@ export class QuickReplyApi {
151161 /**
152162 * Adds a quick reply set to the list of the current chat's active quick reply sets.
153163 *
154164 * @param {Stringstring} name the name of the set
155165 * @param {Booleanboolean} isVisible whether to show the set's buttons or not
156166 */
157167 addChatSet(name, isVisible = true) {
158168 if (!this.settings.chatConfig) return;
@@ -166,7 +176,7 @@ export class QuickReplyApi {
166176 /**
167177 * Removes a quick reply set from the list of the current chat's active quick reply sets.
168178 *
169179 * @param {Stringstring} name the name of the set
170180 */
171181 removeChatSet(name) {
172182 if (!this.settings.chatConfig) return;
@@ -181,21 +191,25 @@ export class QuickReplyApi {
181191 /**
182192 * Creates a new quick reply in an existing quick reply set.
183193 *
184194 * @param {Stringstring} setName name of the quick reply set to insert the new quick reply into
185195 * @param {Stringstring} label label for the new quick reply (text on the button)
186196 * @param {Objectobject} [props]
187197 * @param {Stringstring} [props.messageicon] the message to be sent or slash commandicon to be executedshow byon the new quickQR replybutton
188198 * @param {Stringboolean} [props.titleshowLabel] the titlewhether /to tooltipshow tothe belabel showneven onwhen thean quickicon replyis buttonassigned
189199 * @param {Booleanstring} [props.isHiddenmessage] whetherthe message to hidebe sent or showslash command to be executed by the buttonnew quick reply
190200 * @param {Booleanstring} [props.executeOnStartuptitle] whetherthe title / tooltip to executebe shown on the quick reply when SillyTavern startsbutton
191201 * @param {Booleanboolean} [props.executeOnUserisHidden] whether to execute the quick reply after a userhide hasor sentshow athe messagebutton
192202 * @param {Booleanboolean} [props.executeOnAiexecuteOnStartup] whether to execute the quick reply after the AI has sentwhen aSillyTavern messagestarts
193203 * @param {Booleanboolean} [props.executeOnChatChangeexecuteOnUser] whether to execute the quick reply whenafter a newuser chathas issent loadeda message
194204 * @param {Booleanboolean} [props.executeOnGroupMemberDraftexecuteOnAi] whether to execute the quick reply whenafter athe groupAI memberhas issent selecteda message
195205 * @param {Stringboolean} [props.automationIdexecuteOnChatChange] whenwhether notto empty,execute the quick reply will be executed when the WI with the givena automationnew IDchat is activatedloaded
206+ * @param {boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected
207+ * @param {string} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated
196208 * @returns {QuickReply} the new quick reply
197209 */
198210 createQuickReply(setName, label, {
211+ icon,
212+ showLabel,
199213 message,
200214 title,
201215 isHidden,
@@ -212,6 +226,8 @@ export class QuickReplyApi {
212226 }
213227 const qr = set.addQuickReply();
214228 qr.label = label ?? '';
229+ qr.icon = icon ?? '';
230+ qr.showLabel = showLabel ?? false;
215231 qr.message = message ?? '';
216232 qr.title = title ?? '';
217233 qr.isHidden = isHidden ?? false;
@@ -228,22 +244,26 @@ export class QuickReplyApi {
228244 /**
229245 * Updates an existing quick reply.
230246 *
231247 * @param {Stringstring} setName name of the existing quick reply set
232248 * @param {Stringstring|number} label label of the existing quick reply (text on the button) or its numeric ID
233249 * @param {Objectobject} [props]
234250 * @param {Stringstring} [props.newLabelicon] new label forthe quickicon replyto (textshow on the QR button)
235251 * @param {Stringboolean} [props.messageshowLabel] the messagewhether to be sent or slashshow commandthe tolabel beeven executedwhen byan theicon quickis replyassigned
236252 * @param {Stringstring} [props.titlenewLabel] the titlenew /label tooltipfor toquick bereply shown(text on the quick reply button)
237253 * @param {Booleanstring} [props.isHiddenmessage] whetherthe message to hidebe sent or showslash command to be executed by the buttonquick reply
238254 * @param {Booleanstring} [props.executeOnStartuptitle] whetherthe title / tooltip to executebe shown on the quick reply when SillyTavern startsbutton
239255 * @param {Booleanboolean} [props.executeOnUserisHidden] whether to execute the quick reply after a userhide hasor sentshow athe messagebutton
240256 * @param {Booleanboolean} [props.executeOnAiexecuteOnStartup] whether to execute the quick reply after the AI has sentwhen aSillyTavern messagestarts
241257 * @param {Booleanboolean} [props.executeOnChatChangeexecuteOnUser] whether to execute the quick reply whenafter a newuser chathas issent loadeda message
242258 * @param {Booleanboolean} [props.executeOnGroupMemberDraftexecuteOnAi] whether to execute the quick reply whenafter athe groupAI memberhas issent selecteda message
243259 * @param {Stringboolean} [props.automationIdexecuteOnChatChange] whenwhether notto empty,execute the quick reply will be executed when the WI with the givena automationnew IDchat is activatedloaded
260+ * @param {boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected
261+ * @param {string} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated
244262 * @returns {QuickReply} the altered quick reply
245263 */
246264 updateQuickReply(setName, label, {
265+ icon,
266+ showLabel,
247267 newLabel,
248268 message,
249269 title,
@@ -259,6 +279,8 @@ export class QuickReplyApi {
259279 if (!qr) {
260280 throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
261281 }
282+ qr.updateIcon(icon ?? qr.icon);
283+ qr.updateShowLabel(showLabel ?? qr.showLabel);
262284 qr.updateLabel(newLabel ?? qr.label);
263285 qr.updateMessage(message ?? qr.message);
264286 qr.updateTitle(title ?? qr.title);
@@ -276,8 +298,8 @@ export class QuickReplyApi {
276298 /**
277299 * Deletes an existing quick reply.
278300 *
279301 * @param {Stringstring} setName name of the existing quick reply set
280302 * @param {Stringstring|number} label label of the existing quick reply (text on the button) or its numeric ID
281303 */
282304 deleteQuickReply(setName, label) {
283305 const qr = this.getQrByLabel(setName, label);
@@ -291,10 +313,10 @@ export class QuickReplyApi {
291313 /**
292314 * Adds an existing quick reply set as a context menu to an existing quick reply.
293315 *
294316 * @param {Stringstring} setName name of the existing quick reply set containing the quick reply
295317 * @param {Stringstring|number} label label of the existing quick reply or its numeric ID
296318 * @param {Stringstring} contextSetName name of the existing quick reply set to be used as a context menu
297319 * @param {Booleanboolean} isChained whether or not to chain the context menu quick replies
298320 */
299321 createContextItem(setName, label, contextSetName, isChained = false) {
300322 const qr = this.getQrByLabel(setName, label);
@@ -314,9 +336,9 @@ export class QuickReplyApi {
314336 /**
315337 * Removes a quick reply set from a quick reply's context menu.
316338 *
317339 * @param {Stringstring} setName name of the existing quick reply set containing the quick reply
318340 * @param {Stringstring|number} label label of the existing quick reply or its numeric ID
319341 * @param {Stringstring} contextSetName name of the existing quick reply set to be used as a context menu
320342 */
321343 deleteContextItem(setName, label, contextSetName) {
322344 const qr = this.getQrByLabel(setName, label);
@@ -333,8 +355,8 @@ export class QuickReplyApi {
333355 /**
334356 * Removes all entries from a quick reply's context menu.
335357 *
336358 * @param {Stringstring} setName name of the existing quick reply set containing the quick reply
337359 * @param {Stringstring|number} label label of the existing quick reply or its numeric ID
338360 */
339361 clearContextMenu(setName, label) {
340362 const qr = this.getQrByLabel(setName, label);
@@ -348,11 +370,11 @@ export class QuickReplyApi {
348370 /**
349371 * Create a new quick reply set.
350372 *
351373 * @param {Stringstring} name name of the new quick reply set
352374 * @param {Objectobject} [props]
353375 * @param {Booleanboolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
354376 * @param {Booleanboolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
355377 * @param {Booleanboolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
356378 * @returns {Promise<QuickReplySet>} the new quick reply set
357379 */
358380 async createSet(name, {
@@ -384,11 +406,11 @@ export class QuickReplyApi {
384406 /**
385407 * Update an existing quick reply set.
386408 *
387409 * @param {Stringstring} name name of the existing quick reply set
388410 * @param {Objectobject} [props]
389411 * @param {Booleanboolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
390412 * @param {Booleanboolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
391413 * @param {Booleanboolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
392414 * @returns {Promise<QuickReplySet>} the altered quick reply set
393415 */
394416 async updateSet(name, {
@@ -411,7 +433,7 @@ export class QuickReplyApi {
411433 /**
412434 * Delete an existing quick reply set.
413435 *
414436 * @param {Stringstring} name name of the existing quick reply set
415437 */
416438 async deleteSet(name) {
417439 const set = this.getSetByName(name);
@@ -451,7 +473,7 @@ export class QuickReplyApi {
451473 /**
452474 * Gets a list of all quick replies in the quick reply set.
453475 *
454476 * @param {Stringstring} setName name of the existing quick reply set
455477 * @returns array with the labels of this set's quick replies
456478 */
457479 listQuickReplies(setName) {
public/scripts/extensions/quick-reply/html/qrEditor.html+33 -7
@@ -2,10 +2,23 @@
22 <div id="qr--main">
33 <h3 data-i18n="Labels and Message">Labels and Message</h3>
44 <div class="qr--labels">
5- <label>
5+ <label class="qr--fit">
6+ <span class="qr--labelText" data-i18n="Label">Icon</span>
7+ <small class="qr--labelHint">&nbsp;</small>
8+ <div class="menu_button fa-fw" id="qr--modal-icon" title="Click to change icon"></div>
9+ </label>
10+ <div class="label">
611 <span class="qr--labelText" data-i18n="Label">Label</span>
7- <input type="text" class="text_pole" id="qr--modal-label">
12+ <small class="qr--labelHint" data-i18n="(label of the button, if no icon is chosen) ">(label of the button, if no icon is chosen)</small>
13+ <div class="qr--inputGroup">
14+ <label class="checkbox_label" title="Show label even if an icon is assigned">
15+ <input type="checkbox" id="qr--modal-showLabel">
16+ Show
817 </label>
18+ <input type="text" class="text_pole" id="qr--modal-label">
19+ <div class="menu_button fa-fw fa-solid fa-chevron-down" id="qr--modal-switcher" title="Switch to another QR"></div>
20+ </div>
21+ </div>
922 <label>
1023 <span class="qr--labelText" data-i18n="Title">Title</span>
1124 <small class="qr--labelHint" data-i18n="(tooltip, leave empty to show message or /command)">(tooltip, leave empty to show message or /command)</small>
@@ -33,6 +46,8 @@
3346 <input type="checkbox" id="qr--modal-syntax">
3447 <span>Syntax highlight</span>
3548 </label>
49+ <small>Ctrl+Alt+Click (or F9) to set / remove breakpoints</small>
50+ <small>Ctrl+<span id="qr--modal-commentKey"></span> to toggle block comments</small>
3651 </div>
3752 <div id="qr--modal-messageHolder">
3853 <pre id="qr--modal-messageSyntax"><code id="qr--modal-messageSyntaxInner" class="hljs language-stscript"></code></pre>
@@ -43,6 +58,10 @@
4358
4459
4560
61+ <div id="qr--resizeHandle"></div>
62+
63+
64+
4665 <div id="qr--qrOptions">
4766 <h3 data-i18n="Context Menu">Context Menu</h3>
4867 <div id="qr--ctxEditor">
@@ -64,7 +83,7 @@
6483
6584
6685 <h3 data-i18n="Auto-Execute">Auto-Execute</h3>
6786 <div id="qr--autoExec" class="flex-container flexFlowColumn">
6887 <label class="checkbox_label" title="Prevent this quick reply from triggering other auto-executed quick replies while auto-executing (i.e., prevent recursive auto-execution)">
6988 <input type="checkbox" id="qr--preventAutoExecute" >
7089 <span><i class="fa-solid fa-fw fa-plane-slash"></i><span data-i18n="Don't trigger auto-execute">Don't trigger auto-execute</span></span>
@@ -117,11 +136,18 @@
117136 </div>
118137 </div>
119138 <div id="qr--modal-executeProgress"></div>
120- <label class="checkbox_label">
121- <input type="checkbox" id="qr--modal-executeHide">
122- <span title="Hide editor while executing"> Hide editor while executing</span>
123- </label>
124139 <div id="qr--modal-executeErrors"></div>
125140 <div id="qr--modal-executeResult"></div>
141+
142+ <div id="qr--modal-debugButtons">
143+ <div title="Resume" id="qr--modal-resume" class="qr--modal-debugButton menu_button"></div>
144+ <div title="Step Over" id="qr--modal-step" class="qr--modal-debugButton menu_button"></div>
145+ <div title="Step Into" id="qr--modal-stepInto" class="qr--modal-debugButton menu_button"></div>
146+ <div title="Step Out" id="qr--modal-stepOut" class="qr--modal-debugButton menu_button"></div>
147+ <div title="Minimize" id="qr--modal-minimize" class="qr--modal-debugButton menu_button fa-solid fa-minimize"></div>
148+ <div title="Maximize" id="qr--modal-maximize" class="qr--modal-debugButton menu_button fa-solid fa-maximize"></div>
149+ </div>
150+ <textarea rows="1" id="qr--modal-send_textarea" placeholder="Chat input for use with {{input}}" title="Chat input for use with {{input}}"></textarea>
151+ <div id="qr--modal-debugState"></div>
126152 </div>
127153</div>
public/scripts/extensions/quick-reply/html/settings.html+10 -0
@@ -60,10 +60,20 @@
6060 <label class="flex-container" id="qr--injectInputContainer">
6161 <input type="checkbox" id="qr--injectInput"> <span><span data-i18n="Inject user input automatically">Inject user input automatically</span> <small><span data-i18n="(if disabled, use ">(if disabled, use</span><code>{{input}}</code> <span data-i18n="macro for manual injection)">macro for manual injection)</span></small></span>
6262 </label>
63+ <div class="flex-container alignItemsCenter">
64+ <toolcool-color-picker id="qr--color"></toolcool-color-picker>
65+ <div class="menu_button" id="qr--colorClear">Clear</div>
66+ <span data-i18n="Color">Color</span>
67+ </div>
68+ <label class="flex-container" id="qr--onlyBorderColorContainer">
69+ <input type="checkbox" id="qr--onlyBorderColor"> <span data-i18n="Only apply color as accent">Only apply color as accent</span>
70+ </label>
6371 </div>
6472 <div id="qr--set-qrList" class="qr--qrList"></div>
6573 <div class="qr--set-qrListActions">
6674 <div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-add" title="Add quick reply"></div>
75+ <div class="qr--paste menu_button menu_button_icon fa-solid fa-paste" id="qr--set-paste" title="Paste quick reply from clipboard"></div>
76+ <div class="qr--import menu_button menu_button_icon fa-solid fa-file-import" id="qr--set-importQr" title="Import quick reply from file"></div>
6777 </div>
6878 </div>
6979 </div>
public/scripts/extensions/quick-reply/index.js+2 -2
@@ -176,7 +176,7 @@ const init = async () => {
176176 buttons.show();
177177 settings.onSave = ()=>buttons.refresh();
178178
179179 window['executeQuickReplyByName'] = async(name, args = {}, options = {}) => {
180180 let qr = [...settings.config.setList, ...(settings.chatConfig?.setList ?? [])]
181181 .map(it=>it.set.qrList)
182182 .flat()
@@ -191,7 +191,7 @@ const init = async () => {
191191 }
192192 }
193193 if (qr && qr.onExecute) {
194194 return await qr.execute(args, false, true, options);
195195 } else {
196196 throw new Error(`No Quick Reply found for "${name}".`);
197197 }
public/scripts/extensions/quick-reply/lib/morphdom-esm.js+769 -0
@@ -0,0 +1,769 @@
1+var DOCUMENT_FRAGMENT_NODE = 11;
2+
3+function morphAttrs(fromNode, toNode) {
4+ var toNodeAttrs = toNode.attributes;
5+ var attr;
6+ var attrName;
7+ var attrNamespaceURI;
8+ var attrValue;
9+ var fromValue;
10+
11+ // document-fragments dont have attributes so lets not do anything
12+ if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) {
13+ return;
14+ }
15+
16+ // update attributes on original DOM element
17+ for (var i = toNodeAttrs.length - 1; i >= 0; i--) {
18+ attr = toNodeAttrs[i];
19+ attrName = attr.name;
20+ attrNamespaceURI = attr.namespaceURI;
21+ attrValue = attr.value;
22+
23+ if (attrNamespaceURI) {
24+ attrName = attr.localName || attrName;
25+ fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName);
26+
27+ if (fromValue !== attrValue) {
28+ if (attr.prefix === 'xmlns'){
29+ attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix
30+ }
31+ fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue);
32+ }
33+ } else {
34+ fromValue = fromNode.getAttribute(attrName);
35+
36+ if (fromValue !== attrValue) {
37+ fromNode.setAttribute(attrName, attrValue);
38+ }
39+ }
40+ }
41+
42+ // Remove any extra attributes found on the original DOM element that
43+ // weren't found on the target element.
44+ var fromNodeAttrs = fromNode.attributes;
45+
46+ for (var d = fromNodeAttrs.length - 1; d >= 0; d--) {
47+ attr = fromNodeAttrs[d];
48+ attrName = attr.name;
49+ attrNamespaceURI = attr.namespaceURI;
50+
51+ if (attrNamespaceURI) {
52+ attrName = attr.localName || attrName;
53+
54+ if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) {
55+ fromNode.removeAttributeNS(attrNamespaceURI, attrName);
56+ }
57+ } else {
58+ if (!toNode.hasAttribute(attrName)) {
59+ fromNode.removeAttribute(attrName);
60+ }
61+ }
62+ }
63+}
64+
65+var range; // Create a range object for efficently rendering strings to elements.
66+var NS_XHTML = 'http://www.w3.org/1999/xhtml';
67+
68+var doc = typeof document === 'undefined' ? undefined : document;
69+var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template');
70+var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange();
71+
72+function createFragmentFromTemplate(str) {
73+ var template = doc.createElement('template');
74+ template.innerHTML = str;
75+ return template.content.childNodes[0];
76+}
77+
78+function createFragmentFromRange(str) {
79+ if (!range) {
80+ range = doc.createRange();
81+ range.selectNode(doc.body);
82+ }
83+
84+ var fragment = range.createContextualFragment(str);
85+ return fragment.childNodes[0];
86+}
87+
88+function createFragmentFromWrap(str) {
89+ var fragment = doc.createElement('body');
90+ fragment.innerHTML = str;
91+ return fragment.childNodes[0];
92+}
93+
94+/**
95+ * This is about the same
96+ * var html = new DOMParser().parseFromString(str, 'text/html');
97+ * return html.body.firstChild;
98+ *
99+ * @method toElement
100+ * @param {String} str
101+ */
102+function toElement(str) {
103+ str = str.trim();
104+ if (HAS_TEMPLATE_SUPPORT) {
105+ // avoid restrictions on content for things like `<tr><th>Hi</th></tr>` which
106+ // createContextualFragment doesn't support
107+ // <template> support not available in IE
108+ return createFragmentFromTemplate(str);
109+ } else if (HAS_RANGE_SUPPORT) {
110+ return createFragmentFromRange(str);
111+ }
112+
113+ return createFragmentFromWrap(str);
114+}
115+
116+/**
117+ * Returns true if two node's names are the same.
118+ *
119+ * NOTE: We don't bother checking `namespaceURI` because you will never find two HTML elements with the same
120+ * nodeName and different namespace URIs.
121+ *
122+ * @param {Element} a
123+ * @param {Element} b The target element
124+ * @return {boolean}
125+ */
126+function compareNodeNames(fromEl, toEl) {
127+ var fromNodeName = fromEl.nodeName;
128+ var toNodeName = toEl.nodeName;
129+ var fromCodeStart, toCodeStart;
130+
131+ if (fromNodeName === toNodeName) {
132+ return true;
133+ }
134+
135+ fromCodeStart = fromNodeName.charCodeAt(0);
136+ toCodeStart = toNodeName.charCodeAt(0);
137+
138+ // If the target element is a virtual DOM node or SVG node then we may
139+ // need to normalize the tag name before comparing. Normal HTML elements that are
140+ // in the "http://www.w3.org/1999/xhtml"
141+ // are converted to upper case
142+ if (fromCodeStart <= 90 && toCodeStart >= 97) { // from is upper and to is lower
143+ return fromNodeName === toNodeName.toUpperCase();
144+ } else if (toCodeStart <= 90 && fromCodeStart >= 97) { // to is upper and from is lower
145+ return toNodeName === fromNodeName.toUpperCase();
146+ } else {
147+ return false;
148+ }
149+}
150+
151+/**
152+ * Create an element, optionally with a known namespace URI.
153+ *
154+ * @param {string} name the element name, e.g. 'div' or 'svg'
155+ * @param {string} [namespaceURI] the element's namespace URI, i.e. the value of
156+ * its `xmlns` attribute or its inferred namespace.
157+ *
158+ * @return {Element}
159+ */
160+function createElementNS(name, namespaceURI) {
161+ return !namespaceURI || namespaceURI === NS_XHTML ?
162+ doc.createElement(name) :
163+ doc.createElementNS(namespaceURI, name);
164+}
165+
166+/**
167+ * Copies the children of one DOM element to another DOM element
168+ */
169+function moveChildren(fromEl, toEl) {
170+ var curChild = fromEl.firstChild;
171+ while (curChild) {
172+ var nextChild = curChild.nextSibling;
173+ toEl.appendChild(curChild);
174+ curChild = nextChild;
175+ }
176+ return toEl;
177+}
178+
179+function syncBooleanAttrProp(fromEl, toEl, name) {
180+ if (fromEl[name] !== toEl[name]) {
181+ fromEl[name] = toEl[name];
182+ if (fromEl[name]) {
183+ fromEl.setAttribute(name, '');
184+ } else {
185+ fromEl.removeAttribute(name);
186+ }
187+ }
188+}
189+
190+var specialElHandlers = {
191+ OPTION: function(fromEl, toEl) {
192+ var parentNode = fromEl.parentNode;
193+ if (parentNode) {
194+ var parentName = parentNode.nodeName.toUpperCase();
195+ if (parentName === 'OPTGROUP') {
196+ parentNode = parentNode.parentNode;
197+ parentName = parentNode && parentNode.nodeName.toUpperCase();
198+ }
199+ if (parentName === 'SELECT' && !parentNode.hasAttribute('multiple')) {
200+ if (fromEl.hasAttribute('selected') && !toEl.selected) {
201+ // Workaround for MS Edge bug where the 'selected' attribute can only be
202+ // removed if set to a non-empty value:
203+ // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12087679/
204+ fromEl.setAttribute('selected', 'selected');
205+ fromEl.removeAttribute('selected');
206+ }
207+ // We have to reset select element's selectedIndex to -1, otherwise setting
208+ // fromEl.selected using the syncBooleanAttrProp below has no effect.
209+ // The correct selectedIndex will be set in the SELECT special handler below.
210+ parentNode.selectedIndex = -1;
211+ }
212+ }
213+ syncBooleanAttrProp(fromEl, toEl, 'selected');
214+ },
215+ /**
216+ * The "value" attribute is special for the <input> element since it sets
217+ * the initial value. Changing the "value" attribute without changing the
218+ * "value" property will have no effect since it is only used to the set the
219+ * initial value. Similar for the "checked" attribute, and "disabled".
220+ */
221+ INPUT: function(fromEl, toEl) {
222+ syncBooleanAttrProp(fromEl, toEl, 'checked');
223+ syncBooleanAttrProp(fromEl, toEl, 'disabled');
224+
225+ if (fromEl.value !== toEl.value) {
226+ fromEl.value = toEl.value;
227+ }
228+
229+ if (!toEl.hasAttribute('value')) {
230+ fromEl.removeAttribute('value');
231+ }
232+ },
233+
234+ TEXTAREA: function(fromEl, toEl) {
235+ var newValue = toEl.value;
236+ if (fromEl.value !== newValue) {
237+ fromEl.value = newValue;
238+ }
239+
240+ var firstChild = fromEl.firstChild;
241+ if (firstChild) {
242+ // Needed for IE. Apparently IE sets the placeholder as the
243+ // node value and vise versa. This ignores an empty update.
244+ var oldValue = firstChild.nodeValue;
245+
246+ if (oldValue == newValue || (!newValue && oldValue == fromEl.placeholder)) {
247+ return;
248+ }
249+
250+ firstChild.nodeValue = newValue;
251+ }
252+ },
253+ SELECT: function(fromEl, toEl) {
254+ if (!toEl.hasAttribute('multiple')) {
255+ var selectedIndex = -1;
256+ var i = 0;
257+ // We have to loop through children of fromEl, not toEl since nodes can be moved
258+ // from toEl to fromEl directly when morphing.
259+ // At the time this special handler is invoked, all children have already been morphed
260+ // and appended to / removed from fromEl, so using fromEl here is safe and correct.
261+ var curChild = fromEl.firstChild;
262+ var optgroup;
263+ var nodeName;
264+ while(curChild) {
265+ nodeName = curChild.nodeName && curChild.nodeName.toUpperCase();
266+ if (nodeName === 'OPTGROUP') {
267+ optgroup = curChild;
268+ curChild = optgroup.firstChild;
269+ } else {
270+ if (nodeName === 'OPTION') {
271+ if (curChild.hasAttribute('selected')) {
272+ selectedIndex = i;
273+ break;
274+ }
275+ i++;
276+ }
277+ curChild = curChild.nextSibling;
278+ if (!curChild && optgroup) {
279+ curChild = optgroup.nextSibling;
280+ optgroup = null;
281+ }
282+ }
283+ }
284+
285+ fromEl.selectedIndex = selectedIndex;
286+ }
287+ }
288+};
289+
290+var ELEMENT_NODE = 1;
291+var DOCUMENT_FRAGMENT_NODE$1 = 11;
292+var TEXT_NODE = 3;
293+var COMMENT_NODE = 8;
294+
295+function noop() {}
296+
297+function defaultGetNodeKey(node) {
298+ if (node) {
299+ return (node.getAttribute && node.getAttribute('id')) || node.id;
300+ }
301+}
302+
303+function morphdomFactory(morphAttrs) {
304+
305+ return function morphdom(fromNode, toNode, options) {
306+ if (!options) {
307+ options = {};
308+ }
309+
310+ if (typeof toNode === 'string') {
311+ if (fromNode.nodeName === '#document' || fromNode.nodeName === 'HTML' || fromNode.nodeName === 'BODY') {
312+ var toNodeHtml = toNode;
313+ toNode = doc.createElement('html');
314+ toNode.innerHTML = toNodeHtml;
315+ } else {
316+ toNode = toElement(toNode);
317+ }
318+ } else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
319+ toNode = toNode.firstElementChild;
320+ }
321+
322+ var getNodeKey = options.getNodeKey || defaultGetNodeKey;
323+ var onBeforeNodeAdded = options.onBeforeNodeAdded || noop;
324+ var onNodeAdded = options.onNodeAdded || noop;
325+ var onBeforeElUpdated = options.onBeforeElUpdated || noop;
326+ var onElUpdated = options.onElUpdated || noop;
327+ var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop;
328+ var onNodeDiscarded = options.onNodeDiscarded || noop;
329+ var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop;
330+ var skipFromChildren = options.skipFromChildren || noop;
331+ var addChild = options.addChild || function(parent, child){ return parent.appendChild(child); };
332+ var childrenOnly = options.childrenOnly === true;
333+
334+ // This object is used as a lookup to quickly find all keyed elements in the original DOM tree.
335+ var fromNodesLookup = Object.create(null);
336+ var keyedRemovalList = [];
337+
338+ function addKeyedRemoval(key) {
339+ keyedRemovalList.push(key);
340+ }
341+
342+ function walkDiscardedChildNodes(node, skipKeyedNodes) {
343+ if (node.nodeType === ELEMENT_NODE) {
344+ var curChild = node.firstChild;
345+ while (curChild) {
346+
347+ var key = undefined;
348+
349+ if (skipKeyedNodes && (key = getNodeKey(curChild))) {
350+ // If we are skipping keyed nodes then we add the key
351+ // to a list so that it can be handled at the very end.
352+ addKeyedRemoval(key);
353+ } else {
354+ // Only report the node as discarded if it is not keyed. We do this because
355+ // at the end we loop through all keyed elements that were unmatched
356+ // and then discard them in one final pass.
357+ onNodeDiscarded(curChild);
358+ if (curChild.firstChild) {
359+ walkDiscardedChildNodes(curChild, skipKeyedNodes);
360+ }
361+ }
362+
363+ curChild = curChild.nextSibling;
364+ }
365+ }
366+ }
367+
368+ /**
369+ * Removes a DOM node out of the original DOM
370+ *
371+ * @param {Node} node The node to remove
372+ * @param {Node} parentNode The nodes parent
373+ * @param {Boolean} skipKeyedNodes If true then elements with keys will be skipped and not discarded.
374+ * @return {undefined}
375+ */
376+ function removeNode(node, parentNode, skipKeyedNodes) {
377+ if (onBeforeNodeDiscarded(node) === false) {
378+ return;
379+ }
380+
381+ if (parentNode) {
382+ parentNode.removeChild(node);
383+ }
384+
385+ onNodeDiscarded(node);
386+ walkDiscardedChildNodes(node, skipKeyedNodes);
387+ }
388+
389+ // // TreeWalker implementation is no faster, but keeping this around in case this changes in the future
390+ // function indexTree(root) {
391+ // var treeWalker = document.createTreeWalker(
392+ // root,
393+ // NodeFilter.SHOW_ELEMENT);
394+ //
395+ // var el;
396+ // while((el = treeWalker.nextNode())) {
397+ // var key = getNodeKey(el);
398+ // if (key) {
399+ // fromNodesLookup[key] = el;
400+ // }
401+ // }
402+ // }
403+
404+ // // NodeIterator implementation is no faster, but keeping this around in case this changes in the future
405+ //
406+ // function indexTree(node) {
407+ // var nodeIterator = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT);
408+ // var el;
409+ // while((el = nodeIterator.nextNode())) {
410+ // var key = getNodeKey(el);
411+ // if (key) {
412+ // fromNodesLookup[key] = el;
413+ // }
414+ // }
415+ // }
416+
417+ function indexTree(node) {
418+ if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
419+ var curChild = node.firstChild;
420+ while (curChild) {
421+ var key = getNodeKey(curChild);
422+ if (key) {
423+ fromNodesLookup[key] = curChild;
424+ }
425+
426+ // Walk recursively
427+ indexTree(curChild);
428+
429+ curChild = curChild.nextSibling;
430+ }
431+ }
432+ }
433+
434+ indexTree(fromNode);
435+
436+ function handleNodeAdded(el) {
437+ onNodeAdded(el);
438+
439+ var curChild = el.firstChild;
440+ while (curChild) {
441+ var nextSibling = curChild.nextSibling;
442+
443+ var key = getNodeKey(curChild);
444+ if (key) {
445+ var unmatchedFromEl = fromNodesLookup[key];
446+ // if we find a duplicate #id node in cache, replace `el` with cache value
447+ // and morph it to the child node.
448+ if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) {
449+ curChild.parentNode.replaceChild(unmatchedFromEl, curChild);
450+ morphEl(unmatchedFromEl, curChild);
451+ } else {
452+ handleNodeAdded(curChild);
453+ }
454+ } else {
455+ // recursively call for curChild and it's children to see if we find something in
456+ // fromNodesLookup
457+ handleNodeAdded(curChild);
458+ }
459+
460+ curChild = nextSibling;
461+ }
462+ }
463+
464+ function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) {
465+ // We have processed all of the "to nodes". If curFromNodeChild is
466+ // non-null then we still have some from nodes left over that need
467+ // to be removed
468+ while (curFromNodeChild) {
469+ var fromNextSibling = curFromNodeChild.nextSibling;
470+ if ((curFromNodeKey = getNodeKey(curFromNodeChild))) {
471+ // Since the node is keyed it might be matched up later so we defer
472+ // the actual removal to later
473+ addKeyedRemoval(curFromNodeKey);
474+ } else {
475+ // NOTE: we skip nested keyed nodes from being removed since there is
476+ // still a chance they will be matched up later
477+ removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
478+ }
479+ curFromNodeChild = fromNextSibling;
480+ }
481+ }
482+
483+ function morphEl(fromEl, toEl, childrenOnly) {
484+ var toElKey = getNodeKey(toEl);
485+
486+ if (toElKey) {
487+ // If an element with an ID is being morphed then it will be in the final
488+ // DOM so clear it out of the saved elements collection
489+ delete fromNodesLookup[toElKey];
490+ }
491+
492+ if (!childrenOnly) {
493+ // optional
494+ var beforeUpdateResult = onBeforeElUpdated(fromEl, toEl);
495+ if (beforeUpdateResult === false) {
496+ return;
497+ } else if (beforeUpdateResult instanceof HTMLElement) {
498+ fromEl = beforeUpdateResult;
499+ // reindex the new fromEl in case it's not in the same
500+ // tree as the original fromEl
501+ // (Phoenix LiveView sometimes returns a cloned tree,
502+ // but keyed lookups would still point to the original tree)
503+ indexTree(fromEl);
504+ }
505+
506+ // update attributes on original DOM element first
507+ morphAttrs(fromEl, toEl);
508+ // optional
509+ onElUpdated(fromEl);
510+
511+ if (onBeforeElChildrenUpdated(fromEl, toEl) === false) {
512+ return;
513+ }
514+ }
515+
516+ if (fromEl.nodeName !== 'TEXTAREA') {
517+ morphChildren(fromEl, toEl);
518+ } else {
519+ specialElHandlers.TEXTAREA(fromEl, toEl);
520+ }
521+ }
522+
523+ function morphChildren(fromEl, toEl) {
524+ var skipFrom = skipFromChildren(fromEl, toEl);
525+ var curToNodeChild = toEl.firstChild;
526+ var curFromNodeChild = fromEl.firstChild;
527+ var curToNodeKey;
528+ var curFromNodeKey;
529+
530+ var fromNextSibling;
531+ var toNextSibling;
532+ var matchingFromEl;
533+
534+ // walk the children
535+ outer: while (curToNodeChild) {
536+ toNextSibling = curToNodeChild.nextSibling;
537+ curToNodeKey = getNodeKey(curToNodeChild);
538+
539+ // walk the fromNode children all the way through
540+ while (!skipFrom && curFromNodeChild) {
541+ fromNextSibling = curFromNodeChild.nextSibling;
542+
543+ if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) {
544+ curToNodeChild = toNextSibling;
545+ curFromNodeChild = fromNextSibling;
546+ continue outer;
547+ }
548+
549+ curFromNodeKey = getNodeKey(curFromNodeChild);
550+
551+ var curFromNodeType = curFromNodeChild.nodeType;
552+
553+ // this means if the curFromNodeChild doesnt have a match with the curToNodeChild
554+ var isCompatible = undefined;
555+
556+ if (curFromNodeType === curToNodeChild.nodeType) {
557+ if (curFromNodeType === ELEMENT_NODE) {
558+ // Both nodes being compared are Element nodes
559+
560+ if (curToNodeKey) {
561+ // The target node has a key so we want to match it up with the correct element
562+ // in the original DOM tree
563+ if (curToNodeKey !== curFromNodeKey) {
564+ // The current element in the original DOM tree does not have a matching key so
565+ // let's check our lookup to see if there is a matching element in the original
566+ // DOM tree
567+ if ((matchingFromEl = fromNodesLookup[curToNodeKey])) {
568+ if (fromNextSibling === matchingFromEl) {
569+ // Special case for single element removals. To avoid removing the original
570+ // DOM node out of the tree (since that can break CSS transitions, etc.),
571+ // we will instead discard the current node and wait until the next
572+ // iteration to properly match up the keyed target element with its matching
573+ // element in the original tree
574+ isCompatible = false;
575+ } else {
576+ // We found a matching keyed element somewhere in the original DOM tree.
577+ // Let's move the original DOM node into the current position and morph
578+ // it.
579+
580+ // NOTE: We use insertBefore instead of replaceChild because we want to go through
581+ // the `removeNode()` function for the node that is being discarded so that
582+ // all lifecycle hooks are correctly invoked
583+ fromEl.insertBefore(matchingFromEl, curFromNodeChild);
584+
585+ // fromNextSibling = curFromNodeChild.nextSibling;
586+
587+ if (curFromNodeKey) {
588+ // Since the node is keyed it might be matched up later so we defer
589+ // the actual removal to later
590+ addKeyedRemoval(curFromNodeKey);
591+ } else {
592+ // NOTE: we skip nested keyed nodes from being removed since there is
593+ // still a chance they will be matched up later
594+ removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
595+ }
596+
597+ curFromNodeChild = matchingFromEl;
598+ curFromNodeKey = getNodeKey(curFromNodeChild);
599+ }
600+ } else {
601+ // The nodes are not compatible since the "to" node has a key and there
602+ // is no matching keyed node in the source tree
603+ isCompatible = false;
604+ }
605+ }
606+ } else if (curFromNodeKey) {
607+ // The original has a key
608+ isCompatible = false;
609+ }
610+
611+ isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild);
612+ if (isCompatible) {
613+ // We found compatible DOM elements so transform
614+ // the current "from" node to match the current
615+ // target DOM node.
616+ // MORPH
617+ morphEl(curFromNodeChild, curToNodeChild);
618+ }
619+
620+ } else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) {
621+ // Both nodes being compared are Text or Comment nodes
622+ isCompatible = true;
623+ // Simply update nodeValue on the original node to
624+ // change the text value
625+ if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) {
626+ curFromNodeChild.nodeValue = curToNodeChild.nodeValue;
627+ }
628+
629+ }
630+ }
631+
632+ if (isCompatible) {
633+ // Advance both the "to" child and the "from" child since we found a match
634+ // Nothing else to do as we already recursively called morphChildren above
635+ curToNodeChild = toNextSibling;
636+ curFromNodeChild = fromNextSibling;
637+ continue outer;
638+ }
639+
640+ // No compatible match so remove the old node from the DOM and continue trying to find a
641+ // match in the original DOM. However, we only do this if the from node is not keyed
642+ // since it is possible that a keyed node might match up with a node somewhere else in the
643+ // target tree and we don't want to discard it just yet since it still might find a
644+ // home in the final DOM tree. After everything is done we will remove any keyed nodes
645+ // that didn't find a home
646+ if (curFromNodeKey) {
647+ // Since the node is keyed it might be matched up later so we defer
648+ // the actual removal to later
649+ addKeyedRemoval(curFromNodeKey);
650+ } else {
651+ // NOTE: we skip nested keyed nodes from being removed since there is
652+ // still a chance they will be matched up later
653+ removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
654+ }
655+
656+ curFromNodeChild = fromNextSibling;
657+ } // END: while(curFromNodeChild) {}
658+
659+ // If we got this far then we did not find a candidate match for
660+ // our "to node" and we exhausted all of the children "from"
661+ // nodes. Therefore, we will just append the current "to" node
662+ // to the end
663+ if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) {
664+ // MORPH
665+ if(!skipFrom){ addChild(fromEl, matchingFromEl); }
666+ morphEl(matchingFromEl, curToNodeChild);
667+ } else {
668+ var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild);
669+ if (onBeforeNodeAddedResult !== false) {
670+ if (onBeforeNodeAddedResult) {
671+ curToNodeChild = onBeforeNodeAddedResult;
672+ }
673+
674+ if (curToNodeChild.actualize) {
675+ curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc);
676+ }
677+ addChild(fromEl, curToNodeChild);
678+ handleNodeAdded(curToNodeChild);
679+ }
680+ }
681+
682+ curToNodeChild = toNextSibling;
683+ curFromNodeChild = fromNextSibling;
684+ }
685+
686+ cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey);
687+
688+ var specialElHandler = specialElHandlers[fromEl.nodeName];
689+ if (specialElHandler) {
690+ specialElHandler(fromEl, toEl);
691+ }
692+ } // END: morphChildren(...)
693+
694+ var morphedNode = fromNode;
695+ var morphedNodeType = morphedNode.nodeType;
696+ var toNodeType = toNode.nodeType;
697+
698+ if (!childrenOnly) {
699+ // Handle the case where we are given two DOM nodes that are not
700+ // compatible (e.g. <div> --> <span> or <div> --> TEXT)
701+ if (morphedNodeType === ELEMENT_NODE) {
702+ if (toNodeType === ELEMENT_NODE) {
703+ if (!compareNodeNames(fromNode, toNode)) {
704+ onNodeDiscarded(fromNode);
705+ morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI));
706+ }
707+ } else {
708+ // Going from an element node to a text node
709+ morphedNode = toNode;
710+ }
711+ } else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { // Text or comment node
712+ if (toNodeType === morphedNodeType) {
713+ if (morphedNode.nodeValue !== toNode.nodeValue) {
714+ morphedNode.nodeValue = toNode.nodeValue;
715+ }
716+
717+ return morphedNode;
718+ } else {
719+ // Text node to something else
720+ morphedNode = toNode;
721+ }
722+ }
723+ }
724+
725+ if (morphedNode === toNode) {
726+ // The "to node" was not compatible with the "from node" so we had to
727+ // toss out the "from node" and use the "to node"
728+ onNodeDiscarded(fromNode);
729+ } else {
730+ if (toNode.isSameNode && toNode.isSameNode(morphedNode)) {
731+ return;
732+ }
733+
734+ morphEl(morphedNode, toNode, childrenOnly);
735+
736+ // We now need to loop over any keyed nodes that might need to be
737+ // removed. We only do the removal if we know that the keyed node
738+ // never found a match. When a keyed node is matched up we remove
739+ // it out of fromNodesLookup and we use fromNodesLookup to determine
740+ // if a keyed node has been matched up or not
741+ if (keyedRemovalList) {
742+ for (var i=0, len=keyedRemovalList.length; i<len; i++) {
743+ var elToRemove = fromNodesLookup[keyedRemovalList[i]];
744+ if (elToRemove) {
745+ removeNode(elToRemove, elToRemove.parentNode, false);
746+ }
747+ }
748+ }
749+ }
750+
751+ if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) {
752+ if (morphedNode.actualize) {
753+ morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc);
754+ }
755+ // If we had to swap out the from node with a new node because the old
756+ // node was not compatible with the target node then we need to
757+ // replace the old DOM node in the original DOM tree. This is only
758+ // possible if the original DOM node was part of a DOM tree which
759+ // we know is the case if it has a parent node.
760+ fromNode.parentNode.replaceChild(morphedNode, fromNode);
761+ }
762+
763+ return morphedNode;
764+ };
765+}
766+
767+var morphdom = morphdomFactory(morphAttrs);
768+
769+export default morphdom;
public/scripts/extensions/quick-reply/lib/morphdom.LICENSE.txt+21 -0
@@ -0,0 +1,21 @@
1+The MIT License (MIT)
2+
3+Copyright (c) Patrick Steele-Idem <pnidem@gmail.com> (psteeleidem.com)
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in
13+all copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+THE SOFTWARE.
21 \ No newline at end of file
public/scripts/extensions/quick-reply/src/QuickReply.js+1260 -70
@@ -1,10 +1,17 @@
11import { POPUP_RESULT, POPUP_TYPE, Popup } from '../../../popup.js';
22import { setSlashCommandAutoComplete } from '../../../slash-commands.js';
33import { SlashCommandAbortController } from '../../../slash-commands/SlashCommandAbortController.js';
4+import { SlashCommandBreakPoint } from '../../../slash-commands/SlashCommandBreakPoint.js';
5+import { SlashCommandClosure } from '../../../slash-commands/SlashCommandClosure.js';
6+import { SlashCommandClosureResult } from '../../../slash-commands/SlashCommandClosureResult.js';
7+import { SlashCommandDebugController } from '../../../slash-commands/SlashCommandDebugController.js';
8+import { SlashCommandExecutor } from '../../../slash-commands/SlashCommandExecutor.js';
9+import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
410import { SlashCommandParserError } from '../../../slash-commands/SlashCommandParserError.js';
511import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
612import { debounce, delay, getSortableDelay, showFontAwesomePicker } from '../../../utils.js';
713import { log, quickReplyApi, warn } from '../index.js';
14+import morphdom from '../lib/morphdom-esm.js';
815import { QuickReplyContextLink } from './QuickReplyContextLink.js';
916import { QuickReplySet } from './QuickReplySet.js';
1017import { ContextMenu } from './ui/ctx/ContextMenu.js';
@@ -21,48 +28,61 @@ export class QuickReply {
2128
2229
2330
2431 /**@type {Numbernumber}*/ id;
2532 /**@type {Stringstring}*/ label = ''icon;
2633 /**@type {Stringstring}*/ titlelabel = '';
2734 /**@type {Stringboolean}*/ messageshowLabel = ''false;
35+ /**@type {string}*/ title = '';
36+ /**@type {string}*/ message = '';
2837
2938 /**@type {QuickReplyContextLink[]}*/ contextList;
3039
3140 /**@type {Booleanboolean}*/ preventAutoExecute = true;
3241 /**@type {Booleanboolean}*/ isHidden = false;
3342 /**@type {Booleanboolean}*/ executeOnStartup = false;
3443 /**@type {Booleanboolean}*/ executeOnUser = false;
3544 /**@type {Booleanboolean}*/ executeOnAi = false;
3645 /**@type {Booleanboolean}*/ executeOnChatChange = false;
3746 /**@type {Booleanboolean}*/ executeOnGroupMemberDraft = false;
3847 /**@type {Stringstring}*/ automationId = '';
3948
4049 /**@type {Functionfunction}*/ onExecute;
41- /**@type {Function}*/ onDelete;
50+ /**@type {(qr:QuickReply)=>AsyncGenerator<SlashCommandClosureResult|{closure:SlashCommandClosure, executor:SlashCommandExecutor|SlashCommandClosureResult}, SlashCommandClosureResult, boolean>}*/ onDebug;
4251 /**@type {Functionfunction}*/ onUpdateonDelete;
52+ /**@type {function}*/ onUpdate;
53+ /**@type {function}*/ onInsertBefore;
54+ /**@type {function}*/ onTransfer;
4355
4456
4557 /**@type {HTMLElement}*/ dom;
58+ /**@type {HTMLElement}*/ domIcon;
4659 /**@type {HTMLElement}*/ domLabel;
4760 /**@type {HTMLElement}*/ settingsDom;
61+ /**@type {HTMLElement}*/ settingsDomIcon;
4862 /**@type {HTMLInputElement}*/ settingsDomLabel;
4963 /**@type {HTMLTextAreaElement}*/ settingsDomMessage;
5064
5165 /**@type {Popup}*/ editorPopup;
66+ /**@type {HTMLElement}*/ editorDom;
5267
68+ /**@type {HTMLTextAreaElement}*/ editorMessage;
69+ /**@type {HTMLTextAreaElement}*/ editorMessageLabel;
70+ /**@type {HTMLElement}*/ editorSyntax;
5371 /**@type {HTMLElement}*/ editorExecuteBtn;
5472 /**@type {HTMLElement}*/ editorExecuteBtnPause;
5573 /**@type {HTMLElement}*/ editorExecuteBtnStop;
5674 /**@type {HTMLElement}*/ editorExecuteProgress;
5775 /**@type {HTMLElement}*/ editorExecuteErrors;
5876 /**@type {HTMLElement}*/ editorExecuteResult;
5977 /**@type {HTMLInputElementHTMLElement}*/ editorExecuteHideeditorDebugState;
6078 /**@type {Promise}*/ editorExecutePromise;
79+ /**@type {boolean}*/ isExecuting;
6180 /**@type {SlashCommandAbortController}*/ abortController;
81+ /**@type {SlashCommandDebugController}*/ debugController;
6282
6383
6484 get hasContext() {
6585 return this.contextList && this.contextList.filter(it => it.set).length > 0;
6686 }
6787
6888
@@ -75,6 +95,14 @@ export class QuickReply {
7595 updateRender() {
7696 if (!this.dom) return;
7797 this.dom.title = this.title || this.message;
98+ if (this.icon) {
99+ this.domIcon.classList.remove('qr--hidden');
100+ if (this.showLabel) this.domLabel.classList.remove('qr--hidden');
101+ else this.domLabel.classList.add('qr--hidden');
102+ } else {
103+ this.domIcon.classList.add('qr--hidden');
104+ this.domLabel.classList.remove('qr--hidden');
105+ }
78106 this.domLabel.textContent = this.label;
79107 this.dom.classList[this.hasContext ? 'add' : 'remove']('qr--hasCtx');
80108 }
@@ -105,9 +133,18 @@ export class QuickReply {
105133 }
106134 this.execute();
107135 });
136+ const icon = document.createElement('div'); {
137+ this.domIcon = icon;
138+ icon.classList.add('qr--button-icon');
139+ icon.classList.add('fa-solid');
140+ if (!this.icon) icon.classList.add('qr--hidden');
141+ else icon.classList.add(this.icon);
142+ root.append(icon);
143+ }
108144 const lbl = document.createElement('div'); {
109145 this.domLabel = lbl;
110146 lbl.classList.add('qr--button-label');
147+ if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
111148 lbl.textContent = this.label;
112149 root.append(lbl);
113150 }
@@ -138,14 +175,90 @@ export class QuickReply {
138175 item.classList.add('qr--set-item');
139176 item.setAttribute('data-order', String(idx));
140177 item.setAttribute('data-id', String(this.id));
178+ const adder = document.createElement('div'); {
179+ adder.classList.add('qr--set-itemAdder');
180+ const actions = document.createElement('div'); {
181+ actions.classList.add('qr--actions');
182+ const addNew = document.createElement('div'); {
183+ addNew.classList.add('qr--action');
184+ addNew.classList.add('qr--add');
185+ addNew.classList.add('menu_button');
186+ addNew.classList.add('menu_button_icon');
187+ addNew.classList.add('fa-solid');
188+ addNew.classList.add('fa-plus');
189+ addNew.title = 'Add quick reply';
190+ addNew.addEventListener('click', ()=>this.onInsertBefore());
191+ actions.append(addNew);
192+ }
193+ const paste = document.createElement('div'); {
194+ paste.classList.add('qr--action');
195+ paste.classList.add('qr--paste');
196+ paste.classList.add('menu_button');
197+ paste.classList.add('menu_button_icon');
198+ paste.classList.add('fa-solid');
199+ paste.classList.add('fa-paste');
200+ paste.title = 'Add quick reply from clipboard';
201+ paste.addEventListener('click', async()=>{
202+ const text = await navigator.clipboard.readText();
203+ this.onInsertBefore(text);
204+ });
205+ actions.append(paste);
206+ }
207+ const importFile = document.createElement('div'); {
208+ importFile.classList.add('qr--action');
209+ importFile.classList.add('qr--importFile');
210+ importFile.classList.add('menu_button');
211+ importFile.classList.add('menu_button_icon');
212+ importFile.classList.add('fa-solid');
213+ importFile.classList.add('fa-file-import');
214+ importFile.title = 'Add quick reply from JSON file';
215+ importFile.addEventListener('click', async()=>{
216+ const inp = document.createElement('input'); {
217+ inp.type = 'file';
218+ inp.accept = '.json';
219+ inp.addEventListener('change', async()=>{
220+ if (inp.files.length > 0) {
221+ for (const file of inp.files) {
222+ const text = await file.text();
223+ this.onInsertBefore(text);
224+ }
225+ }
226+ });
227+ inp.click();
228+ }
229+ });
230+ actions.append(importFile);
231+ }
232+ adder.append(actions);
233+ }
234+ item.append(adder);
235+ }
236+ const itemContent = document.createElement('div'); {
237+ itemContent.classList.add('qr--content');
141238 const drag = document.createElement('div'); {
142239 drag.classList.add('drag-handle');
143240 drag.classList.add('ui-sortable-handle');
144241 drag.textContent = '☰';
145242 item itemContent.append(drag);
146243 }
147244 const lblContainer = document.createElement('div'); {
148245 lblContainer.classList.add('qr--set-itemLabelContainer');
246+ const icon = document.createElement('div'); {
247+ this.settingsDomIcon = icon;
248+ icon.title = 'Click to change icon';
249+ icon.classList.add('qr--set-itemIcon');
250+ icon.classList.add('menu_button');
251+ icon.classList.add('fa-fw');
252+ if (this.icon) {
253+ icon.classList.add('fa-solid');
254+ icon.classList.add(this.icon);
255+ }
256+ icon.addEventListener('click', async()=>{
257+ let value = await showFontAwesomePicker();
258+ this.updateIcon(value);
259+ });
260+ lblContainer.append(icon);
261+ }
149262 const lbl = document.createElement('input'); {
150263 this.settingsDomLabel = lbl;
151264 lbl.classList.add('qr--set-itemLabel');
@@ -154,20 +267,23 @@ export class QuickReply {
154267 lbl.addEventListener('input', ()=>this.updateLabel(lbl.value));
155268 lblContainer.append(lbl);
156269 }
157270 item itemContent.append(lblContainer);
271+ }
272+ item.append(itemContent);
158273 }
159274 const optContainer = document.createElement('div'); {
160275 optContainer.classList.add('qr--set-optionsContainer');
161276 const opt = document.createElement('div'); {
162277 opt.classList.add('qr--action');
163278 opt.classList.add('menu_button');
279+ opt.classList.add('fa-fw');
164280 opt.classList.add('fa-solid');
165281 opt.textContent = '⁝';
166282 opt.title = 'Additional options:\n - large editor\n - context menu\n - auto-execution\n - tooltip';
167283 opt.addEventListener('click', ()=>this.showEditor());
168284 optContainer.append(opt);
169285 }
170286 itemitemContent.append(optContainer);
171287 }
172288 const mes = document.createElement('textarea'); {
173289 this.settingsDomMessage = mes;
@@ -176,22 +292,89 @@ export class QuickReply {
176292 mes.value = this.message;
177293 //HACK need to use jQuery to catch the triggered event from the expanded editor
178294 $(mes).on('input', ()=>this.updateMessage(mes.value));
179295 itemitemContent.append(mes);
180296 }
181297 const actions = document.createElement('div'); {
182298 actions.classList.add('qr--actions');
299+ const move = document.createElement('div'); {
300+ move.classList.add('qr--action');
301+ move.classList.add('menu_button');
302+ move.classList.add('fa-fw');
303+ move.classList.add('fa-solid');
304+ move.classList.add('fa-truck-arrow-right');
305+ move.title = 'Move quick reply to other set';
306+ move.addEventListener('click', ()=>this.onTransfer(this));
307+ actions.append(move);
308+ }
309+ const copy = document.createElement('div'); {
310+ copy.classList.add('qr--action');
311+ copy.classList.add('menu_button');
312+ copy.classList.add('fa-fw');
313+ copy.classList.add('fa-solid');
314+ copy.classList.add('fa-copy');
315+ copy.title = 'Copy quick reply to clipboard';
316+ copy.addEventListener('click', async()=>{
317+ await navigator.clipboard.writeText(JSON.stringify(this));
318+ copy.classList.add('qr--success');
319+ await delay(3010);
320+ copy.classList.remove('qr--success');
321+ });
322+ actions.append(copy);
323+ }
324+ const cut = document.createElement('div'); {
325+ cut.classList.add('qr--action');
326+ cut.classList.add('menu_button');
327+ cut.classList.add('fa-fw');
328+ cut.classList.add('fa-solid');
329+ cut.classList.add('fa-cut');
330+ cut.title = 'Cut quick reply to clipboard (copy and remove)';
331+ cut.addEventListener('click', async()=>{
332+ await navigator.clipboard.writeText(JSON.stringify(this));
333+ this.delete();
334+ });
335+ actions.append(cut);
336+ }
337+ const exp = document.createElement('div'); {
338+ exp.classList.add('qr--action');
339+ exp.classList.add('menu_button');
340+ exp.classList.add('fa-fw');
341+ exp.classList.add('fa-solid');
342+ exp.classList.add('fa-file-export');
343+ exp.title = 'Export quick reply as file';
344+ exp.addEventListener('click', ()=>{
345+ const blob = new Blob([JSON.stringify(this)], { type:'text' });
346+ const url = URL.createObjectURL(blob);
347+ const a = document.createElement('a'); {
348+ a.href = url;
349+ a.download = `${this.label}.qr.json`;
350+ a.click();
351+ }
352+ });
353+ actions.append(exp);
354+ }
183355 const del = document.createElement('div'); {
184356 del.classList.add('qr--action');
185357 del.classList.add('menu_button');
186358 del.classList.add('menu_button_iconfa-fw');
187359 del.classList.add('fa-solid');
188360 del.classList.add('fa-trash-can');
189361 del.classList.add('redWarningBG');
190362 del.title = 'Remove quickQuick replyReply\n---\nShit+Click to skip confirmation';
191363 del.addEventListener('click', async(evt)=>this.delete());{
364+ if (!evt.shiftKey) {
365+ const result = await Popup.show.confirm(
366+ 'Remove Quick Reply',
367+ 'Are you sure you want to remove this Quick Reply?',
368+ );
369+ if (result != POPUP_RESULT.AFFIRMATIVE) {
370+ return;
371+ }
372+ }
373+ this.delete();
374+ });
192375 actions.append(del);
193376 }
194377 itemitemContent.append(actions);
195378 }
196379 }
197380 }
@@ -208,22 +391,156 @@ export class QuickReply {
208391 /**@type {HTMLElement} */
209392 // @ts-ignore
210393 const dom = this.template.cloneNode(true);
394+ this.editorDom = dom;
211395 this.editorPopup = new Popup(dom, POPUP_TYPE.TEXT, undefined, { okButton: 'OK', wide: true, large: true, rows: 1 });
212396 const popupResult = this.editorPopup.show();
213397
214398 // basics
399+ /**@type {HTMLElement}*/
400+ const icon = dom.querySelector('#qr--modal-icon');
401+ if (this.icon) {
402+ icon.classList.add('fa-solid');
403+ icon.classList.add(this.icon);
404+ }
405+ else {
406+ icon.textContent = '…';
407+ }
408+ icon.addEventListener('click', async()=>{
409+ let value = await showFontAwesomePicker();
410+ if (value === null) return;
411+ if (this.icon) icon.classList.remove(this.icon);
412+ if (value == '') {
413+ icon.classList.remove('fa-solid');
414+ icon.textContent = '…';
415+ } else {
416+ icon.textContent = '';
417+ icon.classList.add('fa-solid');
418+ icon.classList.add(value);
419+ }
420+ this.updateIcon(value);
421+ });
422+ /**@type {HTMLInputElement}*/
423+ const showLabel = dom.querySelector('#qr--modal-showLabel');
424+ showLabel.checked = this.showLabel;
425+ showLabel.addEventListener('click', ()=>{
426+ this.updateShowLabel(showLabel.checked);
427+ });
215428 /**@type {HTMLInputElement}*/
216429 const label = dom.querySelector('#qr--modal-label');
217430 label.value = this.label;
218431 label.addEventListener('input', ()=>{
219432 this.updateLabel(label.value);
220433 });
434+ let switcherList;
435+ dom.querySelector('#qr--modal-switcher').addEventListener('click', (evt)=>{
436+ if (switcherList) {
437+ switcherList.remove();
438+ switcherList = null;
439+ return;
440+ }
441+ const list = document.createElement('ul'); {
442+ switcherList = list;
443+ list.classList.add('qr--modal-switcherList');
444+ const makeList = (qrs)=>{
445+ const setItem = document.createElement('li'); {
446+ setItem.classList.add('qr--modal-switcherItem');
447+ setItem.addEventListener('click', ()=>{
448+ list.innerHTML = '';
449+ for (const qrs of quickReplyApi.listSets()) {
450+ const item = document.createElement('li'); {
451+ item.classList.add('qr--modal-switcherItem');
452+ item.addEventListener('click', ()=>{
453+ list.innerHTML = '';
454+ makeList(quickReplyApi.getSetByName(qrs));
455+ });
456+ const lbl = document.createElement('div'); {
457+ lbl.classList.add('qr--label');
458+ lbl.textContent = qrs;
459+ item.append(lbl);
460+ }
461+ list.append(item);
462+ }
463+ }
464+ });
465+ const lbl = document.createElement('div'); {
466+ lbl.classList.add('qr--label');
467+ const icon = document.createElement('i'); {
468+ icon.classList.add('fa-solid');
469+ icon.classList.add('fa-arrow-alt-circle-right');
470+ icon.classList.add('menu_button');
471+ lbl.append(icon);
472+ }
473+ const text = document.createElement('span'); {
474+ text.textContent = 'Switch QR Sets...';
475+ lbl.append(text);
476+ }
477+ setItem.append(lbl);
478+ }
479+ list.append(setItem);
480+ }
481+ const addItem = document.createElement('li'); {
482+ addItem.classList.add('qr--modal-switcherItem');
483+ addItem.addEventListener('click', ()=>{
484+ const qr = quickReplyApi.getSetByQr(this).addQuickReply();
485+ this.editorPopup.completeAffirmative();
486+ qr.showEditor();
487+ });
488+ const lbl = document.createElement('div'); {
489+ lbl.classList.add('qr--label');
490+ const icon = document.createElement('i'); {
491+ icon.classList.add('fa-solid');
492+ icon.classList.add('fa-plus');
493+ icon.classList.add('menu_button');
494+ lbl.append(icon);
495+ }
496+ const text = document.createElement('span'); {
497+ text.textContent = 'Add QR';
498+ lbl.append(text);
499+ }
500+ addItem.append(lbl);
501+ }
502+ list.append(addItem);
503+ }
504+ for (const qr of qrs.qrList.toSorted((a,b)=>a.label.toLowerCase().localeCompare(b.label.toLowerCase()))) {
505+ const item = document.createElement('li'); {
506+ item.classList.add('qr--modal-switcherItem');
507+ if (qr == this) item.classList.add('qr--current');
508+ else item.addEventListener('click', ()=>{
509+ this.editorPopup.completeAffirmative();
510+ qr.showEditor();
511+ });
512+ const lbl = document.createElement('div'); {
513+ lbl.classList.add('qr--label');
514+ lbl.textContent = qr.label;
515+ item.append(lbl);
516+ }
517+ const id = document.createElement('div'); {
518+ id.classList.add('qr--id');
519+ id.textContent = qr.id.toString();
520+ item.append(id);
521+ }
522+ const mes = document.createElement('div'); {
523+ mes.classList.add('qr--message');
524+ mes.textContent = qr.message;
525+ item.append(mes);
526+ }
527+ list.append(item);
528+ }
529+ }
530+ };
531+ makeList(quickReplyApi.getSetByQr(this));
532+ }
533+ label.parentElement.append(list);
534+ });
221535 /**@type {HTMLInputElement}*/
222536 const title = dom.querySelector('#qr--modal-title');
223537 title.value = this.title;
224538 title.addEventListener('input', () => {
225539 this.updateTitle(title.value);
226540 });
541+ /**@type {HTMLElement}*/
542+ const messageSyntaxInner = dom.querySelector('#qr--modal-messageSyntaxInner');
543+ this.editorSyntax = messageSyntaxInner;
227544 /**@type {HTMLInputElement}*/
228545 const wrap = dom.querySelector('#qr--modal-wrap');
229546 wrap.checked = JSON.parse(localStorage.getItem('qr--wrap') ?? 'false');
@@ -235,9 +552,15 @@ export class QuickReply {
235552 if (wrap.checked) {
236553 message.style.whiteSpace = 'pre-wrap';
237554 messageSyntaxInner.style.whiteSpace = 'pre-wrap';
555+ if (this.clone) {
556+ this.clone.style.whiteSpace = 'pre-wrap';
557+ }
238558 } else {
239559 message.style.whiteSpace = 'pre';
240560 messageSyntaxInner.style.whiteSpace = 'pre';
561+ if (this.clone) {
562+ this.clone.style.whiteSpace = 'pre';
563+ }
241564 }
242565 updateScrollDebounced();
243566 };
@@ -261,11 +584,8 @@ export class QuickReply {
261584 });
262585 };
263586 const updateScrollDebounced = updateScroll;
264- const updateSyntax = ()=>{
265- messageSyntaxInner.innerHTML = hljs.highlight(`${message.value}${message.value.slice(-1) == '\n' ? ' ' : ''}`, { language:'stscript', ignoreIllegals:true })?.value;
266- };
267587 const updateSyntaxEnabled = ()=>{
268- if (JSON.parse(localStorage.getItem('qr--syntax'))) {
588+ if (syntax.checked) {
269589 dom.querySelector('#qr--modal-messageHolder').classList.remove('qr--noSyntax');
270590 } else {
271591 dom.querySelector('#qr--modal-messageHolder').classList.add('qr--noSyntax');
@@ -296,48 +616,108 @@ export class QuickReply {
296616 localStorage.setItem('qr--syntax', JSON.stringify(syntax.checked));
297617 updateSyntaxEnabled();
298618 });
619+ if (navigator.keyboard) {
620+ navigator.keyboard.getLayoutMap().then(it=>dom.querySelector('#qr--modal-commentKey').textContent = it.get('Backslash'));
621+ } else {
622+ dom.querySelector('#qr--modal-commentKey').closest('small').remove();
623+ }
624+ this.editorMessageLabel = dom.querySelector('label[for="qr--modal-message"]');
299625 /**@type {HTMLTextAreaElement}*/
300626 const message = dom.querySelector('#qr--modal-message');
627+ this.editorMessage = message;
301628 message.value = this.message;
629+ const updateMessageDebounced = debounce((value)=>this.updateMessage(value), 10);
302630 message.addEventListener('input', () => {
303631 updateSyntaxupdateMessageDebounced(message.value);
304- this.updateMessage(message.value);
305632 updateScrollDebounced();
306- });
633+ }, { passive:true });
307- setSlashCommandAutoComplete(message, true);
634+ const getLineStart = ()=>{
308- //TODO move tab support for textarea into its own helper(?) and use for both this and .editor_maximize
635+ const start = message.selectionStart;
636+ const end = message.selectionEnd;
637+ let lineStart;
638+ if (start == 0 || message.value[start - 1] == '\n') {
639+ // cursor is already at beginning of line
640+ // -> keep start
641+ lineStart = start;
642+ } else {
643+ // cursor is at end of line or somewhere in the line
644+ // -> find last newline before cursor and start after that
645+ lineStart = message.value.lastIndexOf('\n', start - 1) + 1;
646+ }
647+ return lineStart;
648+ };
309649 message.addEventListener('keydown', async(evt) => {
650+ if (this.isExecuting) return;
310651 if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
652+ // increase indent
311653 evt.preventDefault();
312654 const start = message.selectionStart;
313655 const end = message.selectionEnd;
314656 if (end - start > 0 && message.value.substring(start, end).includes('\n')) {
315- const lineStart = message.value.lastIndexOf('\n', start);
657+ evt.stopImmediatePropagation();
316- const count = message.value.substring(lineStart, end).split('\n').length - 1;
658+ evt.stopPropagation();
317- message.value = `${message.value.substring(0, lineStart)}${message.value.substring(lineStart, end).replace(/\n/g, '\n\t')}${message.value.substring(end)}`;
659+ const lineStart = getLineStart();
318660 message.selectionStart = start + 1lineStart;
319- message.selectionEnd = end + count;
661+ const affectedLines = message.value.substring(lineStart, end).split('\n');
320- updateSyntax();
662+ // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
321- } else {
663+ document.execCommand('insertText', false, `\t${affectedLines.join('\n\t')}`);
322- message.value = `${message.value.substring(0, start)}\t${message.value.substring(end)}`;
323664 message.selectionStart = start + 1;
324665 message.selectionEnd = end + 1affectedLines.length;
325- updateSyntax();
666+ message.dispatchEvent(new Event('input', { bubbles:true }));
667+ } else if (!(ac.isReplaceable && ac.isActive)) {
668+ evt.stopImmediatePropagation();
669+ evt.stopPropagation();
670+ // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
671+ document.execCommand('insertText', false, '\t');
672+ message.dispatchEvent(new Event('input', { bubbles:true }));
326673 }
327674 } else if (evt.key == 'Tab' && evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
675+ // decrease indent
328676 evt.preventDefault();
677+ evt.stopImmediatePropagation();
678+ evt.stopPropagation();
329679 const start = message.selectionStart;
330680 const end = message.selectionEnd;
331681 const lineStart = message.value.lastIndexOfgetLineStart('\n', start);
332- const count = message.value.substring(lineStart, end).split('\n\t').length - 1;
682+ message.selectionStart = lineStart;
333- message.value = `${message.value.substring(0, lineStart)}${message.value.substring(lineStart, end).replace(/\n\t/g, '\n')}${message.value.substring(end)}`;
683+ const affectedLines = message.value.substring(lineStart, end).split('\n');
334- message.selectionStart = start - 1;
684+ const newText = affectedLines.map(it=>it.replace(/^\t/, '')).join('\n');
335- message.selectionEnd = end - count;
685+ const delta = affectedLines.join('\n').length - newText.length;
336- updateSyntax();
686+ // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
337- } else if (evt.key == 'Enter' && evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
687+ if (delta > 0) {
688+ if (newText == '') {
689+ document.execCommand('delete', false);
690+ } else {
691+ document.execCommand('insertText', false, newText);
692+ }
693+ message.selectionStart = start - (affectedLines[0].startsWith('\t') ? 1 : 0);
694+ message.selectionEnd = end - delta;
695+ message.dispatchEvent(new Event('input', { bubbles:true }));
696+ } else {
697+ message.selectionStart = start;
698+ }
699+ } else if (evt.key == 'Enter' && !evt.ctrlKey && !evt.shiftKey && !evt.altKey && !(ac.isReplaceable && ac.isActive)) {
700+ // new line, keep indent
701+ const start = message.selectionStart;
702+ const end = message.selectionEnd;
703+ let lineStart = getLineStart();
704+ const indent = /^([^\S\n]*)/.exec(message.value.slice(lineStart))[1] ?? '';
705+ if (indent.length) {
706+ evt.stopImmediatePropagation();
338707 evt.stopPropagation();
339708 evt.preventDefault();
709+ // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
710+ document.execCommand('insertText', false, `\n${indent}`);
711+ message.selectionStart = start + 1 + indent.length;
712+ message.selectionEnd = message.selectionStart;
713+ message.dispatchEvent(new Event('input', { bubbles:true }));
714+ }
715+ } else if (evt.key == 'Enter' && evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
340716 if (executeShortcut.checked) {
717+ // execute QR
718+ evt.stopImmediatePropagation();
719+ evt.stopPropagation();
720+ evt.preventDefault();
341721 const selectionStart = message.selectionStart;
342722 const selectionEnd = message.selectionEnd;
343723 message.blur();
@@ -348,17 +728,175 @@ export class QuickReply {
348728 message.selectionEnd = selectionEnd;
349729 }
350730 }
731+ } else if (evt.key == 'F9' && !evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
732+ // toggle breakpoint
733+ evt.stopImmediatePropagation();
734+ evt.stopPropagation();
735+ evt.preventDefault();
736+ preBreakPointStart = message.selectionStart;
737+ preBreakPointEnd = message.selectionEnd;
738+ toggleBreakpoint();
739+ } else if (evt.code == 'Backslash' && evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
740+ // toggle block comment
741+ // (evt.code will use the same physical key on the keyboard across different keyboard layouts)
742+ evt.stopImmediatePropagation();
743+ evt.stopPropagation();
744+ evt.preventDefault();
745+ // check if we are inside a comment -> uncomment
746+ const parser = new SlashCommandParser();
747+ parser.parse(message.value, false);
748+ const start = message.selectionStart;
749+ const end = message.selectionEnd;
750+ const comment = parser.commandIndex.findLast(it=>it.name == '*' && (it.start <= start && it.end >= start || it.start <= end && it.end >= end));
751+ if (comment) {
752+ // uncomment
753+ let content = message.value.slice(comment.start + 1, comment.end - 1);
754+ let len = content.length;
755+ content = content.replace(/^ /, '');
756+ const offsetStart = len - content.length;
757+ len = content.length;
758+ content = content.replace(/ $/, '');
759+ const offsetEnd = len - content.length;
760+ message.selectionStart = comment.start - 1;
761+ message.selectionEnd = comment.end + 1;
762+ // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
763+ document.execCommand('insertText', false, content);
764+ message.selectionStart = start - (start >= comment.start ? 2 + offsetStart : 0);
765+ message.selectionEnd = end - 2 - offsetStart - (end >= comment.end ? 2 + offsetEnd : 0);
766+ } else {
767+ // comment
768+ const lineStart = getLineStart();
769+ const lineEnd = message.value.indexOf('\n', end);
770+ message.selectionStart = lineStart;
771+ message.selectionEnd = lineEnd;
772+ // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
773+ document.execCommand('insertText', false, `/* ${message.value.slice(lineStart, lineEnd)} *|`);
774+ message.selectionStart = start + 3;
775+ message.selectionEnd = end + 3;
776+ }
777+ message.dispatchEvent(new Event('input', { bubbles:true }));
351778 }
352779 });
780+ const ac = await setSlashCommandAutoComplete(message, true);
353781 message.addEventListener('wheel', (evt)=>{
354782 updateScrollDebounced(evt);
355783 });
356784 message.addEventListener('scroll', (evt)=>{
357785 updateScrollDebounced();
358786 });
787+ let preBreakPointStart;
788+ let preBreakPointEnd;
789+ /**
790+ * @param {SlashCommandBreakPoint} bp
791+ */
792+ const removeBreakpoint = (bp)=>{
793+ // start at -1 because "/" is not included in start-end
794+ let start = bp.start - 1;
795+ // step left until forward slash "/"
796+ while (message.value[start] != '/') start--;
797+ // step left while whitespace (except newline) before start
798+ while (/[^\S\n]/.test(message.value[start - 1])) start--;
799+ // if newline before indent, include the newline for removal
800+ if (message.value[start - 1] == '\n') start--;
801+ let end = bp.end;
802+ // step right while whitespace
803+ while (/\s/.test(message.value[end])) end++;
804+ // if pipe after whitepace, include pipe for removal
805+ if (message.value[end] == '|') end++;
806+ message.selectionStart = start;
807+ message.selectionEnd = end;
808+ // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
809+ document.execCommand('insertText', false, '');
810+ message.dispatchEvent(new Event('input', { bubbles:true }));
811+ let postStart = preBreakPointStart;
812+ let postEnd = preBreakPointEnd;
813+ // set caret back to where it was
814+ if (preBreakPointStart <= start) {
815+ // selection start was before breakpoint: do nothing
816+ } else if (preBreakPointStart > start && preBreakPointEnd < end) {
817+ // selection start was inside breakpoint: move to index before breakpoint
818+ postStart = start;
819+ } else if (preBreakPointStart >= end) {
820+ // selection start was behind breakpoint: move back by length of removed string
821+ postStart = preBreakPointStart - (end - start);
822+ }
823+ if (preBreakPointEnd <= start) {
824+ // do nothing
825+ } else if (preBreakPointEnd > start && preBreakPointEnd < end) {
826+ // selection end was inside breakpoint: move to index before breakpoint
827+ postEnd = start;
828+ } else if (preBreakPointEnd >= end) {
829+ // selection end was behind breakpoint: move back by length of removed string
830+ postEnd = preBreakPointEnd - (end - start);
831+ }
832+ return { start:postStart, end:postEnd };
833+ };
834+ /**
835+ * @param {SlashCommandExecutor} cmd
836+ */
837+ const addBreakpoint = (cmd)=>{
838+ // start at -1 because "/" is not included in start-end
839+ let start = cmd.start - 1;
840+ let indent = '';
841+ // step left until forward slash "/"
842+ while (message.value[start] != '/') start--;
843+ // step left while whitespace (except newline) before start, collect the whitespace to help build indentation
844+ while (/[^\S\n]/.test(message.value[start - 1])) {
845+ start--;
846+ indent += message.value[start];
847+ }
848+ // if newline before indent, include the newline
849+ if (message.value[start - 1] == '\n') {
850+ start--;
851+ indent = `\n${indent}`;
852+ }
853+ const breakpointText = `${indent}/breakpoint |`;
854+ message.selectionStart = start;
855+ message.selectionEnd = start;
856+ // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
857+ document.execCommand('insertText', false, breakpointText);
858+ message.dispatchEvent(new Event('input', { bubbles:true }));
859+ return breakpointText.length;
860+ };
861+ const toggleBreakpoint = ()=>{
862+ const idx = message.selectionStart;
863+ let postStart = preBreakPointStart;
864+ let postEnd = preBreakPointEnd;
865+ const parser = new SlashCommandParser();
866+ parser.parse(message.value, false);
867+ const cmdIdx = parser.commandIndex.findLastIndex(it=>it.start <= idx);
868+ if (cmdIdx > -1) {
869+ const cmd = parser.commandIndex[cmdIdx];
870+ if (cmd instanceof SlashCommandBreakPoint) {
871+ const bp = cmd;
872+ const { start, end } = removeBreakpoint(bp);
873+ postStart = start;
874+ postEnd = end;
875+ } else if (parser.commandIndex[cmdIdx - 1] instanceof SlashCommandBreakPoint) {
876+ const bp = parser.commandIndex[cmdIdx - 1];
877+ const { start, end } = removeBreakpoint(bp);
878+ postStart = start;
879+ postEnd = end;
880+ } else {
881+ const len = addBreakpoint(cmd);
882+ postStart += len;
883+ postEnd += len;
884+ }
885+ message.selectionStart = postStart;
886+ message.selectionEnd = postEnd;
887+ }
888+ };
889+ message.addEventListener('pointerdown', (evt)=>{
890+ if (!evt.ctrlKey || !evt.altKey) return;
891+ preBreakPointStart = message.selectionStart;
892+ preBreakPointEnd = message.selectionEnd;
893+ });
894+ message.addEventListener('pointerup', async(evt)=>{
895+ if (!evt.ctrlKey || !evt.altKey || message.selectionStart != message.selectionEnd) return;
896+ toggleBreakpoint();
897+ });
359898 /** @type {any} */
360899 const resizeListener = debounce((evt) => {
361- updateSyntax();
362900 updateScrollDebounced(evt);
363901 if (document.activeElement == message) {
364902 message.blur();
@@ -367,10 +905,43 @@ export class QuickReply {
367905 });
368906 window.addEventListener('resize', resizeListener);
369907 updateSyntaxEnabled();
370- message.style.setProperty('text-shadow', 'none', 'important');
908+ const updateSyntax = ()=>{
371- /**@type {HTMLElement}*/
909+ if (messageSyntaxInner && syntax.checked) {
372- const messageSyntaxInner = dom.querySelector('#qr--modal-messageSyntaxInner');
910+ morphdom(
911+ messageSyntaxInner,
912+ `<div>${hljs.highlight(`${message.value}${message.value.slice(-1) == '\n' ? ' ' : ''}`, { language:'stscript', ignoreIllegals:true })?.value}</div>`,
913+ { childrenOnly: true },
914+ );
915+ updateScrollDebounced();
916+ }
917+ };
918+ let lastSyntaxUpdate = 0;
919+ const fpsTime = 1000 / 30;
920+ let lastMessageValue = null;
921+ let wasSyntax = null;
922+ const updateSyntaxLoop = ()=>{
923+ const now = Date.now();
924+ // fps limit
925+ if (now - lastSyntaxUpdate < fpsTime) return requestAnimationFrame(updateSyntaxLoop);
926+ // elements don't exist (yet?)
927+ if (!messageSyntaxInner || !message) return requestAnimationFrame(updateSyntaxLoop);
928+ // elements no longer part of the document
929+ if (!messageSyntaxInner.closest('body')) return;
930+ // debugger is running
931+ if (this.isExecuting) {
932+ lastMessageValue = null;
933+ return requestAnimationFrame(updateSyntaxLoop);
934+ }
935+ // value hasn't changed
936+ if (wasSyntax == syntax.checked && lastMessageValue == message.value) return requestAnimationFrame(updateSyntaxLoop);
937+ wasSyntax = syntax.checked;
938+ lastSyntaxUpdate = now;
939+ lastMessageValue = message.value;
373940 updateSyntax();
941+ requestAnimationFrame(updateSyntaxLoop);
942+ };
943+ requestAnimationFrame(()=>updateSyntaxLoop());
944+ message.style.setProperty('text-shadow', 'none', 'important');
374945 updateWrap();
375946 updateTabSize();
376947
@@ -379,7 +950,7 @@ export class QuickReply {
379950 const tpl = dom.querySelector('#qr--ctxItem');
380951 const linkList = dom.querySelector('#qr--ctxEditor');
381952 const fillQrSetSelect = (/**@type {HTMLSelectElement}*/select, /**@type {QuickReplyContextLink}*/ link) => {
382953 [{ name: 'Select a QR set' }, ...QuickReplySet.list.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase()))].forEach(qrs => {
383954 const opt = document.createElement('option'); {
384955 opt.value = qrs.name;
385956 opt.textContent = qrs.name;
@@ -388,7 +959,7 @@ export class QuickReply {
388959 }
389960 });
390961 };
391962 const addCtxItem = (/**@type {QuickReplyContextLink}*/link, /**@type {Numbernumber}*/idx) => {
392963 /**@type {HTMLElement} */
393964 // @ts-ignore
394965 const itemDom = tpl.content.querySelector('.qr--ctxItem').cloneNode(true); {
@@ -506,9 +1077,9 @@ export class QuickReply {
5061077 /**@type {HTMLElement}*/
5071078 const executeResult = dom.querySelector('#qr--modal-executeResult');
5081079 this.editorExecuteResult = executeResult;
5091080 /**@type {HTMLInputElementHTMLElement}*/
5101081 const executeHidedebugState = dom.querySelector('#qr--modal-executeHidedebugState');
5111082 this.editorExecuteHideeditorDebugState = executeHidedebugState;
5121083 /**@type {HTMLElement}*/
5131084 const executeBtn = dom.querySelector('#qr--modal-execute');
5141085 this.editorExecuteBtn = executeBtn;
@@ -536,6 +1107,76 @@ export class QuickReply {
5361107 this.abortController?.abort('Stop button clicked');
5371108 });
5381109
1110+ /**@type {HTMLTextAreaElement} */
1111+ const inputOg = document.querySelector('#send_textarea');
1112+ const inputMirror = dom.querySelector('#qr--modal-send_textarea');
1113+ inputMirror.value = inputOg.value;
1114+ const inputOgMo = new MutationObserver(muts=>{
1115+ if (muts.find(it=>[...it.removedNodes].includes(inputMirror) || [...it.removedNodes].find(n=>n.contains(inputMirror)))) {
1116+ inputOg.removeEventListener('input', inputOgListener);
1117+ }
1118+ });
1119+ inputOgMo.observe(document.body, { childList:true });
1120+ const inputOgListener = ()=>{
1121+ inputMirror.value = inputOg.value;
1122+ };
1123+ inputOg.addEventListener('input', inputOgListener);
1124+ inputMirror.addEventListener('input', ()=>{
1125+ inputOg.value = inputMirror.value;
1126+ });
1127+
1128+ /**@type {HTMLElement}*/
1129+ const resumeBtn = dom.querySelector('#qr--modal-resume');
1130+ resumeBtn.addEventListener('click', ()=>{
1131+ this.debugController?.resume();
1132+ });
1133+ /**@type {HTMLElement}*/
1134+ const stepBtn = dom.querySelector('#qr--modal-step');
1135+ stepBtn.addEventListener('click', ()=>{
1136+ this.debugController?.step();
1137+ });
1138+ /**@type {HTMLElement}*/
1139+ const stepIntoBtn = dom.querySelector('#qr--modal-stepInto');
1140+ stepIntoBtn.addEventListener('click', ()=>{
1141+ this.debugController?.stepInto();
1142+ });
1143+ /**@type {HTMLElement}*/
1144+ const stepOutBtn = dom.querySelector('#qr--modal-stepOut');
1145+ stepOutBtn.addEventListener('click', ()=>{
1146+ this.debugController?.stepOut();
1147+ });
1148+ /**@type {HTMLElement}*/
1149+ const minimizeBtn = dom.querySelector('#qr--modal-minimize');
1150+ minimizeBtn.addEventListener('click', ()=>{
1151+ this.editorDom.classList.add('qr--minimized');
1152+ });
1153+ const maximizeBtn = dom.querySelector('#qr--modal-maximize');
1154+ maximizeBtn.addEventListener('click', ()=>{
1155+ this.editorDom.classList.remove('qr--minimized');
1156+ });
1157+ /**@type {boolean}*/
1158+ let isResizing = false;
1159+ let resizeStart;
1160+ let wStart;
1161+ /**@type {HTMLElement}*/
1162+ const resizeHandle = dom.querySelector('#qr--resizeHandle');
1163+ resizeHandle.addEventListener('pointerdown', (evt)=>{
1164+ if (isResizing) return;
1165+ isResizing = true;
1166+ evt.preventDefault();
1167+ resizeStart = evt.x;
1168+ wStart = dom.querySelector('#qr--qrOptions').offsetWidth;
1169+ const dragListener = debounce((evt)=>{
1170+ const w = wStart + resizeStart - evt.x;
1171+ dom.querySelector('#qr--qrOptions').style.setProperty('--width', `${w}px`);
1172+ }, 5);
1173+ window.addEventListener('pointerup', ()=>{
1174+ window.removeEventListener('pointermove', dragListener);
1175+ isResizing = false;
1176+ }, { once:true });
1177+ window.addEventListener('pointermove', dragListener);
1178+ });
1179+
5391180 await popupResult;
5401181
5411182 window.removeEventListener('resize', resizeListener);
@@ -544,8 +1185,62 @@ export class QuickReply {
5441185 }
5451186 }
5461187
1188+ getEditorPosition(start, end, message = null) {
1189+ const inputRect = this.editorMessage.getBoundingClientRect();
1190+ const style = window.getComputedStyle(this.editorMessage);
1191+ if (!this.clone) {
1192+ this.clone = document.createElement('div');
1193+ for (const key of style) {
1194+ this.clone.style[key] = style[key];
1195+ }
1196+ this.clone.style.position = 'fixed';
1197+ this.clone.style.visibility = 'hidden';
1198+ const mo = new MutationObserver(muts=>{
1199+ if (muts.find(it=>[...it.removedNodes].includes(this.editorMessage) || [...it.removedNodes].find(n=>n.contains(this.editorMessage)))) {
1200+ this.clone?.remove();
1201+ this.clone = null;
1202+ }
1203+ });
1204+ mo.observe(document.body, { childList:true });
1205+ }
1206+ document.body.append(this.clone);
1207+ this.clone.style.width = `${inputRect.width}px`;
1208+ this.clone.style.height = `${inputRect.height}px`;
1209+ this.clone.style.left = `${inputRect.left}px`;
1210+ this.clone.style.top = `${inputRect.top}px`;
1211+ this.clone.style.whiteSpace = style.whiteSpace;
1212+ this.clone.style.tabSize = style.tabSize;
1213+ const text = message ?? this.editorMessage.value;
1214+ const before = text.slice(0, start);
1215+ this.clone.textContent = before;
1216+ const locator = document.createElement('span');
1217+ locator.textContent = text.slice(start, end);
1218+ this.clone.append(locator);
1219+ this.clone.append(text.slice(end));
1220+ this.clone.scrollTop = this.editorSyntax.scrollTop;
1221+ this.clone.scrollLeft = this.editorSyntax.scrollLeft;
1222+ const locatorRect = locator.getBoundingClientRect();
1223+ const bodyRect = document.body.getBoundingClientRect();
1224+ const location = {
1225+ left: locatorRect.left - bodyRect.left,
1226+ right: locatorRect.right - bodyRect.left,
1227+ top: locatorRect.top - bodyRect.top,
1228+ bottom: locatorRect.bottom - bodyRect.top,
1229+ };
1230+ // this.clone.remove();
1231+ return location;
1232+ }
5471233 async executeFromEditor() {
5481234 if (this.editorExecutePromiseisExecuting) return;
1235+ this.editorPopup.onClosing = ()=>false;
1236+ const uuidCheck = /^[0-9a-z]{8}(-[0-9a-z]{4}){3}-[0-9a-z]{12}$/;
1237+ const oText = this.message;
1238+ this.isExecuting = true;
1239+ this.editorDom.classList.add('qr--isExecuting');
1240+ const noSyntax = this.editorDom.querySelector('#qr--modal-messageHolder').classList.contains('qr--noSyntax');
1241+ if (noSyntax) {
1242+ this.editorDom.querySelector('#qr--modal-messageHolder').classList.remove('qr--noSyntax');
1243+ }
5491244 this.editorExecuteBtn.classList.add('qr--busy');
5501245 this.editorExecuteProgress.style.setProperty('--prog', '0');
5511246 this.editorExecuteErrors.classList.remove('qr--hasErrors');
@@ -556,12 +1251,451 @@ export class QuickReply {
5561251 this.editorExecuteProgress.classList.remove('qr--aborted');
5571252 this.editorExecuteErrors.innerHTML = '';
5581253 this.editorExecuteResult.innerHTML = '';
559- if (this.editorExecuteHide.checked) {
1254+ const syntax = this.editorDom.querySelector('#qr--modal-messageSyntaxInner');
560- this.editorPopup.dlg.classList.add('qr--hide');
1255+ const updateScroll = (evt) => {
1256+ let left = syntax.scrollLeft;
1257+ let top = syntax.scrollTop;
1258+ if (evt) {
1259+ evt.preventDefault();
1260+ left = syntax.scrollLeft + evt.deltaX;
1261+ top = syntax.scrollTop + evt.deltaY;
1262+ syntax.scrollTo({
1263+ behavior: 'instant',
1264+ left,
1265+ top,
1266+ });
5611267 }
1268+ this.editorMessage.scrollTo({
1269+ behavior: 'instant',
1270+ left,
1271+ top,
1272+ });
1273+ };
1274+ const updateScrollDebounced = updateScroll;
1275+ syntax.addEventListener('wheel', (evt)=>{
1276+ updateScrollDebounced(evt);
1277+ });
1278+ syntax.addEventListener('scroll', (evt)=>{
1279+ updateScrollDebounced();
1280+ });
5621281 try {
5631282 this.editorExecutePromiseabortController = this.execute({},new trueSlashCommandAbortController();
564- const result = await this.editorExecutePromise;
1283+ this.debugController = new SlashCommandDebugController();
1284+ this.debugController.onBreakPoint = async(closure, executor)=>{
1285+ this.editorDom.classList.add('qr--isPaused');
1286+ syntax.innerHTML = hljs.highlight(`${closure.fullText}${closure.fullText.slice(-1) == '\n' ? ' ' : ''}`, { language:'stscript', ignoreIllegals:true })?.value;
1287+ this.editorMessageLabel.innerHTML = '';
1288+ if (uuidCheck.test(closure.source)) {
1289+ const p0 = document.createElement('span'); {
1290+ p0.textContent = 'anonymous: ';
1291+ this.editorMessageLabel.append(p0);
1292+ }
1293+ const p1 = document.createElement('strong'); {
1294+ p1.textContent = executor.source.slice(0,5);
1295+ this.editorMessageLabel.append(p1);
1296+ }
1297+ const p2 = document.createElement('span'); {
1298+ p2.textContent = executor.source.slice(5, -5);
1299+ this.editorMessageLabel.append(p2);
1300+ }
1301+ const p3 = document.createElement('strong'); {
1302+ p3.textContent = executor.source.slice(-5);
1303+ this.editorMessageLabel.append(p3);
1304+ }
1305+ } else {
1306+ this.editorMessageLabel.textContent = executor.source;
1307+ }
1308+ const source = closure.source;
1309+ this.editorDebugState.innerHTML = '';
1310+ let ci = -1;
1311+ const varNames = [];
1312+ const macroNames = [];
1313+ /**
1314+ * @param {SlashCommandScope} scope
1315+ */
1316+ const buildVars = (scope, isCurrent = false)=>{
1317+ if (!isCurrent) {
1318+ ci--;
1319+ }
1320+ const c = this.debugController.stack.slice(ci)[0];
1321+ const wrap = document.createElement('div'); {
1322+ wrap.classList.add('qr--scope');
1323+ if (isCurrent) {
1324+ const executor = this.debugController.cmdStack.slice(-1)[0];
1325+ { // named args
1326+ const namedTitle = document.createElement('div'); {
1327+ namedTitle.classList.add('qr--title');
1328+ namedTitle.textContent = `Named Args - /${executor.name}`;
1329+ if (executor.command.name == 'run') {
1330+ namedTitle.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`;
1331+ }
1332+ wrap.append(namedTitle);
1333+ }
1334+ const keys = new Set([...Object.keys(this.debugController.namedArguments ?? {}), ...(executor.namedArgumentList ?? []).map(it=>it.name)]);
1335+ for (const key of keys) {
1336+ if (key[0] == '_') continue;
1337+ const item = document.createElement('div'); {
1338+ item.classList.add('qr--var');
1339+ const k = document.createElement('div'); {
1340+ k.classList.add('qr--key');
1341+ k.textContent = key;
1342+ item.append(k);
1343+ }
1344+ const vUnresolved = document.createElement('div'); {
1345+ vUnresolved.classList.add('qr--val');
1346+ vUnresolved.classList.add('qr--singleCol');
1347+ const val = executor.namedArgumentList.find(it=>it.name == key)?.value;
1348+ if (val instanceof SlashCommandClosure) {
1349+ vUnresolved.classList.add('qr--closure');
1350+ vUnresolved.title = val.rawText;
1351+ vUnresolved.textContent = val.toString();
1352+ } else if (val === undefined) {
1353+ vUnresolved.classList.add('qr--undefined');
1354+ vUnresolved.textContent = 'undefined';
1355+ } else {
1356+ let jsonVal;
1357+ try { jsonVal = JSON.parse(val); } catch { /* empty */ }
1358+ if (jsonVal && typeof jsonVal == 'object') {
1359+ vUnresolved.textContent = JSON.stringify(jsonVal, null, 2);
1360+ } else {
1361+ vUnresolved.textContent = val;
1362+ vUnresolved.classList.add('qr--simple');
1363+ }
1364+ }
1365+ item.append(vUnresolved);
1366+ }
1367+ const vResolved = document.createElement('div'); {
1368+ vResolved.classList.add('qr--val');
1369+ vResolved.classList.add('qr--singleCol');
1370+ if (this.debugController.namedArguments === undefined) {
1371+ vResolved.classList.add('qr--unresolved');
1372+ } else {
1373+ const val = this.debugController.namedArguments?.[key];
1374+ if (val instanceof SlashCommandClosure) {
1375+ vResolved.classList.add('qr--closure');
1376+ vResolved.title = val.rawText;
1377+ vResolved.textContent = val.toString();
1378+ } else if (val === undefined) {
1379+ vResolved.classList.add('qr--undefined');
1380+ vResolved.textContent = 'undefined';
1381+ } else {
1382+ let jsonVal;
1383+ try { jsonVal = JSON.parse(val); } catch { /* empty */ }
1384+ if (jsonVal && typeof jsonVal == 'object') {
1385+ vResolved.textContent = JSON.stringify(jsonVal, null, 2);
1386+ } else {
1387+ vResolved.textContent = val;
1388+ vResolved.classList.add('qr--simple');
1389+ }
1390+ }
1391+ }
1392+ item.append(vResolved);
1393+ }
1394+ wrap.append(item);
1395+ }
1396+ }
1397+ }
1398+ { // unnamed args
1399+ const unnamedTitle = document.createElement('div'); {
1400+ unnamedTitle.classList.add('qr--title');
1401+ unnamedTitle.textContent = `Unnamed Args - /${executor.name}`;
1402+ if (executor.command.name == 'run') {
1403+ unnamedTitle.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`;
1404+ }
1405+ wrap.append(unnamedTitle);
1406+ }
1407+ let i = 0;
1408+ let unnamed = this.debugController.unnamedArguments ?? [];
1409+ if (!Array.isArray(unnamed)) unnamed = [unnamed];
1410+ while (unnamed.length < executor.unnamedArgumentList?.length ?? 0) unnamed.push(undefined);
1411+ unnamed = unnamed.map((it,idx)=>[executor.unnamedArgumentList?.[idx], it]);
1412+ for (const arg of unnamed) {
1413+ i++;
1414+ const item = document.createElement('div'); {
1415+ item.classList.add('qr--var');
1416+ const k = document.createElement('div'); {
1417+ k.classList.add('qr--key');
1418+ k.textContent = i.toString();
1419+ item.append(k);
1420+ }
1421+ const vUnresolved = document.createElement('div'); {
1422+ vUnresolved.classList.add('qr--val');
1423+ vUnresolved.classList.add('qr--singleCol');
1424+ const val = arg[0]?.value;
1425+ if (val instanceof SlashCommandClosure) {
1426+ vUnresolved.classList.add('qr--closure');
1427+ vUnresolved.title = val.rawText;
1428+ vUnresolved.textContent = val.toString();
1429+ } else if (val === undefined) {
1430+ vUnresolved.classList.add('qr--undefined');
1431+ vUnresolved.textContent = 'undefined';
1432+ } else {
1433+ let jsonVal;
1434+ try { jsonVal = JSON.parse(val); } catch { /* empty */ }
1435+ if (jsonVal && typeof jsonVal == 'object') {
1436+ vUnresolved.textContent = JSON.stringify(jsonVal, null, 2);
1437+ } else {
1438+ vUnresolved.textContent = val;
1439+ vUnresolved.classList.add('qr--simple');
1440+ }
1441+ }
1442+ item.append(vUnresolved);
1443+ }
1444+ const vResolved = document.createElement('div'); {
1445+ vResolved.classList.add('qr--val');
1446+ vResolved.classList.add('qr--singleCol');
1447+ if (this.debugController.unnamedArguments === undefined) {
1448+ vResolved.classList.add('qr--unresolved');
1449+ } else if ((Array.isArray(this.debugController.unnamedArguments) ? this.debugController.unnamedArguments : [this.debugController.unnamedArguments]).length < i) {
1450+ // do nothing
1451+ } else {
1452+ const val = arg[1];
1453+ if (val instanceof SlashCommandClosure) {
1454+ vResolved.classList.add('qr--closure');
1455+ vResolved.title = val.rawText;
1456+ vResolved.textContent = val.toString();
1457+ } else if (val === undefined) {
1458+ vResolved.classList.add('qr--undefined');
1459+ vResolved.textContent = 'undefined';
1460+ } else {
1461+ let jsonVal;
1462+ try { jsonVal = JSON.parse(val); } catch { /* empty */ }
1463+ if (jsonVal && typeof jsonVal == 'object') {
1464+ vResolved.textContent = JSON.stringify(jsonVal, null, 2);
1465+ } else {
1466+ vResolved.textContent = val;
1467+ vResolved.classList.add('qr--simple');
1468+ }
1469+ }
1470+ }
1471+ item.append(vResolved);
1472+ }
1473+ wrap.append(item);
1474+ }
1475+ }
1476+ }
1477+ }
1478+ // current scope
1479+ const title = document.createElement('div'); {
1480+ title.classList.add('qr--title');
1481+ title.textContent = isCurrent ? 'Current Scope' : 'Parent Scope';
1482+ if (c.source == source) {
1483+ let hi;
1484+ title.addEventListener('pointerenter', ()=>{
1485+ const loc = this.getEditorPosition(Math.max(0, c.executorList[0].start - 1), c.executorList.slice(-1)[0].end, c.fullText);
1486+ const layer = syntax.getBoundingClientRect();
1487+ hi = document.createElement('div');
1488+ hi.classList.add('qr--highlight-secondary');
1489+ hi.style.left = `${loc.left - layer.left}px`;
1490+ hi.style.width = `${loc.right - loc.left}px`;
1491+ hi.style.top = `${loc.top - layer.top + syntax.scrollTop}px`;
1492+ hi.style.height = `${loc.bottom - loc.top}px`;
1493+ syntax.append(hi);
1494+ });
1495+ title.addEventListener('pointerleave', ()=>hi?.remove());
1496+ }
1497+ wrap.append(title);
1498+ }
1499+ for (const key of Object.keys(scope.variables)) {
1500+ const isHidden = varNames.includes(key);
1501+ if (!isHidden) varNames.push(key);
1502+ const item = document.createElement('div'); {
1503+ item.classList.add('qr--var');
1504+ if (isHidden) item.classList.add('qr--isHidden');
1505+ const k = document.createElement('div'); {
1506+ k.classList.add('qr--key');
1507+ k.textContent = key;
1508+ item.append(k);
1509+ }
1510+ const v = document.createElement('div'); {
1511+ v.classList.add('qr--val');
1512+ const val = scope.variables[key];
1513+ if (val instanceof SlashCommandClosure) {
1514+ v.classList.add('qr--closure');
1515+ v.title = val.rawText;
1516+ v.textContent = val.toString();
1517+ } else if (val === undefined) {
1518+ v.classList.add('qr--undefined');
1519+ v.textContent = 'undefined';
1520+ } else {
1521+ let jsonVal;
1522+ try { jsonVal = JSON.parse(val); } catch { /* empty */ }
1523+ if (jsonVal && typeof jsonVal == 'object') {
1524+ v.textContent = JSON.stringify(jsonVal, null, 2);
1525+ } else {
1526+ v.textContent = val;
1527+ v.classList.add('qr--simple');
1528+ }
1529+ }
1530+ item.append(v);
1531+ }
1532+ wrap.append(item);
1533+ }
1534+ }
1535+ for (const key of Object.keys(scope.macros)) {
1536+ const isHidden = macroNames.includes(key);
1537+ if (!isHidden) macroNames.push(key);
1538+ const item = document.createElement('div'); {
1539+ item.classList.add('qr--macro');
1540+ if (isHidden) item.classList.add('qr--isHidden');
1541+ const k = document.createElement('div'); {
1542+ k.classList.add('qr--key');
1543+ k.textContent = key;
1544+ item.append(k);
1545+ }
1546+ const v = document.createElement('div'); {
1547+ v.classList.add('qr--val');
1548+ const val = scope.macros[key];
1549+ if (val instanceof SlashCommandClosure) {
1550+ v.classList.add('qr--closure');
1551+ v.title = val.rawText;
1552+ v.textContent = val.toString();
1553+ } else if (val === undefined) {
1554+ v.classList.add('qr--undefined');
1555+ v.textContent = 'undefined';
1556+ } else {
1557+ let jsonVal;
1558+ try { jsonVal = JSON.parse(val); } catch { /* empty */ }
1559+ if (jsonVal && typeof jsonVal == 'object') {
1560+ v.textContent = JSON.stringify(jsonVal, null, 2);
1561+ } else {
1562+ v.textContent = val;
1563+ v.classList.add('qr--simple');
1564+ }
1565+ }
1566+ item.append(v);
1567+ }
1568+ wrap.append(item);
1569+ }
1570+ }
1571+ const pipeItem = document.createElement('div'); {
1572+ pipeItem.classList.add('qr--pipe');
1573+ const k = document.createElement('div'); {
1574+ k.classList.add('qr--key');
1575+ k.textContent = 'pipe';
1576+ pipeItem.append(k);
1577+ }
1578+ const v = document.createElement('div'); {
1579+ v.classList.add('qr--val');
1580+ const val = scope.pipe;
1581+ if (val instanceof SlashCommandClosure) {
1582+ v.classList.add('qr--closure');
1583+ v.title = val.rawText;
1584+ v.textContent = val.toString();
1585+ } else if (val === undefined) {
1586+ v.classList.add('qr--undefined');
1587+ v.textContent = 'undefined';
1588+ } else {
1589+ let jsonVal;
1590+ try { jsonVal = JSON.parse(val); } catch { /* empty */ }
1591+ if (jsonVal && typeof jsonVal == 'object') {
1592+ v.textContent = JSON.stringify(jsonVal, null, 2);
1593+ } else {
1594+ v.textContent = val;
1595+ v.classList.add('qr--simple');
1596+ }
1597+ }
1598+ pipeItem.append(v);
1599+ }
1600+ wrap.append(pipeItem);
1601+ }
1602+ if (scope.parent) {
1603+ wrap.append(buildVars(scope.parent));
1604+ }
1605+ }
1606+ return wrap;
1607+ };
1608+ const buildStack = ()=>{
1609+ const wrap = document.createElement('div'); {
1610+ wrap.classList.add('qr--stack');
1611+ const title = document.createElement('div'); {
1612+ title.classList.add('qr--title');
1613+ title.textContent = 'Call Stack';
1614+ wrap.append(title);
1615+ }
1616+ let ei = -1;
1617+ for (const executor of this.debugController.cmdStack.toReversed()) {
1618+ ei++;
1619+ const c = this.debugController.stack.toReversed()[ei];
1620+ const item = document.createElement('div'); {
1621+ item.classList.add('qr--item');
1622+ if (executor.source == source) {
1623+ let hi;
1624+ item.addEventListener('pointerenter', ()=>{
1625+ const loc = this.getEditorPosition(Math.max(0, executor.start - 1), executor.end, c.fullText);
1626+ const layer = syntax.getBoundingClientRect();
1627+ hi = document.createElement('div');
1628+ hi.classList.add('qr--highlight-secondary');
1629+ hi.style.left = `${loc.left - layer.left}px`;
1630+ hi.style.width = `${loc.right - loc.left}px`;
1631+ hi.style.top = `${loc.top - layer.top + syntax.scrollTop}px`;
1632+ hi.style.height = `${loc.bottom - loc.top}px`;
1633+ syntax.append(hi);
1634+ });
1635+ item.addEventListener('pointerleave', ()=>hi?.remove());
1636+ }
1637+ const cmd = document.createElement('div'); {
1638+ cmd.classList.add('qr--cmd');
1639+ cmd.textContent = `/${executor.name}`;
1640+ if (executor.command.name == 'run') {
1641+ cmd.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`;
1642+ }
1643+ item.append(cmd);
1644+ }
1645+ const src = document.createElement('div'); {
1646+ src.classList.add('qr--source');
1647+ const line = closure.fullText.slice(0, executor.start).split('\n').length;
1648+ if (uuidCheck.test(executor.source)) {
1649+ const p1 = document.createElement('span'); {
1650+ p1.classList.add('qr--fixed');
1651+ p1.textContent = executor.source.slice(0,5);
1652+ src.append(p1);
1653+ }
1654+ const p2 = document.createElement('span'); {
1655+ p2.classList.add('qr--truncated');
1656+ p2.textContent = '…';
1657+ src.append(p2);
1658+ }
1659+ const p3 = document.createElement('span'); {
1660+ p3.classList.add('qr--fixed');
1661+ p3.textContent = `${executor.source.slice(-5)}:${line}`;
1662+ src.append(p3);
1663+ }
1664+ src.title = `anonymous: ${executor.source}`;
1665+ } else {
1666+ src.textContent = `${executor.source}:${line}`;
1667+ }
1668+ item.append(src);
1669+ }
1670+ wrap.append(item);
1671+ }
1672+ }
1673+ }
1674+ return wrap;
1675+ };
1676+ this.editorDebugState.append(buildVars(closure.scope, true));
1677+ this.editorDebugState.append(buildStack());
1678+ this.editorDebugState.classList.add('qr--active');
1679+ const loc = this.getEditorPosition(Math.max(0, executor.start - 1), executor.end, closure.fullText);
1680+ const layer = syntax.getBoundingClientRect();
1681+ const hi = document.createElement('div');
1682+ hi.classList.add('qr--highlight');
1683+ if (this.debugController.namedArguments === undefined) {
1684+ hi.classList.add('qr--unresolved');
1685+ }
1686+ hi.style.left = `${loc.left - layer.left}px`;
1687+ hi.style.width = `${loc.right - loc.left}px`;
1688+ hi.style.top = `${loc.top - layer.top + syntax.scrollTop}px`;
1689+ hi.style.height = `${loc.bottom - loc.top}px`;
1690+ syntax.append(hi);
1691+ const isStepping = await this.debugController.awaitContinue();
1692+ hi.remove();
1693+ this.editorDebugState.textContent = '';
1694+ this.editorDebugState.classList.remove('qr--active');
1695+ this.editorDom.classList.remove('qr--isPaused');
1696+ return isStepping;
1697+ };
1698+ const result = await this.onDebug(this);
5651699 if (this.abortController?.signal?.aborted) {
5661700 this.editorExecuteProgress.classList.add('qr--aborted');
5671701 } else {
@@ -586,9 +1720,18 @@ export class QuickReply {
5861720 `;
5871721 }
5881722 }
1723+ if (noSyntax) {
1724+ this.editorDom.querySelector('#qr--modal-messageHolder').classList.add('qr--noSyntax');
1725+ }
1726+ this.editorMessageLabel.innerHTML = '';
1727+ this.editorMessageLabel.textContent = 'Message / Command: ';
1728+ this.editorMessage.value = oText;
1729+ this.editorMessage.dispatchEvent(new Event('input', { bubbles:true }));
5891730 this.editorExecutePromise = null;
5901731 this.editorExecuteBtn.classList.remove('qr--busy');
5911732 this.editorPopup.dlgeditorDom.classList.remove('qr--hideisExecuting');
1733+ this.isExecuting = false;
1734+ this.editorPopup.onClosing = null;
5921735 }
5931736
5941737 updateEditorProgress(done, total) {
@@ -623,6 +1766,47 @@ export class QuickReply {
6231766 /**
6241767 * @param {string} value
6251768 */
1769+ updateIcon(value) {
1770+ if (this.onUpdate) {
1771+ if (value === null) return;
1772+ if (this.settingsDomIcon) {
1773+ if (this.icon != value) {
1774+ if (value == '') {
1775+ if (this.icon) {
1776+ this.settingsDomIcon.classList.remove(this.icon);
1777+ }
1778+ this.settingsDomIcon.textContent = '…';
1779+ this.settingsDomIcon.classList.remove('fa-solid');
1780+ } else {
1781+ if (this.icon) {
1782+ this.settingsDomIcon.classList.remove(this.icon);
1783+ } else {
1784+ this.settingsDomIcon.classList.add('fa-solid');
1785+ }
1786+ this.settingsDomIcon.classList.add(value);
1787+ }
1788+ }
1789+ }
1790+ this.icon = value;
1791+ this.updateRender();
1792+ this.onUpdate(this);
1793+ }
1794+ }
1795+
1796+ /**
1797+ * @param {boolean} value
1798+ */
1799+ updateShowLabel(value) {
1800+ if (this.onUpdate) {
1801+ this.showLabel = value;
1802+ this.updateRender();
1803+ this.onUpdate(this);
1804+ }
1805+ }
1806+
1807+ /**
1808+ * @param {string} value
1809+ */
6261810 updateLabel(value) {
6271811 if (this.onUpdate) {
6281812 if (this.settingsDomLabel && this.settingsDomLabel.value != value) {
@@ -670,12 +1854,15 @@ export class QuickReply {
6701854 }
6711855
6721856
6731857 async execute(args = {}, isEditor = false, isRun = false, options = {}) {
6741858 if (this.message?.length > 0 && this.onExecute) {
6751859 const scope = new SlashCommandScope();
6761860 for (const key of Object.keys(args)) {
1861+ if (key[0] == '_') continue;
1862+ if (key == 'isAutoExecute') continue;
6771863 scope.setMacro(`arg::${key}`, args[key]);
6781864 }
1865+ scope.setMacro('arg::*', '');
6791866 if (isEditor) {
6801867 this.abortController = new SlashCommandAbortController();
6811868 }
@@ -685,6 +1872,7 @@ export class QuickReply {
6851872 isEditor,
6861873 isRun,
6871874 scope,
1875+ executionOptions: options,
6881876 });
6891877 }
6901878 }
@@ -695,6 +1883,8 @@ export class QuickReply {
6951883 toJSON() {
6961884 return {
6971885 id: this.id,
1886+ icon: this.icon,
1887+ showLabel: this.showLabel,
6981888 label: this.label,
6991889 title: this.title,
7001890 message: this.message,
public/scripts/extensions/quick-reply/src/QuickReplyConfig.js+6 -1
@@ -60,7 +60,12 @@ export class QuickReplyConfig {
6060 /**@type {HTMLElement}*/
6161 this.setListDom = root.querySelector('.qr--setList');
6262 root.querySelector('.qr--setListAdd').addEventListener('click', ()=>{
63- this.addSet(QuickReplySet.list[0]);
63+ const newSet = QuickReplySet.list.find(qr=>!this.setList.find(qrl=>qrl.set == qr));
64+ if (newSet) {
65+ this.addSet(newSet);
66+ } else {
67+ toastr.warning('All existing QR Sets have already been added.');
68+ }
6469 });
6570 this.updateSetListDom();
6671 }
public/scripts/extensions/quick-reply/src/QuickReplySet.js+189 -18
@@ -1,7 +1,9 @@
11import { getRequestHeaders, substituteParams } from '../../../../script.js';
2+import { Popup, POPUP_RESULT, POPUP_TYPE } from '../../../popup.js';
23import { executeSlashCommands, executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions } from '../../../slash-commands.js';
4+import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
35import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
46import { debounceAsync, log, warn } from '../index.js';
57import { QuickReply } from './QuickReply.js';
68
79export class QuickReplySet {
@@ -16,7 +18,7 @@ export class QuickReplySet {
1618 }
1719
1820 /**
1921 * @param {Stringstring} name - name of the QuickReplySet
2022 */
2123 static get(name) {
2224 return this.list.find(it=>it.name == name);
@@ -25,17 +27,19 @@ export class QuickReplySet {
2527
2628
2729
2830 /**@type {Stringstring}*/ name;
2931 /**@type {Booleanboolean}*/ disableSend = false;
3032 /**@type {Booleanboolean}*/ placeBeforeInput = false;
3133 /**@type {Booleanboolean}*/ injectInput = false;
34+ /**@type {string}*/ color = 'transparent';
35+ /**@type {boolean}*/ onlyBorderColor = false;
3236 /**@type {QuickReply[]}*/ qrList = [];
3337
3438 /**@type {Numbernumber}*/ idIndex = 0;
3539
3640 /**@type {Booleanboolean}*/ isDeleted = false;
3741
3842 /**@type {Functionfunction}*/ save;
3943
4044 /**@type {HTMLElement}*/ dom;
4145 /**@type {HTMLElement}*/ settingsDom;
@@ -64,6 +68,7 @@ export class QuickReplySet {
6468 const root = document.createElement('div'); {
6569 this.dom = root;
6670 root.classList.add('qr--buttons');
71+ this.updateColor();
6772 this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
6873 root.append(qr.render());
6974 });
@@ -78,6 +83,22 @@ export class QuickReplySet {
7883 this.dom.append(qr.render());
7984 });
8085 }
86+ updateColor() {
87+ if (!this.dom) return;
88+ if (this.color && this.color != 'transparent') {
89+ this.dom.style.setProperty('--qr--color', this.color);
90+ this.dom.classList.add('qr--color');
91+ if (this.onlyBorderColor) {
92+ this.dom.classList.add('qr--borderColor');
93+ } else {
94+ this.dom.classList.remove('qr--borderColor');
95+ }
96+ } else {
97+ this.dom.style.setProperty('--qr--color', 'transparent');
98+ this.dom.classList.remove('qr--color');
99+ this.dom.classList.remove('qr--borderColor');
100+ }
101+ }
81102
82103
83104
@@ -93,6 +114,11 @@ export class QuickReplySet {
93114 }
94115 return this.settingsDom;
95116 }
117+ /**
118+ *
119+ * @param {QuickReply} qr
120+ * @param {number} idx
121+ */
96122 renderSettingsItem(qr, idx) {
97123 this.settingsDom.append(qr.renderSettings(idx));
98124 }
@@ -102,6 +128,18 @@ export class QuickReplySet {
102128
103129 /**
104130 *
131+ * @param {QuickReply} qr
132+ */
133+ async debug(qr) {
134+ const parser = new SlashCommandParser();
135+ const closure = parser.parse(qr.message, true, [], qr.abortController, qr.debugController);
136+ closure.source = `${this.name}.${qr.label}`;
137+ closure.onProgress = (done, total) => qr.updateEditorProgress(done, total);
138+ closure.scope.setMacro('arg::*', '');
139+ return (await closure.execute())?.pipe;
140+ }
141+ /**
142+ *
105143 * @param {QuickReply} qr The QR to execute.
106144 * @param {object} options
107145 * @param {string} [options.message] (null) altered message to be used
@@ -109,6 +147,7 @@ export class QuickReplySet {
109147 * @param {boolean} [options.isEditor] (false) whether the execution is triggered by the QR editor
110148 * @param {boolean} [options.isRun] (false) whether the execution is triggered by /run or /: (window.executeQuickReplyByName)
111149 * @param {SlashCommandScope} [options.scope] (null) scope to be used when running the command
150+ * @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options.executionOptions] ({}) further execution options
112151 * @returns
113152 */
114153 async executeWithOptions(qr, options = {}) {
@@ -118,7 +157,9 @@ export class QuickReplySet {
118157 isEditor:false,
119158 isRun:false,
120159 scope:null,
160+ executionOptions:{},
121161 }, options);
162+ const execOptions = options.executionOptions;
122163 /**@type {HTMLTextAreaElement}*/
123164 const ta = document.querySelector('#send_textarea');
124165 const finalMessage = options.message ?? qr.message;
@@ -136,21 +177,24 @@ export class QuickReplySet {
136177 if (input[0] == '/' && !this.disableSend) {
137178 let result;
138179 if (options.isAutoExecute || options.isRun) {
139180 result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, {
140181 handleParserErrors: true,
141182 scope: options.scope,
142- });
183+ source: `${this.name}.${qr.label}`,
184+ }));
143185 } else if (options.isEditor) {
144186 result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, {
145187 handleParserErrors: false,
146188 scope: options.scope,
147189 abortController: qr.abortController,
190+ source: `${this.name}.${qr.label}`,
148191 onProgress: (done, total) => qr.updateEditorProgress(done, total),
149192 }));
150193 } else {
151194 result = await executeSlashCommandsOnChatInput(input, Object.assign(execOptions, {
152195 scope: options.scope,
153- });
196+ source: `${this.name}.${qr.label}`,
197+ }));
154198 }
155199 return typeof result === 'object' ? result?.pipe : '';
156200 }
@@ -165,7 +209,7 @@ export class QuickReplySet {
165209 }
166210 /**
167211 * @param {QuickReply} qr
168212 * @param {Stringstring} [message] - optional altered message to be used
169213 * @param {SlashCommandScope} [scope] - optional scope to be used when running the command
170214 */
171215 async execute(qr, message = null, isAutoExecute = false, scope = null) {
@@ -179,10 +223,11 @@ export class QuickReplySet {
179223
180224
181225
182226 addQuickReply(data = {}) {
183227 const id = Math.max(this.idIndex, this.qrList.reduce((max,qr)=>Math.max(max,qr.id),0)) + 1;
228+ data.id =
184229 this.idIndex = id + 1;
185230 const qr = QuickReply.from({ id }data);
186231 this.qrList.push(qr);
187232 this.hookQuickReply(qr);
188233 if (this.settingsDom) {
@@ -194,11 +239,131 @@ export class QuickReplySet {
194239 this.save();
195240 return qr;
196241 }
242+ addQuickReplyFromText(qrJson) {
243+ let data;
244+ if (qrJson) {
245+ try {
246+ data = JSON.parse(qrJson ?? '{}');
247+ delete data.id;
248+ } catch {
249+ // not JSON data
250+ }
251+ if (data) {
252+ // JSON data
253+ if (data.label === undefined || data.message === undefined) {
254+ // not a QR
255+ toastr.error('Not a QR.');
256+ return;
257+ }
258+ } else {
259+ // no JSON, use plaintext as QR message
260+ data = { message: qrJson };
261+ }
262+ } else {
263+ data = {};
264+ }
265+ const newQr = this.addQuickReply(data);
266+ return newQr;
267+ }
197268
269+ /**
270+ *
271+ * @param {QuickReply} qr
272+ */
198273 hookQuickReply(qr) {
274+ qr.onDebug = ()=>this.debug(qr);
199275 qr.onExecute = (_, options)=>this.executeWithOptions(qr, options);
200276 qr.onDelete = ()=>this.removeQuickReply(qr);
201277 qr.onUpdate = ()=>this.save();
278+ qr.onInsertBefore = (qrJson)=>{
279+ this.addQuickReplyFromText(qrJson);
280+ const newQr = this.qrList.pop();
281+ this.qrList.splice(this.qrList.indexOf(qr), 0, newQr);
282+ if (qr.settingsDom) {
283+ qr.settingsDom.insertAdjacentElement('beforebegin', newQr.settingsDom);
284+ }
285+ this.save();
286+ };
287+ qr.onTransfer = async()=>{
288+ /**@type {HTMLSelectElement} */
289+ let sel;
290+ let isCopy = false;
291+ const dom = document.createElement('div'); {
292+ dom.classList.add('qr--transferModal');
293+ const title = document.createElement('h3'); {
294+ title.textContent = 'Transfer Quick Reply';
295+ dom.append(title);
296+ }
297+ const subTitle = document.createElement('h4'); {
298+ const entryName = qr.label;
299+ const bookName = this.name;
300+ subTitle.textContent = `${bookName}: ${entryName}`;
301+ dom.append(subTitle);
302+ }
303+ sel = document.createElement('select'); {
304+ sel.classList.add('qr--transferSelect');
305+ sel.setAttribute('autofocus', '1');
306+ const noOpt = document.createElement('option'); {
307+ noOpt.value = '';
308+ noOpt.textContent = '-- Select QR Set --';
309+ sel.append(noOpt);
310+ }
311+ for (const qrs of QuickReplySet.list) {
312+ const opt = document.createElement('option'); {
313+ opt.value = qrs.name;
314+ opt.textContent = qrs.name;
315+ sel.append(opt);
316+ }
317+ }
318+ sel.addEventListener('keyup', (evt)=>{
319+ if (evt.key == 'Shift') {
320+ (dlg.dom ?? dlg.dlg).classList.remove('qr--isCopy');
321+ return;
322+ }
323+ });
324+ sel.addEventListener('keydown', (evt)=>{
325+ if (evt.key == 'Shift') {
326+ (dlg.dom ?? dlg.dlg).classList.add('qr--isCopy');
327+ return;
328+ }
329+ if (!evt.ctrlKey && !evt.altKey && evt.key == 'Enter') {
330+ evt.preventDefault();
331+ if (evt.shiftKey) isCopy = true;
332+ dlg.completeAffirmative();
333+ }
334+ });
335+ dom.append(sel);
336+ }
337+ const hintP = document.createElement('p'); {
338+ const hint = document.createElement('small'); {
339+ hint.textContent = 'Type or arrows to select QR Set. Enter to transfer. Shift+Enter to copy.';
340+ hintP.append(hint);
341+ }
342+ dom.append(hintP);
343+ }
344+ }
345+ const dlg = new Popup(dom, POPUP_TYPE.CONFIRM, null, { okButton:'Transfer', cancelButton:'Cancel' });
346+ const copyBtn = document.createElement('div'); {
347+ copyBtn.classList.add('qr--copy');
348+ copyBtn.classList.add('menu_button');
349+ copyBtn.textContent = 'Copy';
350+ copyBtn.addEventListener('click', ()=>{
351+ isCopy = true;
352+ dlg.completeAffirmative();
353+ });
354+ (dlg.ok ?? dlg.okButton).insertAdjacentElement('afterend', copyBtn);
355+ }
356+ const prom = dlg.show();
357+ sel.focus();
358+ await prom;
359+ if (dlg.result == POPUP_RESULT.AFFIRMATIVE) {
360+ const qrs = QuickReplySet.list.find(it=>it.name == sel.value);
361+ qrs.addQuickReply(qr.toJSON());
362+ if (!isCopy) {
363+ qr.delete();
364+ }
365+ }
366+ };
202367 }
203368
204369 removeQuickReply(qr) {
@@ -214,6 +379,8 @@ export class QuickReplySet {
214379 disableSend: this.disableSend,
215380 placeBeforeInput: this.placeBeforeInput,
216381 injectInput: this.injectInput,
382+ color: this.color,
383+ onlyBorderColor: this.onlyBorderColor,
217384 qrList: this.qrList,
218385 idIndex: this.idIndex,
219386 };
@@ -245,9 +412,13 @@ export class QuickReplySet {
245412 if (response.ok) {
246413 this.unrender();
247414 const idx = QuickReplySet.list.indexOf(this);
415+ if (idx > -1) {
248416 QuickReplySet.list.splice(idx, 1);
249417 this.isDeleted = true;
250418 } else {
419+ warn(`Deleted Quick Reply Set was not found in the list of sets: ${this.name}`);
420+ }
421+ } else {
251422 warn(`Failed to delete Quick Reply Set: ${this.name}`);
252423 }
253424 }
public/scripts/extensions/quick-reply/src/QuickReplySetLink.js+1 -1
@@ -45,7 +45,7 @@ export class QuickReplySetLink {
4545 this.set = QuickReplySet.get(set.value);
4646 this.update();
4747 });
48- QuickReplySet.list.forEach(qrs=>{
48+ QuickReplySet.list.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase())).forEach(qrs=>{
4949 const opt = document.createElement('option'); {
5050 opt.value = qrs.name;
5151 opt.textContent = qrs.name;
public/scripts/extensions/quick-reply/src/SlashCommandHandler.js+259 -18
@@ -1,8 +1,12 @@
11import { SlashCommand } from '../../../slash-commands/SlashCommand.js';
2+import { SlashCommandAbortController } from '../../../slash-commands/SlashCommandAbortController.js';
23import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../../slash-commands/SlashCommandArgument.js';
4+import { SlashCommandClosure } from '../../../slash-commands/SlashCommandClosure.js';
35import { enumIcons } from '../../../slash-commands/SlashCommandCommonEnumsProvider.js';
6+import { SlashCommandDebugController } from '../../../slash-commands/SlashCommandDebugController.js';
47import { SlashCommandEnumValue, enumTypes } from '../../../slash-commands/SlashCommandEnumValue.js';
58import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
9+import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
610import { isTrueBoolean } from '../../../utils.js';
711// eslint-disable-next-line no-unused-vars
812import { QuickReplyApi } from '../api/QuickReplyApi.js';
@@ -47,6 +51,13 @@ export class SlashCommandHandler {
4751 return new SlashCommandEnumValue(qr.label, message, enumTypes.enum, enumIcons.qr);
4852 }) ?? [],
4953
54+ /** All QRs inside a set, utilizing the "set" named argument, returns the QR's ID */
55+ qrIds: (executor) => QuickReplySet.get(String(executor.namedArgumentList.find(x => x.name == 'set')?.value))?.qrList.map(qr => {
56+ const icons = getExecutionIcons(qr);
57+ const message = `${qr.automationId ? `[${qr.automationId}]` : ''}${icons ? `[auto: ${icons}]` : ''} ${qr.title || qr.message}`.trim();
58+ return new SlashCommandEnumValue(qr.label, message, enumTypes.enum, enumIcons.qr, null, ()=>qr.id.toString(), true);
59+ }) ?? [],
60+
5061 /** All QRs as a set.name string, to be able to execute, for example via the /run command */
5162 qrExecutables: () => {
5263 const globalSetList = this.api.settings.config.setList;
@@ -63,7 +74,7 @@ export class SlashCommandHandler {
6374 ...otherQrs.map(x => new SlashCommandEnumValue(`${x.set.name}.${x.qr.label}`, `${x.qr.title || x.qr.message}`, enumTypes.qr, enumIcons.qr)),
6475 ];
6576 },
6677 };
6778
6879 window['qrEnumProviderExecutables'] = localEnumProviders.qrExecutables;
6980
@@ -234,8 +245,20 @@ export class SlashCommandHandler {
234245 name: 'label',
235246 description: 'text on the button, e.g., label=MyButton',
236247 typeList: [ARGUMENT_TYPE.STRING],
237248 isRequired: truefalse,
238249 enumProvider: localEnumProviders.qrLabelsqrEntries,
250+ }),
251+ SlashCommandNamedArgument.fromProps({
252+ name: 'icon',
253+ description: 'icon to show on the button, e.g., icon=fa-pencil',
254+ typeList: [ARGUMENT_TYPE.STRING],
255+ isRequired: false,
256+ }),
257+ SlashCommandNamedArgument.fromProps({
258+ name: 'showlabel',
259+ description: 'whether to show the label even when an icon is assigned, e.g., icon=fa-pencil showlabel=true',
260+ typeList: [ARGUMENT_TYPE.BOOLEAN],
261+ isRequired: false,
239262 }),
240263 new SlashCommandNamedArgument('hidden', 'whether the button should be hidden, e.g., hidden=true', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false'),
241264 new SlashCommandNamedArgument('startup', 'auto execute on app startup, e.g., startup=true', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false'),
@@ -247,6 +270,13 @@ export class SlashCommandHandler {
247270 ];
248271 const qrUpdateArgs = [
249272 new SlashCommandNamedArgument('newlabel', 'new text for the button', [ARGUMENT_TYPE.STRING], false),
273+ SlashCommandNamedArgument.fromProps({
274+ name: 'id',
275+ description: 'numeric ID of the QR, e.g., id=42',
276+ typeList: [ARGUMENT_TYPE.NUMBER],
277+ isRequired: false,
278+ enumProvider: localEnumProviders.qrIds,
279+ }),
250280 ];
251281
252282 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-create',
@@ -272,13 +302,61 @@ export class SlashCommandHandler {
272302 </div>
273303 `,
274304 }));
305+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-get',
306+ callback: (args, _) => {
307+ return this.getQuickReply(args);
308+ },
309+ namedArgumentList: [
310+ SlashCommandNamedArgument.fromProps({
311+ name: 'set',
312+ description: 'name of the QR set, e.g., set=PresetName1',
313+ typeList: [ARGUMENT_TYPE.STRING],
314+ isRequired: true,
315+ enumProvider: localEnumProviders.qrSets,
316+ }),
317+ SlashCommandNamedArgument.fromProps({
318+ name: 'label',
319+ description: 'text on the button, e.g., label=MyButton',
320+ typeList: [ARGUMENT_TYPE.STRING],
321+ isRequired: false,
322+ enumProvider: localEnumProviders.qrEntries,
323+ }),
324+ SlashCommandNamedArgument.fromProps({
325+ name: 'id',
326+ description: 'numeric ID of the QR, e.g., id=42',
327+ typeList: [ARGUMENT_TYPE.NUMBER],
328+ isRequired: false,
329+ enumProvider: localEnumProviders.qrIds,
330+ }),
331+ ],
332+ returns: 'a dictionary with all the QR\'s properties',
333+ helpString: `
334+ <div>Get a Quick Reply's properties.</div>
335+ <div>
336+ <strong>Examples:</strong>
337+ <ul>
338+ <li>
339+ <pre><code>/qr-get set=MyPreset label=MyButton | /echo</code></pre>
340+ <pre><code>/qr-get set=MyPreset id=42 | /echo</code></pre>
341+ </li>
342+ </ul>
343+ </div>
344+ `,
345+ }));
275346 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-update',
276347 callback: (args, message) => {
277348 this.updateQuickReply(args, message);
278349 return '';
279350 },
280351 returns: 'updated quick reply',
281352 namedArgumentList: [...qrUpdateArgs, ...qrArgs],.map(it=>{
353+ if (it.name == 'label') {
354+ const clone = SlashCommandNamedArgument.fromProps(it);
355+ clone.isRequired = false;
356+ return clone;
357+ }
358+ return it;
359+ })],
282360 unnamedArgumentList: [
283361 new SlashCommandArgument('command', [ARGUMENT_TYPE.STRING]),
284362 ],
@@ -315,6 +393,12 @@ export class SlashCommandHandler {
315393 typeList: [ARGUMENT_TYPE.STRING],
316394 enumProvider: localEnumProviders.qrEntries,
317395 }),
396+ SlashCommandNamedArgument.fromProps({
397+ name: 'id',
398+ description: 'numeric ID of the QR, e.g., id=42',
399+ typeList: [ARGUMENT_TYPE.NUMBER],
400+ enumProvider: localEnumProviders.qrIds,
401+ }),
318402 ],
319403 unnamedArgumentList: [
320404 SlashCommandArgument.fromProps({
@@ -344,6 +428,12 @@ export class SlashCommandHandler {
344428 typeList: [ARGUMENT_TYPE.STRING],
345429 enumProvider: localEnumProviders.qrEntries,
346430 }),
431+ SlashCommandNamedArgument.fromProps({
432+ name: 'id',
433+ description: 'numeric ID of the QR, e.g., id=42',
434+ typeList: [ARGUMENT_TYPE.NUMBER],
435+ enumProvider: localEnumProviders.qrIds,
436+ }),
347437 new SlashCommandNamedArgument(
348438 'chain', 'boolean', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
349439 ),
@@ -389,6 +479,12 @@ export class SlashCommandHandler {
389479 typeList: [ARGUMENT_TYPE.STRING],
390480 enumProvider: localEnumProviders.qrEntries,
391481 }),
482+ SlashCommandNamedArgument.fromProps({
483+ name: 'id',
484+ description: 'numeric ID of the QR, e.g., id=42',
485+ typeList: [ARGUMENT_TYPE.NUMBER],
486+ enumProvider: localEnumProviders.qrIds,
487+ }),
392488 ],
393489 unnamedArgumentList: [
394490 SlashCommandArgument.fromProps({
@@ -425,6 +521,12 @@ export class SlashCommandHandler {
425521 isRequired: true,
426522 enumProvider: localEnumProviders.qrSets,
427523 }),
524+ SlashCommandNamedArgument.fromProps({
525+ name: 'id',
526+ description: 'numeric ID of the QR, e.g., id=42',
527+ typeList: [ARGUMENT_TYPE.NUMBER],
528+ enumProvider: localEnumProviders.qrIds,
529+ }),
428530 ],
429531 unnamedArgumentList: [
430532 SlashCommandArgument.fromProps({
@@ -454,8 +556,8 @@ export class SlashCommandHandler {
454556 new SlashCommandNamedArgument('inject', 'inject user input automatically (if disabled use {{input}})', [ARGUMENT_TYPE.BOOLEAN], false),
455557 ];
456558 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-create',
457559 callback: async (args, name) => {
458560 await this.createSet(name, args);
459561 return '';
460562 },
461563 aliases: ['qr-presetadd'],
@@ -485,8 +587,8 @@ export class SlashCommandHandler {
485587 }));
486588
487589 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-update',
488590 callback: async (args, name) => {
489591 await this.updateSet(name, args);
490592 return '';
491593 },
492594 aliases: ['qr-presetupdate'],
@@ -510,8 +612,8 @@ export class SlashCommandHandler {
510612 `,
511613 }));
512614 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-delete',
513615 callback: async (_, name) => {
514616 await this.deleteSet(name);
515617 return '';
516618 },
517619 aliases: ['qr-presetdelete'],
@@ -533,6 +635,134 @@ export class SlashCommandHandler {
533635 </div>
534636 `,
535637 }));
638+
639+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-arg',
640+ callback: ({ _scope }, [key, value]) => {
641+ _scope.setMacro(`arg::${key}`, value, key.includes('*'));
642+ return '';
643+ },
644+ unnamedArgumentList: [
645+ SlashCommandArgument.fromProps({ description: 'argument name',
646+ typeList: ARGUMENT_TYPE.STRING,
647+ isRequired: true,
648+ }),
649+ SlashCommandArgument.fromProps({ description: 'argument value',
650+ typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.LIST, ARGUMENT_TYPE.DICTIONARY],
651+ isRequired: true,
652+ }),
653+ ],
654+ splitUnnamedArgument: true,
655+ splitUnnamedArgumentCount: 2,
656+ helpString: `
657+ <div>
658+ Set a fallback value for a Quick Reply argument.
659+ </div>
660+ <div>
661+ <strong>Example:</strong>
662+ <pre><code>/qr-arg x foo |\n/echo {{arg::x}}</code></pre>
663+ </div>
664+ `,
665+ }));
666+
667+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'import',
668+ /**
669+ *
670+ * @param {{_scope:SlashCommandScope, _abortController:SlashCommandAbortController, _debugController:SlashCommandDebugController, from:string}} args
671+ * @param {string} value
672+ */
673+ callback: (args, value) => {
674+ if (!args.from) throw new Error('/import requires from= to be set.');
675+ if (!value) throw new Error('/import requires the unnamed argument to be set.');
676+ let qr = [...this.api.listGlobalSets(), ...this.api.listChatSets()]
677+ .map(it=>this.api.getSetByName(it)?.qrList ?? [])
678+ .flat()
679+ .find(it=>it.label == args.from)
680+ ;
681+ if (!qr) {
682+ let [setName, ...qrNameParts] = args.from.split('.');
683+ let qrName = qrNameParts.join('.');
684+ let qrs = QuickReplySet.get(setName);
685+ if (qrs) {
686+ qr = qrs.qrList.find(it=>it.label == qrName);
687+ }
688+ }
689+ if (qr) {
690+ const parser = new SlashCommandParser();
691+ const closure = parser.parse(qr.message, true, [], args._abortController, args._debugController);
692+ if (args._debugController) {
693+ closure.source = args.from;
694+ }
695+ const testCandidates = (executor)=>{
696+ return (
697+ executor.namedArgumentList.find(arg=>arg.name == 'key')
698+ && executor.unnamedArgumentList.length > 0
699+ && executor.unnamedArgumentList[0].value instanceof SlashCommandClosure
700+ ) || (
701+ !executor.namedArgumentList.find(arg=>arg.name == 'key')
702+ && executor.unnamedArgumentList.length > 1
703+ && executor.unnamedArgumentList[1].value instanceof SlashCommandClosure
704+ );
705+ };
706+ const candidates = closure.executorList
707+ .filter(executor=>['let', 'var'].includes(executor.command.name))
708+ .filter(testCandidates)
709+ .map(executor=>({
710+ key: executor.namedArgumentList.find(arg=>arg.name == 'key')?.value ?? executor.unnamedArgumentList[0].value,
711+ value: executor.unnamedArgumentList[executor.namedArgumentList.find(arg=>arg.name == 'key') ? 0 : 1].value,
712+ }))
713+ ;
714+ for (let i = 0; i < value.length; i++) {
715+ const srcName = value[i];
716+ let dstName = srcName;
717+ if (i + 2 < value.length && value[i + 1] == 'as') {
718+ dstName = value[i + 2];
719+ i += 2;
720+ }
721+ const pick = candidates.find(it=>it.key == srcName);
722+ if (!pick) throw new Error(`No scoped closure named "${srcName}" found in "${args.from}"`);
723+ if (args._scope.existsVariableInScope(dstName)) {
724+ args._scope.setVariable(dstName, pick.value);
725+ } else {
726+ args._scope.letVariable(dstName, pick.value);
727+ }
728+ }
729+ } else {
730+ throw new Error(`No Quick Reply found for "${name}".`);
731+ }
732+ return '';
733+ },
734+ namedArgumentList: [
735+ SlashCommandNamedArgument.fromProps({ name: 'from',
736+ description: 'Quick Reply to import from (QRSet.QRLabel)',
737+ typeList: ARGUMENT_TYPE.STRING,
738+ isRequired: true,
739+ }),
740+ ],
741+ unnamedArgumentList: [
742+ SlashCommandArgument.fromProps({ description: 'what to import (x or x as y)',
743+ acceptsMultiple: true,
744+ typeList: ARGUMENT_TYPE.STRING,
745+ isRequired: true,
746+ }),
747+ ],
748+ splitUnnamedArgument: true,
749+ helpString: `
750+ <div>
751+ Import one or more closures from another Quick Reply.
752+ </div>
753+ <div>
754+ Only imports closures that are directly assigned a scoped variable via <code>/let</code> or <code>/var</code>.
755+ </div>
756+ <div>
757+ <strong>Examples:</strong>
758+ <ul>
759+ <li><pre><code>/import from=LibraryQrSet.FooBar foo |\n/:foo</code></pre></li>
760+ <li><pre><code>/import from=LibraryQrSet.FooBar\n\tfoo\n\tbar\n|\n/:foo |\n/:bar</code></pre></li>
761+ <li><pre><code>/import from=LibraryQrSet.FooBar\n\tfoo as x\n\tbar as y\n|\n/:x |\n/:y</code></pre></li>
762+ </ul>
763+ </div>
764+ `,
765+ }));
536766 }
537767
538768
@@ -618,6 +848,8 @@ export class SlashCommandHandler {
618848 args.set ?? '',
619849 args.label ?? '',
620850 {
851+ icon: args.icon,
852+ showLabel: args.showlabel === undefined ? undefined : isTrueBoolean(args.showlabel),
621853 message: message ?? '',
622854 title: args.title,
623855 isHidden: isTrueBoolean(args.hidden),
@@ -633,12 +865,21 @@ export class SlashCommandHandler {
633865 toastr.error(ex.message);
634866 }
635867 }
868+ getQuickReply(args) {
869+ try {
870+ return JSON.stringify(this.api.getQrByLabel(args.set, args.id !== undefined ? Number(args.id) : args.label));
871+ } catch (ex) {
872+ toastr.error(ex.message);
873+ }
874+ }
636875 updateQuickReply(args, message) {
637876 try {
638877 this.api.updateQuickReply(
639878 args.set ?? '',
640- args.label ?? '',
879+ args.id !== undefined ? Number(args.id) : (args.label ?? ''),
641880 {
881+ icon: args.icon,
882+ showLabel: args.showlabel === undefined ? undefined : isTrueBoolean(args.showlabel),
642883 newLabel: args.newlabel,
643884 message: (message ?? '').trim().length > 0 ? message : undefined,
644885 title: args.title,
@@ -657,7 +898,7 @@ export class SlashCommandHandler {
657898 }
658899 deleteQuickReply(args, label) {
659900 try {
660901 this.api.deleteQuickReply(args.set, args.id !== undefined ? Number(args.id) : (args.label ?? label));
661902 } catch (ex) {
662903 toastr.error(ex.message);
663904 }
@@ -692,9 +933,9 @@ export class SlashCommandHandler {
692933 }
693934
694935
695936 async createSet(name, args) {
696937 try {
697938 await this.api.createSet(
698939 args.name ?? name ?? '',
699940 {
700941 disableSend: isTrueBoolean(args.nosend),
@@ -706,9 +947,9 @@ export class SlashCommandHandler {
706947 toastr.error(ex.message);
707948 }
708949 }
709950 async updateSet(name, args) {
710951 try {
711952 await this.api.updateSet(
712953 args.name ?? name ?? '',
713954 {
714955 disableSend: args.nosend !== undefined ? isTrueBoolean(args.nosend) : undefined,
@@ -720,9 +961,9 @@ export class SlashCommandHandler {
720961 toastr.error(ex.message);
721962 }
722963 }
723964 async deleteSet(name) {
724965 try {
725966 await this.api.deleteSet(name ?? '');
726967 } catch (ex) {
727968 toastr.error(ex.message);
728969 }
public/scripts/extensions/quick-reply/src/ui/SettingsUi.js+54 -3
@@ -23,6 +23,8 @@ export class SettingsUi {
2323 /**@type {HTMLInputElement}*/ disableSend;
2424 /**@type {HTMLInputElement}*/ placeBeforeInput;
2525 /**@type {HTMLInputElement}*/ injectInput;
26+ /**@type {HTMLInputElement}*/ color;
27+ /**@type {HTMLInputElement}*/ onlyBorderColor;
2628 /**@type {HTMLSelectElement}*/ currentSet;
2729
2830
@@ -117,10 +119,29 @@ export class SettingsUi {
117119 this.dom.querySelector('#qr--set-add').addEventListener('click', async()=>{
118120 this.currentQrSet.addQuickReply();
119121 });
122+ this.dom.querySelector('#qr--set-paste').addEventListener('click', async()=>{
123+ const text = await navigator.clipboard.readText();
124+ this.currentQrSet.addQuickReplyFromText(text);
125+ });
126+ this.dom.querySelector('#qr--set-importQr').addEventListener('click', async()=>{
127+ const inp = document.createElement('input'); {
128+ inp.type = 'file';
129+ inp.accept = '.json';
130+ inp.addEventListener('change', async()=>{
131+ if (inp.files.length > 0) {
132+ for (const file of inp.files) {
133+ const text = await file.text();
134+ this.currentQrSet.addQuickReply(JSON.parse(text));
135+ }
136+ }
137+ });
138+ inp.click();
139+ }
140+ });
120141 this.qrList = this.dom.querySelector('#qr--set-qrList');
121142 this.currentSet = this.dom.querySelector('#qr--set');
122143 this.currentSet.addEventListener('change', ()=>this.onQrSetChange());
123- QuickReplySet.list.forEach(qrs=>{
144+ QuickReplySet.list.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase())).forEach(qrs=>{
124145 const opt = document.createElement('option'); {
125146 opt.value = qrs.name;
126147 opt.textContent = qrs.name;
@@ -145,6 +166,34 @@ export class SettingsUi {
145166 qrs.injectInput = this.injectInput.checked;
146167 qrs.save();
147168 });
169+ let initialColorChange = true;
170+ this.color = this.dom.querySelector('#qr--color');
171+ this.color.color = this.currentQrSet?.color ?? 'transparent';
172+ this.color.addEventListener('change', (evt)=>{
173+ if (!this.dom.closest('body')) return;
174+ const qrs = this.currentQrSet;
175+ if (initialColorChange) {
176+ initialColorChange = false;
177+ this.color.color = qrs.color;
178+ return;
179+ }
180+ qrs.color = evt.detail.rgb;
181+ qrs.save();
182+ this.currentQrSet.updateColor();
183+ });
184+ this.dom.querySelector('#qr--colorClear').addEventListener('click', (evt)=>{
185+ const qrs = this.currentQrSet;
186+ this.color.color = 'transparent';
187+ qrs.save();
188+ this.currentQrSet.updateColor();
189+ });
190+ this.onlyBorderColor = this.dom.querySelector('#qr--onlyBorderColor');
191+ this.onlyBorderColor.addEventListener('click', ()=>{
192+ const qrs = this.currentQrSet;
193+ qrs.onlyBorderColor = this.onlyBorderColor.checked;
194+ qrs.save();
195+ this.currentQrSet.updateColor();
196+ });
148197 this.onQrSetChange();
149198 }
150199 onQrSetChange() {
@@ -152,6 +201,8 @@ export class SettingsUi {
152201 this.disableSend.checked = this.currentQrSet.disableSend;
153202 this.placeBeforeInput.checked = this.currentQrSet.placeBeforeInput;
154203 this.injectInput.checked = this.currentQrSet.injectInput;
204+ this.color.color = this.currentQrSet.color ?? 'transparent';
205+ this.onlyBorderColor.checked = this.currentQrSet.onlyBorderColor;
155206 this.qrList.innerHTML = '';
156207 const qrsDom = this.currentQrSet.renderSettings();
157208 this.qrList.append(qrsDom);
@@ -265,7 +316,7 @@ export class SettingsUi {
265316 const qrs = new QuickReplySet();
266317 qrs.name = name;
267318 qrs.addQuickReply();
268319 const idx = QuickReplySet.list.findIndex(it=>it.name.toLowerCase().localeCompare(name.toLowerCase()) == 1);
269320 if (idx > -1) {
270321 QuickReplySet.list.splice(idx, 0, qrs);
271322 } else {
@@ -321,7 +372,7 @@ export class SettingsUi {
321372 this.prepareChatSetList();
322373 }
323374 } else {
324375 const idx = QuickReplySet.list.findIndex(it=>it.name.toLowerCase().localeCompare(qrs.name.toLowerCase()) == 1);
325376 if (idx > -1) {
326377 QuickReplySet.list.splice(idx, 0, qrs);
327378 } else {
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js+5 -0
@@ -33,11 +33,14 @@ export class ContextMenu {
3333 */
3434 build(qr, chainedMessage = null, hierarchy = [], labelHierarchy = []) {
3535 const tree = {
36+ icon: qr.icon,
37+ showLabel: qr.showLabel,
3638 label: qr.label,
3739 message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
3840 children: [],
3941 };
4042 qr.contextList.forEach((cl) => {
43+ if (!cl.set) return;
4144 if (!hierarchy.includes(cl.set)) {
4245 const nextHierarchy = [...hierarchy, cl.set];
4346 const nextLabelHierarchy = [...labelHierarchy, tree.label];
@@ -45,6 +48,8 @@ export class ContextMenu {
4548 cl.set.qrList.forEach(subQr => {
4649 const subTree = this.build(subQr, cl.isChained ? tree.message : null, nextHierarchy, nextLabelHierarchy);
4750 tree.children.push(new MenuItem(
51+ subTree.icon,
52+ subTree.showLabel,
4853 subTree.label,
4954 subTree.message,
5055 (evt) => {
public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js+1 -1
@@ -2,7 +2,7 @@ import { MenuItem } from './MenuItem.js';
22
33export class MenuHeader extends MenuItem {
44 constructor(/**@type {String}*/label) {
55 super(null, null, label, null, null);
66 }
77
88
public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js+34 -7
@@ -1,21 +1,34 @@
11import { SubMenu } from './SubMenu.js';
22
33export class MenuItem {
44 /**@type {Stringstring}*/ labelicon;
55 /**@type {Objectboolean}*/ valueshowLabel;
66 /**@type {Functionstring}*/ callbacklabel;
7+ /**@type {object}*/ value;
8+ /**@type {function}*/ callback;
79 /**@type {MenuItem[]}*/ childList = [];
810 /**@type {SubMenu}*/ subMenu;
911 /**@type {Booleanboolean}*/ isForceExpanded = false;
1012
1113 /**@type {HTMLElement}*/ root;
1214
1315 /**@type {Functionfunction}*/ onExpand;
1416
1517
1618
1719
18- constructor(/**@type {String}*/label, /**@type {Object}*/value, /**@type {function}*/callback, /**@type {MenuItem[]}*/children = []) {
20+ /**
21+ *
22+ * @param {string} icon
23+ * @param {boolean} showLabel
24+ * @param {string} label
25+ * @param {object} value
26+ * @param {function} callback
27+ * @param {MenuItem[]} children
28+ */
29+ constructor(icon, showLabel, label, value, callback, children = []) {
30+ this.icon = icon;
31+ this.showLabel = showLabel;
1932 this.label = label;
2033 this.value = value;
2134 this.callback = callback;
@@ -33,7 +46,21 @@ export class MenuItem {
3346 if (this.callback) {
3447 item.addEventListener('click', (evt) => this.callback(evt, this));
3548 }
36- item.append(this.label);
49+ const icon = document.createElement('div'); {
50+ this.domIcon = icon;
51+ icon.classList.add('qr--button-icon');
52+ icon.classList.add('fa-solid');
53+ if (!this.icon) icon.classList.add('qr--hidden');
54+ else icon.classList.add(this.icon);
55+ item.append(icon);
56+ }
57+ const lbl = document.createElement('div'); {
58+ this.domLabel = lbl;
59+ lbl.classList.add('qr--button-label');
60+ if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
61+ lbl.textContent = this.label;
62+ item.append(lbl);
63+ }
3764 if (this.childList.length > 0) {
3865 item.classList.add('ctx-has-children');
3966 const sub = new SubMenu(this.childList);
public/scripts/extensions/quick-reply/style.css+601 -19
@@ -1,3 +1,20 @@
1+@keyframes qr--success {
2+ 0%,
3+ 100% {
4+ color: var(--SmartThemeBodyColor);
5+ }
6+ 25%,
7+ 75% {
8+ color: #51a351;
9+ }
10+}
11+.qr--success {
12+ animation-name: qr--success;
13+ animation-duration: 3s;
14+ animation-timing-function: linear;
15+ animation-delay: 0s;
16+ animation-iteration-count: 1;
17+}
118#qr--bar {
219 outline: none;
320 margin: 0;
@@ -41,6 +58,7 @@
4158}
4259#qr--bar > .qr--buttons,
4360#qr--popout > .qr--body > .qr--buttons {
61+ --qr--color: transparent;
4462 margin: 0;
4563 padding: 0;
4664 display: flex;
@@ -49,10 +67,44 @@
4967 gap: 5px;
5068 width: 100%;
5169}
70+#qr--bar > .qr--buttons.qr--color,
71+#qr--popout > .qr--body > .qr--buttons.qr--color {
72+ background-color: var(--qr--color);
73+}
74+#qr--bar > .qr--buttons.qr--borderColor,
75+#qr--popout > .qr--body > .qr--buttons.qr--borderColor {
76+ background-color: transparent;
77+ border-left: 5px solid var(--qr--color);
78+ border-right: 5px solid var(--qr--color);
79+}
80+#qr--bar > .qr--buttons:has(.qr--buttons.qr--color),
81+#qr--popout > .qr--body > .qr--buttons:has(.qr--buttons.qr--color) {
82+ margin: 5px;
83+}
5284#qr--bar > .qr--buttons > .qr--buttons,
5385#qr--popout > .qr--body > .qr--buttons > .qr--buttons {
5486 display: contents;
5587}
88+#qr--bar > .qr--buttons > .qr--buttons.qr--color .qr--button:before,
89+#qr--popout > .qr--body > .qr--buttons > .qr--buttons.qr--color .qr--button:before {
90+ content: '';
91+ background-color: var(--qr--color);
92+ position: absolute;
93+ inset: -5px;
94+ z-index: -1;
95+}
96+#qr--bar > .qr--buttons > .qr--buttons.qr--color.qr--borderColor .qr--button:before,
97+#qr--popout > .qr--body > .qr--buttons > .qr--buttons.qr--color.qr--borderColor .qr--button:before {
98+ display: none;
99+}
100+#qr--bar > .qr--buttons > .qr--buttons.qr--color.qr--borderColor:before,
101+#qr--popout > .qr--body > .qr--buttons > .qr--buttons.qr--color.qr--borderColor:before,
102+#qr--bar > .qr--buttons > .qr--buttons.qr--color.qr--borderColor:after,
103+#qr--popout > .qr--body > .qr--buttons > .qr--buttons.qr--color.qr--borderColor:after {
104+ content: '';
105+ width: 5px;
106+ background-color: var(--qr--color);
107+}
56108#qr--bar > .qr--buttons .qr--button,
57109#qr--popout > .qr--body > .qr--buttons .qr--button {
58110 color: var(--SmartThemeBodyColor);
@@ -66,11 +118,19 @@
66118 align-items: center;
67119 justify-content: center;
68120 text-align: center;
121+ position: relative;
69122}
70123#qr--bar > .qr--buttons .qr--button:hover,
71124#qr--popout > .qr--body > .qr--buttons .qr--button:hover {
72125 opacitybackground-color: 1#4d4d4d;
73- filter: brightness(1.2);
126+}
127+#qr--bar > .qr--buttons .qr--button .qr--hidden,
128+#qr--popout > .qr--body > .qr--buttons .qr--button .qr--hidden {
129+ display: none;
130+}
131+#qr--bar > .qr--buttons .qr--button .qr--button-icon,
132+#qr--popout > .qr--body > .qr--buttons .qr--button .qr--button-icon {
133+ margin: 0 0.5em;
74134}
75135#qr--bar > .qr--buttons .qr--button > .qr--button-expander,
76136#qr--popout > .qr--body > .qr--buttons .qr--button > .qr--button-expander {
@@ -170,36 +230,80 @@
170230#qr--settings #qr--set-qrList .qr--set-qrListContents {
171231 padding: 0 0.5em;
172232}
173233#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemAdder {
234+ display: flex;
235+ align-items: center;
236+ opacity: 0;
237+ transition: 100ms;
238+ margin: -2px 0 -11px 0;
239+ position: relative;
240+}
241+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemAdder .qr--actions {
242+ display: flex;
243+ gap: 0.25em;
244+ flex: 0 0 auto;
245+}
246+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemAdder .qr--actions .qr--action {
247+ margin: 0;
248+}
249+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemAdder:before,
250+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemAdder:after {
251+ content: "";
252+ display: block;
253+ flex: 1 1 auto;
254+ border: 1px solid;
255+ margin: 0 1em;
256+ height: 0;
257+}
258+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemAdder:hover,
259+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemAdder:focus-within {
260+ opacity: 1;
261+}
262+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content {
174263 display: flex;
175264 flex-direction: row;
176265 gap: 0.5em;
177266 align-items: baseline;
178267 padding: 0.25em 0;
179268}
180269#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content > :nth-child(12) {
181270 flex: 0 0 auto;
182271}
183272#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content > :nth-child(2) {
184273 flex: 1 1 25%;
185274}
186275#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content > :nth-child(3) {
187276 flex: 0 0 auto;
188277}
189278#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content > :nth-child(4) {
190279 flex: 1 1 75%;
191280}
192281#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content > :nth-child(5) {
193282 flex: 0 01 auto;
283+ display: flex;
284+ gap: 0.25em;
285+ justify-content: flex-end;
286+ flex-wrap: wrap;
194287}
195288#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content > .drag-handle {
196289 padding: 0.75em;
197290}
198291#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content .qr--set-itemLabel,itemLabelContainer {
199-#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--action {
292+ display: flex;
293+ align-items: center;
294+ gap: 0.5em;
295+}
296+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content .qr--set-itemLabelContainer .qr--set-itemIcon:not(.fa-solid) {
297+ display: none;
298+}
299+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content .qr--set-itemLabelContainer .qr--set-itemLabel {
300+ min-width: 24px;
301+}
302+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content .qr--set-itemLabel,
303+#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content .qr--action {
200304 margin: 0;
201305}
202306#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--content .qr--set-itemMessage {
203307 font-size: smaller;
204308}
205309#qr--settings .qr--set-qrListActions {
@@ -212,6 +316,7 @@
212316#qr--qrOptions {
213317 display: flex;
214318 flex-direction: column;
319+ padding-right: 1px;
215320}
216321#qr--qrOptions > #qr--ctxEditor .qr--ctxItem {
217322 display: flex;
@@ -219,6 +324,12 @@
219324 gap: 0.5em;
220325 align-items: baseline;
221326}
327+#qr--qrOptions > #qr--autoExec .checkbox_label {
328+ text-wrap: nowrap;
329+}
330+#qr--qrOptions > #qr--autoExec .checkbox_label .fa-fw {
331+ margin-right: 2px;
332+}
222333@media screen and (max-width: 750px) {
223334 body .popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor {
224335 flex-direction: column;
@@ -238,6 +349,72 @@
238349.popup:has(#qr--modalEditor) {
239350 aspect-ratio: unset;
240351}
352+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) {
353+ min-width: unset;
354+ min-height: unset;
355+ height: auto !important;
356+ width: min-content !important;
357+ position: absolute;
358+ right: 1em;
359+ top: 1em;
360+ left: unset;
361+ bottom: unset;
362+ margin: unset;
363+ padding: 0;
364+}
365+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized)::backdrop {
366+ backdrop-filter: unset;
367+ background-color: transparent;
368+}
369+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-body {
370+ flex: 0 0 auto;
371+ height: min-content;
372+ width: min-content;
373+}
374+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content {
375+ flex: 0 0 auto;
376+ margin-top: 0;
377+}
378+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor {
379+ max-height: 50vh;
380+}
381+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor > #qr--main,
382+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor > #qr--resizeHandle,
383+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor > #qr--qrOptions > h3,
384+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor > #qr--qrOptions > #qr--modal-executeButtons,
385+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor > #qr--qrOptions > #qr--modal-executeProgress {
386+ display: none;
387+}
388+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor #qr--qrOptions {
389+ width: auto;
390+}
391+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-maximize {
392+ display: flex;
393+}
394+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-minimize {
395+ display: none;
396+}
397+.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) .popup-content > #qr--modalEditor #qr--modal-debugState {
398+ padding-top: 0;
399+}
400+.popup:has(#qr--modalEditor):has(.qr--isExecuting) .popup-controls {
401+ display: none;
402+}
403+.popup:has(#qr--modalEditor):has(.qr--isExecuting) .qr--highlight {
404+ position: absolute;
405+ z-index: 50000;
406+ pointer-events: none;
407+ background-color: rgba(47, 150, 180, 0.5);
408+}
409+.popup:has(#qr--modalEditor):has(.qr--isExecuting) .qr--highlight.qr--unresolved {
410+ background-color: rgba(255, 255, 0, 0.5);
411+}
412+.popup:has(#qr--modalEditor):has(.qr--isExecuting) .qr--highlight-secondary {
413+ position: absolute;
414+ z-index: 50000;
415+ pointer-events: none;
416+ border: 3px solid red;
417+}
241418.popup:has(#qr--modalEditor) .popup-content {
242419 display: flex;
243420 flex-direction: column;
@@ -249,6 +426,67 @@
249426 gap: 1em;
250427 overflow: hidden;
251428}
429+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--main > h3:first-child,
430+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--main > .qr--labels,
431+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings,
432+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--qrOptions > h3:first-child,
433+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--qrOptions > #qr--ctxEditor,
434+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--qrOptions > .qr--ctxEditorActions,
435+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--qrOptions > .qr--ctxEditorActions + h3,
436+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--qrOptions > .qr--ctxEditorActions + h3 + div {
437+ display: none;
438+}
439+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message {
440+ visibility: hidden;
441+}
442+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--modal-debugButtons {
443+ display: flex;
444+}
445+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--modal-debugButtons .menu_button:not(#qr--modal-minimize, #qr--modal-maximize) {
446+ cursor: not-allowed;
447+ opacity: 0.5;
448+ pointer-events: none;
449+ transition: 200ms;
450+ border-color: transparent;
451+}
452+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting.qr--isPaused #qr--modal-debugButtons .menu_button:not(#qr--modal-minimize, #qr--modal-maximize) {
453+ cursor: pointer;
454+ opacity: 1;
455+ pointer-events: all;
456+}
457+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting.qr--isPaused #qr--modal-debugButtons .menu_button:not(#qr--modal-minimize, #qr--modal-maximize)#qr--modal-resume {
458+ animation-name: qr--debugPulse;
459+ animation-duration: 1500ms;
460+ animation-timing-function: ease-in-out;
461+ animation-delay: 0s;
462+ animation-iteration-count: infinite;
463+}
464+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting.qr--isPaused #qr--modal-debugButtons .menu_button:not(#qr--modal-minimize, #qr--modal-maximize)#qr--modal-resume {
465+ border-color: #51a351;
466+}
467+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting.qr--isPaused #qr--modal-debugButtons .menu_button:not(#qr--modal-minimize, #qr--modal-maximize)#qr--modal-step {
468+ border-color: var(--SmartThemeQuoteColor);
469+}
470+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting.qr--isPaused #qr--modal-debugButtons .menu_button:not(#qr--modal-minimize, #qr--modal-maximize)#qr--modal-stepInto {
471+ border-color: var(--SmartThemeQuoteColor);
472+}
473+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting.qr--isPaused #qr--modal-debugButtons .menu_button:not(#qr--modal-minimize, #qr--modal-maximize)#qr--modal-stepOut {
474+ border-color: var(--SmartThemeQuoteColor);
475+}
476+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--resizeHandle {
477+ width: 6px;
478+ background-color: var(--SmartThemeBorderColor);
479+ border: 2px solid var(--SmartThemeBlurTintColor);
480+ transition: border-color 200ms, background-color 200ms;
481+ cursor: w-resize;
482+}
483+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--resizeHandle:hover {
484+ background-color: var(--SmartThemeQuoteColor);
485+ border-color: var(--SmartThemeQuoteColor);
486+}
487+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor.qr--isExecuting #qr--qrOptions {
488+ width: var(--width, auto);
489+}
252490.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main {
253491 flex: 1 1 auto;
254492 display: flex;
@@ -260,21 +498,115 @@
260498 display: flex;
261499 flex-direction: row;
262500 gap: 0.5em;
501+ padding: 1px;
263502}
264503.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label {,
504+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label {
265505 flex: 1 1 1px;
266506 display: flex;
267507 flex-direction: column;
508+ position: relative;
509+}
510+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label.qr--fit,
511+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label.qr--fit {
512+ flex: 0 0 auto;
513+ justify-content: center;
514+}
515+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--inputGroup,
516+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--inputGroup {
517+ display: flex;
518+ align-items: baseline;
519+ gap: 0.5em;
520+}
521+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--inputGroup input,
522+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--inputGroup input {
523+ flex: 1 1 auto;
268524}
269525.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelText {,
526+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--labelText {
270527 flex: 1 1 auto;
271528}
272529.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelHint {,
530+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--labelHint {
273531 flex: 1 1 auto;
274532}
275533.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label > input {,
534+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label input {
276535 flex: 0 0 auto;
277536}
537+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList,
538+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList {
539+ background-color: var(--stcdx--bgColor);
540+ border: 1px solid var(--SmartThemeBorderColor);
541+ backdrop-filter: blur(var(--SmartThemeBlurStrength));
542+ border-radius: 10px;
543+ font-size: smaller;
544+ position: absolute;
545+ top: 100%;
546+ left: 0;
547+ right: 0;
548+ overflow: auto;
549+ margin: 0;
550+ padding: 0.5em;
551+ max-height: 50vh;
552+ list-style: none;
553+ z-index: 40000;
554+ max-width: 100%;
555+}
556+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--modal-switcherItem,
557+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--modal-switcherItem {
558+ display: flex;
559+ gap: 1em;
560+ text-align: left;
561+ opacity: 0.75;
562+ transition: 200ms;
563+ cursor: pointer;
564+}
565+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--modal-switcherItem:hover,
566+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--modal-switcherItem:hover {
567+ opacity: 1;
568+}
569+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--modal-switcherItem.qr--current,
570+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--modal-switcherItem.qr--current {
571+ opacity: 1;
572+}
573+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--modal-switcherItem.qr--current .qr--label,
574+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--modal-switcherItem.qr--current .qr--label,
575+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--modal-switcherItem.qr--current .qr--id,
576+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--modal-switcherItem.qr--current .qr--id {
577+ font-weight: bold;
578+}
579+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--label,
580+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--label {
581+ white-space: nowrap;
582+}
583+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--label .menu_button,
584+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--label .menu_button {
585+ display: inline-block;
586+ height: min-content;
587+ width: min-content;
588+ margin: 0 0.5em 0 0;
589+}
590+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--id,
591+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--id {
592+ opacity: 0.5;
593+}
594+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--id:before,
595+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--id:before {
596+ content: "[";
597+}
598+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--id:after,
599+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--id:after {
600+ content: "]";
601+}
602+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label .qr--modal-switcherList .qr--message,
603+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > .label .qr--modal-switcherList .qr--message {
604+ height: 1lh;
605+ overflow: hidden;
606+ text-overflow: ellipsis;
607+ white-space: nowrap;
608+ opacity: 0.5;
609+}
278610.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer {
279611 flex: 1 1 auto;
280612 display: flex;
@@ -283,8 +615,9 @@
283615}
284616.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings {
285617 display: flex;
618+ flex-wrap: wrap;
286619 flex-direction: row;
287620 column-gap: 1em;
288621 color: var(--grey70);
289622 font-size: smaller;
290623 align-items: baseline;
@@ -308,6 +641,11 @@
308641 background-color: var(--ac-style-color-background);
309642 color: var(--ac-style-color-text);
310643}
644+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message::-webkit-scrollbar,
645+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message::-webkit-scrollbar-thumb {
646+ visibility: visible;
647+ cursor: unset;
648+}
311649.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message::selection {
312650 color: unset;
313651 background-color: rgba(108 171 251 / 0.25);
@@ -357,11 +695,15 @@
357695 font-family: var(--monoFontFamily);
358696 padding: 0.75em;
359697 margin: 0;
360- border: none;
361698 resize: none;
362699 line-height: 1.2;
363700 border: 1px solid var(--SmartThemeBorderColor);
364701 border-radius: 5px;
702+ position: relative;
703+}
704+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-icon {
705+ height: 100%;
706+ aspect-ratio: 1 / 1;
365707}
366708.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons {
367709 display: flex;
@@ -410,6 +752,46 @@
410752.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons #qr--modal-stop {
411753 border-color: #d78872;
412754}
755+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons {
756+ display: none;
757+ gap: 1em;
758+}
759+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton {
760+ aspect-ratio: 1.25 / 1;
761+ width: 2.25em;
762+ position: relative;
763+}
764+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton:not(.fa-solid) {
765+ border-width: 1px;
766+ border-style: solid;
767+}
768+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton:not(.fa-solid):after {
769+ content: '';
770+ position: absolute;
771+ inset: 3px;
772+ background-color: var(--SmartThemeBodyColor);
773+ mask-size: contain;
774+ mask-position: center;
775+ mask-repeat: no-repeat;
776+}
777+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-resume:after {
778+ mask-image: url('/img/step-resume.svg');
779+}
780+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-step:after {
781+ mask-image: url('/img/step-over.svg');
782+}
783+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-stepInto:after {
784+ mask-image: url('/img/step-into.svg');
785+}
786+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-stepOut:after {
787+ mask-image: url('/img/step-out.svg');
788+}
789+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-maximize {
790+ display: none;
791+}
792+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-send_textarea {
793+ flex: 0 0 auto;
794+}
413795.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeProgress {
414796 --prog: 0;
415797 --progColor: #92befc;
@@ -417,6 +799,7 @@
417799 --progSuccessColor: #51a351;
418800 --progErrorColor: #bd362f;
419801 --progAbortedColor: #d78872;
802+ flex: 0 0 auto;
420803 height: 0.5em;
421804 background-color: var(--black50a);
422805 position: relative;
@@ -469,6 +852,7 @@
469852 overflow: auto;
470853 min-width: 100%;
471854 width: 0;
855+ white-space: pre-wrap;
472856}
473857.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeResult.qr--hasResult {
474858 display: block;
@@ -476,6 +860,150 @@
476860.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeResult:before {
477861 content: 'Result: ';
478862}
863+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState {
864+ display: none;
865+ text-align: left;
866+ font-size: smaller;
867+ font-family: var(--monoFontFamily);
868+ color: white;
869+ padding: 0.5em 0;
870+ overflow: auto;
871+ min-width: 100%;
872+ width: 0;
873+ white-space: pre-wrap;
874+}
875+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState.qr--active {
876+ display: block;
877+}
878+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope {
879+ display: grid;
880+ grid-template-columns: 0fr 1fr 1fr;
881+ column-gap: 0em;
882+}
883+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--title {
884+ grid-column: 1 / 4;
885+ font-weight: bold;
886+ font-family: var(--mainFontFamily);
887+ background-color: var(--black50a);
888+ padding: 0.25em;
889+ margin-top: 0.5em;
890+}
891+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var,
892+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro,
893+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe {
894+ display: contents;
895+}
896+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var:nth-child(2n + 1) .qr--key,
897+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro:nth-child(2n + 1) .qr--key,
898+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe:nth-child(2n + 1) .qr--key,
899+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var:nth-child(2n + 1) .qr--val,
900+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro:nth-child(2n + 1) .qr--val,
901+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe:nth-child(2n + 1) .qr--val {
902+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.25);
903+}
904+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var:nth-child(2n + 1) .qr--val:nth-child(2n),
905+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro:nth-child(2n + 1) .qr--val:nth-child(2n),
906+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe:nth-child(2n + 1) .qr--val:nth-child(2n) {
907+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.125);
908+}
909+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var:nth-child(2n + 1) .qr--val:hover,
910+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro:nth-child(2n + 1) .qr--val:hover,
911+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe:nth-child(2n + 1) .qr--val:hover {
912+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.5);
913+}
914+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var:nth-child(2n) .qr--val:nth-child(2n),
915+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro:nth-child(2n) .qr--val:nth-child(2n),
916+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe:nth-child(2n) .qr--val:nth-child(2n) {
917+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.0625);
918+}
919+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var:nth-child(2n) .qr--val:hover,
920+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro:nth-child(2n) .qr--val:hover,
921+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe:nth-child(2n) .qr--val:hover {
922+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.5);
923+}
924+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var.qr--isHidden .qr--key,
925+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro.qr--isHidden .qr--key,
926+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe.qr--isHidden .qr--key,
927+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var.qr--isHidden .qr--val,
928+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro.qr--isHidden .qr--val,
929+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe.qr--isHidden .qr--val {
930+ opacity: 0.5;
931+}
932+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var .qr--val,
933+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro .qr--val,
934+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe .qr--val {
935+ grid-column: 2 / 4;
936+}
937+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var .qr--val.qr--singleCol,
938+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro .qr--val.qr--singleCol,
939+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe .qr--val.qr--singleCol {
940+ grid-column: unset;
941+}
942+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var .qr--val.qr--simple:before,
943+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro .qr--val.qr--simple:before,
944+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe .qr--val.qr--simple:before,
945+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var .qr--val.qr--simple:after,
946+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro .qr--val.qr--simple:after,
947+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe .qr--val.qr--simple:after {
948+ content: '"';
949+ color: var(--SmartThemeQuoteColor);
950+}
951+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--var .qr--val.qr--unresolved:after,
952+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro .qr--val.qr--unresolved:after,
953+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe .qr--val.qr--unresolved:after {
954+ content: '-UNRESOLVED-';
955+ font-style: italic;
956+ color: var(--SmartThemeQuoteColor);
957+}
958+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--key {
959+ margin-left: 0.5em;
960+ padding-right: 1em;
961+}
962+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--key:after {
963+ content: ": ";
964+}
965+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe > .qr--key:before,
966+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro > .qr--key:before {
967+ content: "{{";
968+}
969+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--pipe > .qr--key:after,
970+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--macro > .qr--key:after {
971+ content: "}}: ";
972+}
973+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--scope {
974+ display: contents;
975+}
976+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--scope .qr--pipe .qr--key,
977+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--scope .qr--scope .qr--pipe .qr--val {
978+ opacity: 0.5;
979+}
980+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack {
981+ display: grid;
982+ grid-template-columns: 1fr 0fr;
983+}
984+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--title {
985+ grid-column: 1 / 3;
986+ font-weight: bold;
987+ font-family: var(--mainFontFamily);
988+ background-color: var(--black50a);
989+ padding: 0.25em;
990+ margin-top: 1em;
991+}
992+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item {
993+ display: contents;
994+}
995+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item:nth-child(2n + 1) .qr--name,
996+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item:nth-child(2n + 1) .qr--source {
997+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.25);
998+}
999+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item .qr--name {
1000+ margin-left: 0.5em;
1001+}
1002+.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-debugState .qr--stack .qr--item .qr--source {
1003+ opacity: 0.5;
1004+ text-align: right;
1005+ white-space: nowrap;
1006+}
4791007@keyframes qr--progressPulse {
4801008 0%,
4811009 100% {
@@ -485,9 +1013,63 @@
4851013 background-color: var(--progFlashColor);
4861014 }
4871015}
1016+@keyframes qr--debugPulse {
1017+ 0%,
1018+ 100% {
1019+ border-color: #51a351;
1020+ }
1021+ 50% {
1022+ border-color: #92befc;
1023+ }
1024+}
4881025.popup.qr--hide {
4891026 opacity: 0 !important;
4901027}
4911028.popup.qr--hide::backdrop {
4921029 opacity: 0 !important;
4931030}
1031+.popup.qr--hide::backdrop {
1032+ opacity: 0 !important;
1033+}
1034+.popup:has(.qr--transferModal) .popup-button-ok {
1035+ display: flex;
1036+ align-items: center;
1037+ flex-direction: column;
1038+ white-space: pre;
1039+ font-weight: normal;
1040+ box-shadow: 0 0 0;
1041+ transition: 200ms;
1042+}
1043+.popup:has(.qr--transferModal) .popup-button-ok:after {
1044+ content: 'Transfer';
1045+ height: 0;
1046+ overflow: hidden;
1047+ font-weight: bold;
1048+}
1049+.popup:has(.qr--transferModal) .qr--copy {
1050+ display: flex;
1051+ align-items: center;
1052+ flex-direction: column;
1053+ white-space: pre;
1054+ font-weight: normal;
1055+ box-shadow: 0 0 0;
1056+ transition: 200ms;
1057+}
1058+.popup:has(.qr--transferModal) .qr--copy:after {
1059+ content: 'Copy';
1060+ height: 0;
1061+ overflow: hidden;
1062+ font-weight: bold;
1063+}
1064+.popup:has(.qr--transferModal):has(.qr--transferSelect:focus) .popup-button-ok {
1065+ font-weight: bold;
1066+ box-shadow: 0 0 10px;
1067+}
1068+.popup:has(.qr--transferModal):has(.qr--transferSelect:focus).qr--isCopy .popup-button-ok {
1069+ font-weight: normal;
1070+ box-shadow: 0 0 0;
1071+}
1072+.popup:has(.qr--transferModal):has(.qr--transferSelect:focus).qr--isCopy .qr--copy {
1073+ font-weight: bold;
1074+ box-shadow: 0 0 10px;
1075+}
public/scripts/extensions/quick-reply/style.less+575 -37
@@ -1,3 +1,18 @@
1+@keyframes qr--success {
2+ 0%, 100% {
3+ color: var(--SmartThemeBodyColor);
4+ }
5+ 25%, 75% {
6+ color: rgb(81, 163, 81);
7+ }
8+}
9+&.qr--success {
10+ animation-name: qr--success;
11+ animation-duration: 3s;
12+ animation-timing-function: linear;
13+ animation-delay: 0s;
14+ animation-iteration-count: 1;
15+}
116#qr--bar {
217 outline: none;
318 margin: 0;
@@ -50,6 +65,18 @@
5065#qr--bar,
5166#qr--popout>.qr--body {
5267 >.qr--buttons {
68+ --qr--color: transparent;
69+ &.qr--color {
70+ background-color: var(--qr--color);
71+ }
72+ &.qr--borderColor {
73+ background-color: transparent;
74+ border-left: 5px solid var(--qr--color);
75+ border-right: 5px solid var(--qr--color);
76+ }
77+ &:has(.qr--buttons.qr--color) {
78+ margin: 5px;
79+ }
5380 margin: 0;
5481 padding: 0;
5582 display: flex;
@@ -60,6 +87,25 @@
6087
6188 >.qr--buttons {
6289 display: contents;
90+ &.qr--color {
91+ .qr--button:before {
92+ content: '';
93+ background-color: var(--qr--color);
94+ position: absolute;
95+ inset: -5px;
96+ z-index: -1;
97+ }
98+ &.qr--borderColor {
99+ .qr--button:before {
100+ display: none;
101+ }
102+ &:before, &:after {
103+ content: '';
104+ width: 5px;
105+ background-color: var(--qr--color);
106+ }
107+ }
108+ }
63109 }
64110
65111 .qr--button {
@@ -75,10 +121,17 @@
75121 align-items: center;
76122 justify-content: center;
77123 text-align: center;
124+ position: relative;
78125
79126 &:hover {
80- opacity: 1;
127+ background-color: rgb(30% 30% 30%);
81- filter: brightness(1.2);
128+ }
129+
130+ .qr--hidden {
131+ display: none;
132+ }
133+ .qr--button-icon {
134+ margin: 0 0.5em;
82135 }
83136
84137 >.qr--button-expander {
@@ -211,14 +264,41 @@
211264 .qr--set-qrListContents> {
212265 padding: 0 0.5em;
213266
214267 >.qr--set-item .qr--set-itemAdder {
268+ display: flex;
269+ align-items: center;
270+ opacity: 0;
271+ transition: 100ms;
272+ margin: -2px 0 -11px 0;
273+ position: relative;
274+ .qr--actions {
275+ display: flex;
276+ gap: 0.25em;
277+ flex: 0 0 auto;
278+ .qr--action {
279+ margin: 0;
280+ }
281+ }
282+ &:before, &:after {
283+ content: "";
284+ display: block;
285+ flex: 1 1 auto;
286+ border: 1px solid;
287+ margin: 0 1em;
288+ height: 0;
289+ }
290+ &:hover, &:focus-within {
291+ opacity: 1;
292+ }
293+ }
294+ >.qr--set-item .qr--content {
215295 display: flex;
216296 flex-direction: row;
217297 gap: 0.5em;
218298 align-items: baseline;
219299 padding: 0.25em 0;
220300
221301 > :nth-child(12) {
222302 flex: 0 0 auto;
223303 }
224304
@@ -235,13 +315,29 @@
235315 }
236316
237317 > :nth-child(5) {
238318 flex: 0 01 auto;
319+ display: flex;
320+ gap: 0.25em;
321+ justify-content: flex-end;
322+ flex-wrap: wrap;
239323 }
240324
241325 >.drag-handle {
242326 padding: 0.75em;
243327 }
244328
329+ .qr--set-itemLabelContainer {
330+ display: flex;
331+ align-items: center;
332+ gap: 0.5em;
333+ .qr--set-itemIcon:not(.fa-solid) {
334+ display: none;
335+ }
336+ .qr--set-itemLabel {
337+ min-width: 24px;
338+ }
339+ }
340+
245341 .qr--set-itemLabel,
246342 .qr--action {
247343 margin: 0;
@@ -251,6 +347,8 @@
251347 font-size: smaller;
252348 }
253349 }
350+
351+
254352 }
255353 }
256354
@@ -270,6 +368,7 @@
270368#qr--qrOptions {
271369 display: flex;
272370 flex-direction: column;
371+ padding-right: 1px;
273372
274373 >#qr--ctxEditor {
275374 .qr--ctxItem {
@@ -279,6 +378,15 @@
279378 align-items: baseline;
280379 }
281380 }
381+ >#qr--autoExec {
382+ .checkbox_label {
383+ text-wrap: nowrap;
384+
385+ .fa-fw {
386+ margin-right: 2px;
387+ }
388+ }
389+ }
282390}
283391
284392
@@ -306,6 +414,78 @@
306414.popup:has(#qr--modalEditor) {
307415 aspect-ratio: unset;
308416
417+ &:has(.qr--isExecuting.qr--minimized) {
418+ min-width: unset;
419+ min-height: unset;
420+ height: auto !important;
421+ width: min-content !important;
422+ position: absolute;
423+ right: 1em;
424+ top: 1em;
425+ left: unset;
426+ bottom: unset;
427+ margin: unset;
428+ padding: 0;
429+ &::backdrop {
430+ backdrop-filter: unset;
431+ background-color: transparent;
432+ }
433+ .popup-body {
434+ flex: 0 0 auto;
435+ height: min-content;
436+ width: min-content;
437+ }
438+ .popup-content {
439+ flex: 0 0 auto;
440+ margin-top: 0;
441+
442+ > #qr--modalEditor {
443+ max-height: 50vh;
444+ > #qr--main,
445+ > #qr--resizeHandle,
446+ > #qr--qrOptions > h3,
447+ > #qr--qrOptions > #qr--modal-executeButtons,
448+ > #qr--qrOptions > #qr--modal-executeProgress
449+ {
450+ display: none;
451+ }
452+ #qr--qrOptions {
453+ width: auto;
454+ }
455+ #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-maximize {
456+ display: flex;
457+ }
458+ #qr--modal-debugButtons .qr--modal-debugButton#qr--modal-minimize {
459+ display: none;
460+ }
461+ #qr--modal-debugState {
462+ padding-top: 0;
463+ }
464+ }
465+ }
466+ }
467+ &:has(.qr--isExecuting) {
468+ .popup-controls {
469+ display: none;
470+ }
471+
472+ .qr--highlight {
473+ position: absolute;
474+ z-index: 50000;
475+ pointer-events: none;
476+ background-color: rgb(47 150 180 / 0.5);
477+ &.qr--unresolved {
478+ background-color: rgb(255 255 0 / 0.5);
479+ }
480+ }
481+ .qr--highlight-secondary {
482+ position: absolute;
483+ z-index: 50000;
484+ pointer-events: none;
485+ border: 3px solid red;
486+ }
487+ }
488+
309489 .popup-content {
310490 display: flex;
311491 flex-direction: column;
@@ -317,86 +497,213 @@
317497 gap: 1em;
318498 overflow: hidden;
319499
500+ &.qr--isExecuting {
501+ #qr--main > h3:first-child,
502+ #qr--main > .qr--labels,
503+ #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings,
504+ #qr--qrOptions > h3:first-child,
505+ #qr--qrOptions > #qr--ctxEditor,
506+ #qr--qrOptions > .qr--ctxEditorActions,
507+ #qr--qrOptions > .qr--ctxEditorActions + h3,
508+ #qr--qrOptions > .qr--ctxEditorActions + h3 + div
509+ {
510+ display: none;
511+ }
512+ #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message {
513+ visibility: hidden;
514+ }
515+ #qr--modal-debugButtons {
516+ display: flex;
517+ .menu_button:not(#qr--modal-minimize, #qr--modal-maximize) {
518+ cursor: not-allowed;
519+ opacity: 0.5;
520+ pointer-events: none;
521+ transition: 200ms;
522+ border-color: transparent;
523+ }
524+ }
525+ &.qr--isPaused #qr--modal-debugButtons {
526+ .menu_button:not(#qr--modal-minimize, #qr--modal-maximize) {
527+ cursor: pointer;
528+ opacity: 1;
529+ pointer-events: all;
530+ &#qr--modal-resume {
531+ animation-name: qr--debugPulse;
532+ animation-duration: 1500ms;
533+ animation-timing-function: ease-in-out;
534+ animation-delay: 0s;
535+ animation-iteration-count: infinite;
536+ }
537+ &#qr--modal-resume {
538+ border-color: rgb(81, 163, 81);
539+ }
540+ &#qr--modal-step {
541+ border-color: var(--SmartThemeQuoteColor);
542+ }
543+ &#qr--modal-stepInto {
544+ border-color: var(--SmartThemeQuoteColor);
545+ }
546+ &#qr--modal-stepOut {
547+ border-color: var(--SmartThemeQuoteColor);
548+ }
549+ }
550+ }
551+ #qr--resizeHandle {
552+ width: 6px;
553+ background-color: var(--SmartThemeBorderColor);
554+ border: 2px solid var(--SmartThemeBlurTintColor);
555+ transition: border-color 200ms, background-color 200ms;
556+ cursor: w-resize;
557+ &:hover {
558+ background-color: var(--SmartThemeQuoteColor);
559+ border-color: var(--SmartThemeQuoteColor);
560+ }
561+ }
562+ #qr--qrOptions {
563+ width: var(--width, auto);
564+ }
565+ }
566+
320567 > #qr--main {
321568 flex: 1 1 auto;
322569 display: flex;
323570 flex-direction: column;
324571 overflow: hidden;
325-
326572 > .qr--labels {
327573 flex: 0 0 auto;
328574 display: flex;
329575 flex-direction: row;
330576 gap: 0.5em;
331-
577+ padding: 1px;
332- >label {
578+ > label, > .label {
333579 flex: 1 1 1px;
334580 display: flex;
335581 flex-direction: column;
336-
582+ position: relative;
337583 >&.qr--labelTextfit {
584+ flex: 0 0 auto;
585+ justify-content: center;
586+ }
587+ .qr--inputGroup {
588+ display: flex;
589+ align-items: baseline;
590+ gap: 0.5em;
591+ input {
338592 flex: 1 1 auto;
339593 }
340-
594+ }
341595 > .qr--labelHintlabelText {
342596 flex: 1 1 auto;
343597 }
344-
598+ .qr--labelHint {
345- >input {
599+ flex: 1 1 auto;
600+ }
601+ input {
346602 flex: 0 0 auto;
347603 }
604+ .qr--modal-switcherList {
605+ background-color: var(--stcdx--bgColor);
606+ border: 1px solid var(--SmartThemeBorderColor);
607+ backdrop-filter: blur(var(--SmartThemeBlurStrength));
608+ border-radius: 10px;
609+ font-size: smaller;
610+ position: absolute;
611+ top: 100%;
612+ left: 0;
613+ right: 0;
614+ overflow: auto;
615+ margin: 0;
616+ padding: 0.5em;
617+ max-height: 50vh;
618+ list-style: none;
619+ z-index: 40000;
620+ max-width: 100%;
621+ .qr--modal-switcherItem {
622+ display: flex;
623+ gap: 1em;
624+ text-align: left;
625+ opacity: 0.75;
626+ transition: 200ms;
627+ cursor: pointer;
628+ &:hover {
629+ opacity: 1;
630+ }
631+ &.qr--current {
632+ opacity: 1;
633+ .qr--label, .qr--id {
634+ font-weight: bold;
635+ }
636+ }
637+ }
638+ .qr--label {
639+ white-space: nowrap;
640+ .menu_button {
641+ display: inline-block;
642+ height: min-content;
643+ width: min-content;
644+ margin: 0 0.5em 0 0;
645+ }
646+ }
647+ .qr--id {
648+ opacity: 0.5;
649+ &:before { content: "["; }
650+ &:after { content: "]"; }
651+ }
652+ .qr--message {
653+ height: 1lh;
654+ overflow: hidden;
655+ text-overflow: ellipsis;
656+ white-space: nowrap;
657+ opacity: 0.5;
658+ }
659+ }
348660 }
349661 }
350-
351662 > .qr--modal-messageContainer {
352663 flex: 1 1 auto;
353664 display: flex;
354665 flex-direction: column;
355666 overflow: hidden;
356-
357667 > .qr--modal-editorSettings {
358668 display: flex;
669+ flex-wrap: wrap;
359670 flex-direction: row;
360671 column-gap: 1em;
361672 color: var(--grey70);
362673 font-size: smaller;
363674 align-items: baseline;
364-
365675 > .checkbox_label {
366676 white-space: nowrap;
367-
368677 > input {
369678 font-size: inherit;
370679 }
371680 }
372681 }
373-
374682 > #qr--modal-messageHolder {
375683 flex: 1 1 auto;
376684 display: grid;
377685 text-align: left;
378686 overflow: hidden;
379-
380687 &.qr--noSyntax {
381688 > #qr--modal-messageSyntax {
382689 display: none;
383690 }
384-
385691 > #qr--modal-message {
386692 background-color: var(--ac-style-color-background);
387693 color: var(--ac-style-color-text);
388-
694+ &::-webkit-scrollbar, &::-webkit-scrollbar-thumb {
695+ visibility: visible;
696+ cursor: unset;
697+ }
389698 &::selection {
390699 color: unset;
391700 background-color: rgba(108 171 251 / 0.25);
392-
393701 @supports (color: rgb(from white r g b / 0.25)) {
394702 background-color: rgb(from var(--ac-style-color-matchedText) r g b / 0.25);
395703 }
396704 }
397705 }
398706 }
399-
400707 > #qr--modal-messageSyntax {
401708 grid-column: 1;
402709 grid-row: 1;
@@ -406,12 +713,10 @@
406713 overflow: hidden;
407714 min-width: 100%;
408715 width: 0;
409-
410716 > #qr--modal-messageSyntaxInner {
411717 height: 100%;
412718 }
413719 }
414-
415720 > #qr--modal-message {
416721 background-color: transparent;
417722 color: transparent;
@@ -419,38 +724,35 @@
419724 grid-row: 1;
420725 caret-color: var(--ac-style-color-text);
421726 overflow: auto;
422-
727+ &::-webkit-scrollbar, &::-webkit-scrollbar-thumb {
423- &::-webkit-scrollbar,
424- &::-webkit-scrollbar-thumb {
425728 visibility: hidden;
426729 cursor: default;
427730 }
428-
429731 &::selection {
430732 color: transparent;
431733 background-color: rgba(108 171 251 / 0.25);
432-
433734 @supports (color: rgb(from white r g b / 0.25)) {
434735 background-color: rgb(from var(--ac-style-color-matchedText) r g b / 0.25);
435736 }
436737 }
437738 }
438-
739+ #qr--modal-message, #qr--modal-messageSyntaxInner {
439- #qr--modal-message,
440- #qr--modal-messageSyntaxInner {
441740 font-family: var(--monoFontFamily);
442741 padding: 0.75em;
443742 margin: 0;
444- border: none;
445743 resize: none;
446744 line-height: 1.2;
447745 border: 1px solid var(--SmartThemeBorderColor);
448746 border-radius: 5px;
747+ position: relative;
449748 }
450749 }
451750 }
452751 }
453-
752+ #qr--modal-icon {
753+ height: 100%;
754+ aspect-ratio: 1 / 1;
755+ }
454756 #qr--modal-executeButtons {
455757 display: flex;
456758 gap: 1em;
@@ -510,6 +812,47 @@
510812 border-color: rgb(215, 136, 114);
511813 }
512814 }
815+ #qr--modal-debugButtons {
816+ display: none;
817+ gap: 1em;
818+ .qr--modal-debugButton {
819+ aspect-ratio: 1.25 / 1;
820+ width: 2.25em;
821+ position: relative;
822+ &:not(.fa-solid) {
823+ border-width: 1px;
824+ border-style: solid;
825+ &:after {
826+ content: '';
827+ position: absolute;
828+ inset: 3px;
829+ background-color: var(--SmartThemeBodyColor);
830+ mask-size: contain;
831+ mask-position: center;
832+ mask-repeat: no-repeat;
833+ }
834+ }
835+ &#qr--modal-resume:after {
836+ mask-image: url('/img/step-resume.svg');
837+ }
838+ &#qr--modal-step:after {
839+ mask-image: url('/img/step-over.svg');
840+ }
841+ &#qr--modal-stepInto:after {
842+ mask-image: url('/img/step-into.svg');
843+ }
844+ &#qr--modal-stepOut:after {
845+ mask-image: url('/img/step-out.svg');
846+ }
847+ &#qr--modal-maximize {
848+ display: none;
849+ }
850+ }
851+ }
852+
853+ #qr--modal-send_textarea {
854+ flex: 0 0 auto;
855+ }
513856
514857 #qr--modal-executeProgress {
515858 --prog: 0;
@@ -518,6 +861,7 @@
518861 --progSuccessColor: rgb(81, 163, 81);
519862 --progErrorColor: rgb(189, 54, 47);
520863 --progAbortedColor: rgb(215, 136, 114);
864+ flex: 0 0 auto;
521865 height: 0.5em;
522866 background-color: var(--black50a);
523867 position: relative;
@@ -588,6 +932,135 @@
588932 overflow: auto;
589933 min-width: 100%;
590934 width: 0;
935+ white-space: pre-wrap;
936+ }
937+ #qr--modal-debugState {
938+ display: none;
939+ &.qr--active {
940+ display: block;
941+ }
942+ text-align: left;
943+ font-size: smaller;
944+ font-family: var(--monoFontFamily);
945+ // background-color: rgb(146, 190, 252);
946+ color: white;
947+ padding: 0.5em 0;
948+ overflow: auto;
949+ min-width: 100%;
950+ width: 0;
951+ white-space: pre-wrap;
952+
953+ .qr--scope {
954+ display: grid;
955+ grid-template-columns: 0fr 1fr 1fr;
956+ column-gap: 0em;
957+ .qr--title {
958+ grid-column: 1 / 4;
959+ font-weight: bold;
960+ font-family: var(--mainFontFamily);
961+ background-color: var(--black50a);
962+ padding: 0.25em;
963+ margin-top: 0.5em;
964+ }
965+ .qr--var, .qr--macro, .qr--pipe {
966+ display: contents;
967+ &:nth-child(2n + 1) {
968+ .qr--key, .qr--val {
969+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.25);
970+ }
971+ .qr--val {
972+ &:nth-child(2n) {
973+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.125);
974+ }
975+ &:hover {
976+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.5);
977+ }
978+ }
979+ }
980+ &:nth-child(2n) {
981+ .qr--val {
982+ &:nth-child(2n) {
983+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.0625);
984+ }
985+ &:hover {
986+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.5);
987+ }
988+ }
989+ }
990+ &.qr--isHidden {
991+ .qr--key, .qr--val {
992+ opacity: 0.5;
993+ }
994+ }
995+ .qr--val {
996+ grid-column: 2 / 4;
997+ &.qr--singleCol {
998+ grid-column: unset;
999+ }
1000+ &.qr--simple {
1001+ &:before, &:after {
1002+ content: '"';
1003+ color: var(--SmartThemeQuoteColor);
1004+ }
1005+ }
1006+ &.qr--unresolved {
1007+ &:after {
1008+ content: '-UNRESOLVED-';
1009+ font-style: italic;
1010+ color: var(--SmartThemeQuoteColor);
1011+ }
1012+ }
1013+ }
1014+ }
1015+ .qr--key {
1016+ margin-left: 0.5em;
1017+ padding-right: 1em;
1018+ &:after { content: ": "; }
1019+ }
1020+ .qr--pipe, .qr--macro {
1021+ > .qr--key {
1022+ &:before { content: "{{"; }
1023+ &:after { content: "}}: "; }
1024+ }
1025+ }
1026+ .qr--scope {
1027+ display: contents;
1028+ .qr--pipe {
1029+ .qr--key, .qr--val {
1030+ opacity: 0.5;
1031+ }
1032+ }
1033+ }
1034+ }
1035+
1036+ .qr--stack {
1037+ display: grid;
1038+ grid-template-columns: 1fr 0fr;
1039+ .qr--title {
1040+ grid-column: 1 / 3;
1041+ font-weight: bold;
1042+ font-family: var(--mainFontFamily);
1043+ background-color: var(--black50a);
1044+ padding: 0.25em;
1045+ margin-top: 1em;
1046+ }
1047+ .qr--item {
1048+ display: contents;
1049+ &:nth-child(2n + 1) {
1050+ .qr--name, .qr--source {
1051+ background-color: rgb(from var(--SmartThemeEmColor) r g b / 0.25);
1052+ }
1053+ }
1054+ .qr--name {
1055+ margin-left: 0.5em;
1056+ }
1057+ .qr--source {
1058+ opacity: 0.5;
1059+ text-align: right;
1060+ white-space: nowrap;
1061+ }
1062+ }
1063+ }
5911064 }
5921065 }
5931066 }
@@ -605,10 +1078,75 @@
6051078 }
6061079}
6071080
1081+@keyframes qr--debugPulse {
1082+ 0%,
1083+ 100% {
1084+ border-color: rgb(81, 163, 81);
1085+ }
1086+
1087+ 50% {
1088+ border-color: rgb(146, 190, 252);
1089+ }
1090+}
1091+
6081092.popup.qr--hide {
6091093 opacity: 0 !important;
1094+ &::backdrop {
1095+ opacity: 0 !important;
1096+ }
6101097}
6111098
6121099.popup.qr--hide::backdrop {
6131100 opacity: 0 !important;
6141101}
1102+
1103+
1104+
1105+.popup:has(.qr--transferModal) {
1106+ .popup-button-ok {
1107+ &:after {
1108+ content: 'Transfer';
1109+ height: 0;
1110+ overflow: hidden;
1111+ font-weight: bold;
1112+ }
1113+ display: flex;
1114+ align-items: center;
1115+ flex-direction: column;
1116+ white-space: pre;
1117+ font-weight: normal;
1118+ box-shadow: 0 0 0;
1119+ transition: 200ms;
1120+ }
1121+ .qr--copy {
1122+ &:after {
1123+ content: 'Copy';
1124+ height: 0;
1125+ overflow: hidden;
1126+ font-weight: bold;
1127+ }
1128+ display: flex;
1129+ align-items: center;
1130+ flex-direction: column;
1131+ white-space: pre;
1132+ font-weight: normal;
1133+ box-shadow: 0 0 0;
1134+ transition: 200ms;
1135+ }
1136+ &:has(.qr--transferSelect:focus) {
1137+ .popup-button-ok {
1138+ font-weight: bold;
1139+ box-shadow: 0 0 10px;
1140+ }
1141+ &.qr--isCopy {
1142+ .popup-button-ok {
1143+ font-weight: normal;
1144+ box-shadow: 0 0 0;
1145+ }
1146+ .qr--copy {
1147+ font-weight: bold;
1148+ box-shadow: 0 0 10px;
1149+ }
1150+ }
1151+ }
1152+}
public/scripts/popup.js+9 -0
@@ -526,6 +526,15 @@ export class Popup {
526526
527527 return this.#promise;
528528 }
529+ async completeAffirmative() {
530+ return await this.complete(POPUP_RESULT.AFFIRMATIVE);
531+ }
532+ async completeNegative() {
533+ return await this.complete(POPUP_RESULT.NEGATIVE);
534+ }
535+ async completeCancelled() {
536+ return await this.complete(POPUP_RESULT.CANCELLED);
537+ }
529538
530539 /**
531540 * Hides the popup, using the internal resolver to return the value to the original show promise
public/scripts/power-user.js+13 -1
@@ -45,7 +45,7 @@ import { FILTER_TYPES } from './filters.js';
4545import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
4646import { SlashCommand } from './slash-commands/SlashCommand.js';
4747import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
4848import { AUTOCOMPLETE_SELECT_KEY, AUTOCOMPLETE_WIDTH } from './autocomplete/AutoComplete.js';
4949import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
5050import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
5151import { POPUP_TYPE, callGenericPopup } from './popup.js';
@@ -277,6 +277,7 @@ let power_user = {
277277 left: AUTOCOMPLETE_WIDTH.CHAT,
278278 right: AUTOCOMPLETE_WIDTH.CHAT,
279279 },
280+ select: AUTOCOMPLETE_SELECT_KEY.TAB + AUTOCOMPLETE_SELECT_KEY.ENTER,
280281 },
281282 parser: {
282283 /**@type {Object.<PARSER_FLAG,boolean>} */
@@ -1494,6 +1495,9 @@ function loadPowerUserSettings(settings, data) {
14941495 if (power_user.stscript.autocomplete.style === undefined) {
14951496 power_user.stscript.autocomplete.style = power_user.stscript.autocomplete_style || defaultStscript.autocomplete.style;
14961497 }
1498+ if (power_user.stscript.autocomplete.select === undefined) {
1499+ power_user.stscript.autocomplete.select = defaultStscript.autocomplete.select;
1500+ }
14971501 }
14981502 if (power_user.stscript.parser === undefined) {
14991503 power_user.stscript.parser = defaultStscript.parser;
@@ -1658,6 +1662,7 @@ function loadPowerUserSettings(settings, data) {
16581662 $('#stscript_matching').val(power_user.stscript.matching ?? 'fuzzy');
16591663 $('#stscript_autocomplete_style').val(power_user.stscript.autocomplete.style ?? 'theme');
16601664 document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete.style);
1665+ $('#stscript_autocomplete_select').val(power_user.stscript.autocomplete.select ?? (AUTOCOMPLETE_SELECT_KEY.TAB + AUTOCOMPLETE_SELECT_KEY.ENTER));
16611666 $('#stscript_parser_flag_strict_escaping').prop('checked', power_user.stscript.parser.flags[PARSER_FLAG.STRICT_ESCAPING] ?? false);
16621667 $('#stscript_parser_flag_replace_getvar').prop('checked', power_user.stscript.parser.flags[PARSER_FLAG.REPLACE_GETVAR] ?? false);
16631668 $('#stscript_autocomplete_font_scale').val(power_user.stscript.autocomplete.font.scale ?? defaultStscript.autocomplete.font.scale);
@@ -3848,6 +3853,12 @@ $(document).ready(() => {
38483853 saveSettingsDebounced();
38493854 });
38503855
3856+ $('#stscript_autocomplete_select').on('change', function () {
3857+ const value = $(this).find(':selected').val();
3858+ power_user.stscript.autocomplete.select = parseInt(String(value));
3859+ saveSettingsDebounced();
3860+ });
3861+
38513862 $('#stscript_autocomplete_font_scale').on('input', function () {
38523863 const value = $(this).val();
38533864 $('#stscript_autocomplete_font_scale_counter').val(value);
@@ -4016,6 +4027,7 @@ $(document).ready(() => {
40164027 ),
40174028 ],
40184029 helpString: 'Enter message deletion mode, and auto-deletes last N messages if numeric argument is provided.',
4030+ returns: 'The text of the deleted messages.',
40194031 }));
40204032 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
40214033 name: 'cut',
public/scripts/slash-commands.js+90 -7
@@ -51,7 +51,7 @@ import { autoSelectPersona, retriggerFirstMessageOnEmptyChat, setPersonaLockStat
5151import { addEphemeralStoppingString, chat_styles, flushEphemeralStoppingStrings, power_user } from './power-user.js';
5252import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
5353import { decodeTextTokens, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync } from './tokenizers.js';
5454import { debounce, delay, isFalseBoolean, isTrueBoolean, showFontAwesomePicker, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.js';
5555import { registerVariableCommands, resolveVariable } from './variables.js';
5656import { background_settings } from './backgrounds.js';
5757import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
@@ -64,6 +64,9 @@ import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashComma
6464import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
6565import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
6666import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
67+import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
68+import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
69+import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
6770export {
6871 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
6972};
@@ -1120,10 +1123,10 @@ export function initDefaultSlashCommands() {
11201123 unnamedArgumentList: [
11211124 SlashCommandArgument.fromProps({
11221125 description: 'scoped variable or qr label',
11231126 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.CLOSURE],
11241127 isRequired: true,
11251128 enumProvider: (executor, scope) => [
11261129 ...commonEnumProviders.variables('scope')(executor, scope),
11271130 ...(typeof window['qrEnumProviderExecutables'] === 'function') ? window['qrEnumProviderExecutables']() : [],
11281131 ],
11291132 }),
@@ -1477,6 +1480,21 @@ export function initDefaultSlashCommands() {
14771480 ],
14781481 helpString: 'Sets the specified prompt manager entry/entries on or off.',
14791482 }));
1483+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'pick-icon',
1484+ callback: async()=>((await showFontAwesomePicker()) ?? false).toString(),
1485+ returns: 'The chosen icon name or false if cancelled.',
1486+ helpString: `
1487+ <div>Opens a popup with all the available Font Awesome icons and returns the selected icon's name.</div>
1488+ <div>
1489+ <strong>Example:</strong>
1490+ <ul>
1491+ <li>
1492+ <pre><code>/pick-icon |\n/if left={{pipe}} rule=eq right=false\n\telse={: /echo chosen icon: "{{pipe}}" :}\n\t{: /echo cancelled icon selection :}\n|</code></pre>
1493+ </li>
1494+ </ul>
1495+ </div>
1496+ `,
1497+ }));
14801498
14811499 registerVariableCommands();
14821500}
@@ -1821,6 +1839,11 @@ async function runCallback(args, name) {
18211839 throw new Error('No name provided for /run command');
18221840 }
18231841
1842+ if (name instanceof SlashCommandClosure) {
1843+ name.breakController = new SlashCommandBreakController();
1844+ return (await name.execute())?.pipe;
1845+ }
1846+
18241847 /**@type {SlashCommandScope} */
18251848 const scope = args._scope;
18261849 if (scope.existsVariable(name)) {
@@ -1829,6 +1852,11 @@ async function runCallback(args, name) {
18291852 throw new Error(`"${name}" is not callable.`);
18301853 }
18311854 closure.scope.parent = scope;
1855+ closure.breakController = new SlashCommandBreakController();
1856+ if (args._debugController && !closure.debugController) {
1857+ closure.debugController = args._debugController;
1858+ }
1859+ while (closure.providedArgumentList.pop());
18321860 closure.argumentList.forEach(arg => {
18331861 if (Object.keys(args).includes(arg.name)) {
18341862 const providedArg = new SlashCommandNamedArgumentAssignment();
@@ -1847,9 +1875,14 @@ async function runCallback(args, name) {
18471875
18481876 try {
18491877 name = name.trim();
1850- return await window['executeQuickReplyByName'](name, args);
1878+ /**@type {ExecuteSlashCommandsOptions} */
1879+ const options = {
1880+ abortController: args._abortController,
1881+ debugController: args._debugController,
1882+ };
1883+ return await window['executeQuickReplyByName'](name, args, options);
18511884 } catch (error) {
18521885 throw new Error(`Error running Quick Reply "${name}": ${error.message}`, 'Error');
18531886 }
18541887}
18551888
@@ -3428,7 +3461,9 @@ const clearCommandProgressDebounced = debounce(clearCommandProgress);
34283461 * @prop {boolean} [handleExecutionErrors] (false) Whether to handle execution errors (show toast on error) or throw
34293462 * @prop {{[id:PARSER_FLAG]:boolean}} [parserFlags] (null) Parser flags to apply
34303463 * @prop {SlashCommandAbortController} [abortController] (null) Controller used to abort or pause command execution
3464+ * @prop {SlashCommandDebugController} [debugController] (null) Controller used to control debug execution
34313465 * @prop {(done:number, total:number)=>void} [onProgress] (null) Callback to handle progress events
3466+ * @prop {string} [source] (null) String indicating where the code come from (e.g., QR name)
34323467 */
34333468
34343469/**
@@ -3436,6 +3471,7 @@ const clearCommandProgressDebounced = debounce(clearCommandProgress);
34363471 * @prop {SlashCommandScope} [scope] (null) The scope to be used when executing the commands.
34373472 * @prop {{[id:PARSER_FLAG]:boolean}} [parserFlags] (null) Parser flags to apply
34383473 * @prop {boolean} [clearChatInput] (false) Whether to clear the chat input textarea
3474+ * @prop {string} [source] (null) String indicating where the code come from (e.g., QR name)
34393475 */
34403476
34413477/**
@@ -3451,6 +3487,7 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) {
34513487 scope: null,
34523488 parserFlags: null,
34533489 clearChatInput: false,
3490+ source: null,
34543491 }, options);
34553492
34563493 isExecutingCommandsFromChatInput = true;
@@ -3473,13 +3510,21 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) {
34733510
34743511 /**@type {SlashCommandClosureResult} */
34753512 let result = null;
3513+ let currentProgress = 0;
34763514 try {
34773515 commandsFromChatInputAbortController = new SlashCommandAbortController();
34783516 result = await executeSlashCommandsWithOptions(text, {
34793517 abortController: commandsFromChatInputAbortController,
34803518 onProgress: (done, total) => ta.style.setProperty('--prog', `${done / total * 100}%`),
3519+ const newProgress = done / total;
3520+ if (newProgress > currentProgress) {
3521+ currentProgress = newProgress;
3522+ ta.style.setProperty('--prog', `${newProgress * 100}%`);
3523+ }
3524+ },
34813525 parserFlags: options.parserFlags,
34823526 scope: options.scope,
3527+ source: options.source,
34833528 });
34843529 if (commandsFromChatInputAbortController.signal.aborted) {
34853530 document.querySelector('#form_sheld').classList.add('script_aborted');
@@ -3492,8 +3537,24 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) {
34923537 result.isError = true;
34933538 result.errorMessage = e.message || 'An unknown error occurred';
34943539 if (e.cause !== 'abort') {
3540+ if (e instanceof SlashCommandExecutionError) {
3541+ /**@type {SlashCommandExecutionError}*/
3542+ const ex = e;
3543+ const toast = `
3544+ <div>${ex.message}</div>
3545+ <div>Line: ${ex.line} Column: ${ex.column}</div>
3546+ <pre style="text-align:left;">${ex.hint}</pre>
3547+ `;
3548+ const clickHint = '<p>Click to see details</p>';
3549+ toastr.error(
3550+ `${toast}${clickHint}`,
3551+ 'SlashCommandExecutionError',
3552+ { escapeHtml: false, timeOut: 10000, onclick: () => callPopup(toast, 'text') },
3553+ );
3554+ } else {
34953555 toastr.error(result.errorMessage);
34963556 }
3557+ }
34973558 } finally {
34983559 delay(1000).then(() => clearCommandProgressDebounced());
34993560
@@ -3520,7 +3581,9 @@ async function executeSlashCommandsWithOptions(text, options = {}) {
35203581 handleExecutionErrors: false,
35213582 parserFlags: null,
35223583 abortController: null,
3584+ debugController: null,
35233585 onProgress: null,
3586+ source: null,
35243587 }, options);
35253588
35263589 let closure;
@@ -3528,6 +3591,8 @@ async function executeSlashCommandsWithOptions(text, options = {}) {
35283591 closure = parser.parse(text, true, options.parserFlags, options.abortController ?? new SlashCommandAbortController());
35293592 closure.scope.parent = options.scope;
35303593 closure.onProgress = options.onProgress;
3594+ closure.debugController = options.debugController;
3595+ closure.source = options.source;
35313596 } catch (e) {
35323597 if (options.handleParserErrors && e instanceof SlashCommandParserError) {
35333598 /**@type {SlashCommandParserError}*/
@@ -3559,7 +3624,23 @@ async function executeSlashCommandsWithOptions(text, options = {}) {
35593624 return result;
35603625 } catch (e) {
35613626 if (options.handleExecutionErrors) {
3627+ if (e instanceof SlashCommandExecutionError) {
3628+ /**@type {SlashCommandExecutionError}*/
3629+ const ex = e;
3630+ const toast = `
3631+ <div>${ex.message}</div>
3632+ <div>Line: ${ex.line} Column: ${ex.column}</div>
3633+ <pre style="text-align:left;">${ex.hint}</pre>
3634+ `;
3635+ const clickHint = '<p>Click to see details</p>';
3636+ toastr.error(
3637+ `${toast}${clickHint}`,
3638+ 'SlashCommandExecutionError',
3639+ { escapeHtml: false, timeOut: 10000, onclick: () => callPopup(toast, 'text') },
3640+ );
3641+ } else {
35623642 toastr.error(e.message);
3643+ }
35633644 const result = new SlashCommandClosureResult();
35643645 result.isError = true;
35653646 result.errorMessage = e.message;
@@ -3596,6 +3677,7 @@ async function executeSlashCommands(text, handleParserErrors = true, scope = nul
35963677 *
35973678 * @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete
35983679 * @param {Boolean} isFloating Whether to show the auto complete as a floating window (e.g., large QR editor)
3680+ * @returns {Promise<AutoComplete>}
35993681 */
36003682export async function setSlashCommandAutoComplete(textarea, isFloating = false) {
36013683 function canUseNegativeLookbehind() {
@@ -3619,6 +3701,7 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false)
36193701 async (text, index) => await parser.getNameAt(text, index),
36203702 isFloating,
36213703 );
3704+ return ac;
36223705}
36233706/**@type {HTMLTextAreaElement} */
36243707const sendTextarea = document.querySelector('#send_textarea');
public/scripts/slash-commands/SlashCommand.js+46 -3
@@ -1,6 +1,7 @@
11import { SlashCommandAbortController } from './SlashCommandAbortController.js';
22import { SlashCommandArgument, SlashCommandNamedArgument } from './SlashCommandArgument.js';
33import { SlashCommandClosure } from './SlashCommandClosure.js';
4+import { SlashCommandDebugController } from './SlashCommandDebugController.js';
45import { PARSER_FLAG } from './SlashCommandParser.js';
56import { SlashCommandScope } from './SlashCommandScope.js';
67
@@ -12,6 +13,7 @@ import { SlashCommandScope } from './SlashCommandScope.js';
1213 * _scope:SlashCommandScope,
1314 * _parserFlags:{[id:PARSER_FLAG]:boolean},
1415 * _abortController:SlashCommandAbortController,
16+ * _debugController:SlashCommandDebugController,
1517 * _hasUnnamedArgument:boolean,
1618 * [id:string]:string|SlashCommandClosure,
1719 * }} NamedArguments
@@ -36,6 +38,7 @@ export class SlashCommand {
3638 * @param {(namedArguments:NamedArguments|NamedArgumentsCapture, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>} [props.callback]
3739 * @param {string} [props.helpString]
3840 * @param {boolean} [props.splitUnnamedArgument]
41+ * @param {Number} [props.splitUnnamedArgumentCount]
3942 * @param {string[]} [props.aliases]
4043 * @param {string} [props.returns]
4144 * @param {SlashCommandNamedArgument[]} [props.namedArgumentList]
@@ -50,9 +53,10 @@ export class SlashCommand {
5053
5154
5255 /**@type {string}*/ name;
5356 /**@type {(namedArguments:{_pipe:string|SlashCommandClosure, _scope:SlashCommandScope, _abortController:SlashCommandAbortController, [id:string]:string|SlashCommandClosure}, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
5457 /**@type {string}*/ helpString;
5558 /**@type {boolean}*/ splitUnnamedArgument = false;
59+ /**@type {Number}*/ splitUnnamedArgumentCount;
5660 /**@type {string[]}*/ aliases = [];
5761 /**@type {string}*/ returns;
5862 /**@type {SlashCommandNamedArgument[]}*/ namedArgumentList = [];
@@ -61,6 +65,10 @@ export class SlashCommand {
6165 /**@type {Object.<string, HTMLElement>}*/ helpCache = {};
6266 /**@type {Object.<string, DocumentFragment>}*/ helpDetailsCache = {};
6367
68+ /**@type {boolean}*/ isExtension = false;
69+ /**@type {boolean}*/ isThirdParty = false;
70+ /**@type {string}*/ source;
71+
6472 renderHelpItem(key = null) {
6573 key = key ?? this.name;
6674 if (!this.helpCache[key]) {
@@ -225,12 +233,35 @@ export class SlashCommand {
225233 const aliasList = [cmd.name, ...(cmd.aliases ?? [])].filter(it=>it != key);
226234 const specs = document.createElement('div'); {
227235 specs.classList.add('specs');
236+ const head = document.createElement('div'); {
237+ head.classList.add('head');
228238 const name = document.createElement('div'); {
229239 name.classList.add('name');
230240 name.classList.add('monospace');
231241 name.title = 'command name';
232242 name.textContent = `/${key}`;
233243 specs head.append(name);
244+ }
245+ const src = document.createElement('div'); {
246+ src.classList.add('source');
247+ src.classList.add('fa-solid');
248+ if (this.isExtension) {
249+ src.classList.add('isExtension');
250+ src.classList.add('fa-cubes');
251+ if (this.isThirdParty) src.classList.add('isThirdParty');
252+ else src.classList.add('isCore');
253+ } else {
254+ src.classList.add('isCore');
255+ src.classList.add('fa-star-of-life');
256+ }
257+ src.title = [
258+ this.isExtension ? 'Extension' : 'Core',
259+ this.isThirdParty ? 'Third Party' : (this.isExtension ? 'Core' : null),
260+ this.source,
261+ ].filter(it=>it).join('\n');
262+ head.append(src);
263+ }
264+ specs.append(head);
234265 }
235266 const body = document.createElement('div'); {
236267 body.classList.add('body');
@@ -303,6 +334,8 @@ export class SlashCommand {
303334 for (const arg of unnamedArguments) {
304335 const listItem = document.createElement('li'); {
305336 listItem.classList.add('argumentItem');
337+ const argSpec = document.createElement('div'); {
338+ argSpec.classList.add('argumentSpec');
306339 const argItem = document.createElement('div'); {
307340 argItem.classList.add('argument');
308341 argItem.classList.add('unnamedArgument');
@@ -336,7 +369,17 @@ export class SlashCommand {
336369 argItem.append(types);
337370 }
338371 }
339372 listItem argSpec.append(argItem);
373+ }
374+ if (arg.defaultValue !== null) {
375+ const argDefault = document.createElement('div'); {
376+ argDefault.classList.add('argument-default');
377+ argDefault.title = 'default value';
378+ argDefault.textContent = arg.defaultValue.toString();
379+ argSpec.append(argDefault);
380+ }
381+ }
382+ listItem.append(argSpec);
340383 }
341384 const desc = document.createElement('div'); {
342385 desc.classList.add('argument-description');
public/scripts/slash-commands/SlashCommandArgument.js+8 -5
@@ -2,6 +2,9 @@ import { SlashCommandClosure } from './SlashCommandClosure.js';
22import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
33import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
44import { SlashCommandExecutor } from './SlashCommandExecutor.js';
5+import { SlashCommandScope } from './SlashCommandScope.js';
6+
7+
58
69/**@readonly*/
710/**@enum {string}*/
@@ -27,7 +30,7 @@ export class SlashCommandArgument {
2730 * @param {boolean} [props.acceptsMultiple=false] default: false - whether argument accepts multiple values
2831 * @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided
2932 * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values
3033 * @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options
3134 * @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values
3235 */
3336 static fromProps(props) {
@@ -49,7 +52,7 @@ export class SlashCommandArgument {
4952 /**@type {boolean}*/ acceptsMultiple = false;
5053 /**@type {string|SlashCommandClosure}*/ defaultValue;
5154 /**@type {SlashCommandEnumValue[]}*/ enumList = [];
5255 /**@type {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]}*/ enumProvider = null;
5356 /**@type {boolean}*/ forceEnum = false;
5457
5558 /**
@@ -57,7 +60,7 @@ export class SlashCommandArgument {
5760 * @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
5861 * @param {string|SlashCommandClosure} defaultValue
5962 * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums
6063 * @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} enumProvider function that returns auto complete options
6164 */
6265 constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = false) {
6366 this.description = description;
@@ -89,7 +92,7 @@ export class SlashCommandNamedArgument extends SlashCommandArgument {
8992 * @param {boolean} [props.acceptsMultiple=false] default: false - whether argument accepts multiple values
9093 * @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided
9194 * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values
9295 * @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options
9396 * @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values
9497 */
9598 static fromProps(props) {
@@ -119,7 +122,7 @@ export class SlashCommandNamedArgument extends SlashCommandArgument {
119122 * @param {string|SlashCommandClosure} [defaultValue=null]
120123 * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [enums=[]]
121124 * @param {string[]} [aliases=[]]
122125 * @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [enumProvider=null] function that returns auto complete options
123126 * @param {boolean} [forceEnum=false]
124127 */
125128 constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = false) {
public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js+19 -9
@@ -8,15 +8,18 @@ import { SlashCommandCommandAutoCompleteOption } from './SlashCommandCommandAuto
88import { SlashCommandEnumAutoCompleteOption } from './SlashCommandEnumAutoCompleteOption.js';
99import { SlashCommandExecutor } from './SlashCommandExecutor.js';
1010import { SlashCommandNamedArgumentAutoCompleteOption } from './SlashCommandNamedArgumentAutoCompleteOption.js';
11+import { SlashCommandScope } from './SlashCommandScope.js';
1112
1213export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
1314 /**@type {SlashCommandExecutor}*/ executor;
15+ /**@type {SlashCommandScope}*/ scope;
1416
1517 /**
1618 * @param {SlashCommandExecutor} executor
19+ * @param {SlashCommandScope} scope
1720 * @param {Object.<string,SlashCommand>} commands
1821 */
1922 constructor(executor, scope, commands) {
2023 super(
2124 executor.name,
2225 executor.start,
@@ -29,6 +32,7 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
2932 ()=>'No slash commands found!',
3033 );
3134 this.executor = executor;
35+ this.scope = scope;
3236 }
3337
3438 getSecondaryNameAt(text, index, isSelect) {
@@ -86,8 +90,8 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
8690 }
8791 } else if (unamedArgLength > 0 && index >= this.executor.startUnnamedArgs && index <= this.executor.endUnnamedArgs) {
8892 // cursor is somewhere within the unnamed arguments
8993 //TODO if index is in first array item and that is a string, treat it as an unfinished named arg
9094 if (typeof this.executor.unnamedArgumentList[0]?.value == 'string') {
9195 if (index <= this.executor.startUnnamedArgs + this.executor.unnamedArgumentList[0].value.length) {
9296 name = this.executor.unnamedArgumentList[0].value.slice(0, index - this.executor.startUnnamedArgs);
9397 start = this.executor.startUnnamedArgs;
@@ -103,7 +107,7 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
103107
104108 if (name.includes('=') && cmdArg) {
105109 // if cursor is already behind "=" check for enums
106110 const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
107111 if (cmdArg && enumList?.length) {
108112 if (isSelect && enumList.find(it=>it.value == value) && argAssign && argAssign.end == index) {
109113 return null;
@@ -111,7 +115,7 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
111115 const result = new AutoCompleteSecondaryNameResult(
112116 value,
113117 start + name.length,
114118 enumList.map(it=>new SlashCommandEnumAutoCompleteOption.from(this.executor.command, it)),
115119 true,
116120 );
117121 result.isRequired = true;
@@ -150,7 +154,10 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
150154 if (idx > -1) {
151155 argAssign = this.executor.unnamedArgumentList[idx];
152156 cmdArg = this.executor.command.unnamedArgumentList[idx];
153- const enumList = cmdArg?.enumProvider?.(this.executor) ?? cmdArg?.enumList;
157+ if (cmdArg === undefined && this.executor.command.unnamedArgumentList.slice(-1)[0]?.acceptsMultiple) {
158+ cmdArg = this.executor.command.unnamedArgumentList.slice(-1)[0];
159+ }
160+ const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
154161 if (cmdArg && enumList.length > 0) {
155162 value = argAssign.value.toString().slice(0, index - argAssign.start);
156163 start = argAssign.start;
@@ -161,23 +168,26 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
161168 value = '';
162169 start = index;
163170 cmdArg = notProvidedArguments[0];
171+ if (cmdArg === undefined && this.executor.command.unnamedArgumentList.slice(-1)[0]?.acceptsMultiple) {
172+ cmdArg = this.executor.command.unnamedArgumentList.slice(-1)[0];
173+ }
164174 }
165175 } else {
166176 return null;
167177 }
168178
169179 const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
170180 if (cmdArg == null || enumList.length == 0) return null;
171181
172182 const result = new AutoCompleteSecondaryNameResult(
173183 value,
174184 start,
175185 enumList.map(it=>new SlashCommandEnumAutoCompleteOption.from(this.executor.command, it)),
176186 false,
177187 );
178188 const isCompleteValue = enumList.find(it=>it.value == value);
179189 const isSelectedValue = isSelect && isCompleteValue;
180190 result.isRequired = cmdArg.isRequired && !isSelectedValue && !isCompleteValue;
181191 result.forceMatch = cmdArg.forceEnum;
182192 return result;
183193 }
public/scripts/slash-commands/SlashCommandBreak.js+7 -0
@@ -0,0 +1,7 @@
1+import { SlashCommandExecutor } from './SlashCommandExecutor.js';
2+
3+export class SlashCommandBreak extends SlashCommandExecutor {
4+ get value() {
5+ return this.unnamedArgumentList[0]?.value;
6+ }
7+}
public/scripts/slash-commands/SlashCommandBreakController.js+7 -0
@@ -0,0 +1,7 @@
1+export class SlashCommandBreakController {
2+ /**@type {boolean} */ isBreak = false;
3+
4+ break() {
5+ this.isBreak = true;
6+ }
7+}
public/scripts/slash-commands/SlashCommandBreakPoint.js+3 -0
@@ -0,0 +1,3 @@
1+import { SlashCommandExecutor } from './SlashCommandExecutor.js';
2+
3+export class SlashCommandBreakPoint extends SlashCommandExecutor {}
public/scripts/slash-commands/SlashCommandClosure.js+249 -55
@@ -1,9 +1,13 @@
11import { substituteParams } from '../../script.js';
22import { delay, escapeRegex, uuidv4 } from '../utils.js';
33import { SlashCommand } from './SlashCommand.js';
44import { SlashCommandAbortController } from './SlashCommandAbortController.js';
55import { SlashCommandClosureExecutorSlashCommandBreak } from './SlashCommandClosureExecutorSlashCommandBreak.js';
6+import { SlashCommandBreakController } from './SlashCommandBreakController.js';
7+import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
68import { SlashCommandClosureResult } from './SlashCommandClosureResult.js';
9+import { SlashCommandDebugController } from './SlashCommandDebugController.js';
10+import { SlashCommandExecutionError } from './SlashCommandExecutionError.js';
711import { SlashCommandExecutor } from './SlashCommandExecutor.js';
812import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
913import { SlashCommandScope } from './SlashCommandScope.js';
@@ -17,8 +21,20 @@ export class SlashCommandClosure {
1721 /**@type {SlashCommandNamedArgumentAssignment[]}*/ providedArgumentList = [];
1822 /**@type {SlashCommandExecutor[]}*/ executorList = [];
1923 /**@type {SlashCommandAbortController}*/ abortController;
24+ /**@type {SlashCommandBreakController}*/ breakController;
25+ /**@type {SlashCommandDebugController}*/ debugController;
2026 /**@type {(done:number, total:number)=>void}*/ onProgress;
2127 /**@type {string}*/ rawText;
28+ /**@type {string}*/ fullText;
29+ /**@type {string}*/ parserContext;
30+ /**@type {string}*/ #source = uuidv4();
31+ get source() { return this.#source; }
32+ set source(value) {
33+ this.#source = value;
34+ for (const executor of this.executorList) {
35+ executor.source = value;
36+ }
37+ }
2238
2339 /**@type {number}*/
2440 get commandCount() {
@@ -30,7 +46,7 @@ export class SlashCommandClosure {
3046 }
3147
3248 toString() {
33- return '[Closure]';
49+ return `[Closure]${this.executeNow ? '()' : ''}`;
3450 }
3551
3652 /**
@@ -43,16 +59,37 @@ export class SlashCommandClosure {
4359 let isList = false;
4460 let listValues = [];
4561 scope = scope ?? this.scope;
46- const macros = scope.macroList.map(it=>escapeRegex(it.key)).join('|');
62+ const escapeMacro = (it, isAnchored = false)=>{
47- const re = new RegExp(`({{pipe}})|(?:{{var::([^\\s]+?)(?:::((?!}}).+))?}})|(?:{{(${macros})}})`);
63+ const regexText = escapeRegex(it.key.replace(/\*/g, '~~~WILDCARD~~~'))
64+ .replaceAll('~~~WILDCARD~~~', '(?:(?:(?!(?:::|}})).)*)')
65+ ;
66+ if (isAnchored) {
67+ return `^${regexText}$`;
68+ }
69+ return regexText;
70+ };
71+ const macroList = scope.macroList.toSorted((a,b)=>{
72+ if (a.key.includes('*') && !b.key.includes('*')) return 1;
73+ if (!a.key.includes('*') && b.key.includes('*')) return -1;
74+ if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*');
75+ return 0;
76+ });
77+ const macros = macroList.map(it=>escapeMacro(it)).join('|');
78+ const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`);
4879 let done = '';
4980 let remaining = text;
5081 while (re.test(remaining)) {
5182 const match = re.exec(remaining);
5283 const before = substituteParams(remaining.slice(0, match.index));
5384 const after = remaining.slice(match.index + match[0].length);
5485 const replacer = match[1].groups.pipe ? scope.pipe : match[2].groups.var ? scope.getVariable(match[2].groups.var, match[3].groups.index) : scope.macroList.find(it=>it.key == match[4].groups.macro || new RegExp(escapeMacro(it, true)).test(match.groups.macro))?.value;
5586 if (replacer instanceof SlashCommandClosure) {
87+ replacer.abortController = this.abortController;
88+ replacer.breakController = this.breakController;
89+ replacer.scope.parent = this.scope;
90+ if (this.debugController && !replacer.debugController) {
91+ replacer.debugController = this.debugController;
92+ }
5693 isList = true;
5794 if (match.index > 0) {
5895 listValues.push(before);
@@ -87,6 +124,12 @@ export class SlashCommandClosure {
87124 closure.providedArgumentList = this.providedArgumentList;
88125 closure.executorList = this.executorList;
89126 closure.abortController = this.abortController;
127+ closure.breakController = this.breakController;
128+ closure.debugController = this.debugController;
129+ closure.rawText = this.rawText;
130+ closure.fullText = this.fullText;
131+ closure.parserContext = this.parserContext;
132+ closure.source = this.source;
90133 closure.onProgress = this.onProgress;
91134 return closure;
92135 }
@@ -96,11 +139,22 @@ export class SlashCommandClosure {
96139 * @returns {Promise<SlashCommandClosureResult>}
97140 */
98141 async execute() {
142+ // execute a copy of the closure to no taint it and its scope with the effects of its execution
143+ // as this would affect the closure being called a second time (e.g., loop, multiple /run calls)
99144 const closure = this.getCopy();
100145 returnconst awaitgen = closure.executeDirect();
146+ let step;
147+ while (!step?.done) {
148+ step = await gen.next(this.debugController?.testStepping(this) ?? false);
149+ if (!(step.value instanceof SlashCommandClosureResult) && this.debugController) {
150+ this.debugController.isStepping = await this.debugController.awaitBreakPoint(step.value.closure, step.value.executor);
151+ }
152+ }
153+ return step.value;
101154 }
102155
103156 async * executeDirect() {
157+ this.debugController?.down(this);
104158 // closure arguments
105159 for (const arg of this.argumentList) {
106160 let v = arg.value;
@@ -108,6 +162,7 @@ export class SlashCommandClosure {
108162 /**@type {SlashCommandClosure}*/
109163 const closure = v;
110164 closure.scope.parent = this.scope;
165+ closure.breakController = this.breakController;
111166 if (closure.executeNow) {
112167 v = (await closure.execute())?.pipe;
113168 } else {
@@ -131,6 +186,7 @@ export class SlashCommandClosure {
131186 /**@type {SlashCommandClosure}*/
132187 const closure = v;
133188 closure.scope.parent = this.scope;
189+ closure.breakController = this.breakController;
134190 if (closure.executeNow) {
135191 v = (await closure.execute())?.pipe;
136192 } else {
@@ -149,34 +205,190 @@ export class SlashCommandClosure {
149205 this.scope.setVariable(arg.name, v);
150206 }
151207
152- let done = 0;
153208 if (this.executorList.length == 0) {
154209 this.scope.pipe = '';
155210 }
211+ const stepper = this.executeStep();
212+ let step;
213+ while (!step?.done && !this.breakController?.isBreak) {
214+ // get executor before execution
215+ step = await stepper.next();
216+ if (step.value instanceof SlashCommandBreakPoint) {
217+ console.log('encountered SlashCommandBreakPoint');
218+ if (this.debugController) {
219+ // resolve args
220+ step = await stepper.next();
221+ // "execute" breakpoint
222+ step = await stepper.next();
223+ // get next executor
224+ step = await stepper.next();
225+ // breakpoint has to yield before arguments are resolved if one of the
226+ // arguments is an immediate closure, otherwise you cannot step into the
227+ // immediate closure
228+ const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
229+ const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
230+ if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) {
231+ this.debugController.isStepping = yield { closure:this, executor:step.value };
232+ } else {
233+ this.debugController.isStepping = true;
234+ this.debugController.stepStack[this.debugController.stepStack.length - 1] = true;
235+ }
236+ }
237+ } else if (!step.done && this.debugController?.testStepping(this)) {
238+ this.debugController.isSteppingInto = false;
239+ // if stepping, have to yield before arguments are resolved if one of the arguments
240+ // is an immediate closure, otherwise you cannot step into the immediate closure
241+ const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
242+ const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
243+ if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) {
244+ this.debugController.isStepping = yield { closure:this, executor:step.value };
245+ }
246+ }
247+ // resolve args
248+ step = await stepper.next();
249+ if (step.value instanceof SlashCommandBreak) {
250+ console.log('encountered SlashCommandBreak');
251+ if (this.breakController) {
252+ this.breakController?.break();
253+ break;
254+ }
255+ } else if (!step.done && this.debugController?.testStepping(this)) {
256+ this.debugController.isSteppingInto = false;
257+ this.debugController.isStepping = yield { closure:this, executor:step.value };
258+ }
259+ // execute executor
260+ step = await stepper.next();
261+ }
262+
263+ // if execution has returned a closure result, return that (should only happen on abort)
264+ if (step.value instanceof SlashCommandClosureResult) {
265+ this.debugController?.up();
266+ return step.value;
267+ }
268+ /**@type {SlashCommandClosureResult} */
269+ const result = Object.assign(new SlashCommandClosureResult(), { pipe: this.scope.pipe, isBreak: this.breakController?.isBreak ?? false });
270+ this.debugController?.up();
271+ return result;
272+ }
273+ /**
274+ * Generator that steps through the executor list.
275+ * Every executor is split into three steps:
276+ * - before arguments are resolved
277+ * - after arguments are resolved
278+ * - after execution
279+ */
280+ async * executeStep() {
281+ let done = 0;
282+ let isFirst = true;
156283 for (const executor of this.executorList) {
157284 this.onProgress?.(done, this.commandCount);
158285 if (executor instanceof SlashCommandClosureExecutorthis.debugController) {
159286 const closure = this.scopedebugController.getVariablesetExecutor(executor.name);
160- if (!closure || !(closure instanceof SlashCommandClosure)) throw new Error(`${executor.name} is not a closure.`);
287+ this.debugController.namedArguments = undefined;
161288 closurethis.scopedebugController.parentunnamedArguments = this.scopeundefined;
162- closure.providedArgumentList = executor.providedArgumentList;
289+ }
163- const result = await closure.execute();
290+ // yield before doing anything with this executor, the debugger might want to do
164- this.scope.pipe = result.pipe;
291+ // something with it (e.g., breakpoint, immediate closures that need resolving
165- } else {
292+ // or stepping into)
293+ yield executor;
166294 /**@type {import('./SlashCommand.js').NamedArguments} */
295+ // @ts-ignore
167296 let args = {
168297 _scope: this.scope,
169298 _parserFlags: executor.parserFlags,
170299 _abortController: this.abortController,
300+ _debugController: this.debugController,
171301 _hasUnnamedArgument: executor.unnamedArgumentList.length > 0,
172302 };
173- let value;
303+ if (executor instanceof SlashCommandBreakPoint) {
304+ // nothing to do for breakpoints, just raise counter and yield for "before exec"
305+ done++;
306+ yield executor;
307+ isFirst = false;
308+ } else if (executor instanceof SlashCommandBreak) {
309+ // /break need to resolve the unnamed arg and put it into pipe, then yield
310+ // for "before exec"
311+ const value = await this.substituteUnnamedArgument(executor, isFirst, args);
312+ done += this.executorList.length - this.executorList.indexOf(executor);
313+ this.scope.pipe = value ?? this.scope.pipe;
314+ yield executor;
315+ isFirst = false;
316+ } else {
317+ // regular commands do all the argument resolving logic...
318+ await this.substituteNamedArguments(executor, args);
319+ let value = await this.substituteUnnamedArgument(executor, isFirst, args);
320+
321+ let abortResult = await this.testAbortController();
322+ if (abortResult) {
323+ return abortResult;
324+ }
325+ if (this.debugController) {
326+ this.debugController.namedArguments = args;
327+ this.debugController.unnamedArguments = value ?? '';
328+ }
329+ // then yield for "before exec"
330+ yield executor;
331+ // followed by command execution
332+ executor.onProgress = (subDone, subTotal)=>this.onProgress?.(done + subDone, this.commandCount);
333+ const isStepping = this.debugController?.testStepping(this);
334+ if (this.debugController) {
335+ this.debugController.isStepping = false || this.debugController.isSteppingInto;
336+ }
337+ try {
338+ this.scope.pipe = await executor.command.callback(args, value ?? '');
339+ } catch (ex) {
340+ throw new SlashCommandExecutionError(ex, ex.message, executor.name, executor.start, executor.end, this.fullText.slice(executor.start, executor.end), this.fullText);
341+ }
342+ if (this.debugController) {
343+ this.debugController.namedArguments = undefined;
344+ this.debugController.unnamedArguments = undefined;
345+ this.debugController.isStepping = isStepping;
346+ }
347+ this.#lintPipe(executor.command);
348+ done += executor.commandCount;
349+ this.onProgress?.(done, this.commandCount);
350+ abortResult = await this.testAbortController();
351+ if (abortResult) {
352+ return abortResult;
353+ }
354+ }
355+ // finally, yield for "after exec"
356+ yield executor;
357+ isFirst = false;
358+ }
359+ }
360+
361+ async testPaused() {
362+ while (!this.abortController?.signal?.aborted && this.abortController?.signal?.paused) {
363+ await delay(200);
364+ }
365+ }
366+ async testAbortController() {
367+ await this.testPaused();
368+ if (this.abortController?.signal?.aborted) {
369+ const result = new SlashCommandClosureResult();
370+ result.isAborted = true;
371+ result.isQuietlyAborted = this.abortController.signal.isQuiet;
372+ result.abortReason = this.abortController.signal.reason.toString();
373+ return result;
374+ }
375+ }
376+
377+ /**
378+ * @param {SlashCommandExecutor} executor
379+ * @param {import('./SlashCommand.js').NamedArguments} args
380+ */
381+ async substituteNamedArguments(executor, args) {
174382 // substitute named arguments
175383 for (const arg of executor.namedArgumentList) {
176384 if (arg.value instanceof SlashCommandClosure) {
177385 /**@type {SlashCommandClosure}*/
178386 const closure = arg.value;
179387 closure.scope.parent = this.scope;
388+ closure.breakController = this.breakController;
389+ if (this.debugController && !closure.debugController) {
390+ closure.debugController = this.debugController;
391+ }
180392 if (closure.executeNow) {
181393 args[arg.name] = (await closure.execute())?.pipe;
182394 } else {
@@ -193,10 +405,19 @@ export class SlashCommandClosure {
193405 ;
194406 }
195407 }
408+ }
196409
410+ /**
411+ * @param {SlashCommandExecutor} executor
412+ * @param {boolean} isFirst
413+ * @param {import('./SlashCommand.js').NamedArguments} args
414+ * @returns {Promise<string|SlashCommandClosure|(string|SlashCommandClosure)[]>}
415+ */
416+ async substituteUnnamedArgument(executor, isFirst, args) {
417+ let value;
197418 // substitute unnamed argument
198419 if (executor.unnamedArgumentList.length == 0) {
199420 if (!isFirst && executor.injectPipe) {
200421 value = this.scope.pipe;
201422 args._hasUnnamedArgument = this.scope.pipe !== null && this.scope.pipe !== undefined;
202423 }
@@ -208,6 +429,10 @@ export class SlashCommandClosure {
208429 /**@type {SlashCommandClosure}*/
209430 const closure = v;
210431 closure.scope.parent = this.scope;
432+ closure.breakController = this.breakController;
433+ if (this.debugController && !closure.debugController) {
434+ closure.debugController = this.debugController;
435+ }
211436 if (closure.executeNow) {
212437 v = (await closure.execute())?.pipe;
213438 } else {
@@ -242,41 +467,7 @@ export class SlashCommandClosure {
242467 return v;
243468 });
244469 }
245-
470+ return value;
246- let abortResult = await this.testAbortController();
247- if (abortResult) {
248- return abortResult;
249- }
250- executor.onProgress = (subDone, subTotal)=>this.onProgress?.(done + subDone, this.commandCount);
251- this.scope.pipe = await executor.command.callback(args, value ?? '');
252- this.#lintPipe(executor.command);
253- done += executor.commandCount;
254- this.onProgress?.(done, this.commandCount);
255- abortResult = await this.testAbortController();
256- if (abortResult) {
257- return abortResult;
258- }
259- }
260- }
261- /**@type {SlashCommandClosureResult} */
262- const result = Object.assign(new SlashCommandClosureResult(), { pipe: this.scope.pipe });
263- return result;
264- }
265-
266- async testPaused() {
267- while (!this.abortController?.signal?.aborted && this.abortController?.signal?.paused) {
268- await delay(200);
269- }
270- }
271- async testAbortController() {
272- await this.testPaused();
273- if (this.abortController?.signal?.aborted) {
274- const result = new SlashCommandClosureResult();
275- result.isAborted = true;
276- result.isQuietlyAborted = this.abortController.signal.isQuiet;
277- result.abortReason = this.abortController.signal.reason.toString();
278- return result;
279- }
280471 }
281472
282473 /**
@@ -285,8 +476,11 @@ export class SlashCommandClosure {
285476 */
286477 #lintPipe(command) {
287478 if (this.scope.pipe === undefined || this.scope.pipe === null) {
288479 console.warn(`/${command.name} returned undefined or null. Auto-fixing to empty string.`);
289480 this.scope.pipe = '';
481+ } else if (!(typeof this.scope.pipe == 'string' || this.scope.pipe instanceof SlashCommandClosure)) {
482+ console.warn(`/${command.name} returned illegal type (${typeof this.scope.pipe} - ${this.scope.pipe.constructor?.name ?? ''}). Auto-fixing to stringified JSON.`);
483+ this.scope.pipe = JSON.stringify(this.scope.pipe) ?? '';
290484 }
291485 }
292486}
public/scripts/slash-commands/SlashCommandClosureExecutor.js+0 -7
@@ -1,7 +0,0 @@
1-import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
2-
3-export class SlashCommandClosureExecutor {
4- /**@type {String}*/ name = '';
5- // @ts-ignore
6- /**@type {SlashCommandNamedArgumentAssignment[]}*/ providedArgumentList = [];
7-}
public/scripts/slash-commands/SlashCommandClosureResult.js+1 -0
@@ -1,6 +1,7 @@
11export class SlashCommandClosureResult {
22 /**@type {boolean}*/ interrupt = false;
33 /**@type {string}*/ pipe;
4+ /**@type {boolean}*/ isBreak = false;
45 /**@type {boolean}*/ isAborted = false;
56 /**@type {boolean}*/ isQuietlyAborted = false;
67 /**@type {string}*/ abortReason;
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+6 -5
@@ -6,6 +6,7 @@ import { searchCharByName, getTagsList, tags } from '../tags.js';
66import { world_names } from '../world-info.js';
77import { SlashCommandClosure } from './SlashCommandClosure.js';
88import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js';
9+import { SlashCommandScope } from "./SlashCommandScope.js";
910
1011/**
1112 * A collection of regularly used enum icons
@@ -134,16 +135,16 @@ export const commonEnumProviders = {
134135 * Can be filtered by `type` to only show global or local variables
135136 *
136137 * @param {...('global'|'local'|'scope'|'all')} type - The type of variables to include in the array. Can be 'all', 'global', or 'local'.
137138 * @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
138139 */
139140 variables: (...type) => (executor, scope) => {
140141 const types = type.flat();
141142 const isAll = types.includes('all');
142143 return [
143144 ...isAll || types.includes('globalscope') ? Object.keys(extension_settings.variablesscope.global ?? [])allVariableNames.map(name => new SlashCommandEnumValue(name, null, enumTypes.macrovariable, enumIcons.globalVariablescopeVariable)) : [],
144145 ...isAll || types.includes('local') ? Object.keys(chat_metadata.variables ?? []).map(name => new SlashCommandEnumValue(name, null, enumTypes.name, enumIcons.localVariable)) : [],
145146 ...isAll || types.includes('scopeglobal') ? Object.keys(extension_settings.variables.global ?? []).map(name => new SlashCommandEnumValue(name, null, enumTypes.variablemacro, enumIcons.scopeVariableglobalVariable)) : [], // TODO: Add scoped variables here, Lenny
146- ];
147+ ].filter((item, idx, list)=>idx == list.findIndex(it=>it.value == item.value));
147148 },
148149
149150 /**
public/scripts/slash-commands/SlashCommandDebugController.js+83 -0
@@ -0,0 +1,83 @@
1+import { SlashCommandClosure } from './SlashCommandClosure.js';
2+import { SlashCommandExecutor } from './SlashCommandExecutor.js';
3+
4+export class SlashCommandDebugController {
5+ /**@type {SlashCommandClosure[]} */ stack = [];
6+ /**@type {SlashCommandExecutor[]} */ cmdStack = [];
7+ /**@type {boolean[]} */ stepStack = [];
8+ /**@type {boolean} */ isStepping = false;
9+ /**@type {boolean} */ isSteppingInto = false;
10+ /**@type {boolean} */ isSteppingOut = false;
11+
12+ /**@type {object} */ namedArguments;
13+ /**@type {string|SlashCommandClosure|(string|SlashCommandClosure)[]} */ unnamedArguments;
14+
15+ /**@type {Promise<boolean>} */ continuePromise;
16+ /**@type {(boolean)=>void} */ continueResolver;
17+
18+ /**@type {(closure:SlashCommandClosure, executor:SlashCommandExecutor)=>Promise<boolean>} */ onBreakPoint;
19+
20+
21+
22+
23+ testStepping(closure) {
24+ return this.stepStack[this.stack.indexOf(closure)];
25+ }
26+
27+
28+
29+
30+ down(closure) {
31+ this.stack.push(closure);
32+ if (this.stepStack.length < this.stack.length) {
33+ this.stepStack.push(this.isSteppingInto);
34+ }
35+ }
36+ up() {
37+ this.stack.pop();
38+ while (this.cmdStack.length > this.stack.length) this.cmdStack.pop();
39+ this.stepStack.pop();
40+ }
41+
42+ setExecutor(executor) {
43+ this.cmdStack[this.stack.length - 1] = executor;
44+ }
45+
46+
47+
48+ resume() {
49+ this.continueResolver?.(false);
50+ this.continuePromise = null;
51+ this.stepStack.forEach((_,idx)=>this.stepStack[idx] = false);
52+ }
53+ step() {
54+ this.stepStack.forEach((_,idx)=>this.stepStack[idx] = true);
55+ this.continueResolver?.(true);
56+ this.continuePromise = null;
57+ }
58+ stepInto() {
59+ this.isSteppingInto = true;
60+ this.stepStack.forEach((_,idx)=>this.stepStack[idx] = true);
61+ this.continueResolver?.(true);
62+ this.continuePromise = null;
63+ }
64+ stepOut() {
65+ this.isSteppingOut = true;
66+ this.stepStack[this.stepStack.length - 1] = false;
67+ this.continueResolver?.(false);
68+ this.continuePromise = null;
69+ }
70+
71+ async awaitContinue() {
72+ this.continuePromise ??= new Promise(resolve=>{
73+ this.continueResolver = resolve;
74+ });
75+ this.isStepping = await this.continuePromise;
76+ return this.isStepping;
77+ }
78+
79+ async awaitBreakPoint(closure, executor) {
80+ this.isStepping = await this.onBreakPoint(closure, executor);
81+ return this.isStepping;
82+ }
83+}
public/scripts/slash-commands/SlashCommandEnumAutoCompleteOption.js+12 -1
@@ -3,6 +3,17 @@ import { SlashCommand } from './SlashCommand.js';
33import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
44
55export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption {
6+ /**
7+ * @param {SlashCommand} cmd
8+ * @param {SlashCommandEnumValue} enumValue
9+ * @returns {SlashCommandEnumAutoCompleteOption}
10+ */
11+ static from(cmd, enumValue) {
12+ const mapped = this.valueToOptionMap.find(it=>enumValue instanceof it.value)?.option ?? this;
13+ return new mapped(cmd, enumValue);
14+ }
15+ /**@type {{value:(typeof SlashCommandEnumValue), option:(typeof SlashCommandEnumAutoCompleteOption)}[]} */
16+ static valueToOptionMap = [];
617 /**@type {SlashCommand}*/ cmd;
718 /**@type {SlashCommandEnumValue}*/ enumValue;
819
@@ -13,7 +24,7 @@ export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption {
1324 * @param {SlashCommandEnumValue} enumValue
1425 */
1526 constructor(cmd, enumValue) {
1627 super(enumValue.value, enumValue.typeIcon, enumValue.type, enumValue.matchProvider, enumValue.valueProvider, enumValue.makeSelectable);
1728 this.cmd = cmd;
1829 this.enumValue = enumValue;
1930 }
public/scripts/slash-commands/SlashCommandEnumValue.js+16 -4
@@ -1,3 +1,6 @@
1+import { SlashCommandExecutor } from './SlashCommandExecutor.js';
2+import { SlashCommandScope } from './SlashCommandScope.js';
3+
14
25/**
36 * @typedef {'enum' | 'command' | 'namedArgument' | 'variable' | 'qr' | 'macro' | 'number' | 'name'} EnumType
@@ -37,14 +40,17 @@ export const enumTypes = {
3740 getBasedOnIndex(index) {
3841 const keys = Object.keys(this);
3942 return this[keys[(index ?? 0) % keys.length]];
4043 },
4144};
4245
4346export class SlashCommandEnumValue {
4447 /**@type {string}*/ value;
4548 /**@type {string}*/ description;
4649 /**@type {EnumType}*/ type = 'enum';
4750 /**@type {string}*/ typeIcon = '◊';
51+ /**@type {(input:string)=>boolean}*/ matchProvider;
52+ /**@type {(input:string)=>string}*/ valueProvider;
53+ /**@type {boolean}*/ makeSelectable = false;
4854
4955 /**
5056 * A constructor for creating a SlashCommandEnumValue instance.
@@ -52,13 +58,19 @@ export class SlashCommandEnumValue {
5258 * @param {string} value - The value
5359 * @param {string?} description - Optional description, displayed in a second line
5460 * @param {EnumType?} type - type of the enum (defining its color)
5561 * @param {string?} typeIcon - The icon to display (Can be pulled from `enumIcons` for common ones)
62+ * @param {(input:string)=>boolean?} matchProvider - A custom function to match autocomplete input instead of startsWith/includes/fuzzy. Should only be used for generic options like "any number" or "any string". "input" is the part of the text that is getting auto completed.
63+ * @param {(input:string)=>string?} valueProvider - A function returning a value to be used in autocomplete instead of the enum value. "input" is the part of the text that is getting auto completed. By default, values with a valueProvider will not be selectable in the autocomplete (with tab/enter).
64+ * @param {boolean?} makeSelectable - Set to true to make the value selectable (through tab/enter) even though a valueProvider exists.
5665 */
5766 constructor(value, description = null, type = 'enum', typeIcon = '◊', matchProvider = null, valueProvider = null, makeSelectable = false) {
5867 this.value = value;
5968 this.description = description;
6069 this.type = type ?? 'enum';
6170 this.typeIcon = typeIcon;
71+ this.matchProvider = matchProvider;
72+ this.valueProvider = valueProvider;
73+ this.makeSelectable = makeSelectable;
6274 }
6375
6476 toString() {
public/scripts/slash-commands/SlashCommandExecutionError.js+60 -0
@@ -0,0 +1,60 @@
1+export class SlashCommandExecutionError extends Error {
2+ /**@type {string} */ commandName;
3+ /**@type {number} */ start;
4+ /**@type {number} */ end;
5+ /**@type {string} */ commandText;
6+
7+ /**@type {string} */ text;
8+ get index() { return this.start; }
9+
10+ get line() {
11+ return this.text.slice(0, this.index).replace(/[^\n]/g, '').length;
12+ }
13+ get column() {
14+ return this.text.slice(0, this.index).split('\n').pop().length;
15+ }
16+ get hint() {
17+ let lineOffset = this.line.toString().length;
18+ let lineStart = this.index;
19+ let start = this.index;
20+ let end = this.index;
21+ let offset = 0;
22+ let lineCount = 0;
23+ while (offset < 10000 && lineCount < 3 && start >= 0) {
24+ if (this.text[start] == '\n') lineCount++;
25+ if (lineCount == 0) lineStart--;
26+ offset++;
27+ start--;
28+ }
29+ if (this.text[start + 1] == '\n') start++;
30+ offset = 0;
31+ while (offset < 10000 && this.text[end] != '\n') {
32+ offset++;
33+ end++;
34+ }
35+ let hint = [];
36+ let lines = this.text.slice(start + 1, end - 1).split('\n');
37+ let lineNum = this.line - lines.length + 1;
38+ let tabOffset = 0;
39+ for (const line of lines) {
40+ const num = `${' '.repeat(lineOffset - lineNum.toString().length)}${lineNum}`;
41+ lineNum++;
42+ const untabbedLine = line.replace(/\t/g, ' '.repeat(4));
43+ tabOffset = untabbedLine.length - line.length;
44+ hint.push(`${num}: ${untabbedLine}`);
45+ }
46+ hint.push(`${' '.repeat(this.index - lineStart + lineOffset + 1 + tabOffset)}^^^^^`);
47+ return hint.join('\n');
48+ }
49+
50+
51+
52+ constructor(cause, message, commandName, start, end, commandText, fullText) {
53+ super(message, { cause });
54+ this.commandName = commandName;
55+ this.start = start;
56+ this.end = end;
57+ this.commandText = commandText;
58+ this.text = fullText;
59+ }
60+}
public/scripts/slash-commands/SlashCommandExecutor.js+12 -0
@@ -1,4 +1,5 @@
11// eslint-disable-next-line no-unused-vars
2+import { uuidv4 } from '../utils.js';
23import { SlashCommand } from './SlashCommand.js';
34// eslint-disable-next-line no-unused-vars
45import { SlashCommandClosure } from './SlashCommandClosure.js';
@@ -16,6 +17,17 @@ export class SlashCommandExecutor {
1617 /**@type {Number}*/ startUnnamedArgs;
1718 /**@type {Number}*/ endUnnamedArgs;
1819 /**@type {String}*/ name = '';
20+ /**@type {String}*/ #source = uuidv4();
21+ get source() { return this.#source; }
22+ set source(value) {
23+ this.#source = value;
24+ for (const arg of this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure)) {
25+ arg.value.source = value;
26+ }
27+ for (const arg of this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure)) {
28+ arg.value.source = value;
29+ }
30+ }
1931 /**@type {SlashCommand}*/ command;
2032 // @ts-ignore
2133 /**@type {SlashCommandNamedArgumentAssignment[]}*/ namedArgumentList = [];
public/scripts/slash-commands/SlashCommandParser.js+320 -25
@@ -17,7 +17,10 @@ import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNa
1717import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
1818import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
1919import { MacroAutoCompleteOption } from '../autocomplete/MacroAutoCompleteOption.js';
20+import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
21+import { SlashCommandDebugController } from './SlashCommandDebugController.js';
2022import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
23+import { SlashCommandBreak } from './SlashCommandBreak.js';
2124
2225/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
2326/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
@@ -53,7 +56,7 @@ export class SlashCommandParser {
5356 * @param {SlashCommand} command
5457 */
5558 static addCommandObject(command) {
5659 const reserved = ['/', '#', ':', 'parser-flag', 'breakpoint'];
5760 for (const start of reserved) {
5861 if (command.name.toLowerCase().startsWith(start) || (command.aliases ?? []).find(a=>a.toLowerCase().startsWith(start))) {
5962 throw new Error(`Illegal Name. Slash command name cannot begin with "${start}".`);
@@ -70,6 +73,18 @@ export class SlashCommandParser {
7073 console.trace('WARN: Duplicate slash command registered!', [command.name, ...command.aliases]);
7174 }
7275
76+ const stack = new Error().stack.split('\n').map(it=>it.trim());
77+ command.isExtension = stack.find(it=>it.includes('/scripts/extensions/')) != null;
78+ command.isThirdParty = stack.find(it=>it.includes('/scripts/extensions/third-party/')) != null;
79+ if (command.isThirdParty) {
80+ command.source = stack.find(it=>it.includes('/scripts/extensions/third-party/')).replace(/^.*?\/scripts\/extensions\/third-party\/([^/]+)\/.*$/, '$1');
81+ } else if (command.isExtension) {
82+ command.source = stack.find(it=>it.includes('/scripts/extensions/')).replace(/^.*?\/scripts\/extensions\/([^/]+)\/.*$/, '$1');
83+ } else {
84+ const idx = stack.findLastIndex(it=>it.includes('at SlashCommandParser.')) + 1;
85+ command.source = stack[idx].replace(/^.*?\/((?:scripts\/)?(?:[^/]+)\.js).*$/, '$1');
86+ }
87+
7388 this.commands[command.name] = command;
7489
7590 if (Array.isArray(command.aliases)) {
@@ -89,6 +104,7 @@ export class SlashCommandParser {
89104 /**@type {string}*/ text;
90105 /**@type {number}*/ index;
91106 /**@type {SlashCommandAbortController}*/ abortController;
107+ /**@type {SlashCommandDebugController}*/ debugController;
92108 /**@type {SlashCommandScope}*/ scope;
93109 /**@type {SlashCommandClosure}*/ closure;
94110
@@ -101,6 +117,8 @@ export class SlashCommandParser {
101117 /**@type {SlashCommandExecutor[]}*/ commandIndex;
102118 /**@type {SlashCommandScope[]}*/ scopeIndex;
103119
120+ /**@type {string}*/ parserContext;
121+
104122 get userIndex() { return this.index; }
105123
106124 get ahead() {
@@ -154,6 +172,21 @@ export class SlashCommandParser {
154172 helpString: 'Write a comment.',
155173 }));
156174 }
175+ if (!Object.keys(this.commands).includes('breakpoint')) {
176+ SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'breakpoint',
177+ helpString: 'Set a breakpoint for debugging in the QR Editor.',
178+ }));
179+ }
180+ if (!Object.keys(this.commands).includes('break')) {
181+ SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'break',
182+ helpString: 'Break out of a loop or closure executed through /run or /:',
183+ unnamedArgumentList: [
184+ SlashCommandArgument.fromProps({ description: 'value to pass down the pipe instead of the current pipe value',
185+ typeList: Object.values(ARGUMENT_TYPE),
186+ }),
187+ ],
188+ }));
189+ }
157190
158191 //TODO should not be re-registered from every instance
159192 this.registerLanguage();
@@ -191,13 +224,19 @@ export class SlashCommandParser {
191224
192225 function getQuotedRunRegex() {
193226 try {
194227 return new RegExp('(".+?(?<!\\\\)")|(\\S+?)(\\||$|\\s)');
195228 } catch {
196229 // fallback for browsers that don't support lookbehind
197230 return /(".+?")|(\S+?)(\||$|\s)/;
198231 }
199232 }
200233
234+ const BLOCK_COMMENT = {
235+ scope: 'comment',
236+ begin: /\/\*/,
237+ end: /\*\|/,
238+ contains: [],
239+ };
201240 const COMMENT = {
202241 scope: 'comment',
203242 begin: /\/[/#]/,
@@ -205,9 +244,29 @@ export class SlashCommandParser {
205244 contains: [],
206245 };
207246 const ABORT = {
208- scope: 'abort',
247+ begin: /\/(abort|breakpoint)/,
209248 beginbeginScope: /\/'abort/',
210249 end: /\||$|(?=:})/,
250+ excludeEnd: false,
251+ returnEnd: true,
252+ contains: [],
253+ };
254+ const IMPORT = {
255+ scope: 'command',
256+ begin: /\/(import)/,
257+ beginScope: 'keyword',
258+ end: /\||$|(?=:})/,
259+ excludeEnd: false,
260+ returnEnd: true,
261+ contains: [],
262+ };
263+ const BREAK = {
264+ scope: 'command',
265+ begin: /\/(break)/,
266+ beginScope: 'keyword',
267+ end: /\||$|(?=:})/,
268+ excludeEnd: false,
269+ returnEnd: true,
211270 contains: [],
212271 };
213272 const LET = {
@@ -218,26 +277,31 @@ export class SlashCommandParser {
218277 1: 'variable',
219278 },
220279 end: /\||$|:}/,
280+ excludeEnd: false,
281+ returnEnd: true,
221282 contains: [],
222283 };
223284 const SETVAR = {
224285 begin: /\/(setvar|setglobalvar)\s+/,
225286 beginScope: 'variable',
226287 end: /\||$|:}/,
227288 excludeEnd: truefalse,
289+ returnEnd: true,
228290 contains: [],
229291 };
230292 const GETVAR = {
231293 begin: /\/(getvar|getglobalvar)\s+/,
232294 beginScope: 'variable',
233295 end: /\||$|:}/,
234296 excludeEnd: truefalse,
297+ returnEnd: true,
235298 contains: [],
236299 };
237300 const RUN = {
238301 match: [
239302 /\/:/,
240303 getQuotedRunRegex(),
304+ /\||$|(?=:})/,
241305 ],
242306 className: {
243307 1: 'variable.language',
@@ -250,7 +314,8 @@ export class SlashCommandParser {
250314 begin: /\/\S+/,
251315 beginScope: 'title.function',
252316 end: /\||$|(?=:})/,
253317 excludeEnd: truefalse,
318+ returnEnd: true,
254319 contains: [], // defined later
255320 };
256321 const CLOSURE = {
@@ -271,6 +336,19 @@ export class SlashCommandParser {
271336 begin: /{{/,
272337 end: /}}/,
273338 };
339+ const PIPEBREAK = {
340+ beginScope: 'pipebreak',
341+ begin: /\|\|/,
342+ end: '',
343+ };
344+ const PIPE = {
345+ beginScope: 'pipe',
346+ begin: /\|/,
347+ end: '',
348+ };
349+ BLOCK_COMMENT.contains.push(
350+ BLOCK_COMMENT,
351+ );
274352 RUN.contains.push(
275353 hljs.BACKSLASH_ESCAPE,
276354 NAMED_ARG,
@@ -279,6 +357,22 @@ export class SlashCommandParser {
279357 MACRO,
280358 CLOSURE,
281359 );
360+ IMPORT.contains.push(
361+ hljs.BACKSLASH_ESCAPE,
362+ NAMED_ARG,
363+ NUMBER,
364+ MACRO,
365+ CLOSURE,
366+ hljs.QUOTE_STRING_MODE,
367+ );
368+ BREAK.contains.push(
369+ hljs.BACKSLASH_ESCAPE,
370+ NAMED_ARG,
371+ NUMBER,
372+ MACRO,
373+ CLOSURE,
374+ hljs.QUOTE_STRING_MODE,
375+ );
282376 LET.contains.push(
283377 hljs.BACKSLASH_ESCAPE,
284378 NAMED_ARG,
@@ -303,6 +397,14 @@ export class SlashCommandParser {
303397 MACRO,
304398 CLOSURE,
305399 );
400+ ABORT.contains.push(
401+ hljs.BACKSLASH_ESCAPE,
402+ NAMED_ARG,
403+ NUMBER,
404+ MACRO,
405+ CLOSURE,
406+ hljs.QUOTE_STRING_MODE,
407+ );
306408 COMMAND.contains.push(
307409 hljs.BACKSLASH_ESCAPE,
308410 NAMED_ARG,
@@ -313,8 +415,11 @@ export class SlashCommandParser {
313415 );
314416 CLOSURE.contains.push(
315417 hljs.BACKSLASH_ESCAPE,
418+ BLOCK_COMMENT,
316419 COMMENT,
317420 ABORT,
421+ IMPORT,
422+ BREAK,
318423 NAMED_ARG,
319424 NUMBER,
320425 MACRO,
@@ -325,20 +430,27 @@ export class SlashCommandParser {
325430 COMMAND,
326431 'self',
327432 hljs.QUOTE_STRING_MODE,
433+ PIPEBREAK,
434+ PIPE,
328435 );
329436 hljs.registerLanguage('stscript', ()=>({
330437 case_insensitive: false,
331438 keywords: ['|'],
332439 contains: [
333440 hljs.BACKSLASH_ESCAPE,
441+ BLOCK_COMMENT,
334442 COMMENT,
335443 ABORT,
444+ IMPORT,
445+ BREAK,
336446 RUN,
337447 LET,
338448 GETVAR,
339449 SETVAR,
340450 COMMAND,
341451 CLOSURE,
452+ PIPEBREAK,
453+ PIPE,
342454 ],
343455 }));
344456 }
@@ -415,7 +527,7 @@ export class SlashCommandParser {
415527 );
416528 return result;
417529 }
418530 const result = new SlashCommandAutoCompleteNameResult(executor, this.scopeIndex[this.commandIndex.indexOf(executor)], this.commands);
419531 return result;
420532 }
421533 return null;
@@ -515,11 +627,14 @@ export class SlashCommandParser {
515627 }
516628
517629 replaceGetvar(value) {
518630 return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (_match, cmd, name, idx) => {
519631 name = name.trim();
632+ const startIdx = this.index - value.length + idx;
633+ const endIdx = this.index - value.length + idx + match.length;
520634 // store pipe
521635 const pipeName = `_PARSER__PARSER_PIPE_${uuidv4()}`;
522636 const storePipe = new SlashCommandExecutor(nullstartIdx); {
637+ storePipe.end = endIdx;
523638 storePipe.command = this.commands['let'];
524639 storePipe.name = 'let';
525640 const nameAss = new SlashCommandUnnamedArgumentAssignment();
@@ -530,17 +645,19 @@ export class SlashCommandParser {
530645 this.closure.executorList.push(storePipe);
531646 }
532647 // getvar / getglobalvar
533648 const getvar = new SlashCommandExecutor(nullstartIdx); {
649+ getvar.end = endIdx;
534650 getvar.command = this.commands[cmd];
535651 getvar.name = 'cmd';
536652 const nameAss = new SlashCommandUnnamedArgumentAssignment();
537653 nameAss.value = name;
538654 getvar.unnamedArgumentList = [nameAss];
539655 this.closure.executorList.push(getvar);
540656 }
541657 // set to temp scoped var
542658 const varName = `_PARSER__PARSER_VAR_${uuidv4()}`;
543659 const setvar = new SlashCommandExecutor(nullstartIdx); {
660+ setvar.end = endIdx;
544661 setvar.command = this.commands['let'];
545662 setvar.name = 'let';
546663 const nameAss = new SlashCommandUnnamedArgumentAssignment();
@@ -551,7 +668,8 @@ export class SlashCommandParser {
551668 this.closure.executorList.push(setvar);
552669 }
553670 // return pipe
554671 const returnPipe = new SlashCommandExecutor(nullstartIdx); {
672+ returnPipe.end = endIdx;
555673 returnPipe.command = this.commands['return'];
556674 returnPipe.name = 'return';
557675 const varAss = new SlashCommandUnnamedArgumentAssignment();
@@ -564,12 +682,13 @@ export class SlashCommandParser {
564682 }
565683
566684
567685 parse(text, verifyCommandNames = true, flags = null, abortController = null, debugController = null) {
568686 this.verifyCommandNames = verifyCommandNames;
569687 for (const key of Object.keys(PARSER_FLAG)) {
570688 this.flags[PARSER_FLAG[key]] = flags?.[PARSER_FLAG[key]] ?? power_user.stscript.parser.flags[PARSER_FLAG[key]] ?? false;
571689 }
572690 this.abortController = abortController;
691+ this.debugController = debugController;
573692 this.text = text;
574693 this.index = 0;
575694 this.scope = null;
@@ -577,6 +696,7 @@ export class SlashCommandParser {
577696 this.commandIndex = [];
578697 this.scopeIndex = [];
579698 this.macroIndex = [];
699+ this.parserContext = uuidv4();
580700 const closure = this.parseClosure(true);
581701 return closure;
582702 }
@@ -604,8 +724,12 @@ export class SlashCommandParser {
604724 if (!isRoot) this.take(2); // discard opening {:
605725 const textStart = this.index;
606726 let closure = new SlashCommandClosure(this.scope);
727+ closure.parserContext = this.parserContext;
728+ closure.fullText = this.text;
607729 closure.abortController = this.abortController;
730+ closure.debugController = this.debugController;
608731 this.scope = closure.scope;
732+ const oldClosure = this.closure;
609733 this.closure = closure;
610734 this.discardWhitespace();
611735 while (this.testNamedArgument()) {
@@ -615,7 +739,9 @@ export class SlashCommandParser {
615739 this.discardWhitespace();
616740 }
617741 while (!this.testClosureEnd()) {
618742 if (this.testCommenttestBlockComment()) {
743+ this.parseBlockComment();
744+ } else if (this.testComment()) {
619745 this.parseComment();
620746 } else if (this.testParserFlag()) {
621747 this.parseParserFlag();
@@ -623,6 +749,14 @@ export class SlashCommandParser {
623749 const cmd = this.parseRunShorthand();
624750 closure.executorList.push(cmd);
625751 injectPipe = true;
752+ } else if (this.testBreakPoint()) {
753+ const bp = this.parseBreakPoint();
754+ if (this.debugController) {
755+ closure.executorList.push(bp);
756+ }
757+ } else if (this.testBreak()) {
758+ const b = this.parseBreak();
759+ closure.executorList.push(b);
626760 } else if (this.testCommand()) {
627761 const cmd = this.parseCommand();
628762 cmd.injectPipe = injectPipe;
@@ -651,14 +785,83 @@ export class SlashCommandParser {
651785 }
652786 closureIndexEntry.end = this.index - 1;
653787 this.scope = closure.scope.parent;
788+ this.closure = oldClosure ?? closure;
654789 return closure;
655790 }
656791
792+ testBreakPoint() {
793+ return this.testSymbol(/\/breakpoint\s*\|/);
794+ }
795+ parseBreakPoint() {
796+ const cmd = new SlashCommandBreakPoint();
797+ cmd.name = 'breakpoint';
798+ cmd.command = this.commands['breakpoint'];
799+ cmd.start = this.index + 1;
800+ this.take('/breakpoint'.length);
801+ cmd.end = this.index;
802+ this.commandIndex.push(cmd);
803+ this.scopeIndex.push(this.scope.getCopy());
804+ return cmd;
805+ }
806+
807+ testBreak() {
808+ return this.testSymbol(/\/break(\s|\||$)/);
809+ }
810+ parseBreak() {
811+ const cmd = new SlashCommandBreak();
812+ cmd.name = 'break';
813+ cmd.command = this.commands['break'];
814+ cmd.start = this.index + 1;
815+ this.take('/break'.length);
816+ this.discardWhitespace();
817+ if (this.testUnnamedArgument()) {
818+ cmd.unnamedArgumentList.push(...this.parseUnnamedArgument());
819+ }
820+ cmd.end = this.index;
821+ this.commandIndex.push(cmd);
822+ this.scopeIndex.push(this.scope.getCopy());
823+ return cmd;
824+ }
825+
826+ testBlockComment() {
827+ return this.testSymbol('/*');
828+ }
829+ testBlockCommentEnd() {
830+ if (!this.verifyCommandNames) {
831+ if (this.index >= this.text.length) return true;
832+ } else {
833+ if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed block comment at position ${this.userIndex}`, this.text, this.index);
834+ }
835+ return this.testSymbol('*|');
836+ }
837+ parseBlockComment() {
838+ const start = this.index + 1;
839+ const cmd = new SlashCommandExecutor(start);
840+ cmd.command = this.commands['*'];
841+ this.commandIndex.push(cmd);
842+ this.scopeIndex.push(this.scope.getCopy());
843+ this.take(); // discard "/"
844+ cmd.name = this.take(); //set "*" as name
845+ while (!this.testBlockCommentEnd()) {
846+ if (this.testBlockComment()) {
847+ this.parseBlockComment();
848+ }
849+ this.take();
850+ }
851+ this.take(2); // take closing "*|"
852+ cmd.end = this.index - 1;
853+ }
854+
657855 testComment() {
658856 return this.testSymbol(/\/[/#]/);
659857 }
660858 testCommentEnd() {
661859 returnif (!this.testCommandEnd(verifyCommandNames); {
860+ if (this.index >= this.text.length) return true;
861+ } else {
862+ if (this.endOfText) throw new SlashCommandParserError(`Unclosed comment at position ${this.userIndex}`, this.text, this.index);
863+ }
864+ return this.testSymbol('|');
662865 }
663866 parseComment() {
664867 const start = this.index + 1;
@@ -719,11 +922,13 @@ export class SlashCommandParser {
719922 else assignment.value = this.parseValue();
720923 cmd.unnamedArgumentList = [assignment];
721924 this.discardWhitespace();
925+ cmd.startNamedArgs = this.index;
722926 while (this.testNamedArgument()) {
723927 const arg = this.parseNamedArgument();
724928 cmd.namedArgumentList.push(arg);
725929 this.discardWhitespace();
726930 }
931+ cmd.endNamedArgs = this.index;
727932 this.discardWhitespace();
728933 // /run shorthand does not take unnamed arguments (the command name practically *is* the unnamed argument)
729934 if (this.testRunShorthandEnd()) {
@@ -761,10 +966,10 @@ export class SlashCommandParser {
761966 this.discardWhitespace();
762967 }
763968 this.discardWhitespace();
764- cmd.startUnnamedArgs = this.index;
969+ cmd.startUnnamedArgs = this.index - (/\s(\s*)$/s.exec(this.behind)?.[1]?.length ?? 0);
765970 cmd.endUnnamedArgs = this.index;
766971 if (this.testUnnamedArgument()) {
767972 cmd.unnamedArgumentList = this.parseUnnamedArgument(cmd.command?.unnamedArgumentList?.length && cmd?.command?.splitUnnamedArgument, cmd?.command?.splitUnnamedArgumentCount);
768973 cmd.endUnnamedArgs = this.index;
769974 if (cmd.name == 'let') {
770975 const keyArg = cmd.namedArgumentList.find(it=>it.name == 'key');
@@ -773,6 +978,17 @@ export class SlashCommandParser {
773978 } else if (typeof cmd.unnamedArgumentList[0]?.value == 'string') {
774979 this.scope.variableNames.push(cmd.unnamedArgumentList[0].value);
775980 }
981+ } else if (cmd.name == 'import') {
982+ const value = /**@type {string[]}*/(cmd.unnamedArgumentList.map(it=>it.value));
983+ for (let i = 0; i < value.length; i++) {
984+ const srcName = value[i];
985+ let dstName = srcName;
986+ if (i + 2 < value.length && value[i + 1] == 'as') {
987+ dstName = value[i + 2];
988+ i += 2;
989+ }
990+ this.scope.variableNames.push(dstName);
991+ }
776992 }
777993 }
778994 if (this.testCommandEnd()) {
@@ -813,25 +1029,61 @@ export class SlashCommandParser {
8131029 testUnnamedArgumentEnd() {
8141030 return this.testCommandEnd();
8151031 }
8161032 parseUnnamedArgument(split, splitCount = null) {
1033+ const wasSplit = split;
8171034 /**@type {SlashCommandClosure|String}*/
8181035 let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one
8191036 let isList = split;
8201037 let listValues = [];
1038+ let listQuoted = []; // keep track of which listValues were quoted
8211039 /**@type {SlashCommandUnnamedArgumentAssignment}*/
8221040 let assignment = new SlashCommandUnnamedArgumentAssignment();
8231041 assignment.start = this.index;
1042+ if (!split && this.testQuotedValue()) {
1043+ // if the next bit is a quoted value, take the whole value and gather contents as a list
1044+ assignment.value = this.parseQuotedValue();
1045+ assignment.end = this.index;
1046+ isList = true;
1047+ listValues.push(assignment);
1048+ listQuoted.push(true);
1049+ assignment = new SlashCommandUnnamedArgumentAssignment();
1050+ assignment.start = this.index;
1051+ }
8241052 while (!this.testUnnamedArgumentEnd()) {
1053+ if (split && splitCount && listValues.length >= splitCount) {
1054+ // the split count has just been reached: stop splitting, the rest is one singular value
1055+ split = false;
1056+ if (this.testQuotedValue()) {
1057+ // if the next bit is a quoted value, take the whole value
1058+ assignment.value = this.parseQuotedValue();
1059+ assignment.end = this.index;
1060+ listValues.push(assignment);
1061+ listQuoted.push(true);
1062+ assignment = new SlashCommandUnnamedArgumentAssignment();
1063+ assignment.start = this.index;
1064+ }
1065+ }
8251066 if (this.testClosure()) {
8261067 isList = true;
8271068 if (value.length > 0) {
8281069 this.indexMacros(this.index - value.length, value);
8291070 assignment.value = value;
8301071 listValues.push(assignment);
1072+ listQuoted.push(false);
1073+ assignment = new SlashCommandUnnamedArgumentAssignment();
1074+ assignment.start = this.index;
1075+ if (!split && this.testQuotedValue()) {
1076+ // if where currently not splitting and the next bit is a quoted value, take the whole value
1077+ assignment.value = this.parseQuotedValue();
1078+ assignment.end = this.index;
1079+ listValues.push(assignment);
1080+ listQuoted.push(true);
8311081 assignment = new SlashCommandUnnamedArgumentAssignment();
8321082 assignment.start = this.index;
1083+ } else {
8331084 value = '';
8341085 }
1086+ }
8351087 assignment.start = this.index;
8361088 assignment.value = this.parseClosure();
8371089 assignment.end = this.index;
@@ -845,18 +1097,21 @@ export class SlashCommandParser {
8451097 assignment.value = this.parseQuotedValue();
8461098 assignment.end = this.index;
8471099 listValues.push(assignment);
1100+ listQuoted.push(true);
8481101 assignment = new SlashCommandUnnamedArgumentAssignment();
8491102 } else if (this.testListValue()) {
8501103 assignment.start = this.index;
8511104 assignment.value = this.parseListValue();
8521105 assignment.end = this.index;
8531106 listValues.push(assignment);
1107+ listQuoted.push(false);
8541108 assignment = new SlashCommandUnnamedArgumentAssignment();
8551109 } else if (this.testValue()) {
8561110 assignment.start = this.index;
8571111 assignment.value = this.parseValue();
8581112 assignment.end = this.index;
8591113 listValues.push(assignment);
1114+ listQuoted.push(false);
8601115 assignment = new SlashCommandUnnamedArgumentAssignment();
8611116 } else {
8621117 throw new SlashCommandParserError(`Unexpected end of unnamed argument at index ${this.userIndex}.`);
@@ -870,8 +1125,48 @@ export class SlashCommandParser {
8701125 if (isList && value.length > 0) {
8711126 assignment.value = value;
8721127 listValues.push(assignment);
1128+ listQuoted.push(false);
8731129 }
8741130 if (isList) {
1131+ const firstVal = listValues[0];
1132+ if (typeof firstVal?.value == 'string') {
1133+ if (!listQuoted[0]) {
1134+ // only trim the first part if it wasn't quoted
1135+ firstVal.value = firstVal.value.trimStart();
1136+ }
1137+ if (firstVal.value.length == 0) {
1138+ listValues.shift();
1139+ listQuoted.shift();
1140+ }
1141+ }
1142+ const lastVal = listValues.slice(-1)[0];
1143+ if (typeof lastVal?.value == 'string') {
1144+ if (!listQuoted.slice(-1)[0]) {
1145+ // only trim the last part if it wasn't quoted
1146+ lastVal.value = lastVal.value.trimEnd();
1147+ }
1148+ if (lastVal.value.length == 0) {
1149+ listValues.pop();
1150+ listQuoted.pop();
1151+ }
1152+ }
1153+ if (wasSplit && splitCount && splitCount + 1 < listValues.length) {
1154+ // if split with a split count and there are more values than expected
1155+ // -> should be result of quoting + additional (non-whitespace) text
1156+ // -> join the parts into one and restore quotes
1157+ const joined = new SlashCommandUnnamedArgumentAssignment();
1158+ joined.start = listValues[splitCount].start;
1159+ joined.end = listValues.slice(-1)[0].end;
1160+ joined.value = '';
1161+ for (let i = splitCount; i < listValues.length; i++) {
1162+ if (listQuoted[i]) joined.value += `"${listValues[i].value}"`;
1163+ else joined.value += listValues[i].value;
1164+ }
1165+ listValues = [
1166+ ...listValues.slice(0, splitCount),
1167+ joined,
1168+ ];
1169+ }
8751170 return listValues;
8761171 }
8771172 this.indexMacros(this.index - value.length, value);
public/scripts/slash-commands/SlashCommandScope.js+4 -2
@@ -38,9 +38,11 @@ export class SlashCommandScope {
3838 }
3939
4040
4141 setMacro(key, value, overwrite = true) {
42+ if (overwrite || !this.macroList.find(it=>it.key == key)) {
4243 this.macros[key] = value;
4344 }
45+ }
4446
4547
4648 existsVariableInScope(key) {
@@ -95,7 +97,7 @@ export class SlashCommandScope {
9597 return v ?? '';
9698 } else {
9799 const value = this.variables[key];
98100 return (value?.trim?.() === '' || isNaN(Number(value))) ? (value || '') : Number(value);
99101 }
100102 }
101103 if (this.parent) {
public/scripts/utils.js+73 -1
@@ -3,7 +3,7 @@ import { getRequestHeaders } from '../script.js';
33import { isMobile } from './RossAscends-mods.js';
44import { collapseNewlines } from './power-user.js';
55import { debounce_timeout } from './constants.js';
66import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
77
88/**
99 * Pagination status string template.
@@ -1929,3 +1929,75 @@ export function getFreeName(name, list, numberFormatter = (n) => ` #${n}`) {
19291929 }
19301930 return `${name}${numberFormatter(counter)}`;
19311931}
1932+
1933+export async function fetchFaFile(name) {
1934+ const style = document.createElement('style');
1935+ style.innerHTML = await (await fetch(`/css/${name}`)).text();
1936+ document.head.append(style);
1937+ const sheet = style.sheet;
1938+ style.remove();
1939+ return [...sheet.cssRules].filter(it=>it.style?.content).map(it=>it.selectorText.split('::').shift().slice(1));
1940+}
1941+export async function fetchFa() {
1942+ return [...new Set((await Promise.all([
1943+ fetchFaFile('fontawesome.min.css'),
1944+ ])).flat())];
1945+}
1946+/**
1947+ * Opens a popup with all the available Font Awesome icons and returns the selected icon's name.
1948+ * @prop {string[]} customList A custom list of Font Awesome icons to use instead of all available icons.
1949+ * @returns {Promise<string>} The icon name (fa-pencil) or null if cancelled.
1950+ */
1951+export async function showFontAwesomePicker(customList = null) {
1952+ const faList = customList ?? await fetchFa();
1953+ const fas = {};
1954+ const dom = document.createElement('div'); {
1955+ dom.classList.add('faPicker-container');
1956+ const search = document.createElement('div'); {
1957+ search.classList.add('faQuery-container');
1958+ const qry = document.createElement('input'); {
1959+ qry.classList.add('text_pole');
1960+ qry.classList.add('faQuery');
1961+ qry.type = 'search';
1962+ qry.placeholder = 'Filter icons';
1963+ qry.autofocus = true;
1964+ const qryDebounced = debounce(()=>{
1965+ const result = faList.filter(it=>it.includes(qry.value));
1966+ for (const fa of faList) {
1967+ if (!result.includes(fa)) {
1968+ fas[fa].classList.add('hidden');
1969+ } else {
1970+ fas[fa].classList.remove('hidden');
1971+ }
1972+ }
1973+ });
1974+ qry.addEventListener('input', () => qryDebounced());
1975+ search.append(qry);
1976+ }
1977+ dom.append(search);
1978+ }
1979+ const grid = document.createElement('div'); {
1980+ grid.classList.add('faPicker');
1981+ for (const fa of faList) {
1982+ const opt = document.createElement('div'); {
1983+ fas[fa] = opt;
1984+ opt.classList.add('menu_button');
1985+ opt.classList.add('fa-solid');
1986+ opt.classList.add(fa);
1987+ opt.title = fa.slice(3);
1988+ opt.dataset.result = POPUP_RESULT.AFFIRMATIVE.toString();
1989+ opt.addEventListener('click', () => value = fa);
1990+ grid.append(opt);
1991+ }
1992+ }
1993+ dom.append(grid);
1994+ }
1995+ }
1996+ let value = '';
1997+ const picker = new Popup(dom, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true, okButton: 'No Icon', cancelButton: 'Cancel' });
1998+ await picker.show();
1999+ if (picker.result == POPUP_RESULT.AFFIRMATIVE) {
2000+ return value;
2001+ }
2002+ return null;
2003+}
public/scripts/variables.js+91 -44
@@ -4,10 +4,11 @@ import { executeSlashCommandsWithOptions } from './slash-commands.js';
44import { SlashCommand } from './slash-commands/SlashCommand.js';
55import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
66import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
7+import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
78import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
89import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js';
910import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
1011import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
1112import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
1213import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
1314import { isFalseBoolean } from './utils.js';
@@ -40,7 +41,7 @@ function getLocalVariable(name, args = {}) {
4041 }
4142 }
4243
4344 return (localVariable?.trim?.() === '' || isNaN(Number(localVariable))) ? (localVariable || '') : Number(localVariable);
4445}
4546
4647function setLocalVariable(name, value, args = {}) {
@@ -93,7 +94,7 @@ function getGlobalVariable(name, args = {}) {
9394 }
9495 }
9596
9697 return (globalVariable?.trim?.() === '' || isNaN(Number(globalVariable))) ? (globalVariable || '') : Number(globalVariable);
9798}
9899
99100function setGlobalVariable(name, value, args = {}) {
@@ -348,11 +349,13 @@ async function whileCallback(args, value) {
348349
349350 if (result && command) {
350351 if (command instanceof SlashCommandClosure) {
352+ command.breakController = new SlashCommandBreakController();
351353 commandResult = await command.execute();
352354 } else {
353355 commandResult = await executeSubCommands(command, args._scope, args._parserFlags, args._abortController);
354356 }
355357 if (commandResult.isAborted) break;
358+ if (commandResult.isBreak) break;
356359 } else {
357360 break;
358361 }
@@ -390,8 +393,8 @@ async function timesCallback(args, value) {
390393 const iterations = Math.min(Number(repeats), isGuardOff ? Number.MAX_SAFE_INTEGER : MAX_LOOPS);
391394 let result;
392395 for (let i = 0; i < iterations; i++) {
393- /**@type {SlashCommandClosureResult}*/
394396 if (command instanceof SlashCommandClosure) {
397+ command.breakController = new SlashCommandBreakController();
395398 command.scope.setMacro('timesIndex', i);
396399 result = await command.execute();
397400 }
@@ -399,6 +402,7 @@ async function timesCallback(args, value) {
399402 result = await executeSubCommands(command.replace(/\{\{timesIndex\}\}/g, i.toString()), args._scope, args._parserFlags, args._abortController);
400403 }
401404 if (result.isAborted) break;
405+ if (result.isBreak) break;
402406 }
403407
404408 return result?.pipe ?? '';
@@ -461,7 +465,7 @@ function existsGlobalVariable(name) {
461465 * @param {object} args Command arguments
462466 * @returns {{a: string | number, b: string | number, rule: string}} Boolean operands
463467 */
464468export function parseBooleanOperands(args) {
465469 // Resolution order: numeric literal, local variable, global variable, string literal
466470 /**
467471 * @param {string} operand Boolean operand candidate
@@ -510,36 +514,15 @@ function parseBooleanOperands(args) {
510514 * @param {string|number} b The right operand
511515 * @returns {boolean} True if the rule yields true, false otherwise
512516 */
513517export function evalBoolean(rule, a, b) {
514518 if (!rule) {
515519 toastr.warning('The rule must be specified for the boolean comparison.', 'Invalid command');
516520 throw new Error('Invalid command.');
517521 }
518522
519523 let result = false;
520-
524+ if (typeof a === 'number' && typeof b === 'number') {
521- if (typeof a === 'string' && typeof b !== 'number') {
525+ // only do numeric comparison if both operands are numbers
522- const aString = String(a).toLowerCase();
523- const bString = String(b).toLowerCase();
524-
525- switch (rule) {
526- case 'in':
527- result = aString.includes(bString);
528- break;
529- case 'nin':
530- result = !aString.includes(bString);
531- break;
532- case 'eq':
533- result = aString === bString;
534- break;
535- case 'neq':
536- result = aString !== bString;
537- break;
538- default:
539- toastr.error('Unknown boolean comparison rule for type string.', 'Invalid /if command');
540- throw new Error('Invalid command.');
541- }
542- } else if (typeof a === 'number') {
543526 const aNumber = Number(a);
544527 const bNumber = Number(b);
545528
@@ -569,6 +552,38 @@ function evalBoolean(rule, a, b) {
569552 toastr.error('Unknown boolean comparison rule for type number.', 'Invalid command');
570553 throw new Error('Invalid command.');
571554 }
555+ } else {
556+ // otherwise do case-insensitive string comparsion, stringify non-strings
557+ let aString;
558+ let bString;
559+ if (typeof a == 'string') {
560+ aString = a.toLowerCase();
561+ } else {
562+ aString = JSON.stringify(a).toLowerCase();
563+ }
564+ if (typeof b == 'string') {
565+ bString = b.toLowerCase();
566+ } else {
567+ bString = JSON.stringify(b).toLowerCase();
568+ }
569+
570+ switch (rule) {
571+ case 'in':
572+ result = aString.includes(bString);
573+ break;
574+ case 'nin':
575+ result = !aString.includes(bString);
576+ break;
577+ case 'eq':
578+ result = aString === bString;
579+ break;
580+ case 'neq':
581+ result = aString !== bString;
582+ break;
583+ default:
584+ toastr.error('Unknown boolean comparison rule for type string.', 'Invalid /if command');
585+ throw new Error('Invalid command.');
586+ }
572587 }
573588
574589 return result;
@@ -783,24 +798,29 @@ function randValuesCallback(from, to, args) {
783798 * @returns The variable's value
784799 */
785800function letCallback(args, value) {
786801 if (!Array.isArray(value)) {value = [value];
787- args._scope.letVariable(value[0], typeof value[1] == 'string' ? value.slice(1).join(' ') : value[1]);
788- return value[1];
789- }
790802 if (args.key !== undefined) {
791803 const key = args.key;
792- const val = value;
804+ if (typeof key != 'string') throw new Error('Key must be a string');
805+ if (args._hasUnnamedArgument) {
806+ const val = typeof value[0] == 'string' ? value.join(' ') : value[0];
793807 args._scope.letVariable(key, val);
794808 return val;
809+ } else {
810+ args._scope.letVariable(key);
811+ return '';
812+ }
795813 }
796- if (value instanceof SlashCommandClosure) throw new Error('/let unnamed argument does not support closures if no key is provided');
814+ const key = value.shift();
797- if (value.includes(' ')) {
815+ if (typeof key != 'string') throw new Error('Key must be a string');
798- const key = value.split(' ')[0];
816+ if (value.length > 0) {
799817 const val = typeof value.split('[0] == ').slice(1)string' ? value.join(' ') : value[0];
800818 args._scope.letVariable(key, val);
801819 return val;
820+ } else {
821+ args._scope.letVariable(key);
822+ return '';
802823 }
803- args._scope.letVariable(value);
804824}
805825
806826/**
@@ -813,8 +833,9 @@ function varCallback(args, value) {
813833 if (!Array.isArray(value)) value = [value];
814834 if (args.key !== undefined) {
815835 const key = args.key;
836+ if (typeof key != 'string') throw new Error('Key must be a string');
816837 if (args._hasUnnamedArgument) {
817838 const val = typeof value[0] == 'string' ? value.join(' ') : value[0];
818839 args._scope.setVariable(key, val, args.index);
819840 return val;
820841 } else {
@@ -822,8 +843,9 @@ function varCallback(args, value) {
822843 }
823844 }
824845 const key = value.shift();
846+ if (typeof key != 'string') throw new Error('Key must be a string');
825847 if (value.length > 0) {
826848 const val = typeof value[0] == 'string' ? value.join(' ') : value[0];
827849 args._scope.setVariable(key, val, args.index);
828850 return val;
829851 } else {
@@ -1382,6 +1404,7 @@ export function registerVariableCommands() {
13821404 ),
13831405 ],
13841406 splitUnnamedArgument: true,
1407+ splitUnnamedArgumentCount: 1,
13851408 helpString: `
13861409 <div>
13871410 Execute any valid slash command enclosed in quotes <code>repeats</code> number of times.
@@ -1459,7 +1482,7 @@ export function registerVariableCommands() {
14591482 }));
14601483 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
14611484 name: 'add',
1462- callback: addValuesCallback,
1485+ callback: (args, /**@type {string[]}*/value) => addValuesCallback(args, value.join(' ')),
14631486 returns: 'sum of the provided values',
14641487 unnamedArgumentList: [
14651488 SlashCommandArgument.fromProps({
@@ -1467,10 +1490,32 @@ export function registerVariableCommands() {
14671490 typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME],
14681491 isRequired: true,
14691492 acceptsMultiple: true,
1470- enumProvider: commonEnumProviders.variables('all'),
1493+ enumProvider: (executor, scope)=>{
1494+ const vars = commonEnumProviders.variables('all')(executor, scope);
1495+ vars.push(
1496+ new SlashCommandEnumValue(
1497+ 'any variable name',
1498+ null,
1499+ enumTypes.variable,
1500+ enumIcons.variable,
1501+ (input)=>/^\w*$/.test(input),
1502+ (input)=>input,
1503+ ),
1504+ new SlashCommandEnumValue(
1505+ 'any number',
1506+ null,
1507+ enumTypes.number,
1508+ enumIcons.number,
1509+ (input)=>input == '' || !Number.isNaN(Number(input)),
1510+ (input)=>input,
1511+ ),
1512+ );
1513+ return vars;
1514+ },
14711515 forceEnum: false,
14721516 }),
14731517 ],
1518+ splitUnnamedArgument: true,
14741519 helpString: `
14751520 <div>
14761521 Performs an addition of the set of values and passes the result down the pipe.
@@ -2001,6 +2046,7 @@ export function registerVariableCommands() {
20012046 ),
20022047 ],
20032048 splitUnnamedArgument: true,
2049+ splitUnnamedArgumentCount: 1,
20042050 helpString: `
20052051 <div>
20062052 Get or set a variable.
@@ -2043,6 +2089,7 @@ export function registerVariableCommands() {
20432089 ),
20442090 ],
20452091 splitUnnamedArgument: true,
2092+ splitUnnamedArgumentCount: 1,
20462093 helpString: `
20472094 <div>
20482095 Declares a new variable in the current scope.
public/style.css+117 -6
@@ -1383,6 +1383,8 @@ body[data-stscript-style="dark"] {
13831383 --ac-style-color-matchedText: rgba(108 171 251 / 1);
13841384 --ac-style-color-selectedBackground: rgba(32 57 92 / 1);
13851385 --ac-style-color-selectedText: rgba(255 255 255 / 1);
1386+ --ac-style-color-notSelectableBackground: rgb(65, 78, 95);
1387+ --ac-style-color-notSelectableText: rgba(255 255 255 / 1);
13861388 --ac-style-color-hoveredBackground: rgba(43 45 46 / 1);
13871389 --ac-style-color-hoveredText: rgba(204 204 204 / 1);
13881390 --ac-style-color-argName: rgba(171 209 239 / 1);
@@ -1398,6 +1400,7 @@ body[data-stscript-style="dark"] {
13981400 --ac-style-color-punctuationL2: rgba(98 160 251 / 1);
13991401 --ac-style-color-currentParenthesis: rgba(195 118 210 / 1);
14001402 --ac-style-color-comment: rgba(122 151 90 / 1);
1403+ --ac-style-color-keyword: rgba(182 137 190 / 1);
14011404}
14021405
14031406body[data-stscript-style="light"] {
@@ -1408,6 +1411,8 @@ body[data-stscript-style="light"] {
14081411 --ac-style-color-matchedText: rgba(61 104 188 / 1);
14091412 --ac-style-color-selectedBackground: rgba(232 232 232 / 1);
14101413 --ac-style-color-selectedText: rgba(0 0 0 / 1);
1414+ --ac-style-color-notSelectableBackground: rgba(232 232 232 / 1);
1415+ --ac-style-color-notSelectableText: rgb(83, 83, 83);
14111416 --ac-style-color-hoveredBackground: rgba(242 242 242 / 1);
14121417 --ac-style-color-hoveredText: rgba(59 59 59 / 1);
14131418 --ac-style-color-argName: rgba(16 24 125 / 1);
@@ -1419,6 +1424,7 @@ body[data-stscript-style="light"] {
14191424 --ac-style-color-variable: rgba(16 24 125 / 1);
14201425 --ac-style-color-currentParenthesis: rgba(195 118 210 / 1);
14211426 --ac-style-color-comment: rgba(70 126 26 / 1);
1427+ --ac-style-color-keyword: rgba(182 137 190 / 1);
14221428}
14231429
14241430body[data-stscript-style="theme"] {
@@ -1429,6 +1435,8 @@ body[data-stscript-style="theme"] {
14291435 --ac-style-color-matchedText: var(--SmartThemeQuoteColor);
14301436 --ac-style-color-selectedBackground: color-mix(in srgb, rgb(128 128 128) 75%, var(--SmartThemeChatTintColor));
14311437 --ac-style-color-selectedText: var(--SmartThemeBodyColor);
1438+ --ac-style-color-notSelectableBackground: color-mix(in srgb, rgb(128 128 128) 50%, var(--SmartThemeChatTintColor));
1439+ --ac-style-color-notSelectableText: var(--SmartThemeBodyColor);
14321440 --ac-style-color-hoveredBackground: color-mix(in srgb, rgb(128 128 128) 30%, var(--SmartThemeChatTintColor));
14331441 --ac-style-color-hoveredText: var(--SmartThemeEmColor);
14341442 --ac-style-color-argName: rgba(171 209 239 / 1);
@@ -1439,6 +1447,7 @@ body[data-stscript-style="theme"] {
14391447 --ac-style-color-variable: rgba(131 193 252 / 1);
14401448 --ac-style-color-currentParenthesis: rgba(195 118 210 / 1);
14411449 --ac-style-color-comment: rgba(122 151 90 / 1);
1450+ --ac-style-color-keyword: rgba(182 137 190 / 1);
14421451}
14431452
14441453body {
@@ -1454,6 +1463,8 @@ body {
14541463 --ac-color-matchedText: var(--ac-style-color-matchedText, rgba(108 171 251 / 1));
14551464 --ac-color-selectedBackground: var(--ac-style-color-selectedBackground, rgba(32 57 92 / 1));
14561465 --ac-color-selectedText: var(--ac-style-color-selectedText, rgba(255 255 255 / 1));
1466+ --ac-color-notSelectableBackground: var(--ac-style-color-notSelectableBackground, rgb(60, 73, 92));
1467+ --ac-color-notSelectableText: var(--ac-style-color-notSelectableText, rgba(255 255 255 / 1));
14571468 --ac-color-hoveredBackground: var(--ac-style-color-hoveredBackground, rgba(43 45 46 / 1));
14581469 --ac-color-hoveredText: var(--ac-style-color-hoveredText, rgba(204 204 204 / 1));
14591470 --ac-color-argName: var(--ac-style-color-argName, rgba(171 209 239 / 1));
@@ -1469,6 +1480,7 @@ body {
14691480 --ac-color-punctuationL2: var(--ac-style-color-punctuationL2, rgba(98 160 251 / 1));
14701481 --ac-color-currentParenthesis: var(--ac-style-color-currentParenthesis, rgba(195 118 210 / 1));
14711482 --ac-color-comment: var(--ac-style-color-comment, rgba(122 151 90 / 1));
1483+ --ac-color-keyword: var(--ac-style-color-keyword, rgba(182 137 190 / 1));
14721484
14731485 font-size: calc(var(--ac-font-scale) * 1em);
14741486
@@ -1569,16 +1581,25 @@ body[data-stscript-style] .hljs.language-stscript {
15691581 color: var(--ac-style-color-punctuation);
15701582 }
15711583
1572- .hljs-keyword {
1573- color: var(--ac-style-color-variableLanguage);
1574- }
1575-
15761584 .hljs-comment {
15771585 color: var(--ac-style-color-comment);
15781586 }
15791587
15801588 .hljs-abort {
15811589 color: var(--ac-style-color-abort, #e38e23);
1590+ font-weight: bold;
1591+ }
1592+
1593+ .hljs-keyword {
1594+ color: var(--ac-style-color-keyword);
1595+ font-weight: bold;
1596+ }
1597+
1598+ .hljs-pipe {
1599+ color: var(--ac-style-color-punctuation);
1600+ }
1601+ .hljs-pipebreak {
1602+ color: var(--ac-style-color-type);
15821603 }
15831604
15841605 .hljs-closure {
@@ -1670,6 +1691,10 @@ body[data-stscript-style] .hljs.language-stscript {
16701691 background-color: var(--ac-color-selectedBackground);
16711692 color: var(--ac-color-selectedText);
16721693 }
1694+ &.selected.not-selectable>* {
1695+ background-color: var(--ac-color-notSelectableBackground);
1696+ color: var(--ac-color-notSelectableText);
1697+ }
16731698
16741699 >* {
16751700 height: 100%;
@@ -1747,7 +1772,12 @@ body[data-stscript-style] .hljs.language-stscript {
17471772 padding: 0.25em 0.25em 0.5em 0.25em;
17481773 border-bottom: 1px solid var(--ac-color-border);
17491774
17501775 > .namehead {
1776+ display: flex;
1777+ gap: 0.5em;
1778+ }
1779+ > .head > .name, > .name {
1780+ flex: 1 1 auto;
17511781 font-weight: bold;
17521782 color: var(--ac-color-text);
17531783 cursor: help;
@@ -1756,6 +1786,35 @@ body[data-stscript-style] .hljs.language-stscript {
17561786 text-decoration: 1px dotted underline;
17571787 }
17581788 }
1789+ > .head > .source {
1790+ padding: 0 0.5em;
1791+ cursor: help;
1792+ display: flex;
1793+ align-items: center;
1794+ gap: 0.5em;
1795+ &.isThirdParty.isExtension {
1796+ color: #F89406;
1797+ }
1798+ &.isCore {
1799+ color: transparent;
1800+ &.isExtension {
1801+ color: #51A351;
1802+ }
1803+ &:after {
1804+ content: '';
1805+ order: -1;
1806+ height: 14px;
1807+ aspect-ratio: 1 / 1;
1808+ background-image: url('/favicon.ico');
1809+ background-size: contain;
1810+ background-repeat: no-repeat;
1811+ }
1812+ }
1813+
1814+ &:hover {
1815+ text-decoration: 1px dotted underline;
1816+ }
1817+ }
17591818
17601819 >.body {
17611820 flex-direction: column;
@@ -1843,7 +1902,8 @@ body[data-stscript-style] .hljs.language-stscript {
18431902
18441903 >code {
18451904 display: block;
18461905 padding: 01px;
1906+ tab-size: 4;
18471907 }
18481908 }
18491909 }
@@ -3291,6 +3351,11 @@ grammarly-extension {
32913351 text-align: center;
32923352}
32933353
3354+.menu_button.fa-fw {
3355+ /* Font Awesome icons that are a menu button and should be fixed width need a slight fix. 1.25em is their default, but we need to account for button spacing. */
3356+ min-width: calc(1.25em + 12px);
3357+}
3358+
32943359.avatar_div .menu_button,
32953360.form_create_bottom_buttons_block .menu_button {
32963361 padding: 5px;
@@ -5300,3 +5365,49 @@ body:not(.movingUI) .drawer-content.maximized {
53005365.regex-highlight {
53015366 color: #FAF8F6;
53025367}
5368+
5369+.popup:has(.faPicker) {
5370+ /* Fix height for fa picker popup, otherwise search is making it resize weirdly */
5371+ height: 70%;
5372+ .popup-content {
5373+ display: flex;
5374+ flex-direction: column;
5375+ }
5376+}
5377+
5378+.faPicker-container {
5379+ display: flex;
5380+ flex-direction: column;
5381+ overflow: hidden;;
5382+}
5383+
5384+.faQuery-container {
5385+ flex: 0 0 auto;
5386+}
5387+
5388+.faPicker {
5389+ flex: 1 1 auto;
5390+ overflow: auto;
5391+ gap: 1em;
5392+ display: grid;
5393+ grid-template-columns: repeat(auto-fill, minmax(3.5em, 1fr));
5394+
5395+ .menu_button {
5396+ aspect-ratio: 1 / 1;
5397+ font-size: 2em;
5398+ height: 1lh;
5399+ line-height: 1.2;
5400+ padding: 0.25em;
5401+ width: unset;
5402+ box-sizing: content-box;
5403+ &.hidden {
5404+ display: none;
5405+ }
5406+ }
5407+}
5408+
5409+.faPicker:not(:has(:not(.hidden)))::after {
5410+ content: 'No icons found';
5411+ color: var(--SmartThemeBodyColor);
5412+ opacity: 0.7;
5413+}