diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index f01be1e91..6afa27588 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -107,7 +107,10 @@ import { ProjectOverviewComponent } from './projects/project-overview/project-ov import { ProjectWrapperComponent } from './projects/project-wrapper/project-wrapper.component'; import { WhitespaceUrlInterceptor } from './services/encoder/encoder.interceptor'; import { DeleteSessionDialogComponent } from './sessions/delete-session-dialog/delete-session-dialog.component'; +import { FloatingWindowManagerComponent } from './sessions/session/floating-window-manager/floating-window-manager.component'; +import { SessionIFrameComponent } from './sessions/session/session-iframe/session-iframe.component'; import { SessionComponent } from './sessions/session/session.component'; +import { TilingWindowManagerComponent } from './sessions/session/tiling-window-manager/tiling-window-manager.component'; import { SessionOverviewComponent } from './sessions/session-overview/session-overview.component'; import { SessionsComponent } from './sessions/sessions.component'; import { ActiveSessionsComponent } from './sessions/user-sessions-wrapper/active-sessions/active-sessions.component'; @@ -172,6 +175,7 @@ import { SettingsComponent } from './settings/settings.component'; EventsComponent, FileBrowserDialogComponent, FileExistsDialogComponent, + FloatingWindowManagerComponent, FooterComponent, FormFieldSkeletonLoaderComponent, GitSettingsComponent, @@ -211,6 +215,7 @@ import { SettingsComponent } from './settings/settings.component'; ProjectWrapperComponent, PureVariantsComponent, SessionComponent, + SessionIFrameComponent, SessionOverviewComponent, SessionsComponent, SettingsComponent, @@ -220,6 +225,7 @@ import { SettingsComponent } from './settings/settings.component'; T4CSettingsComponent, T4CSettingsWrapperComponent, TextLineSkeletonLoaderComponent, + TilingWindowManagerComponent, ToolDeletionDialogComponent, ToolDetailsComponent, ToolIntegrationsComponent, diff --git a/frontend/src/app/schemes.ts b/frontend/src/app/schemes.ts index 9e71d8461..cf37f6e06 100644 --- a/frontend/src/app/schemes.ts +++ b/frontend/src/app/schemes.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SafeResourceUrl } from '@angular/platform-browser'; import { User } from 'src/app/services/user/user.service'; import { ToolVersionWithTool } from 'src/app/settings/core/tools-settings/tool.service'; import { Project } from './projects/service/project.service'; @@ -24,9 +23,6 @@ export interface Session { owner: User; t4c_password: string; download_in_progress: boolean; - safeResourceURL?: SafeResourceUrl; - focused: boolean; - reloadToResize: boolean; } export interface ReadonlySession extends Session { diff --git a/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.html b/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.html new file mode 100644 index 000000000..a9b0b19b2 --- /dev/null +++ b/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.html @@ -0,0 +1,58 @@ + + + +
+
+
+
+ control_camera + + {{ session.version?.tool?.name }} {{ session.version?.name }}, + {{ session.type }} + + (project {{ session.project!.name }}) + + +
+ +
+
+ Focusedphonelink +
+
+ Not focused + phonelink_off +
+
+ + +
+ + +
+
+
diff --git a/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.ts b/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.ts new file mode 100644 index 000000000..debd6543e --- /dev/null +++ b/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, OnInit } from '@angular/core'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { debounceTime, fromEvent } from 'rxjs'; +import { SessionViewerService, ViewerSession } from '../session-viewer.service'; + +@Component({ + selector: 'app-floating-window-manager', + templateUrl: './floating-window-manager.component.html', +}) +@UntilDestroy() +export class FloatingWindowManagerComponent implements OnInit { + draggingActive = false; + + constructor(public sessionViewerService: SessionViewerService) {} + + ngOnInit(): void { + fromEvent(window, 'resize') + .pipe(untilDestroyed(this), debounceTime(250)) + .subscribe(() => this.sessionViewerService.resizeSessions()); + } + + dragStart(): void { + this.draggingActive = true; + } + + dragStop(): void { + this.draggingActive = false; + } + + trackBySessionId(_: number, session: ViewerSession): string { + return session.id; + } +} diff --git a/frontend/src/app/sessions/session/session-iframe/session-iframe.component.css b/frontend/src/app/sessions/session/session-iframe/session-iframe.component.css new file mode 100644 index 000000000..229fd309c --- /dev/null +++ b/frontend/src/app/sessions/session/session-iframe/session-iframe.component.css @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.iframe-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + z-index: 40; +} diff --git a/frontend/src/app/sessions/session/session-iframe/session-iframe.component.html b/frontend/src/app/sessions/session/session-iframe/session-iframe.component.html new file mode 100644 index 000000000..c304f291f --- /dev/null +++ b/frontend/src/app/sessions/session/session-iframe/session-iframe.component.html @@ -0,0 +1,25 @@ + + +
+
+ +
diff --git a/frontend/src/app/sessions/session/session-iframe/session-iframe.component.ts b/frontend/src/app/sessions/session/session-iframe/session-iframe.component.ts new file mode 100644 index 000000000..577cbec71 --- /dev/null +++ b/frontend/src/app/sessions/session/session-iframe/session-iframe.component.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Input } from '@angular/core'; +import { ViewerSession } from '../session-viewer.service'; + +@Component({ + selector: 'app-session-iframe', + templateUrl: './session-iframe.component.html', + styleUrls: ['./session-iframe.component.css'], +}) +export class SessionIFrameComponent { + @Input({ required: true }) session!: ViewerSession; +} diff --git a/frontend/src/app/sessions/session/session-viewer.service.ts b/frontend/src/app/sessions/session/session-viewer.service.ts new file mode 100644 index 000000000..8adc3df87 --- /dev/null +++ b/frontend/src/app/sessions/session/session-viewer.service.ts @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { BehaviorSubject, map } from 'rxjs'; +import { LocalStorageService } from 'src/app/general/auth/local-storage/local-storage.service'; +import { Session } from 'src/app/schemes'; +import { GuacamoleService } from 'src/app/services/guacamole/guacamole.service'; + +@Injectable({ + providedIn: 'root', +}) +export class SessionViewerService { + constructor( + private guacamoleService: GuacamoleService, + private localStorageService: LocalStorageService, + private domSanitizer: DomSanitizer, + ) {} + + private _sessions = new BehaviorSubject( + undefined, + ); + + public readonly sessions$ = this._sessions.pipe( + map((sessions) => { + const fullscreenSession = sessions?.find((session) => session.fullscreen); + return fullscreenSession ? [fullscreenSession] : sessions; + }), + ); + + pushJupyterSession(session: Session): void { + if (session.jupyter_uri) { + const viewerSession = session as ViewerSession; + + viewerSession.focused = false; + viewerSession.safeResourceURL = + this.domSanitizer.bypassSecurityTrustResourceUrl(session.jupyter_uri); + viewerSession.reloadToResize = false; + + this.insertViewerSession(viewerSession); + } + } + + pushGuacamoleSession(session: Session): void { + const viewerSession = session as ViewerSession; + + this.guacamoleService.getGucamoleToken(session.id).subscribe({ + next: (guacamoleAuthInfo) => { + this.localStorageService.setValue('GUAC_AUTH', guacamoleAuthInfo.token); + + viewerSession.focused = false; + viewerSession.safeResourceURL = + this.domSanitizer.bypassSecurityTrustResourceUrl( + guacamoleAuthInfo.url, + ); + viewerSession.reloadToResize = true; + + this.insertViewerSession(viewerSession); + }, + }); + } + + focusSession(session: Session): void { + document.getElementById('session-' + session.id)?.focus(); + + const updatedSessions = this._sessions.value?.map((curSession) => ({ + ...curSession, + focused: session.id === curSession.id, + })); + + this._sessions.next(updatedSessions); + } + + resizeSessions(): void { + document.querySelectorAll('iframe').forEach((iframe) => { + const session = this._sessions.value?.find( + (session) => `session-${session.id}` === iframe.id, + ); + + if (session?.reloadToResize) { + this.reloadIFrame(iframe); + } + }); + } + + resizeSession(session: ViewerSession): void { + const sessionIFrame = document.querySelector( + `iframe#session-${session.id}`, + ); + + if (sessionIFrame && session.reloadToResize) { + this.reloadIFrame(sessionIFrame); + } + } + + toggleFullscreen(session: ViewerSession): void { + const updatedSessions = this._sessions.value?.map((curSession) => { + if (session.id === curSession.id) { + return { ...curSession, fullscreen: !curSession.fullscreen }; + } + return curSession; + }); + this._sessions.next(updatedSessions); + this.resizeSession(session); + } + + clearSessions(): void { + this._sessions.next(undefined); + } + + private reloadIFrame(iframe: HTMLIFrameElement) { + const src = iframe.src; + + iframe.removeAttribute('src'); + + setTimeout(() => { + iframe.src = src; + }, 100); + } + + private insertViewerSession(viewerSession: ViewerSession): void { + const currentSessions = this._sessions.value; + + if (currentSessions === undefined) { + this._sessions.next([viewerSession]); + } else { + this._sessions.next([...currentSessions, viewerSession]); + } + } +} + +export type ViewerSession = Session & { + safeResourceURL?: SafeResourceUrl; + focused: boolean; + reloadToResize: boolean; + fullscreen: boolean; +}; diff --git a/frontend/src/app/sessions/session/session.component.css b/frontend/src/app/sessions/session/session.component.css index 00d1f5573..24491c155 100644 --- a/frontend/src/app/sessions/session/session.component.css +++ b/frontend/src/app/sessions/session/session.component.css @@ -6,13 +6,3 @@ .height { height: calc(100vh - 2vh - 65px - 110px); } - -.iframe-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - - z-index: 40; -} diff --git a/frontend/src/app/sessions/session/session.component.html b/frontend/src/app/sessions/session/session.component.html index 5404bc475..56d1a4ea5 100644 --- a/frontend/src/app/sessions/session/session.component.html +++ b/frontend/src/app/sessions/session/session.component.html @@ -3,11 +3,15 @@ ~ SPDX-License-Identifier: Apache-2.0 --> -
- Please select the sessions that you'd like to open in the session - viewer: +
+ + Please select the sessions that you'd like to open in the session viewer: +
No sessions found. Please create a session in the 'Sessions' tab first.
@@ -33,7 +37,7 @@ matTooltip="Please wait until the session is running. You can check the status in the 'Sessions' tab." [matTooltipDisabled]="sessionService.beautifyState(session.state).success" [value]="session.id" - (change)="changeSessionSelection($event, session)" + [(ngModel)]="session.checked" *ngFor="let session of cachedSessions" >{{ session.version?.tool?.name }} {{ session.version?.name }}, {{ session.type @@ -49,6 +53,15 @@ server will not let you connect to the sessions. We're working on making this possible.
+
+ + + Floating window manager + + Tiling window manager + +
+
-
+
-
-
- control_camera - - {{ session.version?.tool?.name }} {{ session.version?.name }}, - {{ session.type }} - (project {{ session.project!.name }}) - -
-
- Focusedphonelink -
-
- Not focused - phonelink_off -
-
+ -
-
-
- -
+ + +
-
-
- -
+ + fullscreen + + + fullscreen_exit + + +
+ diff --git a/frontend/src/app/sessions/session/session.component.ts b/frontend/src/app/sessions/session/session.component.ts index f4f23cd51..c1bd4f95d 100644 --- a/frontend/src/app/sessions/session/session.component.ts +++ b/frontend/src/app/sessions/session/session.component.ts @@ -3,17 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Component, HostListener, OnInit } from '@angular/core'; -import { MatLegacyCheckboxChange as MatCheckboxChange } from '@angular/material/legacy-checkbox'; -import { DomSanitizer } from '@angular/platform-browser'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { filter, take } from 'rxjs'; -import { LocalStorageService } from 'src/app/general/auth/local-storage/local-storage.service'; import { Session } from 'src/app/schemes'; -import { GuacamoleService } from 'src/app/services/guacamole/guacamole.service'; import { FullscreenService } from 'src/app/sessions/service/fullscreen.service'; import { SessionService } from 'src/app/sessions/service/session.service'; import { UserSessionService } from 'src/app/sessions/service/user-session.service'; +import { SessionViewerService } from './session-viewer.service'; @Component({ selector: 'app-session', @@ -21,38 +18,42 @@ import { UserSessionService } from 'src/app/sessions/service/user-session.servic styleUrls: ['./session.component.css'], }) @UntilDestroy() -export class SessionComponent implements OnInit { +export class SessionComponent implements OnInit, OnDestroy { cachedSessions?: CachedSession[] = undefined; - selectedSessions: Session[] = []; - private debounceTimer?: number; - draggingActive = false; + selectedWindowType: string = 'floating'; constructor( public userSessionService: UserSessionService, public sessionService: SessionService, + public sessionViewerService: SessionViewerService, public fullscreenService: FullscreenService, - private guacamoleService: GuacamoleService, - private localStorageService: LocalStorageService, - private domSanitizer: DomSanitizer, ) { this.userSessionService.loadSessions(); + + this.fullscreenService.isFullscreen$ + .pipe(untilDestroyed(this)) + .subscribe(() => this.sessionViewerService.resizeSessions()); } get checkedSessions(): undefined | CachedSession[] { return this.cachedSessions?.filter((session) => session.checked); } - changeSessionSelection(event: MatCheckboxChange, session: CachedSession) { - session.checked = event.checked; + get isTilingWindowManager(): boolean { + return this.selectedWindowType === 'tiling'; + } + + get isFloatingWindowManager(): boolean { + return this.selectedWindowType === 'floating'; } ngOnInit(): void { this.initializeCachedSessions(); + } - this.fullscreenService.isFullscreen$ - .pipe(untilDestroyed(this)) - .subscribe(() => this.resizeSessions()); + ngOnDestroy(): void { + this.sessionViewerService.clearSessions(); } initializeCachedSessions() { @@ -71,77 +72,13 @@ export class SessionComponent implements OnInit { selectSessions() { this.checkedSessions?.forEach((session) => { - session.focused = false; if (session.jupyter_uri) { - session.safeResourceURL = - this.domSanitizer.bypassSecurityTrustResourceUrl(session.jupyter_uri); - session.reloadToResize = false; - this.selectedSessions.push(session); + this.sessionViewerService.pushJupyterSession(session); } else { - this.guacamoleService.getGucamoleToken(session?.id).subscribe((res) => { - this.localStorageService.setValue('GUAC_AUTH', res.token); - session.safeResourceURL = - this.domSanitizer.bypassSecurityTrustResourceUrl(res.url); - session.reloadToResize = true; - this.selectedSessions.push(session); - }); + this.sessionViewerService.pushGuacamoleSession(session); } }); } - - focusSession(session: Session) { - this.unfocusSession(session); - - document.getElementById('session-' + session.id)?.focus(); - session.focused = true; - } - - unfocusSession(focusedSession: Session) { - this.selectedSessions - .filter((session) => session !== focusedSession) - .map((session) => { - session.focused = false; - }); - } - - dragStart() { - this.draggingActive = true; - } - - dragStop() { - this.draggingActive = false; - } - - @HostListener('window:resize', ['$event']) - onResize() { - window.clearTimeout(this.debounceTimer); - - this.debounceTimer = window.setTimeout(() => { - this.resizeSessions(); - }, 250); - } - - resizeSessions() { - Array.from(document.getElementsByTagName('iframe')).forEach((iframe) => { - const session = this.selectedSessions.find( - (session) => 'session-' + session.id === iframe.id, - ); - - if (session?.reloadToResize) { - this.reloadIFrame(iframe); - } - }); - } - - reloadIFrame(iframe: HTMLIFrameElement) { - const src = iframe.src; - - iframe.removeAttribute('src'); - - setTimeout(() => { - iframe.src = src; - }, 100); - } } type CachedSession = Session & { diff --git a/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.html b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.html new file mode 100644 index 000000000..d516f4918 --- /dev/null +++ b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.html @@ -0,0 +1,78 @@ + + +
+ +
+
+ + {{ session.version?.tool?.name }} {{ session.version?.name }}, + {{ session.type }} + + (project {{ session.project!.name }}) + + + +
+
+ Focusedphonelink +
+
+ Not focused + phonelink_off +
+
+ +
+
+ + + +
+ + +
+
+ + +
+ +
+
+
diff --git a/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.ts b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.ts new file mode 100644 index 000000000..a061060b8 --- /dev/null +++ b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.ts @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, HostListener, OnInit } from '@angular/core'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { filter } from 'rxjs'; +import { SessionViewerService, ViewerSession } from '../session-viewer.service'; + +@Component({ + selector: 'app-tiling-window-manager', + templateUrl: './tiling-window-manager.component.html', +}) +@UntilDestroy() +export class TilingWindowManagerComponent implements OnInit { + private _tilingSessions: TilingSession[] = []; + + get sessions(): TilingSession[] { + return this._tilingSessions.sort((a, b) => a.index - b.index); + } + + public resizeState: ResizeState = {}; + + private minimumSessionWidth = 0; + + constructor(public sessionViewerService: SessionViewerService) {} + + ngOnInit(): void { + this.sessionViewerService.sessions$ + .pipe(untilDestroyed(this), filter(Boolean)) + .subscribe((viewerSessions) => { + const sessionMap = new Map(viewerSessions.map((s) => [s.id, s])); + + const hasSessionDifference = + this._tilingSessions.length !== viewerSessions.length || + viewerSessions.some( + (session) => + !this._tilingSessions.some((ts) => ts.id === session.id), + ); + + if (hasSessionDifference) { + this._tilingSessions = viewerSessions.map((session, index) => ({ + ...session, + index: index, + width: -1, + fullscreen: false, + })); + this.resetWidths(); + } else { + this._tilingSessions = this._tilingSessions.map((tilingSession) => { + const sessionUpdate = sessionMap.get(tilingSession.id); + return sessionUpdate + ? { ...tilingSession, ...sessionUpdate } + : tilingSession; + }); + } + }); + } + + onMouseDown(event: MouseEvent, index: number): void { + const leftSession = this.getSessionByIndex(index); + const rightSession = this.getSessionByIndex(index + 1); + + if (leftSession && rightSession) { + this.resizeState = { + index: index, + startX: event.clientX, + leftSession: leftSession, + rightSession: rightSession, + startWidthLeft: leftSession.width, + startWidthRight: rightSession.width, + }; + } + } + + @HostListener('window:mousemove', ['$event']) + onMouseMove(event: MouseEvent): void { + if (this.isValidResizeState(this.resizeState)) { + const delta = event.clientX - this.resizeState.startX; + const [newWidthLeft, newWidthRight] = this.calculateNewWidths( + this.resizeState, + delta, + ); + + this.resizeState.leftSession.width = newWidthLeft; + this.resizeState.rightSession.width = newWidthRight; + } + } + + @HostListener('window:mouseup') + onMouseUp(): void { + if (this.isValidResizeState(this.resizeState)) { + this.resizeState = {}; + this.sessionViewerService.resizeSessions(); + } + } + + @HostListener('window:resize') + onResize() { + this.resetWidths(); + } + + onLeftArrowClick(session: TilingSession) { + const leftIndexSession = this.getSessionByIndex(session.index - 1); + + if (leftIndexSession) { + this.swapSessions(session, leftIndexSession); + } + } + + onRightArrowClick(session: TilingSession) { + const rightIndexSession = this.getSessionByIndex(session.index + 1); + + if (rightIndexSession) { + this.swapSessions(session, rightIndexSession); + } + } + + trackBySessionIndexAndId(_: number, session: TilingSession): string { + return `${session.index}-${session.id}`; + } + + isValidResizeState(state: ResizeState): state is ValidResizeState { + return ( + state.index !== undefined && + state.startX !== undefined && + state.leftSession !== undefined && + state.rightSession !== undefined && + state.startWidthLeft !== undefined && + state.startWidthRight !== undefined + ); + } + + private calculateNewWidths( + validResizeState: ValidResizeState, + delta: number, + ): [number, number] { + let newWidthLeft = validResizeState.startWidthLeft + delta; + let newWidthRight = validResizeState.startWidthRight - delta; + + [newWidthLeft, newWidthRight] = this.adjustWidthsWithMinimum( + validResizeState, + newWidthLeft, + newWidthRight, + ); + return [newWidthLeft, newWidthRight]; + } + + private adjustWidthsWithMinimum( + validResizeState: ValidResizeState, + newWidthLeft: number, + newWidthRight: number, + ): [number, number] { + if (newWidthLeft < this.minimumSessionWidth) { + newWidthLeft = this.minimumSessionWidth; + newWidthRight = + validResizeState.startWidthLeft + + validResizeState.startWidthRight - + newWidthLeft; + } else if (newWidthRight < this.minimumSessionWidth) { + newWidthRight = this.minimumSessionWidth; + newWidthLeft = + validResizeState.startWidthLeft + + validResizeState.startWidthRight - + newWidthRight; + } + return [newWidthLeft, newWidthRight]; + } + + private resetWidths() { + this.minimumSessionWidth = window.innerWidth * 0.15; + this._tilingSessions.forEach( + (session) => + (session.width = window.innerWidth / this._tilingSessions.length), + ); + this.sessionViewerService.resizeSessions(); + } + + private getSessionByIndex(index: number): TilingSession | undefined { + return this._tilingSessions.find((session) => session.index === index); + } + + private swapSessions( + firstSession: TilingSession, + secondSession: TilingSession, + ) { + const firstSessionIndex = firstSession.index; + const firstSessionWidth = firstSession.width; + + firstSession.index = secondSession.index; + firstSession.width = secondSession.width; + + secondSession.index = firstSessionIndex; + secondSession.width = firstSessionWidth; + } +} + +type ResizeState = { + index?: number; + startX?: number; + leftSession?: TilingSession; + rightSession?: TilingSession; + startWidthLeft?: number; + startWidthRight?: number; +}; + +type ValidResizeState = Required; + +type TilingSession = ViewerSession & { + index: number; + width: number; + fullscreen: boolean; +};