Skip to content

Commit 1eb35d8

Browse files
wip
1 parent 5bda4f2 commit 1eb35d8

File tree

4 files changed

+266
-0
lines changed

4 files changed

+266
-0
lines changed

packages/teams-js/src/internal/telemetry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,8 @@ export const enum ApiName {
332332
Settings_SetValidityState = 'settings.setValidityState',
333333
Sharing_History_GetContent = 'sharing.history.getContent',
334334
Sharing_ShareWebContent = 'sharing.shareWebContent',
335+
ShortcutRelay_RequestHostShortcuts = 'shortcutRelay.requestHostShortcuts',
336+
ShortcutRelay_ForwardShortcutEvent = 'shortcutRelay.forwardShortcutEvent',
335337
StageView_Open = 'stageView.open',
336338
StageView_Self_Close = 'stageView.self.close',
337339
Store_OpenFullStore = 'store.openFullStore',

packages/teams-js/src/public/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,4 @@ export * as liveShare from './liveShareHost';
139139
export { LiveShareHost } from './liveShareHost';
140140
export * as marketplace from './marketplace';
141141
export { ISerializable } from './serializable.interface';
142+
export * as shortcutRelay from './shortcutRelay';

packages/teams-js/src/public/runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ interface IRuntimeV4 extends IBaseRuntime {
298298
readonly sharing?: {
299299
readonly history?: {};
300300
};
301+
readonly shortcutRelay?: {};
301302
readonly stageView?: {
302303
readonly self?: {};
303304
};
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/**
2+
* Allows host shortcuts to function in your application by forwarding keyboard shortcuts to the host.
3+
*
4+
* This functionality is in Beta.
5+
* @beta
6+
* @module
7+
*/
8+
9+
import { callFunctionInHost, callFunctionInHostAndHandleResponse } from '../internal/communication';
10+
import { ensureInitialized } from '../internal/internalAPIs';
11+
import { ResponseHandler } from '../internal/responseHandler';
12+
import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry';
13+
import { errorNotSupportedOnPlatform } from './constants';
14+
import { runtime } from './runtime';
15+
import { ISerializable } from './serializable.interface';
16+
17+
/* ------------------------------------------------------------------ */
18+
/* Types */
19+
/* ------------------------------------------------------------------ */
20+
21+
type Shortcuts = Set<string>; // stores shortcut key combinations, for example: ["ctrl+/", "ctrl+shift+/"]
22+
23+
type HostShortcutsResponse = {
24+
shortcuts: Shortcuts;
25+
overridableShortcuts: Shortcuts;
26+
};
27+
28+
export type OverridableShortcutHandlerData = {
29+
matchedShortcut: string;
30+
};
31+
32+
/**
33+
* A handler must return `true` when it wants to **consume** the shortcut
34+
* (i.e. prevent forwarding to host). Any other return value forwards it.
35+
*/
36+
export type OverridableShortcutHandler = (event: KeyboardEvent, data: OverridableShortcutHandlerData) => boolean;
37+
38+
/* ------------------------------------------------------------------ */
39+
/* Utils */
40+
/* ------------------------------------------------------------------ */
41+
42+
class SerializableKeyboardEvent implements ISerializable {
43+
public constructor(private event: KeyboardEvent) {}
44+
public serialize(): object | string {
45+
return {
46+
altKey: this.event.altKey,
47+
bubbles: this.event.bubbles,
48+
cancelBubble: this.event.cancelBubble,
49+
charCode: this.event.charCode,
50+
code: this.event.code,
51+
composed: this.event.composed,
52+
ctrlKey: this.event.ctrlKey,
53+
// currentTarget: skipped
54+
defaultPrevented: this.event.defaultPrevented,
55+
detail: this.event.detail,
56+
eventPhase: this.event.eventPhase,
57+
isComposing: this.event.isComposing,
58+
isTrusted: this.event.isTrusted,
59+
key: this.event.key,
60+
keyCode: this.event.keyCode,
61+
location: this.event.location,
62+
metaKey: this.event.metaKey,
63+
// path - skipped,
64+
repeat: this.event.repeat,
65+
returnValue: this.event.returnValue,
66+
shiftKey: this.event.shiftKey,
67+
// sourceCapabilities - skipped,
68+
// srcElement - slipped.
69+
// target - skipped.
70+
timeStamp: this.event.timeStamp,
71+
type: this.event.type,
72+
// view - skipped
73+
which: this.event.which,
74+
};
75+
}
76+
}
77+
78+
/**
79+
* Normalizes a shortcut string to a canonical form.
80+
*/
81+
function normalizeShortcut(shortcut: string): string {
82+
return shortcut.toLowerCase().split('+').sort().join('+');
83+
}
84+
85+
/**
86+
* Build a canonical, lower-case “ctrl+shift+x” representation of the
87+
* currently pressed keys. The array is sorted so the order in which
88+
* modifiers are pressed does not matter.
89+
*/
90+
function eventToCanonicalShortcut(e: KeyboardEvent): string {
91+
return [e.ctrlKey && 'ctrl', e.shiftKey && 'shift', e.altKey && 'alt', e.metaKey && 'meta', e.key.toLowerCase()]
92+
.filter(Boolean)
93+
.sort()
94+
.join('+');
95+
}
96+
97+
function isMatchingShortcut(
98+
shortcuts: Shortcuts,
99+
e: KeyboardEvent,
100+
): {
101+
matchedShortcut: string | undefined;
102+
isOverridable: boolean;
103+
} {
104+
const pressedShortcut = eventToCanonicalShortcut(e);
105+
const isMatching = shortcuts.has(pressedShortcut);
106+
if (isMatching) {
107+
return {
108+
matchedShortcut: pressedShortcut,
109+
isOverridable: overridableShortcuts.has(pressedShortcut),
110+
};
111+
}
112+
return {
113+
matchedShortcut: undefined,
114+
isOverridable: false,
115+
};
116+
}
117+
118+
class HostShortcutsResponseHandler extends ResponseHandler<HostShortcutsResponse, HostShortcutsResponse> {
119+
public validate(response: HostShortcutsResponse): boolean {
120+
return response && Array.isArray(response.shortcuts) && Array.isArray(response.overridableShortcuts);
121+
}
122+
123+
public deserialize(response: HostShortcutsResponse): HostShortcutsResponse {
124+
this.onSuccess(response);
125+
return response;
126+
}
127+
128+
/** Persist the received shortcuts in memory */
129+
private onSuccess(response: HostShortcutsResponse): void {
130+
hostShortcuts.clear();
131+
response.shortcuts.forEach((shortcut: string) => {
132+
hostShortcuts.add(normalizeShortcut(shortcut));
133+
});
134+
overridableShortcuts.clear();
135+
response.overridableShortcuts.forEach((shortcut: string) => {
136+
overridableShortcuts.add(normalizeShortcut(shortcut));
137+
});
138+
}
139+
}
140+
141+
/* ------------------------------------------------------------------ */
142+
/* In-memory */
143+
/* ------------------------------------------------------------------ */
144+
/**
145+
* @hidden
146+
* @internal
147+
* Stores the shortcuts that can be overridden by the app.
148+
*/
149+
const overridableShortcuts: Set<string> = new Set();
150+
151+
/**
152+
* @hidden
153+
* @internal
154+
* Stores the shortcuts that are enabled in host.
155+
* This set is populated when the host sends the list of enabled shortcuts.
156+
*/
157+
const hostShortcuts: Set<string> = new Set();
158+
159+
/**
160+
* @hidden
161+
* @internal
162+
* Stores the handler for overridable shortcuts.
163+
*/
164+
let overridableShortcutHandler: OverridableShortcutHandler | undefined = undefined;
165+
166+
/* ------------------------------------------------------------------ */
167+
/* API */
168+
/* ------------------------------------------------------------------ */
169+
170+
/**
171+
* Replace the current overridable-shortcut handler.
172+
*
173+
* • Pass `undefined` to remove an existing handler.
174+
* • Returns the previous handler so callers can restore it if needed.
175+
*
176+
* @beta
177+
*/
178+
export function setOverridableShortcutHandler(
179+
handler: OverridableShortcutHandler | undefined,
180+
): OverridableShortcutHandler | undefined {
181+
const previous = overridableShortcutHandler;
182+
overridableShortcutHandler = handler;
183+
return previous;
184+
}
185+
186+
/**
187+
* Enable capability to support host shortcuts.
188+
*
189+
* @beta
190+
*/
191+
export function enableShortcutRelayCapability(): void {
192+
ensureInitialized(runtime);
193+
194+
if (!isSupported()) {
195+
throw errorNotSupportedOnPlatform;
196+
}
197+
198+
/* 1. Ask host for the list of enabled shortcuts */
199+
callFunctionInHostAndHandleResponse(
200+
ApiName.ShortcutRelay_RequestHostShortcuts,
201+
[],
202+
new HostShortcutsResponseHandler(),
203+
getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_RequestHostShortcuts),
204+
);
205+
206+
/* 2. Global key-down handler */
207+
document.addEventListener(
208+
'keydown',
209+
(event: KeyboardEvent) => {
210+
// Skip if the event target is within an element that has the `data-disable-shortcuts-forwarding` attribute
211+
if ((event.target as HTMLElement).closest(`[${DISABLE_SHORTCUT_FORWARDING_ATTRIBUTE}]`)) {
212+
return;
213+
}
214+
215+
const { matchedShortcut, isOverridable } = isMatchingShortcut(hostShortcuts, event);
216+
217+
if (!matchedShortcut) {
218+
return; // ignore unrelated events
219+
}
220+
221+
if (isOverridable && overridableShortcutHandler) {
222+
const shouldOverride = overridableShortcutHandler(event, { matchedShortcut });
223+
if (shouldOverride) {
224+
return; // Do not forward shortcut to host
225+
}
226+
}
227+
228+
/* Forward shortcut to host */
229+
const payload = new SerializableKeyboardEvent(event);
230+
231+
callFunctionInHost(
232+
ApiName.ShortcutRelay_ForwardShortcutEvent,
233+
[payload],
234+
getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_ForwardShortcutEvent),
235+
);
236+
237+
event.preventDefault();
238+
event.stopImmediatePropagation();
239+
},
240+
{ capture: true },
241+
);
242+
}
243+
244+
/**
245+
* Checks if shortcutRelay capability is supported by the host
246+
* @returns boolean to represent whether the shortcutRelay capability is supported
247+
*
248+
* @throws Error if {@link app.initialize} has not successfully completed
249+
*
250+
* @beta
251+
*/
252+
export function isSupported(): boolean {
253+
return ensureInitialized(runtime) && runtime.supports.shortcutRelay ? true : false;
254+
}
255+
256+
/**
257+
* Allow apps to define zones where shortcuts should not be forwarded to the host.
258+
* This is useful for input fields for password where shortcuts should not trigger host actions.
259+
*
260+
* @beta
261+
*/
262+
export const DISABLE_SHORTCUT_FORWARDING_ATTRIBUTE = 'data-disable-shortcuts-forwarding';

0 commit comments

Comments
 (0)