From 3e1871213359eaf58d16ecc1a2e8dc3d2378fd86 Mon Sep 17 00:00:00 2001
From: dominik003 <info@dominik-lammers.de>
Date: Tue, 31 Oct 2023 17:47:28 +0100
Subject: [PATCH] feat: Add tiling window manager with slider

---
 frontend/src/app/app.module.ts                |   4 +
 .../src/app/helpers/default-value.pipe.ts     |  15 ++
 .../floating-window-manager.component.css     |   4 +-
 .../floating-window-manager.component.html    |   7 +
 .../floating-window-manager.component.ts      |   2 +-
 .../sessions/session/session.component.html   |  25 ++-
 .../app/sessions/session/session.component.ts |   6 +-
 .../tiling-window-manager.component.css       |  18 ++
 .../tiling-window-manager.component.html      |  75 +++++++
 .../tiling-window-manager.component.ts        | 199 ++++++++++++++++++
 10 files changed, 342 insertions(+), 13 deletions(-)
 create mode 100644 frontend/src/app/helpers/default-value.pipe.ts
 create mode 100644 frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.css
 create mode 100644 frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.html
 create mode 100644 frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.ts

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/sessions/session/floating-window-manager/floating-window-manager.component.css b/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.css
index 00d1f55736..56719f4d28 100644
--- a/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.css
+++ b/frontend/src/app/sessions/session/floating-window-manager/floating-window-manager.component.css
@@ -7,12 +7,10 @@
   height: calc(100vh - 2vh - 65px - 110px);
 }
 
-.iframe-overlay {
+#iframe-overlay {
   position: absolute;
   top: 0;
   left: 0;
   right: 0;
   bottom: 0;
-
-  z-index: 40;
 }
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..336f59455a 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
@@ -48,6 +48,13 @@
       ></div>
       <div [ngClass]="draggingActive ? 'iframe-overlay' : ''"></div>
       <iframe
+        [title]="
+          (session.version?.tool?.name | defaultValue: 'unknown tool name') +
+          '-' +
+          (session.version?.name | defaultValue: 'unknown version name') +
+          '-' +
+          session.type
+        "
         [id]="'session-' + session.id"
         [src]="session.safeResourceURL"
         class="h-full w-full"
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..2ac1f13c7b 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
@@ -39,7 +39,7 @@ export class FloatingWindowManagerComponent implements OnInit {
   unfocusSession(focusedSession: Session) {
     this.sessions
       .filter((session) => session !== focusedSession)
-      .map((session) => (session.focused = false));
+      .forEach((session) => (session.focused = false));
   }
 
   dragStart() {
diff --git a/frontend/src/app/sessions/session/session.component.html b/frontend/src/app/sessions/session/session.component.html
index 1485192ff7..f48118d1ca 100644
--- a/frontend/src/app/sessions/session/session.component.html
+++ b/frontend/src/app/sessions/session/session.component.html
@@ -51,8 +51,10 @@
   </div>
   <div class="mt-2">
     <mat-radio-group [(ngModel)]="selectedWindowType">
-      <mat-radio-button value="floating">Floating window</mat-radio-button>
-      <mat-radio-button value="tailing">Tailing window</mat-radio-button>
+      <mat-radio-button value="floating"
+        >Floating window manager</mat-radio-button
+      >
+      <mat-radio-button value="tiling">Tiling window manager</mat-radio-button>
     </mat-radio-group>
   </div>
 
@@ -69,10 +71,21 @@
   </div>
 </div>
 
-<app-floating-window-manager
-  *ngIf="selectedSessions.length"
-  [sessions]="selectedSessions"
-></app-floating-window-manager>
+<ng-container *ngIf="selectedSessions.length">
+  <app-floating-window-manager
+    *ngIf="isFloatingWindowManager; else tilingWindow"
+    [sessions]="selectedSessions"
+  >
+  </app-floating-window-manager>
+
+  <ng-template #tilingWindow>
+    <app-tiling-window-manager
+      *ngIf="isTilingWindowManager"
+      [sessions]="selectedSessions"
+    >
+    </app-tiling-window-manager>
+  </ng-template>
+</ng-container>
 
 <div class="fixed bottom-4 right-4 z-50">
   <button
diff --git a/frontend/src/app/sessions/session/session.component.ts b/frontend/src/app/sessions/session/session.component.ts
index d2350426a7..edf205cc98 100644
--- a/frontend/src/app/sessions/session/session.component.ts
+++ b/frontend/src/app/sessions/session/session.component.ts
@@ -39,11 +39,11 @@ export class SessionComponent implements OnInit {
     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';
   }
 
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..02e5368c24
--- /dev/null
+++ b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.html
@@ -0,0 +1,75 @@
+<!--
+ ~ SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors
+ ~ SPDX-License-Identifier: Apache-2.0
+ -->
+
+<div class="flex gap-0.5">
+  <ng-container *ngFor="let session of sessions">
+    <div
+      class="flex flex-col"
+      [ngClass]="{
+        height: (fullscreenService.isFullscreen$ | async) === false,
+        'h-screen': fullscreenService.isFullscreen$ | async
+      }"
+      [style.width]="session.width + 'px'"
+    >
+      <div
+        class="flex items-center justify-between gap-2 rounded-t bg-slate-100 p-1"
+      >
+        <span>
+          {{ session.version?.tool?.name }} {{ session.version?.name }},
+          {{ session.type }}
+          <span *ngIf="session.type === 'readonly'"
+            >(project {{ session.project!.name }})</span
+          >
+          {{ session.id }}
+        </span>
+
+        <div class="flex items-center">
+          <mat-icon
+            *ngIf="session.index !== 0"
+            class="cursor-pointer text-lg text-gray-600 hover:text-gray-800"
+            (click)="onLeftClick(session)"
+          >
+            arrow_back
+          </mat-icon>
+
+          <mat-icon
+            *ngIf="session.index !== sessions.length - 1"
+            class="cursor-pointer text-lg text-gray-600 hover:text-gray-800"
+            (click)="onRightClick(session)"
+          >
+            arrow_forward
+          </mat-icon>
+        </div>
+      </div>
+
+      <div class="relative grow">
+        <div
+          class="iframe-overlay"
+          *ngIf="isValidResizeState(this.resizeState)"
+        ></div>
+        <iframe
+          [title]="
+            (session.version?.tool?.name | defaultValue: 'unknown tool name') +
+            '-' +
+            (session.version?.name | defaultValue: 'unknown version name') +
+            '-' +
+            session.type
+          "
+          [id]="'session-' + session.id"
+          [src]="session.safeResourceURL"
+          class="h-full w-full"
+          allow="clipboard-read; clipboard-write"
+        >
+        </iframe>
+      </div>
+    </div>
+
+    <div
+      *ngIf="session.index < sessions.length - 1"
+      class="my-2 w-2 cursor-col-resize bg-gray-300"
+      (mousedown)="onMouseDown($event, session.index)"
+    ></div>
+  </ng-container>
+</div>
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..07b3c9c7fa
--- /dev/null
+++ b/frontend/src/app/sessions/session/tiling-window-manager/tiling-window-manager.component.ts
@@ -0,0 +1,199 @@
+/*
+ * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Component, HostListener, Input, OnInit } from '@angular/core';
+import { Session } from 'src/app/schemes';
+import { FullscreenService } from '../../service/fullscreen.service';
+
+@Component({
+  selector: 'app-tiling-window-manager',
+  templateUrl: './tiling-window-manager.component.html',
+  styleUrls: ['./tiling-window-manager.component.css'],
+})
+export class TilingWindowManagerComponent implements OnInit {
+  private _tilingSessions: TilingSession[] = [];
+
+  @Input()
+  set sessions(sessions: Session[]) {
+    this._tilingSessions = sessions.map((session, index) => ({
+      ...session,
+      index: index,
+      width: -1,
+      fullscreen: false,
+    }));
+    this.resetWidths();
+  }
+
+  get sessions(): TilingSession[] {
+    return this._tilingSessions.sort((a, b) => a.index - b.index);
+  }
+
+  public resizeState: ResizeState = {};
+
+  private minimumSessionWidth = 0;
+  private existingMargin = 0;
+
+  constructor(public fullscreenService: FullscreenService) {}
+
+  ngOnInit(): void {
+    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 {
+    this.resizeState = {};
+    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.sessions.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<ResizeState>;
+
+type TilingSession = Session & {
+  index: number;
+  width: number;
+  fullscreen: boolean;
+};