@playkit-js/hotspots 3.2.35 → 3.2.36-canary.0-5dda03a

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playkit-js/hotspots",
3
- "version": "3.2.35",
3
+ "version": "3.2.36-canary.0-5dda03a",
4
4
  "private": false,
5
5
  "bugs": {
6
6
  "url": "https://github.com/kaltura/playkit-js-hotspots/issues"
@@ -36,7 +36,10 @@ type Props = {
36
36
  pauseVideo(): void;
37
37
  seekTo(time: number): void;
38
38
  sendAnalytics(event: AnalyticsEvents): void;
39
- dispatcher(name: string, payload?: any): void
39
+ dispatcher(name: string, payload?: any): void;
40
+ tabIndex?: number;
41
+ onKeyDown?(e: KeyboardEvent): void;
42
+ onFocus?(): void;
40
43
  };
41
44
 
42
45
  type State = {
@@ -166,6 +169,14 @@ export default class Hotspot extends Component<Props, State> {
166
169
  }
167
170
  };
168
171
 
172
+ handleInnerKeyDown = (e: KeyboardEvent) => {
173
+ // A11yWrapper overwrites onKeyDown and only handles Arrow keys, Enter, and Space,
174
+ // so this is attached via onKeyDownCapture to keep Home and End working.
175
+ if ((e.key === 'Home' || e.key === 'End') && this.props.onKeyDown) {
176
+ this.props.onKeyDown(e);
177
+ }
178
+ };
179
+
169
180
  render() {
170
181
  const {hotspot} = this.props;
171
182
  const {layout, label} = hotspot;
@@ -192,9 +203,11 @@ export default class Hotspot extends Component<Props, State> {
192
203
  borderRadius: `${hotspot.relativeStyle.radiusBorder}px`,
193
204
  };
194
205
 
206
+ const tabIndex = typeof this.props.tabIndex === 'number' ? this.props.tabIndex : 0;
207
+
195
208
  return (
196
- <A11yWrapper onClick={this.handleClick}>
197
- <div ref={(ref) => (this.hotspotRef = ref)} tabIndex={0} role="button" aria-label={label} aria-disabled={disableClick} style={containerStyles} data-testid="hotspots_hotspot">
209
+ <A11yWrapper onClick={this.handleClick} onUpKeyPressed={this.props.onKeyDown} onDownKeyPressed={this.props.onKeyDown} onLeftKeyPressed={this.props.onKeyDown} onRightKeyPressed={this.props.onKeyDown}>
210
+ <div ref={(ref) => (this.hotspotRef = ref)} tabIndex={tabIndex} role="button" aria-label={label} aria-disabled={disableClick} style={containerStyles} data-testid="hotspots_hotspot" data-hotspot-id={hotspot.id} onKeyDownCapture={this.handleInnerKeyDown} onFocus={this.props.onFocus}>
198
211
  <div style={buttonStyles}>{label}</div>
199
212
  </div>
200
213
  </A11yWrapper>
@@ -15,6 +15,16 @@ const translates = {
15
15
  <Text id="hotspots.hotspot_added" fields={{ hotspotLabel: '' }}>
16
16
  {`{hotspotLabel} hotspot added`}
17
17
  </Text>
18
+ ),
19
+ shortcutHint: (
20
+ <Text id="hotspots.hotspots_shortcut_hint">
21
+ {'Press Shift+Alt+H to jump to hotspots'}
22
+ </Text>
23
+ ),
24
+ toolbarLabel: (
25
+ <Text id="hotspots.hotspots_toolbar_label">
26
+ {'Interactive hotspots'}
27
+ </Text>
18
28
  )
19
29
  };
20
30
 
@@ -36,28 +46,407 @@ export interface Props {
36
46
  dispatcher(name: string, payload?: any): void;
37
47
  hotspotRemoved?: string;
38
48
  hotspotAdded?: string;
49
+ shortcutHint?: string;
50
+ toolbarLabel?: string;
39
51
  player?: any;
40
52
  }
41
53
 
54
+ interface State {
55
+ activeIndex: number;
56
+ }
57
+
42
58
  @withPlayer
43
59
  @withText(translates)
44
- export default class HotspotWrapper extends Component<Props> {
60
+ export default class HotspotWrapper extends Component<Props, State> {
61
+ state: State = {
62
+ activeIndex: 0
63
+ };
45
64
 
46
65
  private liveRegionRef = createRef<HTMLDivElement>();
66
+ private containerRef = createRef<HTMLDivElement>();
47
67
  private previousHotspotMap: Map<string, string> = new Map();
68
+ private lastFocusedHotspotId: string | null = null;
69
+ private previouslyFocusedElement: HTMLElement | null = null;
70
+ private wasHotspotFocused: boolean = false;
71
+ private focusTimers: number[] = [];
72
+ private blurCheckTimer: number | null = null;
73
+ private focusRequestOrigin: Element | null = null;
74
+
75
+ componentDidMount() {
76
+ window.addEventListener('keydown', this.handleGlobalKeyDown);
77
+ if (this.props.player) {
78
+ const endedEvent = this.props.player.Event?.ENDED || 'ended';
79
+ this.props.player.addEventListener?.(endedEvent, this.onPlayerEnded);
80
+ }
81
+ const container = this.containerRef.current;
82
+ if (container) {
83
+ container.addEventListener('focusin', this.handleContainerFocusIn);
84
+ container.addEventListener('focusout', this.handleContainerFocusOut);
85
+ }
86
+ }
87
+
88
+ componentWillUnmount() {
89
+ window.removeEventListener('keydown', this.handleGlobalKeyDown);
90
+ if (this.props.player) {
91
+ const endedEvent = this.props.player.Event?.ENDED || 'ended';
92
+ this.props.player.removeEventListener?.(endedEvent, this.onPlayerEnded);
93
+ }
94
+ const container = this.containerRef.current;
95
+ if (container) {
96
+ container.removeEventListener('focusin', this.handleContainerFocusIn);
97
+ container.removeEventListener('focusout', this.handleContainerFocusOut);
98
+ }
99
+ this.clearFocusTimers();
100
+ if (this.blurCheckTimer !== null) {
101
+ clearTimeout(this.blurCheckTimer);
102
+ }
103
+ }
104
+
105
+ private clearFocusTimers() {
106
+ this.focusTimers.forEach(id => clearTimeout(id));
107
+ this.focusTimers = [];
108
+ }
109
+
110
+ private beginFocusRequest() {
111
+ this.clearFocusTimers();
112
+ this.focusRequestOrigin = document.activeElement;
113
+ }
114
+
115
+ // Retries may only run while focus is where the request started, on body (element removed), or inside the hotspots.
116
+ private canContinueFocusRetry(): boolean {
117
+ const active = document.activeElement;
118
+ return (
119
+ !active ||
120
+ active === this.focusRequestOrigin ||
121
+ active === document.body ||
122
+ active === document.documentElement ||
123
+ !!this.containerRef.current?.contains(active)
124
+ );
125
+ }
126
+
127
+ private releaseFocusRestoration() {
128
+ this.wasHotspotFocused = false;
129
+ this.lastFocusedHotspotId = null;
130
+ this.previouslyFocusedElement = null;
131
+ this.clearFocusTimers();
132
+ }
133
+
134
+ private scheduleFocusTimer(callback: () => void, delay: number) {
135
+ const id = window.setTimeout(() => {
136
+ this.focusTimers = this.focusTimers.filter(t => t !== id);
137
+ callback();
138
+ }, delay);
139
+ this.focusTimers.push(id);
140
+ }
141
+
142
+ shouldComponentUpdate(nextProps: Props, nextState: State) {
143
+ return (
144
+ this.state.activeIndex !== nextState.activeIndex ||
145
+ this.props.hotspotAdded !== nextProps.hotspotAdded ||
146
+ this.props.hotspotRemoved !== nextProps.hotspotRemoved ||
147
+ this.props.shortcutHint !== nextProps.shortcutHint ||
148
+ this.props.toolbarLabel !== nextProps.toolbarLabel ||
149
+ !shallowCompareHotspots(this.props.hotspots, nextProps.hotspots)
150
+ );
151
+ }
152
+
153
+ private onPlayerEnded = () => {
154
+ if (this.wasHotspotFocused) {
155
+ this.wasHotspotFocused = false;
156
+ this.lastFocusedHotspotId = null;
157
+ this.previouslyFocusedElement = null;
158
+ this.returnFocusToPlayPause(true);
159
+ }
160
+ };
161
+
162
+ private getPlayerContainer(): HTMLElement | null {
163
+ const targetId = this.props.player?.config?.targetId;
164
+ return targetId ? document.getElementById(targetId) : null;
165
+ }
166
+
167
+ private handleGlobalKeyDown = (e: KeyboardEvent) => {
168
+ const isHKey = e.key === 'H' || e.key === 'h' || e.code === 'KeyH';
169
+ if (!e.shiftKey || !e.altKey || !isHKey) {
170
+ return;
171
+ }
172
+
173
+ const activeEl = document.activeElement as HTMLElement | null;
174
+ if (!activeEl) {
175
+ return;
176
+ }
177
+
178
+ const playerContainer = this.getPlayerContainer();
179
+ if (!playerContainer || !playerContainer.contains(activeEl)) {
180
+ return;
181
+ }
182
+
183
+ const isEditable =
184
+ activeEl.tagName === 'INPUT' ||
185
+ activeEl.tagName === 'TEXTAREA' ||
186
+ activeEl.tagName === 'SELECT' ||
187
+ activeEl.isContentEditable;
48
188
 
49
- shouldComponentUpdate(nextProps: Props) {
50
- return !shallowCompareHotspots(this.props.hotspots, nextProps.hotspots);
189
+ if (isEditable) {
190
+ return;
191
+ }
192
+
193
+ const {hotspots} = this.props;
194
+ if (!hotspots || hotspots.length === 0) {
195
+ return;
196
+ }
197
+
198
+ e.preventDefault();
199
+
200
+ try {
201
+ this.props.player?.ui?.store?.dispatch?.({type: 'shell/UPDATE_PLAYER_NAV_STATE', nav: true});
202
+ this.props.player?.ui?.store?.dispatch?.({type: 'shell/UPDATE_PLAYER_HOVER_STATE', hover: true});
203
+ } catch (err) {
204
+ // ignore if store is unavailable
205
+ }
206
+
207
+ const targetIndex = this.state.activeIndex >= 0 && this.state.activeIndex < hotspots.length ? this.state.activeIndex : 0;
208
+ this.focusHotspotByIndex(targetIndex);
209
+ };
210
+
211
+ private focusHotspotByIndex(index: number, retryCount = 0) {
212
+ if (retryCount === 0) {
213
+ this.beginFocusRequest();
214
+ } else if (!this.canContinueFocusRetry()) {
215
+ return;
216
+ }
217
+ const container = this.containerRef.current;
218
+ if (!container) return;
219
+ const targetId = this.props.hotspots?.[index]?.id;
220
+ if (!targetId) return;
221
+ const hotspotEl = Array.from(container.querySelectorAll<HTMLElement>('[data-testid="hotspots_hotspot"]')).find(
222
+ el => el.dataset.hotspotId === targetId
223
+ );
224
+ if (hotspotEl) {
225
+ this.setState({ activeIndex: index }, () => {
226
+ hotspotEl.focus();
227
+ });
228
+ } else if (retryCount < 5) {
229
+ // Hotspot components mount asynchronously before setting isReady: true.
230
+ // Retry in the next frames/tick if the replacement DOM element is not yet rendered.
231
+ this.scheduleFocusTimer(() => this.focusHotspotByIndex(index, retryCount + 1), 30);
232
+ }
51
233
  }
52
- componentDidUpdate() {
234
+
235
+ private handleContainerFocusIn = (e: FocusEvent) => {
236
+ const fromEl = e.relatedTarget as HTMLElement | null;
237
+ const container = this.containerRef.current;
238
+ if (fromEl && container && !container.contains(fromEl) && fromEl !== document.body) {
239
+ this.previouslyFocusedElement = fromEl;
240
+ }
241
+
242
+ const target = e.target as HTMLElement | null;
243
+ if (!target) return;
244
+ const hotspotEl = target.closest<HTMLElement>('[data-testid="hotspots_hotspot"]');
245
+ if (hotspotEl && this.containerRef.current) {
246
+ const id = hotspotEl.dataset.hotspotId;
247
+ const index = id && this.props.hotspots ? this.props.hotspots.findIndex(h => h.id === id) : -1;
248
+ if (index !== -1) {
249
+ this.lastFocusedHotspotId = id!;
250
+ this.wasHotspotFocused = true;
251
+ if (this.state.activeIndex !== index) {
252
+ this.setState({ activeIndex: index });
253
+ }
254
+ }
255
+ }
256
+ };
257
+
258
+ private handleContainerFocusOut = (e: FocusEvent) => {
259
+ const related = e.relatedTarget as HTMLElement | null;
260
+ const container = this.containerRef.current;
261
+ // Clear stored focus state if focus intentionally moved to any real element outside the hotspot container.
262
+ // Retain wasHotspotFocused only when related is null or document.body (which happens on DOM removal).
263
+ if (related && related !== document.body && related !== document.documentElement && container && !container.contains(related)) {
264
+ this.releaseFocusRestoration();
265
+ } else if (!related || related === document.body || related === document.documentElement) {
266
+ // A null relatedTarget is also sent when a still-mounted hotspot is blurred by clicking non-focusable content.
267
+ // Defer so DOM removal (element detached) can be told apart from an intentional blur.
268
+ const target = e.target as Node;
269
+ if (this.blurCheckTimer !== null) {
270
+ clearTimeout(this.blurCheckTimer);
271
+ }
272
+ this.blurCheckTimer = window.setTimeout(() => {
273
+ this.blurCheckTimer = null;
274
+ const active = document.activeElement;
275
+ const focusInContainer = !!active && !!this.containerRef.current?.contains(active);
276
+ if (target.isConnected && !focusInContainer) {
277
+ this.releaseFocusRestoration();
278
+ }
279
+ }, 0);
280
+ }
281
+ };
282
+
283
+ private handleHotspotKeyDown = (e: KeyboardEvent, index: number) => {
284
+ const {hotspots} = this.props;
285
+ if (!hotspots || hotspots.length === 0) return;
286
+
287
+ let nextIndex = index;
288
+
289
+ switch (e.key) {
290
+ case 'ArrowRight':
291
+ case 'ArrowDown':
292
+ nextIndex = (index + 1) % hotspots.length;
293
+ break;
294
+ case 'ArrowLeft':
295
+ case 'ArrowUp':
296
+ nextIndex = (index - 1 + hotspots.length) % hotspots.length;
297
+ break;
298
+ case 'Home':
299
+ nextIndex = 0;
300
+ break;
301
+ case 'End':
302
+ nextIndex = hotspots.length - 1;
303
+ break;
304
+ default:
305
+ return;
306
+ }
307
+
308
+ e.preventDefault();
309
+ e.stopPropagation();
310
+ this.focusHotspotByIndex(nextIndex);
311
+ };
312
+
313
+ private handleHotspotFocus = (id: string, index: number) => {
314
+ this.lastFocusedHotspotId = id;
315
+ this.wasHotspotFocused = true;
316
+ if (this.state.activeIndex !== index) {
317
+ this.setState({ activeIndex: index });
318
+ }
319
+ };
320
+
321
+ private findReplayOrPlayButton(playerContainer: HTMLElement): HTMLElement | null {
322
+ // 1. Center play/replay button (shown on start or after video end)
323
+ const centerBtn = playerContainer.querySelector<HTMLElement>(
324
+ '.playkit-pre-playback-play-button button, .playkit-pre-playback-play-button, [data-testid="pre_playback_play_button"]'
325
+ );
326
+ if (centerBtn) {
327
+ return centerBtn;
328
+ }
329
+
330
+ // 2. Control bar play/pause button (standard in Playkit UI)
331
+ const playPauseBtn = playerContainer.querySelector<HTMLElement>(
332
+ '.playkit-control-play-pause button, .playkit-control-play-pause [tabindex="0"]'
333
+ );
334
+ if (playPauseBtn) {
335
+ return playPauseBtn;
336
+ }
337
+
338
+ // 3. Fallback: First visible button in bottom bar or player container
339
+ const fallback = playerContainer.querySelector<HTMLElement>(
340
+ '.playkit-bottom-bar button, button'
341
+ );
342
+ return fallback;
343
+ }
344
+
345
+ private returnFocusToPlayPause(forceReplay = false) {
346
+ this.beginFocusRequest();
347
+ const isPlaybackEnded = forceReplay || !!this.props.player?.ended;
348
+ if (isPlaybackEnded) {
349
+ this.previouslyFocusedElement = null;
350
+ }
351
+
352
+ const attemptFocus = () => {
353
+ const playerContainer = this.getPlayerContainer();
354
+
355
+ // 1. If playback is NOT ended and we have a previously focused element still attached inside the player, restore focus to it
356
+ if (!isPlaybackEnded && this.previouslyFocusedElement && playerContainer?.contains(this.previouslyFocusedElement)) {
357
+ const prev = this.previouslyFocusedElement;
358
+ this.previouslyFocusedElement = null;
359
+ prev.focus();
360
+ if (document.activeElement === prev) {
361
+ return true;
362
+ }
363
+ }
364
+
365
+ // 2. Otherwise find the Replay / Play-Pause button in the player
366
+ if (!playerContainer) return false;
367
+
368
+ const btn = this.findReplayOrPlayButton(playerContainer);
369
+ if (btn) {
370
+ // Trigger hover on player so control bar becomes visible if hidden
371
+ try {
372
+ this.props.player?.ui?.store?.dispatch?.({type: 'shell/UPDATE_PLAYER_HOVER_STATE', hover: true});
373
+ } catch (e) {
374
+ // ignore if store is unavailable
375
+ }
376
+
377
+ btn.focus();
378
+ return document.activeElement === btn;
379
+ }
380
+ return false;
381
+ };
382
+
383
+ if (!attemptFocus()) {
384
+ this.scheduleFocusTimer(() => {
385
+ if (!this.canContinueFocusRetry()) return;
386
+ if (!attemptFocus()) {
387
+ this.scheduleFocusTimer(() => {
388
+ if (!this.canContinueFocusRetry()) return;
389
+ if (!attemptFocus()) {
390
+ this.scheduleFocusTimer(() => {
391
+ if (this.canContinueFocusRetry()) attemptFocus();
392
+ }, 150);
393
+ }
394
+ }, 70);
395
+ }
396
+ }, 30);
397
+ }
398
+ }
399
+
400
+ componentDidUpdate(prevProps: Props) {
401
+ const currentHotspots = this.props.hotspots || [];
402
+ const prevHotspots = prevProps.hotspots || [];
403
+
53
404
  const currentMap = new Map<string, string>(
54
- this.props.hotspots
405
+ currentHotspots
55
406
  .filter(h => typeof h.label === 'string')
56
407
  .map(h => [h.id, h.label!])
57
408
  );
409
+ const currentIds = new Set<string>(currentHotspots.map(h => h.id));
410
+
411
+ // Focus Return on Dismissal/Expiration
412
+ const activeEl = document.activeElement as HTMLElement | null;
413
+ const container = this.containerRef.current;
414
+ const isFocusCurrentlyInHotspots = container && activeEl ? container.contains(activeEl) : false;
415
+
416
+ if (this.wasHotspotFocused || isFocusCurrentlyInHotspots) {
417
+ const focusedId = this.lastFocusedHotspotId;
418
+ const isFocusedHotspotGone = focusedId ? !currentIds.has(focusedId) : false;
419
+
420
+ if (isFocusedHotspotGone || currentHotspots.length === 0) {
421
+ if (currentHotspots.length > 0) {
422
+ const fallbackIndex = Math.min(this.state.activeIndex, currentHotspots.length - 1);
423
+ this.focusHotspotByIndex(Math.max(0, fallbackIndex));
424
+ } else {
425
+ this.lastFocusedHotspotId = null;
426
+ this.wasHotspotFocused = false;
427
+ const player = this.props.player;
428
+ const isPlaybackEnded = !!player?.ended || (typeof player?.duration === 'number' && player?.duration > 0 && player?.currentTime >= player?.duration - 0.5);
429
+ if (isPlaybackEnded) {
430
+ this.previouslyFocusedElement = null;
431
+ }
432
+ this.returnFocusToPlayPause(isPlaybackEnded);
433
+ }
434
+ }
435
+ }
436
+
437
+ // Preserve the active hotspot by ID when the collection changes (e.g. earlier cue removed or inserted)
438
+ if (this.lastFocusedHotspotId && currentIds.has(this.lastFocusedHotspotId)) {
439
+ const newIndex = currentHotspots.findIndex(h => h.id === this.lastFocusedHotspotId);
440
+ if (newIndex !== -1 && newIndex !== this.state.activeIndex) {
441
+ this.setState({ activeIndex: newIndex });
442
+ }
443
+ } else if (currentHotspots.length > 0 && this.state.activeIndex >= currentHotspots.length) {
444
+ // Ensure activeIndex stays in valid bounds
445
+ this.setState({ activeIndex: currentHotspots.length - 1 });
446
+ }
58
447
 
59
448
  let announced = false;
60
-
449
+
61
450
  // Announce removed hotspots
62
451
  this.previousHotspotMap.forEach((label, id) => {
63
452
  if (!currentMap.has(id) && !announced) {
@@ -74,7 +463,8 @@ export default class HotspotWrapper extends Component<Props> {
74
463
  currentMap.forEach((label, id) => {
75
464
  if (!this.previousHotspotMap.has(id) && !announced) {
76
465
  if (this.props.hotspotAdded) {
77
- const message = this.props.hotspotAdded.replace('{hotspotLabel}', label);
466
+ const baseMessage = this.props.hotspotAdded.replace('{hotspotLabel}', label);
467
+ const message = this.props.shortcutHint ? `${baseMessage}. ${this.props.shortcutHint}` : baseMessage;
78
468
  this.announceHotspotChange(message);
79
469
  }
80
470
  announced = true;
@@ -102,8 +492,10 @@ export default class HotspotWrapper extends Component<Props> {
102
492
  }
103
493
 
104
494
  const {seekTo, pauseVideo, sendAnalytics, dispatcher} = this.props;
105
- return visualHotspot.map(hotspotData => (
106
- <Hotspot dispatcher={dispatcher} pauseVideo={pauseVideo} seekTo={seekTo} key={hotspotData.id} visible={true} hotspot={hotspotData} sendAnalytics={sendAnalytics} />
495
+ const {activeIndex} = this.state;
496
+
497
+ return visualHotspot.map((hotspotData, index) => (
498
+ <Hotspot dispatcher={dispatcher} pauseVideo={pauseVideo} seekTo={seekTo} key={hotspotData.id} visible={true} hotspot={hotspotData} sendAnalytics={sendAnalytics} tabIndex={index === activeIndex ? 0 : -1} onKeyDown={(e) => this.handleHotspotKeyDown(e, index)} onFocus={() => this.handleHotspotFocus(hotspotData.id, index)} />
107
499
  ));
108
500
  };
109
501
 
@@ -112,9 +504,10 @@ export default class HotspotWrapper extends Component<Props> {
112
504
  const hotspotsElements = this.renderHotspots(hotspots);
113
505
  const targetId = this.props.player?.config?.targetId;
114
506
  const liveRegionId = `hotspot-liveRegion-${targetId}`;
507
+ const hasHotspots = hotspots && hotspots.length > 0;
115
508
 
116
509
  return (
117
- <div style={hotspotsContainerStyles} data-testid="hotspots_hotspotsContainer">
510
+ <div ref={this.containerRef} style={hotspotsContainerStyles} data-testid="hotspots_hotspotsContainer" role={hasHotspots ? 'toolbar' : undefined} aria-label={hasHotspots ? this.props.toolbarLabel : undefined}>
118
511
  {hotspotsElements}
119
512
  <div
120
513
  ref={this.liveRegionRef}
@@ -206,7 +206,7 @@ export class HotspotsPlugin extends KalturaPlayer.core.BasePlugin {
206
206
  this._hotspots = this._recalculateCuepointLayout(this._hotspots);
207
207
  }
208
208
  return (
209
- <HotspotWrapper dispatcher={(eventType, payload) => this.dispatchEvent(eventType, payload)} key={'hotspotWrapper'} hotspots={this._hotspots} pauseVideo={this._pauseVideo} seekTo={this._seekTo} sendAnalytics={() => {}} />
209
+ <HotspotWrapper player={this._player} dispatcher={(eventType, payload) => this.dispatchEvent(eventType, payload)} key={'hotspotWrapper'} hotspots={this._hotspots} pauseVideo={this._pauseVideo} seekTo={this._seekTo} sendAnalytics={() => {}} />
210
210
  );
211
211
  };
212
212