-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Expand file tree
/
Copy pathKeytipTree.ts
More file actions
344 lines (314 loc) · 11.9 KB
/
Copy pathKeytipTree.ts
File metadata and controls
344 lines (314 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
import { find, getDocument, isElementVisibleAndNotHidden, values } from '../../Utilities';
import { ktpTargetFromSequences, mergeOverflows, sequencesToID } from '../../utilities/keytips/KeytipUtils';
import { KTP_LAYER_ID } from '../../utilities/keytips/KeytipConstants';
import type { IKeytipProps } from '../../Keytip';
import type { IKeytipTreeNode } from './IKeytipTreeNode';
/**
* This class is responsible for handling the parent/child relationships between keytips
*/
export class KeytipTree {
public currentKeytip?: IKeytipTreeNode;
public root: IKeytipTreeNode;
public nodeMap: { [nodeId: string]: IKeytipTreeNode } = {};
/**
* KeytipTree constructor
*/
constructor() {
// Root has no keytipSequence
this.root = {
id: KTP_LAYER_ID,
children: [],
parent: '',
keySequences: [],
};
this.nodeMap[this.root.id] = this.root;
}
/**
* Add a keytip node to this KeytipTree
*
* @param keytipProps - Keytip to add to the Tree
* @param uniqueID - Unique ID for this keytip
* @param persisted - T/F if this keytip should be marked as persisted
*/
public addNode(keytipProps: IKeytipProps, uniqueID: string, persisted?: boolean): void {
const fullSequence = this._getFullSequence(keytipProps);
const nodeID = sequencesToID(fullSequence);
// Take off the last item to calculate the parent sequence
fullSequence.pop();
// Parent ID is the root if there aren't any more sequences
const parentID = this._getParentID(fullSequence);
// Create node and add to map
const node = this._createNode(nodeID, parentID, [], keytipProps, persisted);
this.nodeMap[uniqueID] = node;
// Try to add self to parents children
const parents = this.getNodes([parentID]);
parents.forEach(parent => parent.children.push(nodeID));
}
/**
* Updates a node in the tree
*
* @param keytipProps - Keytip props to update
* @param uniqueID - Unique ID for this keytip
*/
public updateNode(keytipProps: IKeytipProps, uniqueID: string): void {
const node = this.nodeMap[uniqueID];
if (node) {
const fullSequence = this._getFullSequence(keytipProps);
const nodeID = sequencesToID(fullSequence);
// Take off the last item to calculate the parent sequence
fullSequence.pop();
// Parent ID is the root if there aren't any more sequences
const parentID = this._getParentID(fullSequence);
const prevParent = node.parent;
// Fix parent nodes if needed
if (prevParent !== parentID) {
// If parent has changed, remove child from old parent
this._removeChildFromParents(prevParent, node.id);
}
if (node.id !== nodeID) {
// If the ID of the node has changed, update node's parent's array of children with new ID
const parents = this.getNodes([parentID]);
parents.forEach(parent => {
const index = parent.children.indexOf(node.id);
index >= 0 ? (parent.children[index] = nodeID) : parent.children.push(nodeID);
});
}
// Update values
node.id = nodeID;
node.keySequences = keytipProps.keySequences;
node.overflowSetSequence = keytipProps.overflowSetSequence;
node.onExecute = keytipProps.onExecute;
node.onReturn = keytipProps.onReturn;
node.hasDynamicChildren = keytipProps.hasDynamicChildren;
node.hasMenu = keytipProps.hasMenu;
node.parent = parentID;
node.disabled = keytipProps.disabled;
}
}
/**
* Removes a node from the KeytipTree
*
* @param sequence - full string of the node to remove
*/
public removeNode(keytipProps: IKeytipProps, uniqueID: string): void {
const fullSequence = this._getFullSequence(keytipProps);
const nodeID = sequencesToID(fullSequence);
// Take off the last sequence to calculate the parent ID
fullSequence.pop();
// Parent ID is the root if there aren't any more sequences
this._removeChildFromParents(this._getParentID(fullSequence), nodeID);
if (this.nodeMap[uniqueID]) {
// Remove the node from the nodeMap
delete this.nodeMap[uniqueID];
}
}
/**
* Searches the currentKeytip's children to exactly match a sequence. Will not match disabled nodes but
* will match persisted nodes
*
* @param keySequence - string to match
* @param currentKeytip - The keytip whose children will try to match
* @param doc - The document for DOM operations
* @returns The node that exactly matched the keySequence, or undefined if none matched
*/
public getExactMatchedNode(
keySequence: string,
currentKeytip: IKeytipTreeNode,
doc?: Document,
): IKeytipTreeNode | undefined {
const theDoc = doc ?? getDocument()!;
const possibleNodes = this.getNodes(currentKeytip.children);
const matchingNodes = possibleNodes.filter((node: IKeytipTreeNode) => {
return this._getNodeSequence(node) === keySequence && !node.disabled;
});
// If we found no nodes, we are done
if (matchingNodes.length === 0) {
return undefined;
}
// Since the matching nodes all have the same key sequence,
// Grab the first one build the correct selector
const node = matchingNodes[0];
// If we have exactly one node, return it
if (matchingNodes.length === 1) {
return node;
}
// Get the potential target elements based on a selector from the sequences
const keySequences = node.keySequences;
const overflowSetSequence = node.overflowSetSequence;
const fullKeySequences = overflowSetSequence ? mergeOverflows(keySequences, overflowSetSequence) : keySequences;
const keytipTargetSelector = ktpTargetFromSequences(fullKeySequences);
const potentialTargetElements = theDoc.querySelectorAll(keytipTargetSelector);
// If we have less nodes than the potential target elements,
// we won't be able to map element to node, return the first node.
// Note, the number of nodes could be more than the number of potential
// target elements, if an OverflowSet is involved
if (matchingNodes.length < potentialTargetElements.length) {
return node;
}
// Attempt to find the node that corresponds to the first visible/non-hidden element
const matchingIndex = Array.from(potentialTargetElements).findIndex((element: HTMLElement) =>
isElementVisibleAndNotHidden(element, theDoc.defaultView ?? undefined),
);
if (matchingIndex !== -1) {
return matchingNodes[matchingIndex];
}
// We did not find any visible elements associated with any of the nodes.
// We may be dealing with a keytip that is a submenu in an OverflowSet.
// Worst case, fall back to the first node returned
const overflowNode = matchingNodes.find(matchingNode => matchingNode.hasOverflowSubMenu);
return overflowNode || node;
}
/**
* Searches the currentKeytip's children to find nodes that start with the given sequence. Will not match
* disabled nodes but will match persisted nodes
*
* @param keySequence - string to partially match
* @param currentKeytip - The keytip whose children will try to partially match
* @returns List of tree nodes that partially match the given sequence
*/
public getPartiallyMatchedNodes(keySequence: string, currentKeytip: IKeytipTreeNode): IKeytipTreeNode[] {
// Get children that are persisted
const possibleNodes = this.getNodes(currentKeytip.children);
return possibleNodes.filter((node: IKeytipTreeNode) => {
return this._getNodeSequence(node).indexOf(keySequence) === 0 && !node.disabled;
});
}
/**
* Get the non-persisted children of the give node
* If no node is given, will use the 'currentKeytip'
*
* @param node - Node to get the children for
* @returns List of node IDs that are the children of the node
*/
public getChildren(node?: IKeytipTreeNode): string[] {
if (!node) {
node = this.currentKeytip;
if (!node) {
return [];
}
}
const children = node.children;
return Object.keys(this.nodeMap).reduce((nodes: string[], key: string): string[] => {
if (children.indexOf(this.nodeMap[key].id) >= 0 && !this.nodeMap[key].persisted) {
nodes.push(this.nodeMap[key].id);
}
return nodes;
}, []);
}
/**
* Gets all nodes from their IDs
*
* @param ids - List of keytip IDs
* @returns Array of nodes that match the given IDs, can be empty
*/
public getNodes(ids: string[]): IKeytipTreeNode[] {
return Object.keys(this.nodeMap).reduce((nodes: IKeytipTreeNode[], key: string): IKeytipTreeNode[] => {
if (ids.indexOf(this.nodeMap[key].id) >= 0) {
nodes.push(this.nodeMap[key]);
}
return nodes;
}, []);
}
/**
* Gets a single node from its ID
*
* @param id - ID of the node to get
* @returns Node with the given ID, if found
*/
public getNode(id: string): IKeytipTreeNode | undefined {
const nodeMapValues = values<IKeytipTreeNode>(this.nodeMap);
return find(nodeMapValues, (node: IKeytipTreeNode): boolean => {
return node.id === id;
});
}
/**
* Tests if the currentKeytip in this.keytipTree is the parent of 'keytipProps'
*
* @param keytipProps - Keytip to test the parent for
* @returns T/F if the currentKeytip is this keytipProps' parent
*/
public isCurrentKeytipParent(keytipProps: IKeytipProps): boolean {
if (this.currentKeytip) {
let fullSequence = [...keytipProps.keySequences];
if (keytipProps.overflowSetSequence) {
fullSequence = mergeOverflows(fullSequence, keytipProps.overflowSetSequence);
}
// Take off the last sequence to calculate the parent ID
fullSequence.pop();
// Parent ID is the root if there aren't any more sequences
const parentID = fullSequence.length === 0 ? this.root.id : sequencesToID(fullSequence);
let matchesCurrWithoutOverflow = false;
if (this.currentKeytip.overflowSetSequence) {
const currKeytipIdWithoutOverflow = sequencesToID(this.currentKeytip.keySequences);
matchesCurrWithoutOverflow = currKeytipIdWithoutOverflow === parentID;
}
return matchesCurrWithoutOverflow || this.currentKeytip.id === parentID;
}
return false;
}
private _getParentID(fullSequence: string[]): string {
return fullSequence.length === 0 ? this.root.id : sequencesToID(fullSequence);
}
private _getFullSequence(keytipProps: IKeytipProps): string[] {
let fullSequence = [...keytipProps.keySequences];
if (keytipProps.overflowSetSequence) {
fullSequence = mergeOverflows(fullSequence, keytipProps.overflowSetSequence);
}
return fullSequence;
}
private _getNodeSequence(node: IKeytipTreeNode): string {
let fullSequence = [...node.keySequences];
if (node.overflowSetSequence) {
fullSequence = mergeOverflows(fullSequence, node.overflowSetSequence);
}
return fullSequence[fullSequence.length - 1];
}
private _createNode(
id: string,
parentId: string,
children: string[],
keytipProps: IKeytipProps,
persisted?: boolean,
): IKeytipTreeNode {
const {
keySequences,
hasDynamicChildren,
overflowSetSequence,
hasMenu,
onExecute,
onReturn,
disabled,
hasOverflowSubMenu,
} = keytipProps;
const node = {
id,
keySequences,
overflowSetSequence,
parent: parentId,
children,
onExecute,
onReturn,
hasDynamicChildren,
hasMenu,
disabled,
persisted,
hasOverflowSubMenu,
};
node.children = Object.keys(this.nodeMap).reduce((array: string[], nodeMapKey: string): string[] => {
if (this.nodeMap[nodeMapKey].parent === id) {
array.push(this.nodeMap[nodeMapKey].id);
}
return array;
}, []);
return node;
}
private _removeChildFromParents(parentID: string, childID: string): void {
const parents = this.getNodes([parentID]);
parents.forEach(parent => {
const childIndex = parent.children.indexOf(childID);
if (childIndex >= 0) {
parent.children.splice(childIndex, 1);
}
});
}
}