diff --git a/ChangeLog.md b/ChangeLog.md index 9a39b3f2..5df04a8f 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,7 @@ - Improved channel conversation mode detection support - Added support for HTML encoded links (Example would be when copying from Edge the URL) - Enabled context menus for all clickable client tags + - Allowing to drag client tags * **08.12.20** - Fixed the permission editor not resolving unique ids diff --git a/shared/js/conversations/AbstractConversion.ts b/shared/js/conversations/AbstractConversion.ts index 26cc04ec..9960f4b5 100644 --- a/shared/js/conversations/AbstractConversion.ts +++ b/shared/js/conversations/AbstractConversion.ts @@ -215,17 +215,19 @@ export abstract class AbstractChat { return this.conversationMode === ChannelConversationMode.Private; } - protected setConversationMode(mode: ChannelConversationMode) { + protected setConversationMode(mode: ChannelConversationMode, logChange: boolean) { if(this.conversationMode === mode) { return; } - this.registerChatEvent({ - type: "mode-changed", - uniqueId: guid() + "-mode-change", - timestamp: Date.now(), - newMode: mode === ChannelConversationMode.Public ? "normal" : mode === ChannelConversationMode.Private ? "private" : "none" - }, true); + if(logChange) { + this.registerChatEvent({ + type: "mode-changed", + uniqueId: guid() + "-mode-change", + timestamp: Date.now(), + newMode: mode === ChannelConversationMode.Public ? "normal" : mode === ChannelConversationMode.Private ? "private" : "none" + }, true); + } this.conversationMode = mode; this.events.fire("notify_conversation_mode_changed", { newMode: mode }); diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index 1c2f08d2..41186133 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -165,18 +165,18 @@ export class ChannelConversation extends AbstractChat break; case "private": - this.setConversationMode(ChannelConversationMode.Private); + this.setConversationMode(ChannelConversationMode.Private, true); this.setCurrentMode("normal"); break; case "success": - this.setConversationMode(ChannelConversationMode.Public); + this.setConversationMode(ChannelConversationMode.Public, true); this.setCurrentMode("normal"); break; case "unsupported": this.crossChannelChatSupported = false; - this.setConversationMode(ChannelConversationMode.Private); + this.setConversationMode(ChannelConversationMode.Private, true); this.setCurrentMode("normal"); break; } @@ -295,8 +295,8 @@ export class ChannelConversation extends AbstractChat this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), timestamp); } - public setConversationMode(mode: ChannelConversationMode) { - super.setConversationMode(mode); + public setConversationMode(mode: ChannelConversationMode, logChange: boolean) { + super.setConversationMode(mode, logChange); } public localClientSwitchedChannel(type: "join" | "leave") { @@ -356,7 +356,7 @@ export class ChannelConversationManager extends AbstractChatManager { - const entry = channelTree.findEntryId(entryId); + if(entryId.type !== "client") { return; } + + let entry: ServerEntry | ChannelEntry | ClientEntry; + if("uniqueTreeId" in entryId) { + entry = channelTree.findEntryId(entryId.uniqueTreeId); + } else { + let clients = channelTree.clients.filter(client => client.properties.client_unique_identifier === entryId.clientUniqueId); + if(entryId.clientId) { + clients = clients.filter(client => client.clientId() === entryId.clientId); + } + + if(entryId.clientDatabaseId) { + clients = clients.filter(client => client.properties.client_database_id === entryId.clientDatabaseId); + } + + if(clients.length === 1) { + entry = clients[0]; + } + } + 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; @@ -808,7 +827,15 @@ export function initializeChannelTreeController(events: Registry { - const entry = channelTree.findEntryId(entryId); + if(entryId.type !== "channel") { return; } + + let entry: ServerEntry | ChannelEntry | ClientEntry; + if("uniqueTreeId" in entryId) { + entry = channelTree.findEntryId(entryId.uniqueTreeId); + } else { + entry = channelTree.findChannel(entryId.channelId); + } + 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; diff --git a/shared/js/ui/tree/Definitions.ts b/shared/js/ui/tree/Definitions.ts index 5570f38a..14f48478 100644 --- a/shared/js/ui/tree/Definitions.ts +++ b/shared/js/ui/tree/Definitions.ts @@ -37,8 +37,8 @@ export interface ChannelTreeUIEvents { action_channel_open_file_browser: { treeEntryId: number }, action_client_double_click: { treeEntryId: number }, action_client_name_submit: { treeEntryId: number, name: string }, - action_move_clients: { targetTreeEntry: number, entries: number[] }, - action_move_channels: { targetTreeEntry: number, mode: "before" | "after" | "child", entries: number[] }, + action_move_clients: { targetTreeEntry: number, entries: ChannelTreeDragEntry[] }, + action_move_channels: { targetTreeEntry: number, mode: "before" | "after" | "child", entries: ChannelTreeDragEntry[] }, /* queries */ query_tree_entries: {}, @@ -82,11 +82,30 @@ export interface ChannelTreeUIEvents { notify_destroy: {} } +export type ChannelTreeDragEntry = { + type: "channel", + uniqueTreeId: number, +} | { + type: "channel", + channelId: number +} | { + type: "server" +} | { + type: "client", + + uniqueTreeId: number, +} | { + type: "client", + + clientUniqueId: string, + clientId?: number, + clientDatabaseId?: number +}; + export type ChannelTreeDragData = { version: 1, handlerId: string, type: string, - entryIds: number[], - entryTypes: ("server" | "channel" | "client")[] + entries: ChannelTreeDragEntry[], }; \ No newline at end of file diff --git a/shared/js/ui/tree/DragHelper.ts b/shared/js/ui/tree/DragHelper.ts index 86de226a..d606d09f 100644 --- a/shared/js/ui/tree/DragHelper.ts +++ b/shared/js/ui/tree/DragHelper.ts @@ -1,4 +1,3 @@ -import {RDPChannel, RDPChannelTree, RDPClient, RDPEntry, RDPServer} from "./RendererDataProvider"; import * as loader from "tc-loader"; import {Stage} from "tc-loader"; import { @@ -9,7 +8,7 @@ import { spriteWidth as kClientSpriteWidth, } from "svg-sprites/client-icons"; import {LogCategory, logDebug} from "tc-shared/log"; -import {ChannelTreeDragData} from "tc-shared/ui/tree/Definitions"; +import {ChannelTreeDragData, ChannelTreeDragEntry} from "tc-shared/ui/tree/Definitions"; let spriteImage: HTMLImageElement; @@ -29,7 +28,12 @@ function paintClientIcon(context: CanvasRenderingContext2D, icon: ClientIcon, of context.drawImage(spriteImage, sprite.xOffset, sprite.yOffset, sprite.width, sprite.height, offsetX, offsetY, width, height); } -export function generateDragElement(entries: RDPEntry[]) : HTMLElement { +export type DragImageEntryType = { + icon: ClientIcon, + name: string +} + +export function generateDragElement(entries: DragImageEntryType[]) : HTMLElement { const totalHeight = entries.length * 18 + 2; /* the two extra for "low" letters like "gyj" etc. */ const totalWidth = 250; @@ -37,44 +41,19 @@ export function generateDragElement(entries: RDPEntry[]) : HTMLElement { let offsetX = 20; const canvas = document.createElement("canvas"); + document.body.appendChild(canvas); canvas.height = totalHeight; canvas.width = totalWidth; /* TODO: With font size? */ + const ctx = canvas.getContext("2d"); + { - 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); @@ -83,8 +62,8 @@ export function generateDragElement(entries: RDPEntry[]) : HTMLElement { */ ctx.fillStyle = "black"; - paintClientIcon(ctx, icon, offsetX + 1, offsetY + 1, 16, 16); - ctx.fillText(name, offsetX + 20, offsetY + 19); + paintClientIcon(ctx, entry.icon, offsetX + 1, offsetY + 1, 16, 16); + ctx.fillText(entry.name, offsetX + 20, offsetY + 19); offsetY += 18; @@ -98,9 +77,9 @@ export function generateDragElement(entries: RDPEntry[]) : HTMLElement { } canvas.style.position = "absolute"; - canvas.style.left = "-100000000px"; - canvas.style.top = (Math.random() * 1000000).toFixed(0) + "px"; - document.body.appendChild(canvas); + canvas.style.zIndex = "100000"; + canvas.style.top = "0"; + canvas.style.left = -canvas.width + "px"; setTimeout(() => { canvas.remove(); @@ -113,43 +92,19 @@ 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) { +export function setupDragData(transfer: DataTransfer, handlerId: string, entries: ChannelTreeDragEntry[], 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"; - } - }), + handlerId: handlerId, + entries: entries, type: type }; transfer.effectAllowed = "all" transfer.dropEffect = "move"; - transfer.setData(kDragHandlerPrefix + tree.handlerId, ""); + transfer.setData(kDragHandlerPrefix + 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 { diff --git a/shared/js/ui/tree/EntryTags.tsx b/shared/js/ui/tree/EntryTags.tsx index 4a39340a..e88f145a 100644 --- a/shared/js/ui/tree/EntryTags.tsx +++ b/shared/js/ui/tree/EntryTags.tsx @@ -3,6 +3,8 @@ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; import {getIpcInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC"; import {Settings} from "tc-shared/settings"; +import {generateDragElement, setupDragData} from "tc-shared/ui/tree/DragHelper"; +import {ClientIcon} from "svg-sprites/client-icons"; const kIpcChannel = "entry-tags"; const cssStyle = require("./EntryTags.scss"); @@ -24,6 +26,22 @@ export const ClientTag = (props: { clientName: string, clientUniqueId: string, h pageY: event.pageY }); }} + draggable={true} + onDragStart={event => { + /* clients only => move */ + event.dataTransfer.effectAllowed = "move"; /* prohibit copying */ + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.setDragImage(generateDragElement([{ icon: ClientIcon.PlayerOn, name: props.clientName }]), 0, 6); + setupDragData(event.dataTransfer, props.handlerId, [ + { + type: "client", + clientUniqueId: props.clientUniqueId, + clientId: props.clientId, + clientDatabaseId: props.clientDatabaseId + } + ], "client"); + event.dataTransfer.setData("text/plain", props.clientName); + }} > {props.clientName} @@ -43,6 +61,19 @@ export const ChannelTag = (props: { channelName: string, channelId: number, hand pageY: event.pageY }); }} + draggable={true} + onDragStart={event => { + event.dataTransfer.effectAllowed = "all"; + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.setDragImage(generateDragElement([{ icon: ClientIcon.ChannelGreen, name: props.channelName }]), 0, 6); + setupDragData(event.dataTransfer, props.handlerId, [ + { + type: "channel", + channelId: props.channelId + } + ], "channel"); + event.dataTransfer.setData("text/plain", props.channelName); + }} > {props.channelName} diff --git a/shared/js/ui/tree/RendererDataProvider.tsx b/shared/js/ui/tree/RendererDataProvider.tsx index 9d7cec2e..9cab7eb5 100644 --- a/shared/js/ui/tree/RendererDataProvider.tsx +++ b/shared/js/ui/tree/RendererDataProvider.tsx @@ -1,7 +1,7 @@ import {EventHandler, Registry} from "tc-shared/events"; import { ChannelEntryInfo, - ChannelIcons, + ChannelIcons, ChannelTreeDragEntry, ChannelTreeUIEvents, ClientIcons, ClientNameInfo, @@ -22,7 +22,13 @@ import { RendererClient } from "tc-shared/ui/tree/RendererClient"; import {ServerRenderer} from "tc-shared/ui/tree/RendererServer"; -import {generateDragElement, getDragInfo, parseDragData, setupDragData} from "tc-shared/ui/tree/DragHelper"; +import { + DragImageEntryType, + generateDragElement, + getDragInfo, + parseDragData, + setupDragData +} from "tc-shared/ui/tree/DragHelper"; import {createErrorModal} from "tc-shared/ui/elements/Modal"; function isEquivalent(a, b) { @@ -64,6 +70,25 @@ function isEquivalent(a, b) { } } +function generateDragElementFromRdp(entries: RDPEntry[]) : HTMLElement { + return generateDragElement(entries.map(entry => { + if(entry instanceof RDPClient) { + return { name: entry.name?.name, icon: entry.status }; + } else if(entry instanceof RDPChannel) { + return { name: entry.info?.name, icon: entry.icon }; + } else if(entry instanceof RDPServer) { + switch (entry.state.state) { + case "connected": + return { name: entry.state.name, icon: ClientIcon.ServerGreen }; + case "disconnected": + return { name: tr("Not connected"), icon: ClientIcon.ServerGreen }; + case "connecting": + return { name: tr("Connecting"), icon: ClientIcon.ServerGreen }; + } + } + })); +} + /** * auto := Select/unselect/add/remove depending on the selected state & shift key state * exclusive := Only selected these entries @@ -476,8 +501,38 @@ export class RDPChannelTree { } event.dataTransfer.dropEffect = "move"; - event.dataTransfer.setDragImage(generateDragElement(entries), 0, 6); - setupDragData(event.dataTransfer, this, entries, dragType); + event.dataTransfer.setDragImage(generateDragElementFromRdp(entries), 0, 6); + setupDragData(event.dataTransfer, this.handlerId, entries.map(entry => { + if(entry instanceof RDPClient) { + return { + type: "client", + uniqueTreeId: entry.entryId + }; + } else if(entry instanceof RDPChannel) { + return { + type: "channel", + uniqueTreeId: entry.entryId + }; + } else if(entry instanceof RDPServer) { + return { + type: "server", + }; + } + }).filter(entry => !!entry), dragType); + + { + 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); + } + } + event.dataTransfer.setData("text/plain", texts.filter(e => !!e).join(", ")); + } } @@ -565,7 +620,7 @@ export class RDPChannelTree { } this.events.fire("action_move_clients", { - entries: data.entryIds, + entries: data.entries, targetTreeEntry: target.entryId }); } else if(data.type === "channel") { @@ -577,14 +632,14 @@ export class RDPChannelTree { return; } - if(data.entryIds.indexOf(target.entryId) !== -1) { + if(data.entries.findIndex(entry => entry.type === "channel" && "uniqueTreeId" in entry && entry.uniqueTreeId === target.entryId) !== -1) { return; } this.events.fire("action_move_channels", { targetTreeEntry: target.entryId, mode: currentDragHint === "contain" ? "child" : currentDragHint === "top" ? "before" : "after", - entries: data.entryIds + entries: data.entries }); } }