diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 99d8fcf77f..cbb6e5c5c8 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -63,6 +63,7 @@ import { VersionComponent } from './general/metadata/version/version.component'; import { NavBarMenuComponent } from './general/nav-bar-menu/nav-bar-menu.component'; import { NoticeComponent } from './general/notice/notice.component'; import { ConfirmationDialogComponent } from './helpers/confirmation-dialog/confirmation-dialog.component'; +import { DefaultValuePipe } from './helpers/default-value.pipe'; import { DisplayValueComponent } from './helpers/display-value/display-value.component'; import { InputDialogComponent } from './helpers/input-dialog/input-dialog.component'; import { MatIconComponent } from './helpers/mat-icon/mat-icon.component'; @@ -109,6 +110,7 @@ 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 { 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'; @@ -164,6 +166,7 @@ import { SettingsComponent } from './settings/settings.component'; CreateReadonlySessionComponent, CreateReadonlySessionDialogComponent, CreateT4cModelNewRepositoryComponent, + DefaultValuePipe, DeleteGitSettingsDialogComponent, DeleteSessionDialogComponent, DisplayValueComponent, @@ -222,6 +225,7 @@ import { SettingsComponent } from './settings/settings.component'; T4CSettingsComponent, T4CSettingsWrapperComponent, TextLineSkeletonLoaderComponent, + TilingWindowManagerComponent, ToolDeletionDialogComponent, ToolDetailsComponent, ToolIntegrationsComponent, diff --git a/frontend/src/app/helpers/default-value.pipe.ts b/frontend/src/app/helpers/default-value.pipe.ts new file mode 100644 index 0000000000..ab31513185 --- /dev/null +++ b/frontend/src/app/helpers/default-value.pipe.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'defaultValue', +}) +export class DefaultValuePipe implements PipeTransform { + transform(value: string | undefined, defaultValue: string): string { + return value ?? defaultValue; + } +} diff --git a/frontend/src/app/schemes.ts b/frontend/src/app/schemes.ts index 9e71d84612..cf37f6e06e 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 index f82e079b10..eb56d61071 100644 --- 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 @@ -3,57 +3,71 @@ ~ SPDX-License-Identifier: Apache-2.0 --> -
-
+ +
-
- control_camera - - {{ session.version?.tool?.name }} {{ session.version?.name }}, - {{ session.type }} - (project {{ session.project!.name }}) - -
-
- Focusedphonelink -
-
- Not focused - phonelink_off -
-
- -
-
- +
+ 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 index dfdc2cb375..90ea9d8927 100644 --- 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 @@ -3,10 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Component, HostListener, Input, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { Session } from 'src/app/schemes'; +import { debounceTime, fromEvent } from 'rxjs'; import { FullscreenService } from '../../service/fullscreen.service'; +import { SessionViewerService } from '../session-viewer.service'; @Component({ selector: 'app-floating-window-manager', @@ -15,31 +16,17 @@ import { FullscreenService } from '../../service/fullscreen.service'; }) @UntilDestroy() export class FloatingWindowManagerComponent implements OnInit { - @Input() sessions: Session[] = []; - draggingActive = false; - private debounceTimer?: number; - - constructor(public fullscreenService: FullscreenService) {} + constructor( + public sessionViewerService: SessionViewerService, + public fullscreenService: FullscreenService, + ) {} ngOnInit(): void { - this.fullscreenService.isFullscreen$ - .pipe(untilDestroyed(this)) - .subscribe(() => this.resizeSessions()); - } - - focusSession(session: Session) { - this.unfocusSession(session); - - document.getElementById('session-' + session.id)?.focus(); - session.focused = true; - } - - unfocusSession(focusedSession: Session) { - this.sessions - .filter((session) => session !== focusedSession) - .map((session) => (session.focused = false)); + fromEvent(window, 'resize') + .pipe(untilDestroyed(this), debounceTime(250)) + .subscribe(() => this.sessionViewerService.resizeSessions()); } dragStart() { @@ -49,35 +36,4 @@ export class FloatingWindowManagerComponent implements OnInit { 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.sessions.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); - } } 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 0000000000..1a6654cff0 --- /dev/null +++ b/frontend/src/app/sessions/session/session-viewer.service.ts @@ -0,0 +1,117 @@ +/* + * 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 } 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.asObservable(); + + 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() { + document.querySelectorAll('iframe').forEach((iframe) => { + const session = this._sessions.value?.find( + (session) => `session-${session.id}` === iframe.id, + ); + + if (session?.reloadToResize) { + this.reloadIFrame(iframe); + } + }); + } + + trackBySessionId(_: number, session: ViewerSession): string { + return session.id; + } + + 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; +}; diff --git a/frontend/src/app/sessions/session/session.component.html b/frontend/src/app/sessions/session/session.component.html index 1485192ff7..e6a7b1ee58 100644 --- a/frontend/src/app/sessions/session/session.component.html +++ b/frontend/src/app/sessions/session/session.component.html @@ -3,7 +3,12 @@ ~ SPDX-License-Identifier: Apache-2.0 --> -
+
Please select the sessions that you'd like to open in the session viewer:{{ session.version?.tool?.name }} {{ session.version?.name }}, {{ session.type @@ -51,8 +56,10 @@
- Floating window - Tailing window + Floating window manager + Tiling window manager
@@ -69,22 +76,31 @@
- - -
- -
+ fullscreen + fullscreen_exit + + + diff --git a/frontend/src/app/sessions/session/session.component.ts b/frontend/src/app/sessions/session/session.component.ts index d2350426a7..6a110d378d 100644 --- a/frontend/src/app/sessions/session/session.component.ts +++ b/frontend/src/app/sessions/session/session.component.ts @@ -3,58 +3,58 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Component, 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', templateUrl: './session.component.html', }) -export class SessionComponent implements OnInit { +@UntilDestroy() +export class SessionComponent implements OnInit, OnDestroy { cachedSessions?: CachedSession[] = undefined; - selectedSessions: Session[] = []; 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); } - get isTailingWindow(): boolean { - return this.selectedWindowType === 'tailing'; + get isTilingWindowManager(): boolean { + return this.selectedWindowType === 'tiling'; } - get isFloatingWindow() { + get isFloatingWindowManager(): boolean { return this.selectedWindowType === 'floating'; } - changeSessionSelection(event: MatCheckboxChange, session: CachedSession) { - session.checked = event.checked; - } - ngOnInit(): void { this.initializeCachedSessions(); } + ngOnDestroy(): void { + this.sessionViewerService.clearSessions(); + } + initializeCachedSessions() { this.userSessionService.sessions$ .pipe( @@ -71,20 +71,10 @@ 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); } }); } diff --git a/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.css b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.css new file mode 100644 index 0000000000..782c0293cc --- /dev/null +++ b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.css @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.height { + height: calc(100vh - 2vh - 65px - 110px); +} + +.iframe-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + cursor: col-resize; +} 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 0000000000..bdc4aee489 --- /dev/null +++ b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.html @@ -0,0 +1,92 @@ + + +
+ +
+
+ + {{ session.version?.tool?.name }} {{ session.version?.name }}, + {{ session.type }} + (project {{ session.project!.name }}) + + +
+ + arrow_back + + + + arrow_forward + + +
+ 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 0000000000..e0fdc65c55 --- /dev/null +++ b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.ts @@ -0,0 +1,210 @@ +/* + * 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 { FullscreenService } from '../../service/fullscreen.service'; +import { SessionViewerService, ViewerSession } from '../session-viewer.service'; + +@Component({ + selector: 'app-tiling-window-manager', + templateUrl: './tiling-window-manager.component.html', + styleUrls: ['./tiling-window-manager.component.css'], +}) +@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; + private existingMargin = 0; + + constructor( + public sessionViewerService: SessionViewerService, + public fullscreenService: FullscreenService, + ) {} + + ngOnInit(): void { + this.sessionViewerService.sessions$ + .pipe(untilDestroyed(this), filter(Boolean)) + .subscribe((viewerSessions) => { + this._tilingSessions = viewerSessions.map((session, index) => ({ + ...session, + index: index, + width: -1, + fullscreen: false, + })); + this.resetWidths(); + }); + + this.fullscreenService.isFullscreen$.subscribe((isFullscreen) => { + this.existingMargin = isFullscreen ? 0 : 27.48; + this.resetWidths(); + }); + } + + 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, + }; + this.toggleIFrameEvents(false); + } + } + + @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.resizeState) { + this.resizeState = {}; + this.sessionViewerService.resizeSessions(); + this.toggleIFrameEvents(true); + } + } + + @HostListener('window:resize') + onResize() { + this.resetWidths(); + } + + onLeftClick(session: TilingSession) { + const leftIndexSession = this.getSessionByIndex(session.index - 1); + + if (leftIndexSession) { + this.swapSessionIndices(session, leftIndexSession); + } + } + + onRightClick(session: TilingSession) { + const rightIndexSession = this.getSessionByIndex(session.index + 1); + + if (rightIndexSession) { + this.swapSessionIndices(session, rightIndexSession); + } + } + + 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 - this.existingMargin) * 0.15; + this._tilingSessions.forEach( + (session) => + (session.width = + (window.innerWidth - this.existingMargin) / + this._tilingSessions.length), + ); + } + + private getSessionByIndex(index: number): TilingSession | undefined { + return this._tilingSessions.find((session) => session.index === index); + } + + private toggleIFrameEvents(enable: boolean): void { + const action = enable ? 'auto' : 'none'; + document.querySelectorAll('iframe').forEach((iframe) => { + iframe.style.pointerEvents = action; + iframe.style.userSelect = action; + }); + } + + private swapSessionIndices( + firstSession: TilingSession, + secondSession: TilingSession, + ) { + const firstSessionIndex = firstSession.index; + + firstSession.index = secondSession.index; + secondSession.index = firstSessionIndex; + } +} + +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; +};