Added hotkeys to the client
parent
6fc7f9a8dc
commit
3b500daac7
|
@ -1,4 +1,8 @@
|
|||
# Changelog:
|
||||
* **10.03.20**
|
||||
- Improved key code displaying
|
||||
- Added a keymap system (Hotkeys)
|
||||
|
||||
* **09.03.20**
|
||||
- Using React for the client control bar
|
||||
- Saving last away state and message
|
||||
|
|
|
@ -1959,6 +1959,7 @@
|
|||
<div class="entry" container="general-language">{{tr "Language" /}}</div>
|
||||
<!-- <div class="entry" container="general-updates">{{tr "Updates" /}}</div> -->
|
||||
<div class="entry" container="general-chat">{{tr "Chat" /}}</div>
|
||||
<div class="entry" container="general-keymap">{{tr "Keymap" /}}</div>
|
||||
|
||||
<div class="entry group">{{tr "Audio" /}}</div>
|
||||
<div class="entry" container="audio-microphone">{{tr "Microphone" /}}</div>
|
||||
|
@ -2019,6 +2020,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="container general-updates">{{tr "GU" /}}</div>
|
||||
<div class="container general-keymap"></div>
|
||||
<div class="container general-chat">
|
||||
<label>
|
||||
<div class="checkbox">
|
||||
|
|
|
@ -12,7 +12,8 @@ export interface BroadcastMessage {
|
|||
|
||||
function uuidv4() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -317,6 +317,8 @@ export class ConnectionHandler {
|
|||
|
||||
async disconnectFromServer(reason?: string) {
|
||||
this.cancel_reconnect(true);
|
||||
if(!this.connected) return;
|
||||
|
||||
this.handleDisconnect(DisconnectReason.REQUESTED); //TODO message?
|
||||
try {
|
||||
await this.serverConnection.disconnect();
|
||||
|
@ -977,7 +979,7 @@ export class ConnectionHandler {
|
|||
this.sound.play(muted ? Sound.MICROPHONE_MUTED : Sound.MICROPHONE_ACTIVATED);
|
||||
this.update_voice_status();
|
||||
}
|
||||
|
||||
toggleMicrophone() { this.setMicrophoneMuted(!this.isMicrophoneMuted()); }
|
||||
isMicrophoneMuted() { return this.client_status.input_muted; }
|
||||
|
||||
/*
|
||||
|
@ -996,6 +998,7 @@ export class ConnectionHandler {
|
|||
this.update_voice_status();
|
||||
}
|
||||
|
||||
toggleSpeakerMuted() { this.setSpeakerMuted(!this.isSpeakerMuted()); }
|
||||
isSpeakerMuted() { return this.client_status.output_muted; }
|
||||
|
||||
/*
|
||||
|
@ -1044,7 +1047,7 @@ export class ConnectionHandler {
|
|||
state: "away"
|
||||
});
|
||||
}
|
||||
|
||||
toggleAway() { this.setAway(!this.isAway()); }
|
||||
isAway() : boolean { return typeof this.client_status.away !== "boolean" || this.client_status.away; }
|
||||
|
||||
setQueriesShown(flag: boolean) {
|
||||
|
|
|
@ -0,0 +1,199 @@
|
|||
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
|
||||
import {EventType, KeyDescriptor, KeyEvent, KeyHook} from "tc-shared/PPTListener";
|
||||
import * as ppt from "tc-backend/ppt";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
import * as log from "tc-shared/log";
|
||||
import {LogCategory} from "tc-shared/log";
|
||||
|
||||
export interface KeyControl {
|
||||
category: string;
|
||||
description: string;
|
||||
handler: () => void;
|
||||
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const TypeCategories: {[key: string]: { name: string }} = {
|
||||
"connection": {
|
||||
name: "Server connection"
|
||||
},
|
||||
"microphone": {
|
||||
name: "Microphone"
|
||||
},
|
||||
"speaker": {
|
||||
name: "Speaker"
|
||||
},
|
||||
"away": {
|
||||
name: "Away"
|
||||
}
|
||||
};
|
||||
|
||||
export const KeyTypes: {[key: string]:KeyControl} = {
|
||||
"disconnect-current": {
|
||||
category: "connection",
|
||||
description: "Disconnect from the current server",
|
||||
handler: () => server_connections.active_connection()?.disconnectFromServer(),
|
||||
icon: "client-disconnect"
|
||||
},
|
||||
"disconnect-all": {
|
||||
category: "connection",
|
||||
description: "Disconnect from all connected servers",
|
||||
handler: () => server_connections.all_connections().forEach(e => e.disconnectFromServer()),
|
||||
icon: "client-disconnect"
|
||||
},
|
||||
|
||||
"toggle-microphone": {
|
||||
category: "microphone",
|
||||
description: "Toggle your microphone status",
|
||||
handler: () => server_connections.active_connection()?.toggleMicrophone(),
|
||||
icon: "client-input_muted"
|
||||
},
|
||||
"enable-microphone": {
|
||||
category: "microphone",
|
||||
description: "Enable your microphone",
|
||||
handler: () => server_connections.active_connection()?.setMicrophoneMuted(false),
|
||||
icon: "client-input_muted"
|
||||
},
|
||||
"disable-microphone": {
|
||||
category: "microphone",
|
||||
description: "Disable your microphone",
|
||||
handler: () => server_connections.active_connection()?.setMicrophoneMuted(true),
|
||||
icon: "client-input_muted"
|
||||
},
|
||||
|
||||
"toggle-speaker": {
|
||||
category: "speaker",
|
||||
description: "Toggle your speaker status",
|
||||
handler: () => server_connections.active_connection()?.toggleSpeakerMuted(),
|
||||
icon: "client-output_muted"
|
||||
},
|
||||
"enable-speaker": {
|
||||
category: "speaker",
|
||||
description: "Enable your speakers",
|
||||
handler: () => server_connections.active_connection()?.setSpeakerMuted(false),
|
||||
icon: "client-output_muted"
|
||||
},
|
||||
"disable-speaker": {
|
||||
category: "speaker",
|
||||
description: "Disable your speakers",
|
||||
handler: () => server_connections.active_connection()?.setSpeakerMuted(true),
|
||||
icon: "client-output_muted"
|
||||
},
|
||||
|
||||
/* toggle away */
|
||||
"toggle-away-state": {
|
||||
category: "away",
|
||||
description: "Toggle your away state",
|
||||
handler: () => server_connections.active_connection()?.toggleAway(),
|
||||
icon: "client-away"
|
||||
},
|
||||
"enable-away-state": {
|
||||
category: "away",
|
||||
description: "Enable away for the current server",
|
||||
handler: () => server_connections.active_connection()?.setAway(true),
|
||||
icon: "client-away"
|
||||
},
|
||||
"disable-away-state": {
|
||||
category: "away",
|
||||
description: "Disable away for the current server",
|
||||
handler: () => server_connections.active_connection()?.setAway(false),
|
||||
icon: "client-present"
|
||||
},
|
||||
"toggle-away-state-globally": {
|
||||
category: "away",
|
||||
description: "Toggle your away state for every server",
|
||||
handler: () => server_connections.all_connections().forEach(e => e.toggleAway()),
|
||||
icon: "client-away"
|
||||
},
|
||||
"enable-away-state-globally": {
|
||||
category: "away",
|
||||
description: "Enable away for every server",
|
||||
handler: () => server_connections.all_connections().forEach(e => e.setAway(true)),
|
||||
icon: "client-away"
|
||||
},
|
||||
"disable-away-state-globally": {
|
||||
category: "away",
|
||||
description: "Disable away for every server",
|
||||
handler: () => server_connections.all_connections().forEach(e => e.setAway(false)),
|
||||
icon: "client-present"
|
||||
},
|
||||
};
|
||||
|
||||
let key_bindings: {[key: string]: {
|
||||
binding: KeyDescriptor,
|
||||
hook: KeyHook
|
||||
}} = {};
|
||||
|
||||
interface Config {
|
||||
version?: number;
|
||||
|
||||
keys?: {[key: string]: KeyDescriptor};
|
||||
}
|
||||
|
||||
let config: Config;
|
||||
export function initialize() {
|
||||
let cfg: Config;
|
||||
try {
|
||||
cfg = JSON.parse(settings.global(Settings.KEY_KEYCONTROL_DATA));
|
||||
} catch (e) {
|
||||
log.error(LogCategory.GENERAL, tr("Failed to parse old key control data."));
|
||||
cfg = {};
|
||||
}
|
||||
|
||||
if(typeof cfg.version !== "number") {
|
||||
/* new config */
|
||||
cfg.version = 0;
|
||||
}
|
||||
switch (cfg.version) {
|
||||
case 0:
|
||||
cfg.version = 1;
|
||||
cfg.keys = {};
|
||||
default:
|
||||
break;
|
||||
}
|
||||
config = cfg;
|
||||
|
||||
for(const key of Object.keys(config.keys)) {
|
||||
if(typeof KeyTypes[key] !== "object")
|
||||
continue;
|
||||
|
||||
bind_key(key, config.keys[key]);
|
||||
}
|
||||
}
|
||||
|
||||
function save_config() {
|
||||
settings.changeGlobal(Settings.KEY_KEYCONTROL_DATA, JSON.stringify(config));
|
||||
}
|
||||
|
||||
function bind_key(action: string, key: KeyDescriptor) {
|
||||
const control = KeyTypes[action];
|
||||
if(typeof control === "undefined")
|
||||
throw "missing control event";
|
||||
|
||||
key_bindings[action] = {
|
||||
hook: Object.assign({
|
||||
callback_press: () => control.handler(),
|
||||
callback_release: () => {},
|
||||
cancel: false
|
||||
}, key),
|
||||
binding: key
|
||||
};
|
||||
ppt.register_key_hook(key_bindings[action].hook);
|
||||
}
|
||||
|
||||
export function set_key(action: string, key?: KeyDescriptor) {
|
||||
if(typeof key_bindings[action] !== "undefined") {
|
||||
ppt.unregister_key_hook(key_bindings[action].hook);
|
||||
delete key_bindings[action];
|
||||
}
|
||||
if(key) {
|
||||
bind_key(action, key);
|
||||
config.keys[action] = key;
|
||||
} else {
|
||||
delete config.keys[action];
|
||||
}
|
||||
|
||||
save_config();
|
||||
}
|
||||
|
||||
export function key(action: string) : KeyDescriptor | undefined { return key_bindings[action]?.binding; }
|
|
@ -168,7 +168,17 @@ export function key_description(key: KeyDescriptor) {
|
|||
if(!result && !key.key_code)
|
||||
return tr("unset");
|
||||
|
||||
if(key.key_code)
|
||||
result += " + " + key.key_code;
|
||||
if(key.key_code) {
|
||||
let key_name;
|
||||
if(key.key_code.startsWith("Key"))
|
||||
key_name = key.key_code.substr(3);
|
||||
else if(key.key_code.startsWith("Digit"))
|
||||
key_name = key.key_code.substr(5);
|
||||
else if(key.key_code.startsWith("Numpad"))
|
||||
key_name = "Numpad " + key.key_code.substr(6);
|
||||
else
|
||||
key_name = key.key_code;
|
||||
result += " + " + key_name;
|
||||
}
|
||||
return result.substr(3);
|
||||
}
|
|
@ -24,13 +24,13 @@ import {openModalNewcomer} from "tc-shared/ui/modal/ModalNewcomer";
|
|||
import * as aplayer from "tc-backend/audio/player";
|
||||
import * as arecorder from "tc-backend/audio/recorder";
|
||||
import * as ppt from "tc-backend/ppt";
|
||||
|
||||
import * as keycontrol from "./KeyControl";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import * as cbar from "./ui/frames/control-bar";
|
||||
import * as global_ev_handler from "./events/ClientGlobalControlHandler";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {ClientGlobalControlEvents, global_client_actions} from "tc-shared/events/GlobalEvents";
|
||||
import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings";
|
||||
|
||||
/* required import for init */
|
||||
require("./proto").initialize();
|
||||
|
@ -338,6 +338,7 @@ function main() {
|
|||
$(".window-resize-listener").trigger('resize');
|
||||
}, 1000);
|
||||
});
|
||||
keycontrol.initialize();
|
||||
|
||||
stats.initialize({
|
||||
verbose: true,
|
||||
|
@ -400,7 +401,7 @@ function main() {
|
|||
], () => {});
|
||||
*/
|
||||
}, 4000);
|
||||
//Modals.spawnSettingsModal("identity-profiles");
|
||||
spawnSettingsModal("general-keymap");
|
||||
//Modals.spawnKeySelect(console.log);
|
||||
//Modals.spawnBookmarkModal();
|
||||
|
||||
|
|
|
@ -343,6 +343,11 @@ export class Settings extends StaticSettings {
|
|||
default_value: 100
|
||||
};
|
||||
|
||||
static readonly KEY_KEYCONTROL_DATA: SettingsKey<string> = {
|
||||
key: "keycontrol_data",
|
||||
default_value: "{}"
|
||||
};
|
||||
|
||||
static readonly KEY_LAST_INVITE_LINK_TYPE: SettingsKey<string> = {
|
||||
key: "last_invite_link_type",
|
||||
default_value: "tea-web"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from "react";
|
||||
import {ReactComponentBase} from "tc-shared/ui/elements/ReactComponentBase";
|
||||
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
||||
import {DropdownContainer} from "tc-shared/ui/frames/control-bar/dropdown";
|
||||
const cssStyle = require("./button.scss");
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from "react";
|
||||
import {ReactComponentBase} from "tc-shared/ui/elements/ReactComponentBase";
|
||||
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
||||
import {IconRenderer} from "tc-shared/ui/react-elements/Icon";
|
||||
const cssStyle = require("./button.scss");
|
||||
|
||||
export interface DropdownEntryProperties {
|
||||
|
@ -10,36 +11,6 @@ export interface DropdownEntryProperties {
|
|||
onContextMenu?: (event) => void;
|
||||
}
|
||||
|
||||
class IconRenderer extends React.Component<{ icon: string | JQuery<HTMLDivElement> }, {}> {
|
||||
private readonly icon_ref: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if(typeof this.props.icon === "object")
|
||||
this.icon_ref = React.createRef();
|
||||
}
|
||||
|
||||
render() {
|
||||
if(!this.props.icon)
|
||||
return <div className={"icon-container icon-empty"} />;
|
||||
else if(typeof this.props.icon === "string")
|
||||
return <div className={"icon " + this.props.icon} />;
|
||||
|
||||
|
||||
return <div ref={this.icon_ref} />;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
if(this.icon_ref)
|
||||
$(this.icon_ref.current).replaceWith(this.props.icon);
|
||||
}
|
||||
componentWillUnmount(): void {
|
||||
if(this.icon_ref)
|
||||
$(this.icon_ref.current).empty();
|
||||
}
|
||||
}
|
||||
|
||||
export class DropdownEntry extends ReactComponentBase<DropdownEntryProperties, {}> {
|
||||
protected default_state() { return {}; }
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from "react";
|
||||
import {Button} from "./button";
|
||||
import {DropdownEntry} from "tc-shared/ui/frames/control-bar/dropdown";
|
||||
import {Translatable} from "tc-shared/ui/elements/i18n";
|
||||
import {ReactComponentBase} from "tc-shared/ui/elements/ReactComponentBase";
|
||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
||||
import {ConnectionEvents, ConnectionHandler, ConnectionStateUpdateType} from "tc-shared/ConnectionHandler";
|
||||
import {Event, EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
|
||||
import {ConnectionManagerEvents, server_connections} from "tc-shared/ui/frames/connection_handlers";
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import {createModal} from "tc-shared/ui/elements/Modal";
|
||||
import {EventType, key_description, KeyEvent} from "tc-shared/PPTListener";
|
||||
import * as loader from "tc-loader";
|
||||
import * as ppt from "tc-backend/ppt";
|
||||
|
||||
export function spawnKeySelect(callback: (key?: KeyEvent) => void) {
|
||||
|
|
|
@ -30,6 +30,9 @@ import * as loader from "tc-loader";
|
|||
import * as aplayer from "tc-backend/audio/player";
|
||||
import * as arecorder from "tc-backend/audio/recorder";
|
||||
import * as ppt from "tc-backend/ppt";
|
||||
import {KeyMapSettings} from "tc-shared/ui/modal/settings/Keymap";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
|
||||
export function spawnSettingsModal(default_page?: string) : Modal {
|
||||
let modal: Modal;
|
||||
|
@ -71,6 +74,7 @@ export function spawnSettingsModal(default_page?: string) : Modal {
|
|||
settings_general_application(modal.htmlTag.find(".right .container.general-application"), modal);
|
||||
settings_general_language(modal.htmlTag.find(".right .container.general-language"), modal);
|
||||
settings_general_chat(modal.htmlTag.find(".right .container.general-chat"), modal);
|
||||
settings_general_keymap(modal.htmlTag.find(".right .container.general-keymap"), modal);
|
||||
settings_audio_microphone(modal.htmlTag.find(".right .container.audio-microphone"), modal);
|
||||
settings_audio_speaker(modal.htmlTag.find(".right .container.audio-speaker"), modal);
|
||||
settings_audio_sounds(modal.htmlTag.find(".right .container.audio-sounds"), modal);
|
||||
|
@ -342,6 +346,12 @@ function settings_general_language(container: JQuery, modal: Modal) {
|
|||
update_current_selected();
|
||||
}
|
||||
|
||||
function settings_general_keymap(container: JQuery, modal: Modal) {
|
||||
const entry = <KeyMapSettings />;
|
||||
ReactDOM.render(entry, container[0]);
|
||||
modal.close_listener.push(() => ReactDOM.hydrate(entry, container[0])); //FIXME: hydrate does not work!
|
||||
}
|
||||
|
||||
function settings_general_chat(container: JQuery, modal: Modal) {
|
||||
/* timestamp format */
|
||||
{
|
||||
|
@ -587,9 +597,12 @@ function settings_audio_sounds(contianer: JQuery, modal: Modal) {
|
|||
/* initialize sound list */
|
||||
{
|
||||
const container_sounds = contianer.find(".container-sounds");
|
||||
let scrollbar;
|
||||
/*
|
||||
let scrollbar: SimpleBar;
|
||||
if("SimpleBar" in window)
|
||||
scrollbar = new SimpleBar(container_sounds[0]);
|
||||
*/
|
||||
|
||||
const generate_sound = (_sound: Sound) => {
|
||||
let tag_play_pause: JQuery, tag_play: JQuery, tag_pause: JQuery, tag_input_muted: JQuery;
|
||||
|
@ -662,13 +675,13 @@ function settings_audio_sounds(contianer: JQuery, modal: Modal) {
|
|||
|
||||
const overlap_tag = contianer.find(".option-overlap-same");
|
||||
overlap_tag.on('change', event => {
|
||||
const activated = (<HTMLInputElement>event.target).checked;
|
||||
const activated = (event.target as HTMLInputElement).checked;
|
||||
sound.set_overlap_activated(activated);
|
||||
}).prop("checked", sound.overlap_activated());
|
||||
|
||||
const mute_tag = contianer.find(".option-mute-output");
|
||||
mute_tag.on('change', event => {
|
||||
const activated = (<HTMLInputElement>event.target).checked;
|
||||
const activated = (event.target as HTMLInputElement).checked;
|
||||
sound.set_ignore_output_muted(!activated);
|
||||
}).prop("checked", !sound.ignore_output_muted());
|
||||
|
||||
|
@ -2031,10 +2044,10 @@ export namespace modal_settings {
|
|||
let last_value;
|
||||
|
||||
container_select.find("input").on('change', event => {
|
||||
if(!(<HTMLInputElement>event.target).checked)
|
||||
if(!(event.target as HTMLInputElement).checked)
|
||||
return;
|
||||
|
||||
const mode = (<HTMLInputElement>event.target).value;
|
||||
const mode = (event.target as HTMLInputElement).value;
|
||||
if(mode === last_value) return;
|
||||
|
||||
event_registry.fire("set-setting", { setting: "vad-type", value: mode });
|
|
@ -0,0 +1,167 @@
|
|||
@import "../../../../css/static/mixin.scss";
|
||||
@import "../../../../css/static/properties.scss";
|
||||
|
||||
.containerList {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
|
||||
min-height: 6em;
|
||||
|
||||
background-color: $color_list_background;
|
||||
border: 1px $color_list_border solid;
|
||||
|
||||
border-radius: $border_radius_large;
|
||||
|
||||
.elements {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
padding-top: .5em;
|
||||
padding-bottom: .5em;
|
||||
|
||||
@include chat-scrollbar-vertical();
|
||||
|
||||
.row {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
|
||||
height: 1.5em;
|
||||
|
||||
padding-right: .5em;
|
||||
cursor: pointer;
|
||||
|
||||
&.category {
|
||||
padding-left: .25em;
|
||||
|
||||
:global .arrow {
|
||||
align-self: center;
|
||||
margin: .3em;
|
||||
/*
|
||||
margin-right: .5em;
|
||||
|
||||
&.down {
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
a {
|
||||
margin-left: .2em;
|
||||
}
|
||||
}
|
||||
|
||||
&.entry {
|
||||
padding-left: 2em;
|
||||
|
||||
:global .icon {
|
||||
align-self: center;
|
||||
|
||||
margin-right: .25em;
|
||||
}
|
||||
|
||||
.key {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
color: #464646;
|
||||
background-color: #17171a;
|
||||
border-radius: .2em;
|
||||
|
||||
height: 1.5em;
|
||||
font-size: .7em;
|
||||
|
||||
padding-left: .35em;
|
||||
padding-right: .35em;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
font-size: .7em;
|
||||
align-self: center;
|
||||
|
||||
&.error {
|
||||
color: #a10000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
min-width: 2em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color_list_hover;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: $color_list_selected;
|
||||
|
||||
.key {
|
||||
background-color: #0e0e10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.row[hidden] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
|
||||
background-color: #242527;
|
||||
|
||||
padding: .5em;
|
||||
|
||||
border: none;
|
||||
border-top: 1px solid #161616;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 3em;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
padding-bottom: 0.5em;
|
||||
|
||||
a {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
align-self: flex-end;
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,371 @@
|
|||
import * as ppt from "tc-shared/PPTListener";
|
||||
import {KeyDescriptor} from "tc-shared/PPTListener";
|
||||
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
||||
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
|
||||
import * as React from "react";
|
||||
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||
import {KeyTypes, TypeCategories} from "tc-shared/KeyControl";
|
||||
import {IconRenderer} from "tc-shared/ui/react-elements/Icon";
|
||||
import {spawnKeySelect} from "tc-shared/ui/modal/ModalKeySelect";
|
||||
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
||||
import {tra} from "tc-shared/i18n/localize";
|
||||
import * as keycontrol from "./../../../KeyControl";
|
||||
import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
|
||||
|
||||
const cssStyle = require("./Keymap.scss");
|
||||
|
||||
export interface KeyMapEvents {
|
||||
query_keymap: {
|
||||
action: string,
|
||||
query_type: "query-selected" | "general"
|
||||
},
|
||||
query_keymap_result: {
|
||||
action: string,
|
||||
status: "success" | "error" | "timeout",
|
||||
|
||||
error?: string,
|
||||
key?: KeyDescriptor
|
||||
},
|
||||
|
||||
set_keymap: {
|
||||
action: string,
|
||||
key?: KeyDescriptor
|
||||
},
|
||||
set_keymap_result: {
|
||||
action: string,
|
||||
status: "success" | "error" | "timeout",
|
||||
error?: string,
|
||||
key?: KeyDescriptor
|
||||
},
|
||||
|
||||
set_selected_action: {
|
||||
action: string
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyActionEntryState {
|
||||
assignedKey: KeyDescriptor | undefined;
|
||||
selected: boolean;
|
||||
state: "loading" | "applying" | "loaded" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface KeyActionEntryProperties {
|
||||
action: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
eventRegistry: Registry<KeyMapEvents>;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
@ReactEventHandler(e => e.props.eventRegistry)
|
||||
class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyActionEntryState> {
|
||||
protected default_state() : KeyActionEntryState {
|
||||
return {
|
||||
assignedKey: undefined,
|
||||
selected: false,
|
||||
state: "loading"
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.props.eventRegistry.fire("query_keymap", { action: this.props.action, query_type: "general" });
|
||||
}
|
||||
|
||||
render() {
|
||||
let rightItem;
|
||||
if(this.state.state === "loading") {
|
||||
rightItem = <div key={"status-loading"} className={cssStyle.status}><Translatable message={"loading..."} /></div>;
|
||||
} else if(this.state.state === "applying") {
|
||||
rightItem = <div key={"status-applying"} className={cssStyle.status}><Translatable message={"applying..."} /></div>;
|
||||
} else if(this.state.state === "loaded") {
|
||||
rightItem = null;
|
||||
if(this.state.assignedKey)
|
||||
rightItem = <div className={cssStyle.key}>{ppt.key_description(this.state.assignedKey)}</div>;
|
||||
} else {
|
||||
rightItem = <div key={"status-error"} className={this.classList(cssStyle.status, cssStyle.error)}><Translatable message={this.state.error || "unknown error"} /></div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={"action-" + this.props.action}
|
||||
className={this.classList(cssStyle.row, cssStyle.entry, this.state.selected ? cssStyle.selected : "")}
|
||||
onClick={() => this.onClick()}
|
||||
onDoubleClick={() => this.onDoubleClick()}
|
||||
hidden={this.props.hidden}
|
||||
onContextMenu={e => this.onContextMenu(e)}
|
||||
>
|
||||
<IconRenderer icon={this.props.icon}/>
|
||||
<a><Translatable message={this.props.description} /></a>
|
||||
{rightItem}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onClick() {
|
||||
this.props.eventRegistry.fire("set_selected_action", { action: this.props.action });
|
||||
}
|
||||
|
||||
private onDoubleClick() {
|
||||
spawnKeySelect(key => {
|
||||
if(!key) return;
|
||||
|
||||
this.props.eventRegistry.fire("set_keymap", { action: this.props.action, key: key });
|
||||
});
|
||||
}
|
||||
|
||||
private onContextMenu(event) {
|
||||
event.preventDefault();
|
||||
|
||||
spawn_context_menu(event.pageX, event.pageY, {
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Set key"),
|
||||
icon_class: "client-hotkeys",
|
||||
callback: () => this.onDoubleClick()
|
||||
}, {
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Remove key"),
|
||||
icon_class: "client-delete",
|
||||
callback: () => this.props.eventRegistry.fire("set_keymap", { action: this.props.action, key: undefined }),
|
||||
visible: !!this.state.assignedKey
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler<KeyMapEvents>("set_selected_action")
|
||||
private handleSelectedChange(event: KeyMapEvents["set_selected_action"]) {
|
||||
this.updateState({
|
||||
selected: this.props.action === event.action
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler<KeyMapEvents>("query_keymap")
|
||||
private handleQueryKeymap(event: KeyMapEvents["query_keymap"]) {
|
||||
if(event.action !== this.props.action) return;
|
||||
if(event.query_type !== "general") return;
|
||||
|
||||
this.updateState({
|
||||
state: "loading"
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler<KeyMapEvents>("query_keymap_result")
|
||||
private handleQueryKeymapResult(event: KeyMapEvents["query_keymap_result"]) {
|
||||
if(event.action !== this.props.action) return;
|
||||
|
||||
if(event.status === "success") {
|
||||
this.updateState({
|
||||
state: "loaded",
|
||||
assignedKey: event.key
|
||||
});
|
||||
} else {
|
||||
this.updateState({
|
||||
state: "error",
|
||||
error: event.status === "timeout" ? tr("query timeout") : event.error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler<KeyMapEvents>("set_keymap")
|
||||
private handleSetKeymap(event: KeyMapEvents["set_keymap"]) {
|
||||
if(event.action !== this.props.action) return;
|
||||
|
||||
this.updateState({ state: "applying" });
|
||||
}
|
||||
|
||||
@EventHandler<KeyMapEvents>("set_keymap_result")
|
||||
private handleSetKeymapResult(event: KeyMapEvents["set_keymap_result"]) {
|
||||
if(event.action !== this.props.action) return;
|
||||
|
||||
if(event.status === "success") {
|
||||
this.updateState({
|
||||
state: "loaded",
|
||||
assignedKey: event.key
|
||||
});
|
||||
} else {
|
||||
this.updateState({ state: "loaded" });
|
||||
createErrorModal(tr("Failed to change key"), tra("Failed to change key for action \"{}\":{:br:}{}", this.props.action, event.status === "timeout" ? tr("timeout") : event.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyActionGroupProperties {
|
||||
id: string;
|
||||
name: string;
|
||||
eventRegistry: Registry<KeyMapEvents>;
|
||||
}
|
||||
|
||||
class KeyActionGroup extends ReactComponentBase<KeyActionGroupProperties, { collapsed: boolean }> {
|
||||
protected default_state(): { collapsed: boolean } {
|
||||
return { collapsed: false }
|
||||
}
|
||||
|
||||
render() {
|
||||
const result = [];
|
||||
result.push(<div key={"category-" + this.props.id} className={this.classList(cssStyle.row, cssStyle.category)} onClick={() => this.toggleCollapsed()}>
|
||||
<div className={this.classList("arrow", this.state.collapsed ? "right" : "down")} />
|
||||
<a><Translatable message={this.props.name} /></a>
|
||||
</div>);
|
||||
|
||||
result.push(...Object.keys(KeyTypes).filter(e => KeyTypes[e].category === this.props.id).map(e => (
|
||||
<KeyActionEntry hidden={this.state.collapsed} key={e} action={e} icon={KeyTypes[e].icon} description={KeyTypes[e].description} eventRegistry={this.props.eventRegistry} />
|
||||
)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private toggleCollapsed() {
|
||||
this.updateState({
|
||||
collapsed: !this.state.collapsed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyActionListProperties {
|
||||
eventRegistry: Registry<KeyMapEvents>;
|
||||
}
|
||||
|
||||
class KeyActionList extends ReactComponentBase<KeyActionListProperties, {}> {
|
||||
protected default_state(): {} {
|
||||
return {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const categories = [];
|
||||
|
||||
for(const category of Object.keys(TypeCategories)) {
|
||||
categories.push(<KeyActionGroup eventRegistry={this.props.eventRegistry} key={category} id={category} name={TypeCategories[category].name} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssStyle.elements}>
|
||||
{categories}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface ButtonBarState {
|
||||
active_action: string | undefined;
|
||||
loading: boolean;
|
||||
has_key: boolean;
|
||||
}
|
||||
|
||||
@ReactEventHandler(e => e.props.event_registry)
|
||||
class ButtonBar extends ReactComponentBase<{ event_registry: Registry<KeyMapEvents> }, ButtonBarState> {
|
||||
protected default_state(): ButtonBarState {
|
||||
return {
|
||||
active_action: undefined,
|
||||
loading: true,
|
||||
has_key: false
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={cssStyle.buttons}>
|
||||
<Button color={"red"} disabled={!this.state.active_action || this.state.loading || !this.state.has_key} onClick={() => this.onButtonClick()}>
|
||||
<Translatable message={"Clear Key"} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@EventHandler<KeyMapEvents>("set_selected_action")
|
||||
private handleSetSelectedAction(event: KeyMapEvents["set_selected_action"]) {
|
||||
this.updateState({
|
||||
active_action: event.action,
|
||||
loading: true
|
||||
}, () => {
|
||||
this.props.event_registry.fire("query_keymap", { action: event.action, query_type: "query-selected" });
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler<KeyMapEvents>("query_keymap_result")
|
||||
private handleQueryKeymapResult(event: KeyMapEvents["query_keymap_result"]) {
|
||||
this.updateState({
|
||||
loading: false,
|
||||
has_key: event.status === "success" && !!event.key
|
||||
});
|
||||
}
|
||||
|
||||
private onButtonClick() {
|
||||
this.props.event_registry.fire("set_keymap", { action: this.state.active_action, key: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyMapSettings extends React.Component<{}, {}> {
|
||||
private readonly event_registry: Registry<KeyMapEvents>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.event_registry = new Registry<KeyMapEvents>();
|
||||
initialize_timeouts(this.event_registry);
|
||||
initialize_controller(this.event_registry);
|
||||
}
|
||||
|
||||
render() {
|
||||
//TODO: May refresh button?
|
||||
return [
|
||||
<div key={"header"} className={cssStyle.header}>
|
||||
<a><Translatable message={"Keymap"} /></a>
|
||||
</div>,
|
||||
<div key={"body"} className={cssStyle.containerList}>
|
||||
<KeyActionList eventRegistry={this.event_registry} />
|
||||
<ButtonBar event_registry={this.event_registry} />
|
||||
</div>
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function initialize_timeouts(event_registry: Registry<KeyMapEvents>) {
|
||||
/* query */
|
||||
{
|
||||
let timeouts = {};
|
||||
event_registry.on("query_keymap", event => {
|
||||
clearTimeout(timeouts[event.action]);
|
||||
timeouts[event.action] = setTimeout(() => {
|
||||
event_registry.fire("query_keymap_result", { action: event.action, status: "timeout" });
|
||||
}, 5000);
|
||||
});
|
||||
event_registry.on("query_keymap_result", event => {
|
||||
clearTimeout(timeouts[event.action]);
|
||||
delete timeouts[event.action];
|
||||
});
|
||||
}
|
||||
|
||||
/* change */
|
||||
{
|
||||
let timeouts = {};
|
||||
event_registry.on("set_keymap", event => {
|
||||
clearTimeout(timeouts[event.action]);
|
||||
timeouts[event.action] = setTimeout(() => {
|
||||
event_registry.fire("set_keymap_result", { action: event.action, status: "timeout" });
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
event_registry.on("set_keymap_result", event => {
|
||||
clearTimeout(timeouts[event.action]);
|
||||
delete timeouts[event.action];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initialize_controller(event_registry: Registry<KeyMapEvents>) {
|
||||
event_registry.on("query_keymap", event => {
|
||||
event_registry.fire_async("query_keymap_result", {
|
||||
status: "success",
|
||||
action: event.action,
|
||||
key: keycontrol.key(event.action)
|
||||
});
|
||||
});
|
||||
|
||||
event_registry.on("set_keymap", event => {
|
||||
try {
|
||||
keycontrol.set_key(event.action, event.key);
|
||||
event_registry.fire_async("set_keymap_result", { status: "success", action: event.action, key: event.key });
|
||||
} catch (error) {
|
||||
console.warn("Failed to change key for action %s: %o", event.action, error);
|
||||
event_registry.fire_async("set_keymap_result", { status: "error", action: event.action, error: error instanceof Error ? error.message : error?.toString() });
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
@import "../../../css/static/mixin.scss";
|
||||
@import "../../../css/static/properties.scss";
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
border-width: 0;
|
||||
border-radius: $border_radius_middle;
|
||||
border-style: solid;
|
||||
|
||||
color: #7c7c7c;
|
||||
|
||||
height: 2.2em;
|
||||
padding: .25em 1em;
|
||||
|
||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, .14), 0 3px 1px -2px rgba(0, 0, 0, .2), 0 1px 5px 0 rgba(0, 0, 0, .12);
|
||||
|
||||
@include text-dotdotdot();
|
||||
|
||||
&:hover {
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
box-shadow: none;
|
||||
background-color: rgba(0, 0, 0, 0.27);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.27);
|
||||
}
|
||||
}
|
||||
|
||||
&.color-green {
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: #389738;
|
||||
}
|
||||
|
||||
&.color-blue, &.color-default {
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: #386896;
|
||||
}
|
||||
|
||||
&.color-red {
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: #973838;
|
||||
}
|
||||
|
||||
&.color-purple {
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: #5f3586;
|
||||
}
|
||||
|
||||
&.color-brown {
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: #965238;
|
||||
}
|
||||
|
||||
&.color-yellow {
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: #96903a;
|
||||
}
|
||||
|
||||
&.type-normal { }
|
||||
|
||||
&.type-small {
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
&.type-extra-small {
|
||||
font-size: .6em;
|
||||
}
|
||||
|
||||
@include transition(background-color $button_hover_animation_time ease-in-out);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
||||
import * as React from "react";
|
||||
const cssStyle = require("./Button.scss");
|
||||
|
||||
export interface ButtonProperties {
|
||||
color?: "green" | "blue" | "red" | "purple" | "brown" | "yellow" | "default";
|
||||
type?: "normal" | "small" | "extra-small";
|
||||
|
||||
onClick?: () => void;
|
||||
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ButtonState {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
|
||||
protected default_state(): ButtonState {
|
||||
return {
|
||||
disabled: undefined
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<button
|
||||
className={this.classList(
|
||||
cssStyle.button,
|
||||
cssStyle["color-" + this.props.color] || cssStyle["color-default"],
|
||||
cssStyle["type-" + this.props.type] || cssStyle["type-normal"]
|
||||
)}
|
||||
disabled={this.state.disabled || this.props.disabled}
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
{this.props.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import * as React from "react";
|
||||
|
||||
export interface IconProperties {
|
||||
icon: string | JQuery<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export class IconRenderer extends React.Component<IconProperties, {}> {
|
||||
private readonly icon_ref: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if(typeof this.props.icon === "object")
|
||||
this.icon_ref = React.createRef();
|
||||
}
|
||||
|
||||
render() {
|
||||
if(!this.props.icon)
|
||||
return <div className={"icon-container icon-empty"} />;
|
||||
else if(typeof this.props.icon === "string")
|
||||
return <div className={"icon " + this.props.icon} />;
|
||||
|
||||
|
||||
return <div ref={this.icon_ref} />;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
if(this.icon_ref)
|
||||
$(this.icon_ref.current).replaceWith(this.props.icon);
|
||||
}
|
||||
componentWillUnmount(): void {
|
||||
if(this.icon_ref)
|
||||
$(this.icon_ref.current).empty();
|
||||
}
|
||||
}
|
|
@ -11,10 +11,12 @@ export abstract class ReactComponentBase<Properties, State> extends React.Compon
|
|||
protected initialize() { }
|
||||
protected abstract default_state() : State;
|
||||
|
||||
updateState(updates: {[key in keyof State]?: State[key]}) {
|
||||
if(Object.keys(updates).findIndex(e => updates[e] !== this.state[e]) === -1)
|
||||
updateState(updates: {[key in keyof State]?: State[key]}, callback?: () => void) {
|
||||
if(Object.keys(updates).findIndex(e => updates[e] !== this.state[e]) === -1) {
|
||||
if(callback) setTimeout(callback, 0);
|
||||
return; /* no state has been changed */
|
||||
this.setState(Object.assign(this.state, updates));
|
||||
}
|
||||
this.setState(Object.assign(this.state, updates), callback);
|
||||
}
|
||||
|
||||
protected classList(...classes: (string | undefined)[]) {
|
|
@ -9,6 +9,6 @@ export class Translatable extends React.Component<{ message: string }, { transla
|
|||
}
|
||||
|
||||
render() {
|
||||
return this.state.translated;
|
||||
return this.state.translated || "";
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue