@khanacademy/perseus-editor 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/es/index.js +11 -37
- package/dist/es/index.js.map +1 -1
- package/dist/index.js +9 -39
- package/dist/index.js.map +1 -1
- package/dist/widgets/expression-editor.d.ts +1 -5
- package/package.json +2 -2
- package/src/widgets/__tests__/expression-editor.test.tsx +347 -0
- package/src/widgets/expression-editor.tsx +34 -70
- package/tsconfig-build.tsbuildinfo +1 -1
|
@@ -11,13 +11,9 @@ type DefaultProps = {
|
|
|
11
11
|
buttonSets: Props["buttonSets"];
|
|
12
12
|
functions: Props["functions"];
|
|
13
13
|
};
|
|
14
|
-
|
|
15
|
-
isTex: boolean;
|
|
16
|
-
};
|
|
17
|
-
declare class ExpressionEditor extends React.Component<Props, State> {
|
|
14
|
+
declare class ExpressionEditor extends React.Component<Props> {
|
|
18
15
|
static widgetName: "expression";
|
|
19
16
|
static defaultProps: DefaultProps;
|
|
20
|
-
constructor(props: Props);
|
|
21
17
|
change(...args: any[]): any;
|
|
22
18
|
render(): React.ReactNode;
|
|
23
19
|
serialize: () => any;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Perseus editors",
|
|
4
4
|
"author": "Khan Academy",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.13.0",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
9
9
|
},
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@khanacademy/kas": "^0.3.6",
|
|
26
26
|
"@khanacademy/kmath": "^0.1.7",
|
|
27
|
-
"@khanacademy/perseus": "^
|
|
27
|
+
"@khanacademy/perseus": "^17.0.0",
|
|
28
28
|
"@khanacademy/perseus-core": "1.4.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {Dependencies} from "@khanacademy/perseus";
|
|
2
2
|
import {render, screen} from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
3
4
|
import * as React from "react";
|
|
4
5
|
|
|
5
6
|
import "@testing-library/jest-dom";
|
|
@@ -53,4 +54,350 @@ describe("expression-editor", () => {
|
|
|
53
54
|
|
|
54
55
|
expect(await screen.findByText(/π/)).toBeInTheDocument();
|
|
55
56
|
});
|
|
57
|
+
|
|
58
|
+
it("should toggle multiplication checkbox", () => {
|
|
59
|
+
const onChangeMock = jest.fn();
|
|
60
|
+
|
|
61
|
+
render(<ExpressionEditor onChange={onChangeMock} />);
|
|
62
|
+
|
|
63
|
+
userEvent.click(
|
|
64
|
+
screen.getByRole("checkbox", {
|
|
65
|
+
name: "Use × for rendering multiplication instead of a center dot.",
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(onChangeMock).toBeCalledWith({times: true});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should be possible to change function variables", () => {
|
|
73
|
+
const onChangeMock = jest.fn();
|
|
74
|
+
|
|
75
|
+
render(<ExpressionEditor onChange={onChangeMock} functions={[]} />);
|
|
76
|
+
|
|
77
|
+
const input = screen.getByRole("textbox", {
|
|
78
|
+
name: "Function variables:",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
userEvent.type(input, "x");
|
|
82
|
+
|
|
83
|
+
expect(onChangeMock).toBeCalledWith({functions: ["x"]});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should toggle division checkbox", () => {
|
|
87
|
+
const onChangeMock = jest.fn();
|
|
88
|
+
|
|
89
|
+
render(<ExpressionEditor onChange={onChangeMock} />);
|
|
90
|
+
|
|
91
|
+
userEvent.click(
|
|
92
|
+
screen.getByRole("checkbox", {
|
|
93
|
+
name: "show \\div button",
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(onChangeMock).toBeCalledWith(
|
|
98
|
+
{
|
|
99
|
+
answerForms: [],
|
|
100
|
+
buttonSets: ["basic+div"],
|
|
101
|
+
functions: ["f", "g", "h"],
|
|
102
|
+
times: false,
|
|
103
|
+
},
|
|
104
|
+
undefined,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should toggle trig checkbox", () => {
|
|
109
|
+
const onChangeMock = jest.fn();
|
|
110
|
+
|
|
111
|
+
render(<ExpressionEditor onChange={onChangeMock} />);
|
|
112
|
+
|
|
113
|
+
userEvent.click(
|
|
114
|
+
screen.getByRole("checkbox", {
|
|
115
|
+
name: "trig",
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(onChangeMock).toBeCalledWith({
|
|
120
|
+
buttonSets: ["basic", "trig"],
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should toggle prealgebra checkbox", () => {
|
|
125
|
+
const onChangeMock = jest.fn();
|
|
126
|
+
|
|
127
|
+
render(<ExpressionEditor onChange={onChangeMock} />);
|
|
128
|
+
|
|
129
|
+
userEvent.click(
|
|
130
|
+
screen.getByRole("checkbox", {
|
|
131
|
+
name: "prealgebra",
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(onChangeMock).toBeCalledWith({
|
|
136
|
+
buttonSets: ["basic", "prealgebra"],
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should toggle prealgebra checkbox", () => {
|
|
141
|
+
const onChangeMock = jest.fn();
|
|
142
|
+
|
|
143
|
+
render(<ExpressionEditor onChange={onChangeMock} />);
|
|
144
|
+
|
|
145
|
+
userEvent.click(
|
|
146
|
+
screen.getByRole("checkbox", {
|
|
147
|
+
name: "logarithms",
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(onChangeMock).toBeCalledWith({
|
|
152
|
+
buttonSets: ["basic", "logarithms"],
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should toggle prealgebra checkbox", () => {
|
|
157
|
+
const onChangeMock = jest.fn();
|
|
158
|
+
|
|
159
|
+
render(<ExpressionEditor onChange={onChangeMock} />);
|
|
160
|
+
|
|
161
|
+
userEvent.click(
|
|
162
|
+
screen.getByRole("checkbox", {
|
|
163
|
+
name: "basic relations",
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(onChangeMock).toBeCalledWith({
|
|
168
|
+
buttonSets: ["basic", "basic relations"],
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should toggle prealgebra checkbox", () => {
|
|
173
|
+
const onChangeMock = jest.fn();
|
|
174
|
+
|
|
175
|
+
render(<ExpressionEditor onChange={onChangeMock} />);
|
|
176
|
+
|
|
177
|
+
userEvent.click(
|
|
178
|
+
screen.getByRole("checkbox", {
|
|
179
|
+
name: "advanced relations",
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(onChangeMock).toBeCalledWith({
|
|
184
|
+
buttonSets: ["basic", "advanced relations"],
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("should be possible to add an answer", () => {
|
|
189
|
+
const onChangeMock = jest.fn();
|
|
190
|
+
|
|
191
|
+
render(<ExpressionEditor onChange={onChangeMock} />);
|
|
192
|
+
|
|
193
|
+
userEvent.click(
|
|
194
|
+
screen.getByRole("button", {
|
|
195
|
+
name: "Add new answer",
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(onChangeMock).toBeCalledWith(
|
|
200
|
+
{
|
|
201
|
+
answerForms: [
|
|
202
|
+
{
|
|
203
|
+
considered: "correct",
|
|
204
|
+
form: false,
|
|
205
|
+
key: 0,
|
|
206
|
+
simplify: false,
|
|
207
|
+
value: "",
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
buttonSets: ["basic"],
|
|
211
|
+
functions: ["f", "g", "h"],
|
|
212
|
+
times: false,
|
|
213
|
+
},
|
|
214
|
+
undefined,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("should be possible to update answer", () => {
|
|
219
|
+
const onChangeMock = jest.fn();
|
|
220
|
+
|
|
221
|
+
render(
|
|
222
|
+
<ExpressionEditor
|
|
223
|
+
onChange={onChangeMock}
|
|
224
|
+
answerForms={[
|
|
225
|
+
{
|
|
226
|
+
considered: "correct",
|
|
227
|
+
form: false,
|
|
228
|
+
key: "0",
|
|
229
|
+
simplify: false,
|
|
230
|
+
value: "",
|
|
231
|
+
},
|
|
232
|
+
]}
|
|
233
|
+
/>,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
userEvent.click(
|
|
237
|
+
screen.getByRole("switch", {
|
|
238
|
+
name: "open math keypad",
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
userEvent.click(
|
|
243
|
+
screen.getByRole("button", {
|
|
244
|
+
name: "9",
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(onChangeMock).toBeCalledWith(
|
|
249
|
+
{
|
|
250
|
+
answerForms: [
|
|
251
|
+
{
|
|
252
|
+
buttonSets: ["basic"],
|
|
253
|
+
buttonsVisible: "focused",
|
|
254
|
+
considered: "correct",
|
|
255
|
+
form: false,
|
|
256
|
+
functions: ["f", "g", "h"],
|
|
257
|
+
key: "0",
|
|
258
|
+
linterContext: {
|
|
259
|
+
contentType: "",
|
|
260
|
+
highlightLint: false,
|
|
261
|
+
paths: [],
|
|
262
|
+
stack: [],
|
|
263
|
+
},
|
|
264
|
+
simplify: false,
|
|
265
|
+
times: false,
|
|
266
|
+
value: "9",
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
buttonSets: ["basic"],
|
|
270
|
+
functions: ["f", "g", "h"],
|
|
271
|
+
times: false,
|
|
272
|
+
},
|
|
273
|
+
undefined,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("should be possible to toggle same form", () => {
|
|
278
|
+
const onChangeMock = jest.fn();
|
|
279
|
+
|
|
280
|
+
render(
|
|
281
|
+
<ExpressionEditor
|
|
282
|
+
onChange={onChangeMock}
|
|
283
|
+
answerForms={[
|
|
284
|
+
{
|
|
285
|
+
considered: "correct",
|
|
286
|
+
form: false,
|
|
287
|
+
key: "0",
|
|
288
|
+
simplify: false,
|
|
289
|
+
value: "",
|
|
290
|
+
},
|
|
291
|
+
]}
|
|
292
|
+
/>,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
userEvent.click(
|
|
296
|
+
screen.getByRole("checkbox", {
|
|
297
|
+
name: "Answer expression must have the same form.",
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
expect(onChangeMock).toBeCalledWith(
|
|
302
|
+
{
|
|
303
|
+
answerForms: [
|
|
304
|
+
{
|
|
305
|
+
considered: "correct",
|
|
306
|
+
form: true,
|
|
307
|
+
key: "0",
|
|
308
|
+
simplify: false,
|
|
309
|
+
value: "",
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
buttonSets: ["basic"],
|
|
313
|
+
functions: ["f", "g", "h"],
|
|
314
|
+
times: false,
|
|
315
|
+
},
|
|
316
|
+
undefined,
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("should be possible to toggle expanded and simplified", () => {
|
|
321
|
+
const onChangeMock = jest.fn();
|
|
322
|
+
|
|
323
|
+
render(
|
|
324
|
+
<ExpressionEditor
|
|
325
|
+
onChange={onChangeMock}
|
|
326
|
+
answerForms={[
|
|
327
|
+
{
|
|
328
|
+
considered: "correct",
|
|
329
|
+
form: false,
|
|
330
|
+
key: "0",
|
|
331
|
+
simplify: false,
|
|
332
|
+
value: "",
|
|
333
|
+
},
|
|
334
|
+
]}
|
|
335
|
+
/>,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
userEvent.click(
|
|
339
|
+
screen.getByRole("checkbox", {
|
|
340
|
+
name: "Answer expression must be fully expanded and simplified.",
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
expect(onChangeMock).toBeCalledWith(
|
|
345
|
+
{
|
|
346
|
+
answerForms: [
|
|
347
|
+
{
|
|
348
|
+
considered: "correct",
|
|
349
|
+
form: false,
|
|
350
|
+
key: "0",
|
|
351
|
+
simplify: true,
|
|
352
|
+
value: "",
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
buttonSets: ["basic"],
|
|
356
|
+
functions: ["f", "g", "h"],
|
|
357
|
+
times: false,
|
|
358
|
+
},
|
|
359
|
+
undefined,
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("should be possible to delete answer", () => {
|
|
364
|
+
const onChangeMock = jest.fn();
|
|
365
|
+
|
|
366
|
+
render(
|
|
367
|
+
<ExpressionEditor
|
|
368
|
+
onChange={onChangeMock}
|
|
369
|
+
answerForms={[
|
|
370
|
+
{
|
|
371
|
+
considered: "correct",
|
|
372
|
+
form: false,
|
|
373
|
+
key: "0",
|
|
374
|
+
simplify: false,
|
|
375
|
+
value: "",
|
|
376
|
+
},
|
|
377
|
+
]}
|
|
378
|
+
/>,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
userEvent.click(
|
|
382
|
+
screen.getByRole("button", {
|
|
383
|
+
name: "Delete",
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
userEvent.click(
|
|
388
|
+
screen.getByRole("button", {
|
|
389
|
+
name: "I'm sure!",
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
expect(onChangeMock).toBeCalledWith(
|
|
394
|
+
{
|
|
395
|
+
answerForms: [],
|
|
396
|
+
buttonSets: ["basic"],
|
|
397
|
+
functions: ["f", "g", "h"],
|
|
398
|
+
times: false,
|
|
399
|
+
},
|
|
400
|
+
undefined,
|
|
401
|
+
);
|
|
402
|
+
});
|
|
56
403
|
});
|
|
@@ -17,7 +17,7 @@ import SortableArea from "../components/sortable";
|
|
|
17
17
|
|
|
18
18
|
import type {PerseusExpressionWidgetOptions} from "@khanacademy/perseus";
|
|
19
19
|
|
|
20
|
-
const {InfoTip, PropCheckBox
|
|
20
|
+
const {InfoTip, PropCheckBox} = components;
|
|
21
21
|
|
|
22
22
|
type Props = {
|
|
23
23
|
widgetId?: any;
|
|
@@ -36,6 +36,15 @@ type DefaultProps = {
|
|
|
36
36
|
functions: Props["functions"];
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
const buttonSetsList: any = [
|
|
40
|
+
"basic",
|
|
41
|
+
"trig",
|
|
42
|
+
"prealgebra",
|
|
43
|
+
"logarithms",
|
|
44
|
+
"basic relations",
|
|
45
|
+
"advanced relations",
|
|
46
|
+
];
|
|
47
|
+
|
|
39
48
|
const parseAnswerKey = ({key}: AnswerForm): number => {
|
|
40
49
|
// We don't throw here because there is data stored in some
|
|
41
50
|
// exercises/articles where the answer forms don't have a key. If we throw,
|
|
@@ -67,11 +76,7 @@ const _makeNewKey = (answerForms: ReadonlyArray<AnswerForm>) => {
|
|
|
67
76
|
return usedKeys.length;
|
|
68
77
|
};
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
isTex: boolean;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
class ExpressionEditor extends React.Component<Props, State> {
|
|
79
|
+
class ExpressionEditor extends React.Component<Props> {
|
|
75
80
|
static widgetName = "expression" as const;
|
|
76
81
|
|
|
77
82
|
static defaultProps: DefaultProps = {
|
|
@@ -81,29 +86,6 @@ class ExpressionEditor extends React.Component<Props, State> {
|
|
|
81
86
|
functions: ["f", "g", "h"],
|
|
82
87
|
};
|
|
83
88
|
|
|
84
|
-
constructor(props: Props) {
|
|
85
|
-
super(props);
|
|
86
|
-
// Is the format of `value` TeX or plain text?
|
|
87
|
-
// TODO(alex): Remove after backfilling everything to TeX
|
|
88
|
-
// TODO(joel) - sucks if you edit some expression without
|
|
89
|
-
// backslashes or curly braces, then come back to the question and
|
|
90
|
-
// it's surprisingly not TeX anymore.
|
|
91
|
-
|
|
92
|
-
let isTex;
|
|
93
|
-
// default to TeX if new;
|
|
94
|
-
if (props.answerForms.length === 0) {
|
|
95
|
-
isTex = true;
|
|
96
|
-
} else {
|
|
97
|
-
isTex = props.answerForms.some((form) => {
|
|
98
|
-
const {value} = form;
|
|
99
|
-
// only TeX has backslashes and curly braces
|
|
100
|
-
return value.indexOf("\\") !== -1 || value.indexOf("{") !== -1;
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
this.state = {isTex};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
89
|
change(...args) {
|
|
108
90
|
return Changeable.change.apply(this, args);
|
|
109
91
|
}
|
|
@@ -156,38 +138,31 @@ class ExpressionEditor extends React.Component<Props, State> {
|
|
|
156
138
|
);
|
|
157
139
|
|
|
158
140
|
// checkboxes to choose which sets of input buttons are shown
|
|
159
|
-
const buttonSetChoices =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
{name}
|
|
180
|
-
</label>
|
|
181
|
-
);
|
|
182
|
-
},
|
|
183
|
-
);
|
|
141
|
+
const buttonSetChoices = buttonSetsList.map((name) => {
|
|
142
|
+
// The first one gets special cased to always be checked, disabled,
|
|
143
|
+
// and float left.
|
|
144
|
+
const isFirst = name === "basic";
|
|
145
|
+
const checked =
|
|
146
|
+
this.props.buttonSets.includes(
|
|
147
|
+
name as PerseusExpressionWidgetOptions["buttonSets"][number],
|
|
148
|
+
) || isFirst;
|
|
149
|
+
return (
|
|
150
|
+
<label className="button-set-label" key={name}>
|
|
151
|
+
<input
|
|
152
|
+
type="checkbox"
|
|
153
|
+
checked={checked}
|
|
154
|
+
disabled={isFirst}
|
|
155
|
+
onChange={() => this.handleButtonSet(name)}
|
|
156
|
+
/>
|
|
157
|
+
{name}
|
|
158
|
+
</label>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
184
161
|
|
|
185
162
|
const {TeX} = Dependencies.getDependencies(); // OldExpression only
|
|
186
163
|
|
|
187
|
-
buttonSetChoices.
|
|
188
|
-
|
|
189
|
-
1,
|
|
190
|
-
<label key="show-div">
|
|
164
|
+
buttonSetChoices.unshift(
|
|
165
|
+
<label className="button-set-label" key="show-div">
|
|
191
166
|
<input type="checkbox" onChange={this.handleToggleDiv} />
|
|
192
167
|
<span className="show-div-button">
|
|
193
168
|
show <TeX>\div</TeX> button
|
|
@@ -240,15 +215,6 @@ class ExpressionEditor extends React.Component<Props, State> {
|
|
|
240
215
|
{buttonSetChoices}
|
|
241
216
|
</div>
|
|
242
217
|
|
|
243
|
-
{this.state.isTex && (
|
|
244
|
-
<TexButtons
|
|
245
|
-
className="math-input-buttons"
|
|
246
|
-
sets={this.props.buttonSets}
|
|
247
|
-
convertDotToTimes={this.props.times}
|
|
248
|
-
onInsert={this.handleTexInsert}
|
|
249
|
-
/>
|
|
250
|
-
)}
|
|
251
|
-
|
|
252
218
|
<h3 className="expression-editor-h3">Answers</h3>
|
|
253
219
|
|
|
254
220
|
<p style={{margin: "4px 0"}}>
|
|
@@ -397,9 +363,7 @@ class ExpressionEditor extends React.Component<Props, State> {
|
|
|
397
363
|
|
|
398
364
|
// called when the selected buttonset changes
|
|
399
365
|
handleButtonSet: (changingName: string) => void = (changingName) => {
|
|
400
|
-
const buttonSetNames =
|
|
401
|
-
TexButtons.buttonSets,
|
|
402
|
-
) as LegacyButtonSet[];
|
|
366
|
+
const buttonSetNames = buttonSetsList;
|
|
403
367
|
|
|
404
368
|
// Filter to preserve order - using .union and .difference would always
|
|
405
369
|
// move the last added button set to the end.
|