diff --git a/shared/js/ui/frames/connection-handler-list/Controller.ts b/shared/js/ui/frames/connection-handler-list/Controller.ts index 88b3cf5d..0d8f93b8 100644 --- a/shared/js/ui/frames/connection-handler-list/Controller.ts +++ b/shared/js/ui/frames/connection-handler-list/Controller.ts @@ -57,6 +57,19 @@ function initializeController(events: Registry) { events.fire_async("query_handler_list"); })); + events.on("notify_destroy", server_connections.events().on("notify_handler_order_changed", () => events.fire_async("query_handler_list"))); + events.on("action_swap_handler", event => { + const handlerA = server_connections.findConnection(event.handlerIdOne); + const handlerB = server_connections.findConnection(event.handlerIdTwo); + + if(!handlerA || !handlerB) { + logWarn(LogCategory.CLIENT, tr("Tried to switch handler %s with %s, but one of them does not exists"), event.handlerIdOne, event.handlerIdTwo); + return; + } + + server_connections.swapHandlerOrder(handlerA, handlerB); + }); + events.on("action_set_active_handler", event => { const handler = server_connections.findConnection(event.handlerId); diff --git a/shared/js/ui/frames/connection-handler-list/Definitions.ts b/shared/js/ui/frames/connection-handler-list/Definitions.ts index 6bade959..b4121ada 100644 --- a/shared/js/ui/frames/connection-handler-list/Definitions.ts +++ b/shared/js/ui/frames/connection-handler-list/Definitions.ts @@ -1,5 +1,6 @@ import {LocalIcon} from "tc-shared/file/Icons"; +export type MouseMoveCoordinates = { x: number, y: number, xOffset: number }; export type HandlerConnectionState = "disconnected" | "connecting" | "connected"; export type HandlerStatus = { @@ -12,7 +13,19 @@ export type HandlerStatus = { export interface ConnectionListUIEvents { action_set_active_handler: { handlerId: string }, action_destroy_handler: { handlerId: string }, - action_scroll: { direction: "left" | "right" } + action_scroll: { direction: "left" | "right" }, + action_move_handler: { + handlerId: string | undefined, + mouse?: MouseMoveCoordinates + }, + action_set_moving_position: { + offsetX: number, + width: number + }, + action_swap_handler: { + handlerIdOne: string, + handlerIdTwo: string + } query_handler_status: { handlerId: string }, query_handler_list: {}, diff --git a/shared/js/ui/frames/connection-handler-list/Renderer.scss b/shared/js/ui/frames/connection-handler-list/Renderer.scss index a346f650..086339a6 100644 --- a/shared/js/ui/frames/connection-handler-list/Renderer.scss +++ b/shared/js/ui/frames/connection-handler-list/Renderer.scss @@ -23,128 +23,6 @@ position: relative; - .handlerList { - height: 100%; - width: fit-content; - - display: flex; - flex-direction: row; - justify-content: left; - - overflow-x: auto; - overflow-y: visible; - - max-width: 100%; - scroll-behavior: smooth; - - .handler { - padding-top: 4px; - position: relative; - - flex-grow: 0; - flex-shrink: 0; - - cursor: pointer; - display: inline-flex; - - padding-left: 5px; - padding-right: 5px; - - height: 24px; - overflow: hidden; - - border-bottom: 1px solid transparent; - @include transition(all ease-in-out $button_hover_animation_time); - - .icon { - width: 16px; - height: 16px; - - align-self: center; - margin-right: 5px; - } - - .name { - color: #a8a8a8; - - align-self: center; - margin-right: 20px; - - position: relative; - - overflow: visible; - text-overflow: clip; - white-space: nowrap; - } - - .buttonClose { - width: 16px; - height: 16px; - - position: absolute; - right: 5px; - - align-self: center; - - &:hover { - background-color: #212121; - } - } - - &.cutoffName { - .name { - max-width: 15em; - margin-right: -5px; /* 5px padding which have to be overcommed */ - - &:before { - content: ''; - width: 100%; - height: 100%; - position: absolute; - left: 0; - top: 0; - background: linear-gradient(to right, transparent calc(100% - 50px), #1e1e1e calc(100% - 25px)); - } - } - } - - &:hover { - background-color: #242425; - - &.cutoffName .name:before { - background: linear-gradient(to right, transparent calc(100% - 50px), #242425 calc(100% - 25px)); - } - } - - &.active { - border-bottom-color: #0d9cfd; - background-color: #2d2f32; - - &.cutoffName .name:before { - background: linear-gradient(to right, transparent calc(100% - 50px), #2d2f32 calc(100% - 25px)); - } - } - - &.audioPlayback { - border-bottom-color: #68c1fd; - } - } - - .scrollSpacer { - display: none; - - width: calc(2em + 8px); - height: 1px; - - flex-shrink: 0; - flex-grow: 0; - } - - &::-webkit-scrollbar { - display: none; - } - } - .containerScroll { margin-top: 5px; position: absolute; @@ -200,4 +78,153 @@ display: block; } } +} + +.handlerList { + height: 100%; + width: 100%; + + display: flex; + flex-direction: row; + justify-content: left; + + overflow-x: auto; + overflow-y: visible; + + max-width: 100%; + scroll-behavior: smooth; + + position: relative; + + .handler { + padding-top: 4px; + position: relative; + + flex-grow: 0; + flex-shrink: 0; + + cursor: pointer; + display: inline-flex; + + padding-left: 5px; + padding-right: 5px; + + height: 24px; + overflow: hidden; + + border-bottom: 1px solid transparent; + @include transition(all ease-in-out $button_hover_animation_time, opacity ease-in-out 0); + + .icon { + width: 16px; + height: 16px; + + align-self: center; + margin-right: 5px; + } + + .name { + color: #a8a8a8; + + align-self: center; + margin-right: 20px; + + position: relative; + + overflow: visible; + text-overflow: clip; + white-space: nowrap; + } + + .buttonClose { + width: 16px; + height: 16px; + + position: absolute; + right: 5px; + + align-self: center; + + &:hover { + background-color: #212121; + } + } + + &.cutoffName { + .name { + max-width: 15em; + margin-right: -5px; /* 5px padding which have to be overcommed */ + + &:before { + content: ''; + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; + background: linear-gradient(to right, transparent calc(100% - 50px), #1e1e1e calc(100% - 25px)); + } + } + } + + &:hover { + background-color: #242425; + + &.cutoffName .name:before { + background: linear-gradient(to right, transparent calc(100% - 50px), #242425 calc(100% - 25px)); + } + } + + &.mode-active { + border-bottom-color: #0d9cfd; + background-color: #2d2f32; + + &.cutoffName .name:before { + background: linear-gradient(to right, transparent calc(100% - 50px), #2d2f32 calc(100% - 25px)); + } + } + + &.mode-normal { /* nothing */ } + + &.mode-spacer { + opacity: 0; + } + + &.audioPlayback { + border-bottom-color: #68c1fd; + } + } + + .scrollSpacer { + display: none; + + width: calc(2em + 8px); + height: 1px; + + flex-shrink: 0; + flex-grow: 0; + } + + &.hardScroll { + scroll-behavior: unset; + } + + &::-webkit-scrollbar { + display: none; + } +} + +.moveContainer { + position: absolute; + + left: 4em; + top: 0; + right: 0; + + display: none; + flex-direction: row; + + width: min-content; + + background: #1e1e1e; } \ No newline at end of file diff --git a/shared/js/ui/frames/connection-handler-list/Renderer.tsx b/shared/js/ui/frames/connection-handler-list/Renderer.tsx index 20c6a7e0..b76f00a7 100644 --- a/shared/js/ui/frames/connection-handler-list/Renderer.tsx +++ b/shared/js/ui/frames/connection-handler-list/Renderer.tsx @@ -1,5 +1,9 @@ import {Registry} from "tc-shared/events"; -import {ConnectionListUIEvents, HandlerStatus} from "tc-shared/ui/frames/connection-handler-list/Definitions"; +import { + ConnectionListUIEvents, + HandlerStatus, + MouseMoveCoordinates +} from "tc-shared/ui/frames/connection-handler-list/Definitions"; import * as React from "react"; import {useContext, useEffect, useRef, useState} from "react"; import {IconRenderer, LocalIconRenderer} from "tc-shared/ui/react-elements/Icon"; @@ -13,8 +17,8 @@ import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; const cssStyle = require("./Renderer.scss"); const Events = React.createContext>(undefined); - -const ConnectionHandler = (props: { handlerId: string, active: boolean }) => { +type ConnectionHandlerMode = "normal" | "active" | "spacer"; +const ConnectionHandler = React.memo((props: { handlerId: string, mode: ConnectionHandlerMode, refContainer?: React.Ref }) => { const events = useContext(Events); const [ status, setStatus ] = useState(() => { @@ -53,19 +57,22 @@ const ConnectionHandler = (props: { handlerId: string, active: boolean }) => { case "disconnected": displayedName = Not connected; + displayedName = props.handlerId; break; } } return ( -
{ - if(props.active) { - return; + if(props.mode !== "normal") { + return; } events.fire("action_set_active_handler", { handlerId: props.handlerId }); }} + ref={props.refContainer} + x-handler-id={props.handlerId} >
{icon} @@ -78,9 +85,190 @@ const ConnectionHandler = (props: { handlerId: string, active: boolean }) => {
); -} +}) -const HandlerList = (props: { refContainer: React.Ref, refSpacer: React.Ref }) => { +const MoveableConnectionHandler = (props: { handlerId: string, mode: ConnectionHandlerMode }) => { + const events = useContext(Events); + const refContainer = useRef(); + + useEffect(() => { + if(!refContainer.current) { + return; + } + + const attached = { status: false }; + const basePoint = { x: 0, y: 0 }; + + const mouseDownListener = (event: MouseEvent) => { + basePoint.x = event.pageX; + basePoint.y = event.pageY; + attachListener(); + }; + + const mouseMoveListener = (event: MouseEvent) => { + const diffXSqrt = Math.abs(basePoint.x - event.pageX); + if(diffXSqrt > 10) { + events.fire("action_move_handler", { + handlerId: props.handlerId, + mouse: { x: event.pageX, y: event.pageY, xOffset: basePoint.x + refContainer.current.parentElement.scrollLeft - refContainer.current.offsetLeft } + }); + detachListener(); + } + }; + + const mouseUpListener = () => detachListener; + + const attachListener = () => { + if(attached.status) { return; } + attached.status = true; + + document.addEventListener("mousemove", mouseMoveListener); + document.addEventListener("mouseup", mouseUpListener); + document.addEventListener("mouseleave", mouseUpListener); + }; + + const detachListener = () => { + if(!attached.status) { return; } + attached.status = false; + + document.removeEventListener("mousemove", mouseMoveListener); + document.removeEventListener("mouseup", mouseUpListener); + document.removeEventListener("mouseleave", mouseUpListener); + } + + refContainer.current.addEventListener("mousedown", mouseDownListener); + + return () => { + refContainer.current.removeEventListener("mousedown", mouseDownListener); + detachListener(); + } + }); + + return +}; + +const MovingConnectionHandler = React.memo((props: { handlerId: string, handlerActive: boolean, initialMouse: MouseMoveCoordinates }) => { + const events = useContext(Events); + + const refContainer = useRef(); + const offsetX = useRef(0); + const mouseXRef = useRef(props.initialMouse.x); + + const scrollValue = useRef(0); + const scrollIntervalReference = useRef(0); + + useEffect(() => { + if(!refContainer.current ||!refContainer.current.parentElement) { return; } + + refContainer.current.style.display = "flex"; + + let lastMovingEventPositionX = 0; + + const executeUpdate = () => { + if(!refContainer.current?.parentElement) { return; } + const parentElement = refContainer.current.parentElement; + + updateContainerPosition(mouseXRef.current, parentElement, false); + updateScroll(mouseXRef.current, parentElement); + } + + const updateScroll = (mouseX: number, parentElement: HTMLElement) => { + if(mouseX >= parentElement.offsetLeft + parentElement.clientWidth - 100) { + scrollValue.current = +2; + } else if(mouseX <= refContainer.current.parentElement.offsetLeft + 100) { + scrollValue.current = -2; + } else { + scrollValue.current = 0; + } + + if(scrollValue.current) { + if(!scrollIntervalReference.current) { + scrollIntervalReference.current = setInterval(() => { + if(!scrollValue.current) { + logWarn(LogCategory.CLIENT, tr("Scroll interval called, but no scroll direction set")); + return; + } + + const targetScrollLeft = parentElement.scrollLeft + scrollValue.current; + const cancelScroll = targetScrollLeft < 0 || Math.ceil(targetScrollLeft + parentElement.clientWidth) + 1 >= parentElement.scrollWidth; + if(cancelScroll) { + clearInterval(scrollIntervalReference.current); + scrollIntervalReference.current = -1; /* set, but inactive, needs to be re triggered */ + } else { + parentElement.scrollLeft = targetScrollLeft; + } + updateContainerPosition(mouseXRef.current, parentElement, cancelScroll); + }, 5); + parentElement.classList.add(cssStyle.hardScroll); + } + } else { + if(scrollIntervalReference.current) { + clearInterval(scrollIntervalReference.current); + scrollIntervalReference.current = 0; + + /* stop the scroll ( + 1 - 1 has been appended for the ide...) */ + parentElement.scrollLeft = parentElement.scrollLeft + 1 - 1; + parentElement.classList.remove(cssStyle.hardScroll); + } + } + } + + const updateContainerPosition = (mouseX: number, parentElement: HTMLElement, forceFirePositionUpdate: boolean) => { + offsetX.current = (mouseX - parentElement.offsetLeft) - props.initialMouse.xOffset + parentElement.scrollLeft; + if(offsetX.current + props.initialMouse.xOffset < 0) { return; } + if(Math.ceil(offsetX.current + refContainer.current.clientWidth) + 1 >= parentElement.scrollWidth) { return; } + + refContainer.current.style.left = offsetX.current + "px"; + + if(Math.abs(lastMovingEventPositionX - offsetX.current) > 30 || forceFirePositionUpdate) { + lastMovingEventPositionX = offsetX.current; + fireMovingPositionEvent(); + } + }; + + const fireMovingPositionEvent = () => { + events.fire("action_set_moving_position", { offsetX: offsetX.current , width: refContainer.current.clientWidth }); + }; + + const listenerMove = (event: MouseEvent) => { + mouseXRef.current = event.pageX; + executeUpdate(); + } + + const listenerUp = () => { + events.fire("action_move_handler", { handlerId: undefined }); + } + + executeUpdate(); + + document.addEventListener("mousemove", listenerMove); + document.addEventListener("mouseup", listenerUp); + document.addEventListener("mouseleave", listenerUp); + + return () => { + clearInterval(scrollIntervalReference.current); + if(refContainer.current?.parentElement) { + const parentElement = refContainer.current?.parentElement; + parentElement.classList.remove(cssStyle.hardScroll); + + /* stop the scroll ( + 1 - 1 has been appended for the ide...) */ + parentElement.scrollLeft = parentElement.scrollLeft + 1 - 1; + } + + document.removeEventListener("mouseup", listenerUp); + document.removeEventListener("mouseleave", listenerUp); + document.removeEventListener("mousemove", listenerMove); + }; + }); + + return ( +
+ +
+ ); +}); + +const HandlerList = (props: { refContainer: React.RefObject, refSpacer: React.Ref }) => { const events = useContext(Events); const [ handlers, setHandlers ] = useState(() => { @@ -89,18 +277,73 @@ const HandlerList = (props: { refContainer: React.Ref, refSpacer }); const [ activeHandler, setActiveHandler ] = useState(); + const [ movingHandler, setMovingHandler ] = useState<{ handlerId: string, mouse: MouseMoveCoordinates} | undefined>(); + const switchRequestTimestamp = useRef(0); events.reactUse("notify_handler_list", event => { setHandlers(event.handlerIds.slice()); setActiveHandler(event.activeHandlerId); }); events.reactUse("notify_active_handler", event => setActiveHandler(event.handlerId)); + events.reactUse("action_move_handler", event => { + if(typeof event.handlerId === "undefined") { + setMovingHandler(undefined); + } else { + setMovingHandler({ handlerId: event.handlerId, mouse: event.mouse }); + } + }); + + events.reactUse("action_set_moving_position", event => { + if(!movingHandler || handlers === "loading" || (Date.now() - switchRequestTimestamp.current) < 1000) { return; } + + const centerCurrent = event.offsetX + event.width / 2; + + /* get the target element */ + const children = [...props.refContainer.current.childNodes.values()] as HTMLElement[]; + const element = children.find(element => element.offsetLeft <= centerCurrent && centerCurrent <= element.offsetLeft + element.clientWidth); + const handlerId = element?.getAttribute("x-handler-id"); + if(handlerId === movingHandler.handlerId || !handlerId) { return; } + + const oldIndex = handlers.findIndex(handler => handler === movingHandler.handlerId); + const newIndex = handlers.findIndex(handler => handler === handlerId); + + const centerTarget = element.offsetLeft + element.clientWidth / 2; + if(oldIndex < newIndex) { + if(event.offsetX + event.width < centerTarget) { + return; + } + } else { + if(event.offsetX > centerTarget) { + return; + } + } + + events.fire("action_swap_handler", { handlerIdOne: handlers[oldIndex], handlerIdTwo: handlers[newIndex] }); + switchRequestTimestamp.current = Date.now(); + }); + + /* no switch pending, everything has been rendered */ + useEffect(() => { switchRequestTimestamp.current = 0; }); return (
{handlers === "loading" ? undefined : - handlers.map(handlerId => ) + handlers.map(handlerId => ( + + )) } + { movingHandler ? ( + + ) : undefined}
) @@ -143,6 +386,7 @@ export const ConnectionHandlerList = (props: { events: Registry { const container = refHandlerContainer.current; if(!container) { return; } + props.events.fire_async("notify_scroll_status", { right: Math.ceil(scrollLeft + container.clientWidth + 2) < container.scrollWidth, left: scrollLeft !== 0 }); } @@ -193,6 +437,8 @@ export const ConnectionHandlerList = (props: { events: Registry { let currentChild: HTMLDivElement; @@ -206,7 +452,6 @@ export const ConnectionHandlerList = (props: { events: Registry