Added hotkeys to the client

canary
WolverinDEV 2020-04-10 20:57:50 +02:00
parent 6fc7f9a8dc
commit 3b500daac7
20 changed files with 950 additions and 51 deletions

View File

@ -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

View File

@ -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">

View File

@ -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);
});
}

View File

@ -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) {

199
shared/js/KeyControl.ts Normal file
View File

@ -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; }

View File

@ -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);
}

View File

@ -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();

View File

@ -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"

View File

@ -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");

View File

@ -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 {}; }

View File

@ -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";

View File

@ -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) {

View File

@ -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 });

View File

@ -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;
}
}

View File

@ -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() });
}
})
}

View File

@ -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);
}

View File

@ -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>
)
}
}

View File

@ -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();
}
}

View File

@ -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)[]) {

View File

@ -9,6 +9,6 @@ export class Translatable extends React.Component<{ message: string }, { transla
}
render() {
return this.state.translated;
return this.state.translated || "";
}
}