Added channel movement and reordering

canary
WolverinDEV 2020-12-03 17:52:20 +01:00
parent 9a42a02227
commit 903c29ac51
20 changed files with 901 additions and 796 deletions

View File

@ -2,6 +2,7 @@
* **03.12.20**
- Fixed server connection tab move handler
- Fixed file browser context menu when having an overlay
- Added channel movement and reordering
* **02.12.20**
- Fixed a bug within the client entry move mechanic of the channel tree which prevented any client selection

View File

@ -342,16 +342,6 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
return this._family_index;
}
protected onSelect(singleSelect: boolean) {
super.onSelect(singleSelect);
if(!singleSelect) return;
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(this.channelId);
this.channelTree.client.side_bar.show_channel_conversations();
}
}
showContextMenu(x: number, y: number, on_close: () => void = undefined) {
let channelCreate = !![
PermissionType.B_CHANNEL_CREATE_TEMPORARY,

View File

@ -26,22 +26,11 @@ import {tra} from "tc-shared/i18n/localize";
import {EventType} from "tc-shared/ui/frames/log/Definitions";
import {renderChannelTree} from "tc-shared/ui/tree/Controller";
import {ChannelTreePopoutController} from "tc-shared/ui/tree/popout/Controller";
import {Settings, settings} from "tc-shared/settings";
export interface ChannelTreeEvents {
action_select_entries: {
entries: ChannelTreeEntry<any>[],
/**
* auto := Select/unselect/add/remove depending on the selected state & shift key state
* exclusive := Only selected these entries
* append := Append these entries to the current selection
* remove := Remove these entries from the current selection
*/
mode: "auto" | "exclusive" | "append" | "remove";
},
/* general tree notified */
notify_tree_reset: {},
notify_selection_changed: {},
notify_query_view_state_changed: { queries_shown: boolean },
notify_popout_state_changed: { popoutShown: boolean },
@ -80,270 +69,11 @@ export interface ChannelTreeEvents {
message?: string,
isServerLeave: boolean,
sourceChannel: ChannelEntry
}
}
},
export class ChannelTreeEntrySelect {
readonly handle: ChannelTree;
selectedEntries: ChannelTreeEntry<any>[] = [];
private readonly handlerSelectEntries;
constructor(handle: ChannelTree) {
this.handle = handle;
this.handlerSelectEntries = e => {
batch_updates(BatchUpdateType.CHANNEL_TREE);
try {
this.handleSelectEntries(e)
} finally {
flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
}
};
this.handle.events.on("action_select_entries", this.handlerSelectEntries);
}
reset() {
this.selectedEntries.splice(0, this.selectedEntries.length);
}
destroy() {
this.handle.events.off("action_select_entries", this.handlerSelectEntries);
this.selectedEntries.splice(0, this.selectedEntries.length);
}
isMultiSelect() {
return this.selectedEntries.length > 1;
}
isAnythingSelected() {
return this.selectedEntries.length > 0;
}
clearSelection() {
this.handleSelectEntries({
entries: [],
mode: "exclusive"
});
}
/**
* auto := Select/unselect/add/remove depending on the selected state & shift key state
* exclusive := Only selected these entries
* append := Append these entries to the current selection
* remove := Remove these entries from the current selection
*/
select(entries: ChannelTreeEntry<any>[], mode: "auto" | "auto-add" | "exclusive" | "append" | "remove") {
entries = entries.filter(entry => !!entry);
if(mode === "exclusive") {
let deleted_entries = this.selectedEntries;
let new_entries = [];
this.selectedEntries = [];
for(const new_entry of entries) {
if(!deleted_entries.remove(new_entry)) {
new_entries.push(new_entry);
}
this.selectedEntries.push(new_entry);
}
for(const deleted of deleted_entries) {
deleted["onUnselect"]();
}
for(const new_entry of new_entries) {
new_entry["onSelect"](!this.isMultiSelect());
}
if(deleted_entries.length !== 0 || new_entries.length !== 0) {
this.handle.events.fire("notify_selection_changed");
}
} else if(mode === "append") {
let new_entries = [];
for(const entry of entries) {
if(this.selectedEntries.findIndex(e => e === entry) !== -1)
continue;
this.selectedEntries.push(entry);
new_entries.push(entry);
}
for(const new_entry of new_entries) {
new_entry["onSelect"](!this.isMultiSelect());
}
if(new_entries.length !== 0) {
this.handle.events.fire("notify_selection_changed");
}
} else if(mode === "remove") {
let deleted_entries = [];
for(const entry of entries) {
if(this.selectedEntries.remove(entry)) {
deleted_entries.push(entry);
}
}
for(const deleted of deleted_entries) {
deleted["onUnselect"]();
}
if(deleted_entries.length !== 0) {
this.handle.events.fire("notify_selection_changed");
}
} else if(mode === "auto" || mode === "auto-add") {
let deleted_entries = [];
let new_entries = [];
if(ppt.key_pressed(SpecialKey.SHIFT)) {
for(const entry of entries) {
const index = this.selectedEntries.findIndex(e => e === entry);
if(index === -1) {
this.selectedEntries.push(entry);
new_entries.push(entry);
} else if(mode === "auto") {
this.selectedEntries.splice(index, 1);
deleted_entries.push(entry);
}
}
} else {
deleted_entries = this.selectedEntries.splice(0, this.selectedEntries.length);
if(entries.length !== 0) {
const entry = entries[entries.length - 1];
this.selectedEntries.push(entry);
if(!deleted_entries.remove(entry)) {
new_entries.push(entry); /* entry wans't selected yet */
}
}
}
for(const deleted of deleted_entries) {
deleted["onUnselect"]();
}
for(const new_entry of new_entries) {
new_entry["onSelect"](!this.isMultiSelect());
}
if(deleted_entries.length !== 0 || new_entries.length !== 0) {
this.handle.events.fire("notify_selection_changed");
}
} else {
console.warn("Received entry select event with unknown mode: %s", mode);
}
/*
TODO!
if(this.selected_entries.length === 1)
this.handle.view.current?.scrollEntryInView(this.selected_entries[0] as any);
*/
}
private selectNextChannel(currentChannel: ChannelEntry, selectClients: boolean) {
if(selectClients) {
const clients = currentChannel.channelClientsOrdered();
if(clients.length > 0) {
this.select([clients[0]], "exclusive");
return;
}
}
const children = currentChannel.children();
if(children.length > 0) {
this.select([children[0]], "exclusive")
return;
}
const next = currentChannel.channel_next;
if(next) {
this.select([next], "exclusive")
return;
}
let parent = currentChannel.parent_channel();
while(parent) {
const p_next = parent.channel_next;
if(p_next) {
this.select([p_next], "exclusive")
return;
}
parent = parent.parent_channel();
}
}
selectNextTreeEntry() {
if(this.selectedEntries.length !== 1) { return; }
const selected = this.selectedEntries[0];
if(selected instanceof ChannelEntry) {
this.selectNextChannel(selected, true);
} else if(selected instanceof ClientEntry){
const channel = selected.currentChannel();
const clients = channel.channelClientsOrdered();
const index = clients.indexOf(selected);
if(index + 1 < clients.length) {
this.select([clients[index + 1]], "exclusive");
return;
}
this.selectNextChannel(channel, false);
} else if(selected instanceof ServerEntry) {
this.select([this.handle.get_first_channel()], "exclusive");
}
}
selectPreviousTreeEntry() {
if(this.selectedEntries.length !== 1) { return; }
const selected = this.selectedEntries[0];
if(selected instanceof ChannelEntry) {
let previous = selected.channel_previous;
if(previous) {
while(true) {
const siblings = previous.children();
if(siblings.length == 0) break;
previous = siblings.last();
}
const clients = previous.channelClientsOrdered();
if(clients.length > 0) {
this.select([ clients.last() ], "exclusive");
return;
} else {
this.select([ previous ], "exclusive");
return;
}
} else if(selected.hasParent()) {
const channel = selected.parent_channel();
const clients = channel.channelClientsOrdered();
if(clients.length > 0) {
this.select([ clients.last() ], "exclusive");
return;
} else {
this.select([ channel ], "exclusive");
return;
}
} else {
this.select([ this.handle.server ], "exclusive");
}
} else if(selected instanceof ClientEntry) {
const channel = selected.currentChannel();
const clients = channel.channelClientsOrdered();
const index = clients.indexOf(selected);
if(index > 0) {
this.select([ clients[index - 1] ], "exclusive");
return;
}
this.select([ channel ], "exclusive");
return;
}
}
private handleSelectEntries(event: ChannelTreeEvents["action_select_entries"]) {
this.select(event.entries, event.mode);
notify_selected_entry_changed: {
oldEntry: ChannelTreeEntry<any> | undefined,
newEntry: ChannelTreeEntry<any> | undefined
}
}
@ -359,14 +89,14 @@ export class ChannelTree {
/* whatever all channels have been initialized */
channelsInitialized: boolean = false;
readonly selection: ChannelTreeEntrySelect;
readonly popoutController: ChannelTreePopoutController;
private readonly tagContainer: JQuery;
private _show_queries: boolean;
private channel_last?: ChannelEntry;
private channel_first?: ChannelEntry;
private selectedEntry: ChannelTreeEntry<any> | undefined;
private showQueries: boolean;
private channelLast?: ChannelEntry;
private channelFirst?: ChannelEntry;
constructor(client) {
this.events = new Registry<ChannelTreeEvents>();
@ -375,7 +105,6 @@ export class ChannelTree {
this.client = client;
this.server = new ServerEntry(this, "undefined", undefined);
this.selection = new ChannelTreeEntrySelect(this);
this.popoutController = new ChannelTreePopoutController(this);
this.tagContainer = $.spawn("div").addClass("channel-tree-container");
@ -396,7 +125,7 @@ export class ChannelTree {
channel.child_channel_head && visit(channel.child_channel_head);
channel.channel_next && visit(channel.channel_next);
};
this.channel_first && visit(this.channel_first);
this.channelFirst && visit(this.channelFirst);
return result;
}
@ -420,6 +149,40 @@ export class ChannelTree {
return undefined;
}
getSelectedEntry() : ChannelTreeEntry<any> | undefined {
return this.selectedEntry;
}
setSelectedEntry(entry: ChannelTreeEntry<any> | undefined) {
if(this.selectedEntry === entry) { return; }
const oldEntry = this.selectedEntry;
this.selectedEntry = entry;
this.events.fire("notify_selected_entry_changed", { newEntry: entry, oldEntry: oldEntry });
if(this.selectedEntry instanceof ClientEntry) {
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) {
if(this.selectedEntry instanceof MusicClientEntry) {
this.client.side_bar.show_music_player(this.selectedEntry);
} else {
this.client.side_bar.show_client_info(this.selectedEntry);
}
}
} else if(this.selectedEntry instanceof ChannelEntry) {
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
this.client.side_bar.channel_conversations().setSelectedConversation(this.selectedEntry.channelId);
this.client.side_bar.show_channel_conversations();
}
} else if(this.selectedEntry instanceof ServerEntry) {
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
const sidebar = this.client.side_bar;
sidebar.channel_conversations().findOrCreateConversation(0);
sidebar.channel_conversations().setSelectedConversation(0);
sidebar.show_channel_conversations();
}
}
}
destroy() {
ReactDOM.unmountComponentAtNode(this.tagContainer[0]);
@ -429,12 +192,11 @@ export class ChannelTree {
}
this.reset(); /* cleanup channel and clients */
this.channel_first = undefined;
this.channel_last = undefined;
this.channelFirst = undefined;
this.channelLast = undefined;
this.popoutController.destroy();
this.tagContainer.remove();
this.selection.destroy();
this.events.destroy();
}
@ -446,7 +208,7 @@ export class ChannelTree {
rootChannel() : ChannelEntry[] {
const result = [];
let first = this.channel_first;
let first = this.channelFirst;
while(first) {
result.push(first);
first = first.channel_next;
@ -455,6 +217,10 @@ export class ChannelTree {
}
deleteChannel(channel: ChannelEntry) {
if(this.selectedEntry === channel) {
this.setSelectedEntry(undefined);
}
channel.channelTree = null;
batch_updates(BatchUpdateType.CHANNEL_TREE);
@ -517,12 +283,12 @@ export class ChannelTree {
channel.channel_next.channel_previous = channel.channel_previous;
}
if(channel === this.channel_last) {
this.channel_last = channel.channel_previous;
if(channel === this.channelLast) {
this.channelLast = channel.channel_previous;
}
if(channel === this.channel_first) {
this.channel_first = channel.channel_next;
if(channel === this.channelFirst) {
this.channelFirst = channel.channel_next;
}
channel.channel_next = undefined;
@ -542,8 +308,8 @@ export class ChannelTree {
channel.parent = parent;
if(channelPrevious) {
if(channelPrevious == this.channel_last) {
this.channel_last = channel;
if(channelPrevious == this.channelLast) {
this.channelLast = channel;
}
channel.channel_next = channelPrevious.channel_next;
@ -563,12 +329,13 @@ export class ChannelTree {
channel.channel_next.channel_previous = channel;
}
} else {
channel.channel_next = this.channel_first;
if(this.channel_first)
this.channel_first.channel_previous = channel;
channel.channel_next = this.channelFirst;
if(this.channelFirst) {
this.channelFirst.channel_previous = channel;
}
this.channel_first = channel;
this.channel_last = this.channel_last || channel;
this.channelFirst = channel;
this.channelLast = this.channelLast || channel;
}
}
@ -587,6 +354,10 @@ export class ChannelTree {
}
deleteClient(client: ClientEntry, reason: { reason: ViewReasonId, message?: string, serverLeave: boolean }) {
if(this.selectedEntry === client) {
this.setSelectedEntry(undefined);
}
const oldChannel = client.currentChannel();
oldChannel?.unregisterClient(client);
this.unregisterClient(client);
@ -747,7 +518,7 @@ export class ChannelTree {
);
}
public open_multiselect_context_menu(entries: ChannelTreeEntry<any>[], x: number, y: number) {
public showMultiSelectContextMenu(entries: ChannelTreeEntry<any>[], x: number, y: number) {
const clients = entries.filter(e => e instanceof ClientEntry) as ClientEntry[];
const channels = entries.filter(e => e instanceof ChannelEntry) as ChannelEntry[];
const server = entries.find(e => e instanceof ServerEntry) as ServerEntry;
@ -771,13 +542,12 @@ export class ChannelTree {
callback: () => {
createInputModal(tr("Poke clients"), tr("Poke message:<br>"), text => true, result => {
if (typeof(result) === "string") {
for (const client of clients)
for (const client of clients) {
this.client.serverConnection.send_command("clientpoke", {
clid: client.clientId(),
msg: result
});
this.selection.clearSelection();
}
}
}, {width: 400, maxLength: 512}).open();
}
@ -789,12 +559,12 @@ export class ChannelTree {
name: tr("Move clients to your channel"),
callback: () => {
const target = this.client.getClient().currentChannel().getChannelId();
for(const client of clients)
for(const client of clients) {
this.client.serverConnection.send_command("clientmove", {
clid: client.clientId(),
cid: target
});
this.selection.clearSelection();
}
}
});
if (!local_client) {//local client cant be kicked and/or banned or kicked
@ -814,7 +584,6 @@ export class ChannelTree {
});
}
}, {width: 400, maxLength: 255}).open();
this.selection.clearSelection();
}
});
@ -824,7 +593,6 @@ export class ChannelTree {
icon_class: "client-poke",
name: tr("Poke clients"),
callback: () => {
this.selection.clearSelection();
createInputModal(tr("Poke clients"), tr("Poke message:<br>"), text => true, result => {
if (result) {
const elements = clients.map(e => { return { clid: e.clientId() } as any });
@ -838,7 +606,6 @@ export class ChannelTree {
icon_class: "client-kick_server",
name: tr("Kick clients fom server"),
callback: () => {
this.selection.clearSelection();
createInputModal(tr("Kick clients from server"), tr("Kick reason:<br>"), text => true, result => {
if (result) {
for (const client of clients)
@ -856,7 +623,6 @@ export class ChannelTree {
name: tr("Ban clients"),
invalidPermission: !this.client.permissions.neededPermission(PermissionType.I_CLIENT_BAN_MAX_BANTIME).granted(1),
callback: () => {
this.selection.clearSelection();
spawnBanClient(this.client, (clients).map(entry => {
return {
name: entry.clientNickName(),
@ -890,11 +656,11 @@ export class ChannelTree {
const tag_container = $.spawn("div").append(tag);
spawnYesNo(tr("Are you sure?"), tag_container, result => {
if(result) {
for(const client of clients)
for(const client of clients) {
this.client.serverConnection.send_command("musicbotdelete", {
botid: client.properties.client_database_id
});
this.selection.clearSelection();
}
}
});
},
@ -914,16 +680,17 @@ export class ChannelTree {
callback: () => {
spawnYesNo(tr("Are you sure?"), tra("Do you really want to delete {0} channels?", channels.length), result => {
if(typeof result === "boolean" && result) {
for(const channel of channels)
for(const channel of channels) {
this.client.serverConnection.send_command("channeldelete", { cid: channel.channelId });
this.selection.clearSelection();
}
}
});
}
});
}
if(server)
if(server) {
server_menu = server.contextMenuItems();
}
const menus = [
{
@ -982,9 +749,8 @@ export class ChannelTree {
this.channelsInitialized = false;
batch_updates(BatchUpdateType.CHANNEL_TREE);
this.selection.clearSelection();
try {
this.selection.reset();
this.setSelectedEntry(undefined);
const voiceConnection = this.client.serverConnection ? this.client.serverConnection.getVoiceConnection() : undefined;
const videoConnection = this.client.serverConnection ? this.client.serverConnection.getVideoConnection() : undefined;
@ -1006,8 +772,8 @@ export class ChannelTree {
channel.destroy();
this.channels = [];
this.channel_last = undefined;
this.channel_first = undefined;
this.channelLast = undefined;
this.channelFirst = undefined;
this.events.fire("notify_tree_reset");
} finally {
flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
@ -1054,15 +820,15 @@ export class ChannelTree {
}
toggle_server_queries(flag: boolean) {
if(this._show_queries == flag) return;
this._show_queries = flag;
if(this.showQueries == flag) return;
this.showQueries = flag;
this.events.fire("notify_query_view_state_changed", { queries_shown: flag });
}
areServerQueriesShown() { return this._show_queries; }
areServerQueriesShown() { return this.showQueries; }
get_first_channel?() : ChannelEntry {
return this.channel_first;
return this.channelFirst;
}
unsubscribe_all_channels(subscribe_specified?: boolean) {

View File

@ -1,7 +1,6 @@
import {Registry} from "../events";
export interface ChannelTreeEntryEvents {
notify_select_state_change: { selected: boolean },
notify_unread_state_change: { unread: boolean }
}
@ -17,24 +16,6 @@ export abstract class ChannelTreeEntry<Events extends ChannelTreeEntryEvents> {
this.uniqueEntryId = ++treeEntryIdCounter;
}
/* called from the channel tree */
protected onSelect(singleSelect: boolean) {
if(this.selected_ === true) return;
this.selected_ = true;
this.events.fire("notify_select_state_change", { selected: true });
}
/* called from the channel tree */
protected onUnselect() {
if(this.selected_ === false) return;
this.selected_ = false;
this.events.fire("notify_select_state_change", { selected: false });
}
isSelected() { return this.selected_; }
setUnread(flag: boolean) {
if(this.unread_ === flag) return;
this.unread_ = flag;

View File

@ -372,56 +372,6 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
protected initializeListener() {
if(this._listener_initialized) return;
this._listener_initialized = true;
//FIXME: TODO!
/*
this.tag.on('mousedown', event => {
if(event.which != 1) return; //Only the left button
let clients = this.channelTree.currently_selected as (ClientEntry | ClientEntry[]);
if(ppt.key_pressed(SpecialKey.SHIFT)) {
if(clients != this && !($.isArray(clients) && clients.indexOf(this) != -1))
clients = $.isArray(clients) ? [...clients, this] : [clients, this];
} else {
clients = this;
}
this.channelTree.client_mover.activate(clients, target => {
if(!target) return;
for(const client of $.isArray(clients) ? clients : [clients]) {
if(target == client._channel) continue;
const source = client._channel;
const self = this.channelTree.client.getClient();
this.channelTree.client.serverConnection.send_command("clientmove", {
clid: client.clientId(),
cid: target.getChannelId()
}).then(event => {
if(client.clientId() == this.channelTree.client.clientId)
this.channelTree.client.sound.play(Sound.CHANNEL_JOINED);
else if(target !== source && target != self.currentChannel())
this.channelTree.client.sound.play(Sound.USER_MOVED);
});
}
this.channelTree.onSelect();
}, event);
});
*/
}
protected onSelect(singleSelect: boolean) {
super.onSelect(singleSelect);
if(!singleSelect) return;
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) {
if(this instanceof MusicClientEntry)
this.channelTree.client.side_bar.show_music_player(this);
else
this.channelTree.client.side_bar.show_client_info(this);
}
}
protected contextmenu_info() : contextmenu.MenuEntry[] {

View File

@ -170,18 +170,6 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
this.remote_address = undefined;
}
protected onSelect(singleSelect: boolean) {
super.onSelect(singleSelect);
if(!singleSelect) return;
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
const sidebar = this.channelTree.client.side_bar;
sidebar.channel_conversations().findOrCreateConversation(0);
sidebar.channel_conversations().setSelectedConversation(0);
sidebar.show_channel_conversations();
}
}
contextMenuItems() : contextmenu.MenuEntry[] {
return [
{

View File

@ -4,12 +4,12 @@ import {FileType} from "../../../file/FileManager";
import {CommandResult} from "../../../connection/ServerConnectionDeclaration";
import PermissionType from "../../../permission/PermissionType";
import * as log from "../../../log";
import {LogCategory} from "../../../log";
import {LogCategory, logTrace} from "../../../log";
import {Entry, MenuEntry, MenuEntryType, spawn_context_menu} from "../../../ui/elements/ContextMenu";
import * as ppt from "tc-backend/ppt";
import {SpecialKey} from "../../../PPTListener";
import {spawnYesNo} from "../../../ui/modal/ModalYesNo";
import {tra, traj, tr} from "../../../i18n/localize";
import {tr, tra, traj} from "../../../i18n/localize";
import {
FileTransfer,
FileTransferState,
@ -105,6 +105,8 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
});
return;
}
logTrace(LogCategory.FILE_TRANSFER, tr("Requesting a file list for %o"), path);
let request: Promise<ListedFileInfo[]>;
if (path.type === "root") {
request = (async () => {

View File

@ -1,3 +1,7 @@
html:root {
--channel-tree-move-border: #005fa1;
}
.channelEntry {
position: relative;
@ -110,4 +114,58 @@
z-index: -1;
}
}
height: 18px + 2px!important;
margin-top: -1px!important;
padding-top: 1px!important;
margin-bottom: -1px!important;
padding-bottom: 1px!important;
padding-left: 1px!important;
.leftPadding {
height: 100%;
margin-left: -1px;
}
&.drag-top {
height: 20px!important;
padding-top: 0!important;
padding-bottom: 2px!important;
border-top: 2px solid var(--channel-tree-move-border);
.leftPadding {
background-color: var(--channel-tree-background);
margin-top: -4px;
}
}
&.drag-bottom {
padding-bottom: 0!important;
border-bottom: 2px solid var(--channel-tree-move-border);
z-index: 1;
.leftPadding {
background-color: var(--channel-tree-background);
margin-bottom: -3px;
}
}
&.drag-contain {
padding-top: 0!important;
padding-bottom: 0!important;
padding-left: 0!important;
border: 1px solid var(--channel-tree-move-border);
border-radius: 2px;
z-index: 1;
}
}

View File

@ -196,6 +196,7 @@ class ChannelTreeController {
this.channelTree.channels.forEach(channel => this.initializeChannelEvents(channel));
this.channelTree.clients.forEach(channel => this.initializeClientEvents(channel));
this.sendChannelTreeEntries();
this.sendSelectedEntry();
}
@EventHandler<ChannelTreeEvents>("notify_channel_created")
@ -250,15 +251,18 @@ class ChannelTreeController {
this.sendChannelTreeEntries();
}
@EventHandler<ChannelTreeEvents>("notify_selected_entry_changed")
private handleSelectedEntryChanged(_event: ChannelTreeEvents["notify_selected_entry_changed"]) {
if(!this.channelTreeInitialized) { return; }
this.sendSelectedEntry();
}
/* entry event handlers */
private initializeTreeEntryEvents<T extends ChannelTreeEntryEvents>(entry: ChannelTreeEntryModel<T>, events: any[]) {
events.push(entry.events.on("notify_unread_state_change", event => {
this.events.fire("notify_unread_state", { unread: event.unread, treeEntryId: entry.uniqueEntryId });
}));
events.push(entry.events.on("notify_select_state_change", event => {
this.events.fire("notify_select_state", { selected: event.selected, treeEntryId: entry.uniqueEntryId });
}));
}
private initializeChannelEvents(channel: ChannelEntry) {
@ -387,6 +391,11 @@ class ChannelTreeController {
this.events.fire_react("notify_tree_entries", { entries: entries });
}
public sendSelectedEntry() {
const selectedEntry = this.channelTree.getSelectedEntry();
this.events.fire_react("notify_selected_entry", { treeEntryId: selectedEntry ? selectedEntry.uniqueEntryId : 0 });
}
public sendChannelInfo(channel: ChannelEntry) {
this.events.fire_react("notify_channel_info", {
treeEntryId: channel.uniqueEntryId,
@ -558,16 +567,6 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
events.fire_react("notify_unread_state", { treeEntryId: event.treeEntryId, unread: entry.isUnread() });
});
events.on("query_select_state", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the select state of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
events.fire_react("notify_select_state", { treeEntryId: event.treeEntryId, selected: entry.isSelected() });
});
events.on("notify_destroy", channelTree.client.events().on("notify_visibility_changed", event => events.fire("notify_visibility_changed", event)));
events.on("query_tree_entries", () => controller.sendChannelTreeEntries());
@ -663,67 +662,43 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
});
events.on("action_select", event => {
console.error("Select mode: %o", moveSelection);
if(!event.ignoreClientMove && moveSelection?.length) {
console.error("X");
if(event.treeEntryId === 0) {
channelTree.setSelectedEntry(undefined);
return;
}
const entries = [];
for(const entryId of event.entryIds) {
const entry = channelTree.findEntryId(entryId);
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Tried to select an invalid tree entry with id %o. Skipping entry."), entryId);
continue;
logWarn(LogCategory.CHANNEL, tr("Tried to select an invalid channel tree entry with id %o"), event.treeEntryId);
return;
}
entries.push(entry);
}
channelTree.selection.select(entries, event.mode);
});
events.on("action_select_auto", event => {
if(event.direction === "next") {
channelTree.selection.selectNextTreeEntry();
} else if(event.direction === "previous") {
channelTree.selection.selectPreviousTreeEntry();
}
channelTree.setSelectedEntry(entry);
});
events.on("action_show_context_menu", event => {
if(event.treeEntryId === 0) {
const entries = event.treeEntryIds.map(entryId => {
const entry = channelTree.findEntryId(entryId);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Tried to open a context menu for an invalid channel tree entry with id %o"), entryId);
}
return entry;
}).filter(entry => !!entry);
if(entries.length === 0) {
channelTree.showContextMenu(event.pageX, event.pageY);
return;
} else if(entries.length === 1) {
entries[0].showContextMenu(event.pageX, event.pageY);
} else {
channelTree.showMultiSelectContextMenu(entries, event.pageX, event.pageY);
}
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Tried to open a context menu for an invalid channel tree entry with id %o"), event.treeEntryId);
return;
}
if (channelTree.selection.isMultiSelect() && entry.isSelected()) {
channelTree.open_multiselect_context_menu(channelTree.selection.selectedEntries, event.pageX, event.pageY);
return;
}
channelTree.events.fire("action_select_entries", {
entries: [entry],
mode: "exclusive"
});
entry.showContextMenu(event.pageX, event.pageY);
});
events.on("action_channel_join", event => {
if(!event.ignoreMultiSelect && channelTree.selection.isMultiSelect()) {
return;
}
const entry = event.treeEntryId === "selected" ? channelTree.selection.selectedEntries[0] : channelTree.findEntryId(event.treeEntryId);
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ChannelEntry)) {
if(event.treeEntryId !== "selected") {
logWarn(LogCategory.CHANNEL, tr("Tried to join an invalid tree entry with id %o"), event.treeEntryId);
}
return;
}
@ -737,15 +712,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
return;
}
if(moveSelection) {
/* don't select entries while we're moving */
return;
}
channelTree.events.fire("action_select_entries", {
entries: [entry],
mode: "exclusive"
});
channelTree.setSelectedEntry(entry);
spawnFileTransferModal(entry.channelId);
});
@ -756,10 +723,6 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
return;
}
if(channelTree.selection.isMultiSelect()) {
return;
}
if (entry instanceof LocalClientEntry) {
entry.openRename(events);
} else if (entry instanceof MusicClientEntry) {
@ -786,46 +749,111 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
})
});
let moveSelection: ClientEntry[];
events.on("action_start_entry_move", event => {
const selection = channelTree.selection.selectedEntries.slice();
if(selection.length === 0) { return; }
if(selection.findIndex(element => !(element instanceof ClientEntry)) !== -1) { return; }
moveSelection = selection as any;
events.fire_react("notify_entry_move", { entries: selection.map(client => (client as ClientEntry).clientNickName()).join(", "), begin: event.start, current: event.current });
});
events.on("action_move_entries", event => {
if(event.treeEntryId === 0 || !moveSelection?.length) {
moveSelection = undefined;
events.on("action_move_clients", event => {
const entry = channelTree.findEntryId(event.targetTreeEntry);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Received client move notify with an unknown target entry id %o"), event.targetTreeEntry);
return;
}
const entry = channelTree.findEntryId(event.treeEntryId);
let targetChannel: ChannelEntry;
if(entry instanceof ChannelEntry) {
targetChannel = entry;
} else if(entry instanceof ClientEntry) {
if(entry instanceof ClientEntry) {
targetChannel = entry.currentChannel();
} else if(entry instanceof ChannelEntry) {
targetChannel = entry;
} else {
logWarn(LogCategory.CHANNEL, tr("Received client move notify with an invalid target entry id %o"), event.targetTreeEntry);
return;
}
if(!targetChannel) {
logWarn(LogCategory.CHANNEL, tr("Tried to move clients to an tree entry which has no target channel. Tree entry id %o"), event.treeEntryId);
moveSelection = undefined;
/* should not happen often that a client hasn't a channel */
return;
}
moveSelection.filter(e => e.currentChannel() !== entry).forEach(e => {
channelTree.client.serverConnection.send_command("clientmove", {
clid: e.clientId(),
cid: targetChannel.channelId
});
const clients = event.entries.map(entryId => {
const entry = channelTree.findEntryId(entryId);
if(!entry || !(entry instanceof ClientEntry)) {
logWarn(LogCategory.CHANNEL, tr("Received client move notify with an entry id which isn't a client. Entry id: %o"), entryId);
return undefined;
}
return entry;
}).filter(client => !!client).filter(client => client.currentChannel() !== targetChannel);
if(clients.length === 0) {
return;
}
let bulks = clients.map(client => { return { clid: client.clientId() }; });
bulks[0]["cid"] = targetChannel.channelId;
channelTree.client.serverConnection.send_command("clientmove", bulks);
});
moveSelection = undefined;
events.on("action_move_channels", event => {
const targetChannel = channelTree.findEntryId(event.targetTreeEntry);
if(!targetChannel || !(targetChannel instanceof ChannelEntry)) {
logWarn(LogCategory.CHANNEL, tr("Received channel move notify with an unknown/invalid target entry id %o"), event.targetTreeEntry);
return;
}
let channels = event.entries.map(entryId => {
const entry = channelTree.findEntryId(entryId);
if(!entry || !(entry instanceof ChannelEntry)) {
logWarn(LogCategory.CHANNEL, tr("Received channel move notify with a channel id which isn't a channel. Entry id: %o"), entryId);
return undefined;
}
return entry;
}).filter(channel => !!channel);
/* remove all channel in channel channels */
channels = channels.filter(channel => {
while((channel = channel.parent_channel())) {
if(channels.indexOf(channel) !== -1) {
return false;
}
}
return true;
});
channels = channels.filter(channel => channel !== targetChannel);
if(channels.length === 0) {
return;
}
let parentChannelId: number, previousChannelId: number;
if(event.mode === "before") {
parentChannelId = targetChannel.hasParent() ? targetChannel.parent_channel().channelId : 0;
previousChannelId = targetChannel.channel_previous ? targetChannel.channel_previous.channelId : 0;
} else if(event.mode == "after") {
parentChannelId = targetChannel.hasParent() ? targetChannel.parent_channel().channelId : 0;
previousChannelId = targetChannel.channelId;
} else if(event.mode === "child") {
parentChannelId = targetChannel.channelId;
previousChannelId = 0;
} else {
return;
}
(async () => {
let channel: ChannelEntry;
while((channel = channels.pop_front())) {
const success = await channelTree.client.serverConnection.send_command("channelmove", {
cid: channel.channelId,
cpid: parentChannelId,
order: previousChannelId
}).then(() => true).catch(() => false);
if(!success) {
break;
}
previousChannelId = channel.channelId;
}
})();
});
events.on("notify_client_name_edit_failed", event => {

View File

@ -29,27 +29,23 @@ export type ServerState = { state: "disconnected" } | { state: "connecting", tar
export interface ChannelTreeUIEvents {
/* actions */
action_toggle_popout: { shown: boolean },
action_show_context_menu: { treeEntryId: number | 0, pageX: number, pageY: number },
action_show_context_menu: { treeEntryIds: number[], pageX: number, pageY: number },
action_start_entry_move: { start: { x: number, y: number }, current: { x: number, y: number } },
action_set_collapsed_state: { treeEntryId: number, state: "collapsed" | "expended" },
action_select: {
entryIds: number[],
mode: "auto" | "auto-add" | "exclusive" | "append" | "remove",
ignoreClientMove: boolean
},
action_select_auto: { direction: "next" | "previous" },
action_channel_join: { treeEntryId: number | "selected", ignoreMultiSelect: boolean },
action_select: { treeEntryId: number | 0 },
action_channel_join: { treeEntryId: number },
action_channel_open_file_browser: { treeEntryId: number },
action_client_double_click: { treeEntryId: number },
action_client_name_submit: { treeEntryId: number, name: string },
action_move_entries: { treeEntryId: number /* zero if move failed */ }
action_move_clients: { targetTreeEntry: number, entries: number[] },
action_move_channels: { targetTreeEntry: number, mode: "before" | "after" | "child", entries: number[] },
/* queries */
query_tree_entries: {},
query_popout_state: {},
query_selected_entry: {},
query_unread_state: { treeEntryId: number },
query_select_state: { treeEntryId: number },
query_channel_info: { treeEntryId: number },
query_channel_icon: { treeEntryId: number },
@ -65,6 +61,7 @@ export interface ChannelTreeUIEvents {
/* notifies */
notify_tree_entries: { entries: ChannelTreeEntry[] },
notify_popout_state: { shown: boolean, showButton: boolean },
notify_selected_entry: { treeEntryId: number | 0 },
notify_channel_info: { treeEntryId: number, info: ChannelEntryInfo },
notify_channel_icon: { treeEntryId: number, icon: ClientIcon },
@ -80,10 +77,16 @@ export interface ChannelTreeUIEvents {
notify_server_state: { treeEntryId: number, state: ServerState },
notify_unread_state: { treeEntryId: number, unread: boolean },
notify_select_state: { treeEntryId: number, selected: boolean },
notify_entry_move: { entries: string, begin: { x: number, y: number }, current: { x: number, y: number } },
notify_visibility_changed: { visible: boolean },
notify_destroy: {}
}
export type ChannelTreeDragData = {
version: 1,
handlerId: string,
type: string,
entryIds: number[],
entryTypes: ("server" | "channel" | "client")[]
};

View File

@ -0,0 +1,188 @@
import {RDPChannel, RDPChannelTree, RDPClient, RDPEntry, RDPServer} from "./RendererDataProvider";
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {
ClientIcon,
spriteEntries as kClientSpriteEntries,
spriteHeight as kClientSpriteHeight,
spriteUrl as kClientSpriteUrl,
spriteWidth as kClientSpriteWidth,
} from "svg-sprites/client-icons";
import {LogCategory, logDebug} from "tc-shared/log";
import {ChannelTreeDragData} from "tc-shared/ui/tree/Definitions";
let spriteImage: HTMLImageElement;
async function initializeIconSpreedSheet() {
spriteImage = new Image(kClientSpriteWidth, kClientSpriteHeight);
spriteImage.src = loader.config.baseUrl + kClientSpriteUrl;
await new Promise((resolve, reject) => {
spriteImage.onload = resolve;
spriteImage.onerror = () => reject("failed to load client icon sprite");
});
}
function paintClientIcon(context: CanvasRenderingContext2D, icon: ClientIcon, offsetX: number, offsetY: number, width: number, height: number) {
const sprite = kClientSpriteEntries.find(e => e.className === icon);
if(!sprite) return undefined;
context.drawImage(spriteImage, sprite.xOffset, sprite.yOffset, sprite.width, sprite.height, offsetX, offsetY, width, height);
}
export function generateDragElement(entries: RDPEntry[]) : HTMLElement {
const totalHeight = entries.length * 18 + 2; /* the two extra for "low" letters like "gyj" etc. */
const totalWidth = 250;
let offsetY = 0;
let offsetX = 20;
const canvas = document.createElement("canvas");
canvas.height = totalHeight;
canvas.width = totalWidth;
/* TODO: With font size? */
{
const ctx = canvas.getContext("2d");
ctx.textAlign = "left";
ctx.textBaseline = "bottom";
ctx.font = "700 16px Roboto, Helvetica, Arial, sans-serif";
for(const entry of entries) {
let name: string;
let icon: ClientIcon;
if(entry instanceof RDPClient) {
name = entry.name.name;
icon = entry.status;
} else if(entry instanceof RDPChannel) {
name = entry.info?.name;
icon = entry.icon;
} else if(entry instanceof RDPServer) {
icon = ClientIcon.ServerGreen;
switch (entry.state.state) {
case "connected":
name = entry.state.name;
break;
case "disconnected":
name = tr("Not connected");
break;
case "connecting":
name = tr("Connecting");
break;
}
}
/*
ctx.strokeStyle = "red";
ctx.moveTo(offsetX, offsetY);
ctx.lineTo(offsetX + 1000, offsetY);
ctx.stroke();
*/
ctx.fillStyle = "black";
paintClientIcon(ctx, icon, offsetX + 1, offsetY + 1, 16, 16);
ctx.fillText(name, offsetX + 20, offsetY + 19);
offsetY += 18;
/*
ctx.strokeStyle = "blue";
ctx.moveTo(offsetX, offsetY);
ctx.lineTo(offsetX + 1000, offsetY);
ctx.stroke();
*/
}
}
canvas.style.position = "absolute";
canvas.style.left = "-100000000px";
canvas.style.top = (Math.random() * 1000000).toFixed(0) + "px";
document.body.appendChild(canvas);
setTimeout(() => {
canvas.remove();
}, 50);
return canvas as any;
}
const kDragDataType = "application/x-teaspeak-channel-move";
const kDragHandlerPrefix = "application/x-teaspeak-handler-";
const kDragTypePrefix = "application/x-teaspeak-type-";
export function setupDragData(transfer: DataTransfer, tree: RDPChannelTree, entries: RDPEntry[], type: string) {
let data: ChannelTreeDragData = {
version: 1,
handlerId: tree.handlerId,
entryIds: entries.map(e => e.entryId),
entryTypes: entries.map(entry => {
if(entry instanceof RDPServer) {
return "server";
} else if(entry instanceof RDPClient) {
return "client";
} else {
return "channel";
}
}),
type: type
};
transfer.effectAllowed = "all"
transfer.dropEffect = "move";
transfer.setData(kDragHandlerPrefix + tree.handlerId, "");
transfer.setData(kDragTypePrefix + type, "");
transfer.setData(kDragDataType, JSON.stringify(data));
{
let texts = [];
for(const entry of entries) {
if(entry instanceof RDPClient) {
texts.push(entry.name?.name);
} else if(entry instanceof RDPChannel) {
texts.push(entry.info?.name);
} else if(entry instanceof RDPServer) {
texts.push(entry.state.state === "connected" ? entry.state.name : undefined);
}
}
transfer.setData("text/plain", texts.filter(e => !!e).join(", "));
}
/* TODO: Other things as well! */
}
export function parseDragData(transfer: DataTransfer) : ChannelTreeDragData | undefined {
const rawData = transfer.getData(kDragDataType);
if(!rawData) { return undefined; }
try {
const data = JSON.parse(rawData) as ChannelTreeDragData;
if(data.version !== 1) { throw tra("invalid data version {}", data.version); }
return data;
} catch (error) {
logDebug(LogCategory.GENERAL, tr("Drag data parsing failed: %o"), error);
return undefined;
}
}
export function getDragInfo(transfer: DataTransfer) : { handlerId: string, type: string } | undefined {
const keys = [...transfer.items].filter(e => e.kind === "string").map(e => e.type);
const handlerId = keys.find(e => e.startsWith(kDragHandlerPrefix))?.substr(kDragHandlerPrefix.length);
const type = keys.find(e => e.startsWith(kDragTypePrefix))?.substr(kDragTypePrefix.length);
if(!handlerId || !type) {
return undefined;
}
return {
handlerId: handlerId,
type: type
}
}
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "Icon sprite loader",
function: async () => await initializeIconSpreedSheet(),
priority: 10
});

View File

@ -2,7 +2,7 @@ import {Registry} from "tc-shared/events";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import * as React from "react";
import {ChannelTreeView, PopoutButton} from "tc-shared/ui/tree/RendererView";
import {RDPChannelTree} from "./RendererDataProvider";
import {RDPChannel, RDPChannelTree} from "./RendererDataProvider";
import {useEffect, useRef} from "react";
const viewStyle = require("./View.scss");
@ -34,13 +34,17 @@ const ContainerView = (props: { tree: RDPChannelTree, events: Registry<ChannelTr
if(event.key === "ArrowUp") {
event.preventDefault();
props.events.fire("action_select_auto", { direction: "previous" });
props.tree.selection.selectNext(true, "up");
} else if(event.key === "ArrowDown") {
event.preventDefault();
props.events.fire("action_select_auto", { direction: "next" });
props.tree.selection.selectNext(true, "down");
} else if(event.key === "Enter") {
event.preventDefault();
props.events.fire("action_channel_join", { treeEntryId: "selected", ignoreMultiSelect: false });
const selectedEntries = props.tree.selection.selectedEntries;
if(selectedEntries.length !== 1) { return; }
if(!(selectedEntries[0] instanceof RDPChannel)) { return; }
props.events.fire("action_channel_join", { treeEntryId: selectedEntries[0].entryId });
}
});

View File

@ -123,29 +123,31 @@ export class RendererChannel extends React.Component<{ channel: RDPChannel }, {}
);
}
let dragClass;
if(channel.dragHint !== "none") {
dragClass = channelStyle["drag-" + channel.dragHint];
}
return (
<div
className={viewStyle.treeEntry + " " + channelStyle.channelEntry + " " + (channel.selected ? viewStyle.selected : "")}
ref={this.props.channel.refChannelContainer}
className={viewStyle.treeEntry + " " + channelStyle.channelEntry + " " + (channel.selected ? viewStyle.selected : "") + " " + dragClass}
style={{ top: channel.offsetTop }}
onMouseUp={event => {
if (event.button !== 0) {
return; /* only left mouse clicks */
}
events.fire("action_select", {
entryIds: [ entryId ],
mode: "auto",
ignoreClientMove: false
});
this.props.channel.select("auto");
}}
onDoubleClick={() => events.fire("action_channel_join", { ignoreMultiSelect: false, treeEntryId: entryId })}
onDoubleClick={() => events.fire("action_channel_join", { treeEntryId: entryId })}
onContextMenu={event => {
if (settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) {
return;
}
event.preventDefault();
events.fire("action_show_context_menu", { treeEntryId: entryId, pageX: event.pageX, pageY: event.pageY });
this.props.channel.handleUiContextMenu(event.pageX, event.pageY);
}}
onMouseDown={event => {
if (event.buttons !== 4) {
@ -155,8 +157,12 @@ export class RendererChannel extends React.Component<{ channel: RDPChannel }, {}
event.preventDefault();
events.fire("action_channel_open_file_browser", { treeEntryId: entryId });
}}
draggable={true}
onDragStart={event => this.props.channel.handleUiDragStart(event.nativeEvent)}
onDragOver={event => this.props.channel.handleUiDragOver(event.nativeEvent)}
onDrop={event => this.props.channel.handleUiDrop(event.nativeEvent)}
>
<div className={viewStyle.leftPadding} style={{ paddingLeft: channel.offsetLeft + "em" }} />
<div className={viewStyle.leftPadding + " " + channelStyle.leftPadding} style={{ paddingLeft: channel.offsetLeft + "em" }} />
<UnreadMarkerRenderer entry={this.props.channel} ref={this.props.channel.refUnread} />
{collapsedIndicator}
{channelIcon}

View File

@ -183,27 +183,27 @@ export class RendererClient extends React.Component<{ client: RDPClient }, {}> {
}
event.preventDefault();
events.fire("action_show_context_menu", { treeEntryId: client.entryId, pageX: event.pageX, pageY: event.pageY });
this.props.client.handleUiContextMenu(event.pageX, event.pageY);
}}
onMouseUp={event => {
if (event.button !== 0) {
return; /* only left mouse clicks */
}
events.fire("action_select", {
entryIds: [ client.entryId ],
mode: "auto",
ignoreClientMove: false
});
this.props.client.select("auto");
}}
onDoubleClick={() => events.fire("action_client_double_click", { treeEntryId: client.entryId })}
onDoubleClick={() => this.props.client.handleUiDoubleClicked()}
draggable={!client.rename}
onDragStart={event => this.props.client.handleUiDragStart(event.nativeEvent)}
onDragOver={event => this.props.client.handleUiDragOver(event.nativeEvent)}
onDrop={event => this.props.client.handleUiDrop(event.nativeEvent)}
>
<div className={viewStyle.leftPadding} style={{ paddingLeft: client.offsetLeft + "em" }} />
<UnreadMarkerRenderer entry={client} ref={client.refUnread} />
<ClientStatus client={client} ref={client.refStatus} />
{...(client.rename ? [
<ClientNameEdit initialName={client.renameDefault} editFinished={value => {
events.fire_react("action_client_name_submit", { treeEntryId: client.entryId, name: value });
events.fire("action_client_name_submit", { treeEntryId: client.entryId, name: value });
}} key={"rename"} />
] : [
<ClientName client={client} ref={client.refName} key={"name"} />,

View File

@ -4,7 +4,9 @@ import {
ChannelIcons,
ChannelTreeUIEvents,
ClientIcons,
ClientNameInfo, ClientTalkIconState, ServerState
ClientNameInfo,
ClientTalkIconState,
ServerState
} from "tc-shared/ui/tree/Definitions";
import {ChannelTreeView, PopoutButton} from "tc-shared/ui/tree/RendererView";
import * as React from "react";
@ -20,7 +22,8 @@ import {
RendererClient
} from "tc-shared/ui/tree/RendererClient";
import {ServerRenderer} from "tc-shared/ui/tree/RendererServer";
import {RendererMove} from "./RendererMove";
import {generateDragElement, getDragInfo, parseDragData, setupDragData} from "tc-shared/ui/tree/DragHelper";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
function isEquivalent(a, b) {
const typeA = typeof a;
@ -61,16 +64,192 @@ function isEquivalent(a, b) {
}
}
/**
* auto := Select/unselect/add/remove depending on the selected state & shift key state
* exclusive := Only selected these entries
* append := Append these entries to the current selection
* remove := Remove these entries from the current selection
*/
export type RDPTreeSelectType = "auto" | "auto-add" | "exclusive" | "append" | "remove";
export class RDPTreeSelection {
readonly handle: RDPChannelTree;
selectedEntries: RDPEntry[] = [];
private readonly documentKeyListener;
private readonly documentBlurListener;
private shiftKeyPressed = false;
constructor(handle: RDPChannelTree) {
this.handle = handle;
this.documentKeyListener = event => this.shiftKeyPressed = event.shiftKey;
this.documentBlurListener = () => this.shiftKeyPressed = false;
document.addEventListener("keydown", this.documentKeyListener);
document.addEventListener("keyup", this.documentKeyListener);
document.addEventListener("focusout", this.documentBlurListener);
document.addEventListener("mouseout", this.documentBlurListener);
}
reset() {
this.clearSelection();
}
destroy() {
document.removeEventListener("keydown", this.documentKeyListener);
document.removeEventListener("keyup", this.documentKeyListener);
document.removeEventListener("focusout", this.documentBlurListener);
document.removeEventListener("mouseout", this.documentBlurListener);
this.selectedEntries.splice(0, this.selectedEntries.length);
}
isMultiSelect() {
return this.selectedEntries.length > 1;
}
isAnythingSelected() {
return this.selectedEntries.length > 0;
}
clearSelection() {
this.select([], "exclusive", false);
}
select(entries: RDPEntry[], mode: RDPTreeSelectType, selectMaintree: boolean) {
entries = entries.filter(entry => !!entry);
let deletedEntries: RDPEntry[] = [];
let newEntries: RDPEntry[] = [];
if(mode === "exclusive") {
deletedEntries = this.selectedEntries.slice();
newEntries = entries;
} else if(mode === "append") {
newEntries = entries;
} else if(mode === "remove") {
deletedEntries = entries;
} else if(mode === "auto" || mode === "auto-add") {
if(this.shiftKeyPressed) {
for(const entry of entries) {
const index = this.selectedEntries.findIndex(e => e === entry);
if(index === -1) {
newEntries.push(entry);
} else if(mode === "auto") {
deletedEntries.push(entry);
}
}
} else {
deletedEntries = this.selectedEntries.slice();
if(entries.length !== 0) {
const entry = entries[entries.length - 1];
if(!deletedEntries.remove(entry)) {
newEntries.push(entry); /* entry wans't selected yet */
}
}
}
} else {
console.warn("Received entry select event with unknown mode: %s", mode);
}
newEntries.forEach(entry => deletedEntries.remove(entry));
newEntries = newEntries.filter(entry => {
if(this.selectedEntries.indexOf(entry) === -1) {
this.selectedEntries.push(entry);
return true;
} else {
return false;
}
});
deletedEntries = deletedEntries.filter(entry => this.selectedEntries.remove(entry));
deletedEntries.forEach(entry => entry.setSelected(false));
newEntries.forEach(entry => entry.setSelected(true));
/* it's important to keep it sorted from the top to the bottom (example would be the channel move) */
if(deletedEntries.length > 0 || newEntries.length > 0) {
const treeEntries = this.handle.getTreeEntries();
const lookupMap = {};
this.selectedEntries.forEach(entry => lookupMap[entry.entryId] = treeEntries.indexOf(entry));
this.selectedEntries.sort((a, b) => lookupMap[a.entryId] - lookupMap[b.entryId]);
}
if(this.selectedEntries.length === 1 && selectMaintree) {
this.handle.events.fire("action_select", { treeEntryId: this.selectedEntries[0].entryId });
}
}
public selectNext(selectClients: boolean, direction: "up" | "down") {
const entries = this.handle.getTreeEntries();
const selectedEntriesIndex = this.selectedEntries.map(e => entries.indexOf(e)).filter(e => e !== -1);
let index;
if(direction === "up") {
index = selectedEntriesIndex.reduce((previousValue, currentValue) => Math.min(previousValue, currentValue), entries.length);
if(index === entries.length) {
index = entries.length - 1;
}
} else {
index = selectedEntriesIndex.reduce((previousValue, currentValue) => Math.max(previousValue, currentValue), -1);
if(index === -1) {
index = entries.length - 1;
}
}
if(index === -1) {
/* tree contains no entries */
return;
}
if(!this.doSelectNext(entries[index], selectClients, direction)) {
/* There is no next entry. Select the last one. */
if(this.isMultiSelect()) {
this.select([ entries[index] ], "exclusive", true);
}
}
}
private doSelectNext(current: RDPEntry, selectClients: boolean, direction: "up" | "down") : boolean {
const directionModifier = direction === "down" ? 1 : -1;
const entries = this.handle.getTreeEntries();
let index = entries.indexOf(current);
if(index === -1) { return false; }
index += directionModifier;
if(selectClients) {
if(index >= entries.length || index < 0) {
return false;
}
this.select([entries[index]], "exclusive", true);
return true;
} else {
while(index >= 0 && index < entries.length && entries[index] instanceof RDPClient) {
index += directionModifier;
}
if(index === entries.length || index <= 0) {
return false;
}
this.select([entries[index]], "exclusive", true);
return true;
}
}
}
export class RDPChannelTree {
readonly events: Registry<ChannelTreeUIEvents>;
readonly handlerId: string;
private registeredEventHandlers = [];
readonly refMove = React.createRef<RendererMove>();
readonly refTree = React.createRef<ChannelTreeView>();
readonly refPopoutButton = React.createRef<PopoutButton>();
readonly selection: RDPTreeSelection;
popoutShown: boolean = false;
popoutButtonShown: boolean = false;
@ -78,9 +257,12 @@ export class RDPChannelTree {
private orderedTree: RDPEntry[] = [];
private treeEntries: {[key: number]: RDPEntry} = {};
private dragOverChannelEntry: RDPChannel;
constructor(events: Registry<ChannelTreeUIEvents>, handlerId: string) {
this.events = events;
this.handlerId = handlerId;
this.selection = new RDPTreeSelection(this);
}
initialize() {
@ -98,17 +280,6 @@ export class RDPChannelTree {
entry.handleUnreadUpdate(event.unread);
}));
events.push(this.events.on("notify_select_state", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry) {
logError(LogCategory.CHANNEL, tr("Received select notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleSelectUpdate(event.selected);
}));
events.push(this.events.on("notify_channel_info", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPChannel)) {
@ -201,8 +372,17 @@ export class RDPChannelTree {
entry.handleStateUpdate(event.state);
}));
events.push(this.events.on("notify_selected_entry", event => {
const entry = this.getTreeEntries().find(entry => entry.entryId === event.treeEntryId);
this.selection.select(entry ? [entry] : [], "exclusive", false);
if(entry) {
this.refTree.current?.scrollEntryInView(entry.entryId);
}
}));
this.events.fire("query_tree_entries");
this.events.fire("query_popout_state");
this.events.fire("query_selected_entry");
}
destroy() {
@ -215,6 +395,129 @@ export class RDPChannelTree {
return this.orderedTree;
}
handleDragStart(event: DragEvent) {
const entries = this.selection.selectedEntries;
if(entries.length === 0) {
/* should never happen */
event.preventDefault();
return;
}
let dragType;
if(entries.findIndex(e => !(e instanceof RDPClient)) === -1) {
/* clients only => move */
event.dataTransfer.effectAllowed = "move"; /* prohibit copying */
dragType = "client";
} else if(entries.findIndex(e => !(e instanceof RDPServer)) === -1) {
/* server only => doing nothing right now */
event.preventDefault();
return;
} else if(entries.findIndex(e => !(e instanceof RDPChannel)) === -1) {
/* channels only => move */
event.dataTransfer.effectAllowed = "all";
dragType = "channel";
} else {
event.preventDefault();
return;
}
event.dataTransfer.dropEffect = "move";
event.dataTransfer.setDragImage(generateDragElement(entries), 0, 6);
setupDragData(event.dataTransfer, this, entries, dragType);
}
handleUiDragOver(event: DragEvent, target: RDPEntry) {
if(this.dragOverChannelEntry !== target) {
this.dragOverChannelEntry?.setDragHint("none");
this.dragOverChannelEntry = undefined;
}
const info = getDragInfo(event.dataTransfer);
if(!info) {
return;
}
event.dataTransfer.dropEffect = info.handlerId === this.handlerId ? "move" : "copy";
if(info.type === "client") {
if(target instanceof RDPServer) {
/* can't move a client into a server */
return;
}
/* clients can be dropped anywhere (if they're getting dropped on another client we'll use use his channel */
event.preventDefault();
return;
} else if(info.type === "channel") {
if(!(target instanceof RDPChannel) || !target.refChannelContainer.current) {
/* channel could only be moved into channels */
return;
}
const containerPosition = target.refChannelContainer.current.getBoundingClientRect();
const offsetY = (event.pageY - containerPosition.y) / containerPosition.height;
if(offsetY <= .25) {
target.setDragHint("top");
} else if(offsetY <= .75) {
target.setDragHint("contain");
} else {
target.setDragHint("bottom");
}
this.dragOverChannelEntry = target;
event.preventDefault();
} else {
/* unknown => not supported */
}
}
handleUiDrop(event: DragEvent, target: RDPEntry) {
let currentDragHint: RDPChannelDragHint;
if(this.dragOverChannelEntry) {
currentDragHint = this.dragOverChannelEntry?.dragHint;
this.dragOverChannelEntry?.setDragHint("none");
this.dragOverChannelEntry = undefined;
}
const data = parseDragData(event.dataTransfer);
if(!data) {
return;
}
event.preventDefault();
if(data.type === "client") {
if(data.handlerId !== this.handlerId) {
createErrorModal(tr("Action not possible"), tr("You can't move clients between different server connections.")).open();
return;
}
this.events.fire("action_move_clients", {
entries: data.entryIds,
targetTreeEntry: target.entryId
});
} else if(data.type === "channel") {
if(!(target instanceof RDPChannel)) {
return;
}
console.error("hint: %o", currentDragHint);
if(!currentDragHint || currentDragHint === "none") {
return;
}
if(data.entryIds.indexOf(target.entryId) !== -1) {
return;
}
this.events.fire("action_move_channels", {
targetTreeEntry: target.entryId,
mode: currentDragHint === "contain" ? "child" : currentDragHint === "top" ? "before" : "after",
entries: data.entryIds
});
}
}
@EventHandler<ChannelTreeUIEvents>("notify_tree_entries")
private handleNotifyTreeEntries(event: ChannelTreeUIEvents["notify_tree_entries"]) {
const oldEntryInstances = this.treeEntries;
@ -253,9 +556,15 @@ export class RDPChannelTree {
return result;
}).filter(e => !!e);
Object.keys(oldEntryInstances).map(key => oldEntryInstances[key]).forEach(entry => {
entry.destroy();
});
const removedEntries = Object.keys(oldEntryInstances).map(key => oldEntryInstances[key]);
if(removedEntries.indexOf(this.dragOverChannelEntry) !== -1) {
this.dragOverChannelEntry = undefined;
}
if(removedEntries.length > 0) {
this.selection.select(removedEntries, "remove", false);
removedEntries.forEach(entry => entry.destroy());
}
this.refTree.current?.setState({
tree: this.orderedTree.slice(),
@ -264,16 +573,6 @@ export class RDPChannelTree {
}
@EventHandler<ChannelTreeUIEvents>("notify_entry_move")
private handleNotifyEntryMove(event: ChannelTreeUIEvents["notify_entry_move"]) {
if(!this.refMove.current) {
this.events.fire_react("action_move_entries", { treeEntryId: 0 });
return;
}
this.refMove.current.enableEntryMove(event.entries, event.begin, event.current);
}
@EventHandler<ChannelTreeUIEvents>("notify_popout_state")
private handleNotifyPopoutState(event: ChannelTreeUIEvents["notify_popout_state"]) {
this.popoutShown = event.shown;
@ -322,7 +621,6 @@ export abstract class RDPEntry {
const events = this.getEvents();
events.fire("query_unread_state", { treeEntryId: this.entryId });
events.fire("query_select_state", { treeEntryId: this.entryId });
}
handleUnreadUpdate(value: boolean) {
@ -332,7 +630,7 @@ export abstract class RDPEntry {
this.refUnread.current?.forceUpdate();
}
handleSelectUpdate(value: boolean) {
setSelected(value: boolean) {
if(this.selected === value) { return; }
this.selected = value;
@ -353,16 +651,52 @@ export abstract class RDPEntry {
return this.renderedInstance = this.doRender();
}
select(mode: RDPTreeSelectType) {
this.handle.selection.select([ this ], mode, true);
}
handleUiDoubleClicked() {
this.select("exclusive");
this.getEvents().fire("action_client_double_click", { treeEntryId: this.entryId });
}
handleUiContextMenu(pageX: number, pageY: number) {
this.select("auto-add");
this.getEvents().fire("action_show_context_menu", {
pageX: pageX,
pageY: pageY,
treeEntryIds: this.handle.selection.selectedEntries.map(entry => entry.entryId)
});
}
handleUiDragStart(event: DragEvent) {
if(!this.selected) {
this.handle.selection.select([ this ], "exclusive", true);
}
this.handle.handleDragStart(event);
}
handleUiDragOver(event: DragEvent) {
this.handle.handleUiDragOver(event, this);
}
handleUiDrop(event: DragEvent) {
this.handle.handleUiDrop(event, this);
}
protected abstract doRender() : React.ReactElement;
protected abstract renderSelectStateUpdate();
protected abstract renderPositionUpdate();
}
export type RDPChannelDragHint = "none" | "top" | "bottom" | "contain";
export class RDPChannel extends RDPEntry {
readonly refIcon = React.createRef<ChannelIconClass>();
readonly refIcons = React.createRef<ChannelIconsRenderer>();
readonly refChannel = React.createRef<RendererChannel>();
readonly refChannelContainer = React.createRef<HTMLDivElement>();
/* if uninitialized, undefined */
info: ChannelEntryInfo;
@ -373,8 +707,12 @@ export class RDPChannel extends RDPEntry {
/* if uninitialized, undefined */
icons: ChannelIcons;
dragHint: "none" | "top" | "bottom" | "contain";
constructor(handle: RDPChannelTree, entryId: number) {
super(handle, entryId);
this.dragHint = "none";
}
doRender(): React.ReactElement {
@ -418,6 +756,13 @@ export class RDPChannel extends RDPEntry {
this.info = newInfo;
this.refChannel.current?.forceUpdate();
}
setDragHint(hint: RDPChannelDragHint) {
if(this.dragHint === hint) { return; }
this.dragHint = hint;
this.refChannel.current?.forceUpdate();
}
}
export class RDPClient extends RDPEntry {

View File

@ -1,116 +0,0 @@
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
import * as React from "react";
import * as ReactDOM from "react-dom";
import {LogCategory, logWarn} from "tc-shared/log";
const moveStyle = require("./TreeEntryMove.scss");
export interface TreeEntryMoveProps {
onMoveEnd: (point: { x: number, y: number }) => void;
onMoveCancel: () => void;
}
export interface TreeEntryMoveState {
active: boolean;
begin: { x: number, y: number };
description: string;
}
export class RendererMove extends ReactComponentBase<TreeEntryMoveProps, TreeEntryMoveState> {
private readonly domContainer;
private readonly documentMouseOutListener;
private readonly documentMouseListener;
private readonly refContainer: React.RefObject<HTMLDivElement>;
private current: { x: number, y: number };
constructor(props) {
super(props);
this.refContainer = React.createRef();
this.domContainer = document.createElement("div");
this.documentMouseOutListener = (e: MouseEvent) => {
if (e.type === "mouseup") {
if (e.button !== 0) return;
this.props.onMoveEnd({ x: e.pageX, y: e.pageY });
} else {
this.props.onMoveCancel();
}
this.disableEntryMove();
};
this.documentMouseListener = (e: MouseEvent) => {
this.current = {x: e.pageX, y: e.pageY};
const container = this.refContainer.current;
if (!container) return;
container.style.top = e.pageY + "px";
container.style.left = e.pageX + "px";
};
}
componentDidMount() {
document.body.append(this.domContainer);
}
componentWillUnmount() {
this.domContainer.remove();
}
enableEntryMove(description: string, begin: { x: number, y: number }, current: { x: number, y: number }, callback_enabled?: () => void) {
this.current = current;
this.setState({
active: true,
begin: begin,
description: description
}, callback_enabled);
document.addEventListener("mousemove", this.documentMouseListener);
document.addEventListener("mouseleave", this.documentMouseOutListener);
document.addEventListener("mouseup", this.documentMouseOutListener);
}
private disableEntryMove() {
this.setState({
active: false
});
document.removeEventListener("mousemove", this.documentMouseListener);
document.removeEventListener("mouseleave", this.documentMouseOutListener);
document.removeEventListener("mouseup", this.documentMouseOutListener);
}
protected defaultState(): TreeEntryMoveState {
return {
active: false,
begin: { x: 0, y: 0 },
description: ""
}
}
isActive() {
return this.state.active;
}
render() {
if (!this.state.active)
return null;
return ReactDOM.createPortal(this.renderPortal(), this.domContainer);
}
private renderPortal() {
if(!this.current) {
logWarn(LogCategory.CHANNEL, tr("Tried to render the move container without a current position"));
return null;
}
return (
<div style={{ top: this.current.y, left: this.current.x }} className={moveStyle.moveContainer}
ref={this.refContainer}>
{this.state.description}
</div>
);
}
}

View File

@ -13,7 +13,6 @@ export class ServerRenderer extends React.Component<{ server: RDPServer }, {}> {
render() {
const server = this.props.server;
const selected = this.props.server.selected;
const events = server.getEvents();
let name, icon;
switch (server.state?.state) {
@ -39,16 +38,12 @@ export class ServerRenderer extends React.Component<{ server: RDPServer }, {}> {
<div
className={serverStyle.serverEntry + " " + viewStyle.treeEntry + " " + (selected ? viewStyle.selected : "")}
style={{ top: server.offsetTop }}
onMouseUp={event => {
onMouseDown={event => {
if (event.button !== 0) {
return; /* only left mouse clicks */
}
events.fire("action_select", {
entryIds: [ server.entryId ],
mode: "auto",
ignoreClientMove: false
});
this.props.server.select("auto");
}}
onContextMenu={event => {
if (settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) {
@ -56,8 +51,12 @@ export class ServerRenderer extends React.Component<{ server: RDPServer }, {}> {
}
event.preventDefault();
events.fire("action_show_context_menu", { treeEntryId: server.entryId, pageX: event.pageX, pageY: event.pageY });
this.props.server.handleUiContextMenu(event.pageX, event.pageY);
}}
draggable={true}
onDragStart={event => this.props.server.handleUiDragStart(event.nativeEvent)}
onDragOver={event => this.props.server.handleUiDragOver(event.nativeEvent)}
onDrop={event => this.props.server.handleUiDrop(event.nativeEvent)}
>
<div className={viewStyle.leftPadding} style={{ paddingLeft: server.offsetLeft + "em" }} />
<UnreadMarkerRenderer entry={server} ref={server.refUnread} />

View File

@ -8,7 +8,6 @@ import * as React from "react";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import ResizeObserver from 'resize-observer-polyfill';
import {RDPChannelTree, RDPEntry} from "./RendererDataProvider";
import {RendererMove} from "./RendererMove";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
@ -59,14 +58,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
private scrollFixRequested;
private mouseMove: { x: number, y: number, down: boolean, fired: boolean } = {
x: 0,
y: 0,
down: false,
fired: false
};
private readonly documentMouseListener;
private inViewCallbacks: {
index: number,
callback: () => void,
@ -84,16 +75,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
tree: [],
treeRevision: -1
};
this.documentMouseListener = (e: MouseEvent) => {
if (e.type !== "mouseleave" && e.button !== 0)
return;
this.mouseMove.down = false;
this.mouseMove.fired = false;
this.removeDocumentMouseListener();
};
}
componentDidMount(): void {
@ -143,16 +124,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
});
}
private registerDocumentMouseListener() {
document.addEventListener("mouseleave", this.documentMouseListener);
document.addEventListener("mouseup", this.documentMouseListener);
}
private removeDocumentMouseListener() {
document.removeEventListener("mouseleave", this.documentMouseListener);
document.removeEventListener("mouseup", this.documentMouseListener);
}
private visibleEntries() {
let viewEntryCount = Math.ceil(this.state.view_height / ChannelTreeView.EntryHeight);
const viewEntryBegin = Math.floor(this.state.scroll_offset / ChannelTreeView.EntryHeight);
@ -190,13 +161,11 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
className={viewStyle.channelTreeContainer + " " + (this.state.smoothScroll ? viewStyle.smoothScroll : "")}
onScroll={() => this.onScroll()}
ref={this.refContainer}
onMouseDown={e => this.onMouseDown(e)}
onMouseMove={e => this.onMouseMove(e)}
onContextMenu={event => {
if(event.target !== this.refContainer.current) { return; }
event.preventDefault();
this.props.events.fire("action_show_context_menu", { pageY: event.pageY, pageX: event.pageX, treeEntryId: 0 });
this.props.events.fire("action_show_context_menu", { pageY: event.pageY, pageX: event.pageX, treeEntryIds: [] });
}}
>
<div
@ -204,16 +173,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
style={{height: (this.state.tree.length * ChannelTreeView.EntryHeight) + "px"}}>
{elements}
</div>
<RendererMove
onMoveEnd={target => {
const targetEntry = this.getEntryFromPoint(target.x, target.y);
this.props.events.fire("action_move_entries", { treeEntryId: typeof targetEntry === "number" ? targetEntry : 0 });
}}
onMoveCancel={() => {
this.props.events.fire("action_move_entries", { treeEntryId: 0 });
}}
ref={this.props.dataProvider.refMove}
/>
</div>
)
}
@ -224,34 +183,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
});
}
private onMouseDown(e: React.MouseEvent) {
if (e.button !== 0) return; /* left button only */
this.mouseMove.down = true;
this.mouseMove.x = e.pageX;
this.mouseMove.y = e.pageY;
this.registerDocumentMouseListener();
}
private onMouseMove(e: React.MouseEvent) {
if (!this.mouseMove.down || this.mouseMove.fired) {
return;
}
if (Math.abs((this.mouseMove.x - e.pageX) * (this.mouseMove.y - e.pageY)) > (this.props.moveThreshold || 9)) {
const sourceEntry = this.getEntryFromPoint(this.mouseMove.x, this.mouseMove.y);
if(!sourceEntry) { return; }
this.props.events.fire("action_select", { entryIds: [ sourceEntry ], mode: "auto-add", ignoreClientMove: true });
this.mouseMove.fired = true;
this.props.events.fire("action_start_entry_move", {
current: { x: e.pageX, y: e.pageY },
start: { x: this.mouseMove.x, y: this.mouseMove.y }
});
}
}
scrollEntryInView(entryId: number, callback?: () => void) {
const index = this.state.tree.findIndex(e => e.entryId === entryId);
if (index === -1) {

View File

@ -1,22 +0,0 @@
html:root {
--channel-tree-move-color: hsla(220, 5%, 2%, 1);
--channel-tree-move-background: hsla(0, 0%, 25%, 1);
--channel-tree-move-border: hsla(220, 4%, 40%, 1);
}
.moveContainer {
position: absolute;
display: block;
border: 2px solid var(--channel-tree-move-border);
background-color: var(--channel-tree-move-background);
z-index: 10000;
margin-left: 5px;
padding-left: .25em;
padding-right: .25em;
border-radius: 2px;
color: var(--channel-tree-move-color);
}

View File

@ -69,14 +69,17 @@ export class RtpVoiceConnection extends AbstractVoiceConnection {
}
});
/* FIXME: Listener for audio! */
this.listenerClientMoved = this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => {
const localClientId = this.rtcConnection.getConnection().client.getClientId();
for(const data of event.arguments) {
if(parseInt(data["clid"]) === localClientId) {
/* TODO: Error handling if we failed to start */
this.rtcConnection.startTrackBroadcast("audio");
this.rtcConnection.startTrackBroadcast("audio").catch(error => {
logError(LogCategory.VOICE, tr("Failed to start voice audio broadcasting after channel switch: %o"), error);
this.localFailedReason = tr("Failed to start audio broadcasting");
this.setConnectionState(VoiceConnectionStatus.Failed);
}).catch(() => {
this.setConnectionState(VoiceConnectionStatus.Connected);
});
}
}
});