Merge pull request #4103 from FinnStutzenstein/client-projector-ui

Client projector ui (WIP)
This commit is contained in:
Oskar Hahn 2019-01-20 11:39:49 +01:00 committed by GitHub
commit 209105efc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
119 changed files with 2777 additions and 236 deletions

View File

@ -41,6 +41,7 @@
"@tinymce/tinymce-angular": "^2.3.1",
"angular-tree-component": "^8.0.1",
"core-js": "^2.5.4",
"css-element-queries": "^1.1.1",
"file-saver": "^2.0.0",
"material-design-icons": "^3.0.1",
"ngx-file-drop": "^5.0.0",

View File

@ -7,6 +7,7 @@ import { LoginLegalNoticeComponent } from './site/login/components/login-legal-n
import { LoginPrivacyPolicyComponent } from './site/login/components/login-privacy-policy/login-privacy-policy.component';
import { ResetPasswordComponent } from './site/login/components/reset-password/reset-password.component';
import { ResetPasswordConfirmComponent } from './site/login/components/reset-password-confirm/reset-password-confirm.component';
import { FullscreenProjectorComponent } from './site/projector/components/fullscreen-projector/fullscreen-projector.component';
/**
* Global app routing
@ -23,8 +24,8 @@ const routes: Routes = [
{ path: 'privacypolicy', component: LoginPrivacyPolicyComponent }
]
},
{ path: 'projector', loadChildren: './projector-container/projector-container.module#ProjectorContainerModule' },
{ path: 'projector', component: FullscreenProjectorComponent },
{ path: 'projector/:id', component: FullscreenProjectorComponent },
{ path: '', loadChildren: './site/site.module#SiteModule' },
{ path: '**', redirectTo: '' }
];

View File

@ -15,6 +15,8 @@ import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { PruningTranslationLoader } from './core/pruning-loader';
import { LoginModule } from './site/login/login.module';
import { AppLoadService } from './core/services/app-load.service';
import { ProjectorModule } from './site/projector/projector.module';
import { SlidesModule } from './slides/slides.module';
// PWA
import { ServiceWorkerModule } from '@angular/service-worker';
@ -60,7 +62,9 @@ export function AppLoaderFactory(appLoadService: AppLoadService): () => Promise<
CoreModule,
LoginModule,
PapaParseModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production })
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
ProjectorModule,
SlidesModule.forRoot()
],
providers: [{ provide: APP_INITIALIZER, useFactory: AppLoaderFactory, deps: [AppLoadService], multi: true }],
bootstrap: [AppComponent]

View File

@ -14,6 +14,7 @@ import { ViewportService } from './services/viewport.service';
import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component';
import { HttpService } from './services/http.service';
import { ChoiceDialogComponent } from '../shared/components/choice-dialog/choice-dialog.component';
import { ProjectionDialogComponent } from 'app/shared/components/projection-dialog/projection-dialog.component';
/** Global Core Module. Contains all global (singleton) services
*
@ -32,7 +33,7 @@ import { ChoiceDialogComponent } from '../shared/components/choice-dialog/choice
ViewportService,
WebsocketService
],
entryComponents: [PromptDialogComponent, ChoiceDialogComponent]
entryComponents: [PromptDialogComponent, ChoiceDialogComponent, ProjectionDialogComponent]
})
export class CoreModule {
/** make sure CoreModule is imported only by one NgModule, the AppModule */

View File

@ -14,6 +14,7 @@ import { MainMenuService } from './main-menu.service';
import { HistoryAppConfig } from 'app/site/history/history.config';
import { SearchService } from './search.service';
import { isSearchable } from '../../shared/models/base/searchable';
import { ProjectorAppConfig } from 'app/site/projector/projector.config';
/**
* A list of all app configurations of all delivered apps.
@ -27,7 +28,8 @@ const appConfigs: AppConfig[] = [
MediafileAppConfig,
TagAppConfig,
UsersAppConfig,
HistoryAppConfig
HistoryAppConfig,
ProjectorAppConfig
];
/**

View File

@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { Projectable } from 'app/site/base/projectable';
import { MatDialog } from '@angular/material';
import {
ProjectionDialogComponent,
ProjectionDialogReturnType
} from 'app/shared/components/projection-dialog/projection-dialog.component';
import { ProjectorService } from './projector.service';
/**
* Manages the projection dialog. Projects the result of the user's choice.
*/
@Injectable({
providedIn: 'root'
})
export class ProjectionDialogService extends OpenSlidesComponent {
/**
* Constructor.
*
* @param dialog
* @param projectorService
*/
public constructor(private dialog: MatDialog, private projectorService: ProjectorService) {
super();
}
/**
* Opens the projection dialog for the given projectable. After the user's choice,
* the projectors will be updated.
*
* @param obj The projectable.
*/
public async openProjectDialogFor(obj: Projectable): Promise<void> {
const dialogRef = this.dialog.open<ProjectionDialogComponent, Projectable, ProjectionDialogReturnType>(
ProjectionDialogComponent,
{
minWidth: '500px',
maxHeight: '90vh',
data: obj
}
);
const response = await dialogRef.afterClosed().toPromise();
if (response) {
const [projectors, projectorElement]: ProjectionDialogReturnType = response;
this.projectorService.projectOn(projectors, projectorElement);
}
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { ProjectionDialogService } from './projection-dialog.service';
describe('ProjectionDialogService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [ProjectionDialogService]
});
});
it('should be created', inject([ProjectionDialogService], (service: ProjectionDialogService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { ProjectorService } from './projector.service';
describe('ProjectorService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [ProjectorService]
});
});
it('should be created', inject([ProjectorService], (service: ProjectorService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,106 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { Projectable } from 'app/site/base/projectable';
import { DataStoreService } from './data-store.service';
import { Projector, ProjectorElement } from 'app/shared/models/core/projector';
import { DataSendService } from './data-send.service';
/**
* This service cares about Projectables being projected and manage all projection-related
* actions.
*
* We cannot access the ProjectorRepository here, so we will deal with plain projector objects.
*/
@Injectable({
providedIn: 'root'
})
export class ProjectorService extends OpenSlidesComponent {
/**
* Constructor.
*
* @param DS
* @param dataSend
*/
public constructor(private DS: DataStoreService, private dataSend: DataSendService) {
super();
}
/**
* Checks, if a given object is projected.
*
* @param obj The object in question
* @returns true, if the object is projected on one projector.
*/
public isProjected(obj: Projectable): boolean {
return this.DS.getAll<Projector>('core/projector').some(projector => {
return projector.isElementShown(obj.getNameForSlide(), obj.getIdForSlide());
});
}
/**
* Get all projectors where the object is prejected on.
*
* @param obj The object in question
* @return All projectors, where this Object is projected on
*/
public getProjectorsWhichAreProjecting(obj: Projectable): Projector[] {
return this.DS.getAll<Projector>('core/projector').filter(projector => {
return projector.isElementShown(obj.getNameForSlide(), obj.getIdForSlide());
});
}
/**
* Checks, if the object is projected on the given projector.
*
* @param obj The object
* @param projector The projector to test
* @returns true, if the object is projected on the projector.
*/
public isProjectedOn(obj: Projectable, projector: Projector): boolean {
return projector.isElementShown(obj.getNameForSlide(), obj.getIdForSlide());
}
/**
* Projects the given ProjectorElement on the given projectors.
*
* TODO: this does not care about the element being stable. Some more logic must be added later.
*
* On the given projectors: Delete all non-stable elements and add the given element.
* On all other projectors: If the element (compared with name and id) is there, it will be deleted.
*
* @param projectors All projectors where to add the element.
* @param element The element in question.
*/
public projectOn<T extends ProjectorElement>(projectors: Projector[], element: T): void {
const changedProjectors: Projector[] = [];
this.DS.getAll<Projector>('core/projector').forEach(projector => {
if (projectors.includes(projector)) {
projector.removeAllNonStableElements();
projector.addElement(element);
changedProjectors.push(projector);
} else if (projector.isElementShown(element.name, element.id)) {
projector.removeElementByNameAndId(element.name, element.id);
changedProjectors.push(projector);
}
});
// TODO: Use new 'project' route.
changedProjectors.forEach(projector => {
this.dataSend.updateModel(projector);
});
}
/**
* Given a projectiondefault, we want to retrieve the projector, that is assigned
* to this default.
*
* @param projectiondefault The projection default
* @return the projector associated to the given projectiondefault.
*/
public getProjectorForDefault(projectiondefault: string): Projector {
return this.DS.getAll<Projector>('core/projector').find(projector => {
return projector.projectiondefaults.map(pd => pd.name).includes(projectiondefault);
});
}
}

View File

@ -28,12 +28,18 @@ export class ViewportService {
* Simple boolean to determine whether the client is in mobile view or not
* Use in HTML with automatic change detection
*/
public isMobile: boolean;
public get isMobile(): boolean {
return this._isMobileSubject.getValue();
}
private _isMobileSubject = new BehaviorSubject<boolean>(false);
/**
* Returns a subject that contains whether the viewport os mobile or not
*/
public isMobileSubject = new BehaviorSubject<boolean>(false);
public get isMobileSubject(): BehaviorSubject<boolean> {
return this._isMobileSubject;
}
/**
* Get the BreakpointObserver
@ -49,14 +55,6 @@ export class ViewportService {
public checkForChange(): void {
this.breakpointObserver
.observe([Breakpoints.Handset, '(min-width: 600px) and (max-width: 899.99px)'])
.subscribe((state: BreakpointState) => {
if (state.matches) {
this.isMobile = true;
this.isMobileSubject.next(true);
} else {
this.isMobile = false;
this.isMobileSubject.next(false);
}
});
.subscribe((state: BreakpointState) => this._isMobileSubject.next(state.matches));
}
}

View File

@ -43,6 +43,10 @@ export class WebsocketService {
*/
private _connectEvent: EventEmitter<void> = new EventEmitter<void>();
private connectionOpen = false;
private sendQueueWhileNotConnected: string[] = [];
/**
* Getter for the connect event.
*/
@ -122,6 +126,11 @@ export class WebsocketService {
this._reconnectEvent.emit();
}
this._connectEvent.emit();
this.connectionOpen = true;
this.sendQueueWhileNotConnected.forEach(entry => {
this.websocket.send(entry);
});
this.sendQueueWhileNotConnected = [];
});
};
@ -143,6 +152,7 @@ export class WebsocketService {
this.websocket.onclose = (event: CloseEvent) => {
this.zone.run(() => {
this.websocket = null;
this.connectionOpen = false;
if (event.code !== 1000) {
// 1000 is a normal close, like the close on logout
if (!this.connectionErrorNotice) {
@ -211,6 +221,13 @@ export class WebsocketService {
message.id += possible.charAt(Math.floor(Math.random() * possible.length));
}
}
this.websocket.send(JSON.stringify(message));
// Either send directly or add to queue, if not connected.
const jsonMessage = JSON.stringify(message);
if (this.connectionOpen) {
this.websocket.send(jsonMessage);
} else {
this.sendQueueWhileNotConnected.push(jsonMessage);
}
}
}

View File

@ -1,4 +0,0 @@
<p>
projector-container works!
Here an iframe with the real-projector is needed
</p>

View File

@ -1,24 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectorContainerComponent } from './projector-container.component';
describe('ProjectorContainerComponent', () => {
let component: ProjectorContainerComponent;
let fixture: ComponentFixture<ProjectorContainerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProjectorContainerComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectorContainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,12 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'os-projector-container',
templateUrl: './projector-container.component.html',
styleUrls: ['./projector-container.component.css']
})
export class ProjectorContainerComponent implements OnInit {
public constructor() {}
public ngOnInit(): void {}
}

View File

@ -1,13 +0,0 @@
import { ProjectorContainerModule } from './projector-container.module';
describe('ProjectorContainerModule', () => {
let projectorContainerModule: ProjectorContainerModule;
beforeEach(() => {
projectorContainerModule = new ProjectorContainerModule();
});
it('should create an instance', () => {
expect(projectorContainerModule).toBeTruthy();
});
});

View File

@ -1,13 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProjectorContainerComponent } from './projector-container.component';
import { SharedModule } from 'app/shared/shared.module';
import { ProjectorComponent } from './projector/projector.component';
import { ProjectorContainerRoutingModule } from './projector/projector-container.routing.module';
@NgModule({
imports: [CommonModule, ProjectorContainerRoutingModule, SharedModule],
declarations: [ProjectorContainerComponent, ProjectorComponent]
})
export class ProjectorContainerModule {}

View File

@ -1,15 +0,0 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ProjectorContainerComponent } from '../projector-container.component';
import { ProjectorComponent } from './projector.component';
const routes: Routes = [
{ path: '', component: ProjectorContainerComponent },
{ path: 'real', component: ProjectorComponent }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ProjectorContainerRoutingModule {}

View File

@ -1,3 +0,0 @@
<p>
projector works!
</p>

View File

@ -1,19 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { BaseComponent } from 'app/base.component';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'os-projector',
templateUrl: './projector.component.html',
styleUrls: ['./projector.component.css']
})
export class ProjectorComponent extends BaseComponent implements OnInit {
public constructor(titleService: Title, translate: TranslateService) {
super(titleService, translate);
}
public ngOnInit(): void {
super.setTitle('Projector');
}
}

View File

@ -0,0 +1,42 @@
<h2 mat-dialog-title>{{ projectable.getTitle() }}</h2>
<mat-dialog-content>
<mat-card>
<mat-card-title> <span translate>Projectors</span> </mat-card-title>
<mat-card-content>
<div class="projectors" *ngFor="let projector of projectors" [ngClass]="isProjectedOn(projector) ? 'projected' : ''">
<mat-checkbox [checked]="isProjectorSelected(projector)" (change)="toggleProjector(projector)">
{{ projector.name | translate }}
</mat-checkbox>
<span *ngIf="isProjectedOn(projector)" class="right">
<mat-icon>videocam</mat-icon>
</span>
</div>
</mat-card-content>
</mat-card>
<mat-card *ngIf="options.length > 0">
<mat-card-title>
<span translate>Slide options</span>
</mat-card-title>
<mat-card-content>
<div *ngFor="let option of options">
<div *ngIf="isDecisionOption(option)">
<mat-checkbox [checked]="projectorElement[option.key]" (change)="projectorElement[option.key] = !projectorElement[option.key]">
{{ option.displayName | translate }}
</mat-checkbox>
</div>
<div *ngIf="isChoiceOption(option)">
<h3>{{ option.displayName | translate }}</h3>
<mat-radio-group [name]="option.key" [(ngModel)]="projectorElement[option.key]">
<mat-radio-button *ngFor="let choice of option.choices" [value]="choice.value">
{{ choice.displayName | translate }}
</mat-radio-button>
</mat-radio-group>
</div>
</div>
</mat-card-content>
</mat-card>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button (click)="onOk()" color="primary" translate>OK</button>
<button mat-button (click)="onCancel()" translate>Cancel</button>
</mat-dialog-actions>

View File

@ -0,0 +1,19 @@
mat-dialog-content {
overflow: inherit;
div.projectors {
padding: 15px;
&.projected {
background-color: lightblue;
}
.right {
float: right;
}
}
mat-card {
margin-bottom: 10px;
}
}

View File

@ -0,0 +1,26 @@
import { async, TestBed } from '@angular/core/testing';
// import { ProjectionDialogComponent } from './prjection-dialog.component';
import { E2EImportsModule } from 'e2e-imports.module';
describe('ProjectionDialogComponent', () => {
// let component: ProjectionDialogComponent;
// let fixture: ComponentFixture<ProjectionDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
// TODO: You cannot create this component in the standard way. Needs different testing.
beforeEach(() => {
/*fixture = TestBed.createComponent(ProjectionDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();*/
});
/*it('should create', () => {
expect(component).toBeTruthy();
});*/
});

View File

@ -0,0 +1,96 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { Projectable } from 'app/site/base/projectable';
import { DataStoreService } from 'app/core/services/data-store.service';
import { Projector, ProjectorElement } from 'app/shared/models/core/projector';
import { ProjectorService } from 'app/core/services/projector.service';
import {
ProjectorOption,
isProjectorDecisionOption,
isProjectorChoiceOption,
ProjectorDecisionOption,
ProjectorChoiceOption,
ProjectorOptions
} from 'app/site/base/projector-options';
export type ProjectionDialogReturnType = [Projector[], ProjectorElement];
/**
*/
@Component({
selector: 'os-projection-dialog',
templateUrl: './projection-dialog.component.html',
styleUrls: ['./projection-dialog.component.scss']
})
export class ProjectionDialogComponent {
public projectors: Projector[];
private selectedProjectors: Projector[] = [];
public projectorElement: ProjectorElement;
public options: ProjectorOptions;
public constructor(
public dialogRef: MatDialogRef<ProjectionDialogComponent, ProjectionDialogReturnType>,
@Inject(MAT_DIALOG_DATA) public projectable: Projectable,
private DS: DataStoreService,
private projectorService: ProjectorService
) {
this.projectors = this.DS.getAll<Projector>('core/projector');
// TODO: Maybe watch. But this may not be necessary for the short living time of this dialog.
this.selectedProjectors = this.projectorService.getProjectorsWhichAreProjecting(this.projectable);
// Add default projector, if the projectable is not projected on it.
const defaultProjector: Projector = this.projectorService.getProjectorForDefault(
this.projectable.getProjectionDefaultName()
);
if (!this.selectedProjectors.includes(defaultProjector)) {
this.selectedProjectors.push(defaultProjector);
}
this.projectorElement = {
id: this.projectable.getIdForSlide(),
name: this.projectable.getNameForSlide(),
stable: this.projectable.isStableSlide()
};
// Set option defaults
this.projectable.getProjectorOptions().forEach(option => {
this.projectorElement[option.key] = option.default;
});
this.options = this.projectable.getProjectorOptions();
}
public toggleProjector(projector: Projector): void {
const index = this.selectedProjectors.indexOf(projector);
if (index < 0) {
this.selectedProjectors.push(projector);
} else {
this.selectedProjectors.splice(index, 1);
}
}
public isProjectorSelected(projector: Projector): boolean {
return this.selectedProjectors.includes(projector);
}
public isProjectedOn(projector: Projector): boolean {
return this.projectorService.isProjectedOn(this.projectable, projector);
}
public isDecisionOption(option: ProjectorOption): option is ProjectorDecisionOption {
return isProjectorDecisionOption(option);
}
public isChoiceOption(option: ProjectorOption): option is ProjectorChoiceOption {
return isProjectorChoiceOption(option);
}
public onOk(): void {
this.dialogRef.close([this.selectedProjectors, this.projectorElement]);
}
public onCancel(): void {
this.dialogRef.close();
}
}

View File

@ -0,0 +1,3 @@
<button type="button" mat-icon-button (click)="onClick($event)">
<mat-icon>videocam</mat-icon>
</button>

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { ProjectorButtonComponent } from './projector-button.component';
describe('ProjectorButtonComponent', () => {
let component: ProjectorButtonComponent;
let fixture: ComponentFixture<ProjectorButtonComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectorButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,30 @@
import { Component, OnInit, Input } from '@angular/core';
import { Projectable } from 'app/site/base/projectable';
import { ProjectionDialogService } from 'app/core/services/projection-dialog.service';
/**
*/
@Component({
selector: 'os-projector-button',
templateUrl: './projector-button.component.html',
styleUrls: ['./projector-button.component.scss']
})
export class ProjectorButtonComponent implements OnInit {
@Input()
public object: Projectable;
/**
* The consotructor
*/
public constructor(private projectionDialogService: ProjectionDialogService) {}
/**
* Initialization function
*/
public ngOnInit(): void {}
public onClick(event: Event): void {
event.stopPropagation();
this.projectionDialogService.openProjectDialogFor(this.object);
}
}

View File

@ -0,0 +1,59 @@
import { OnInit, ElementRef, Directive, Input } from '@angular/core';
import { ResizeSensor } from 'css-element-queries';
import { Subject } from 'rxjs';
/**
* This directive takes a Subject<void> as input and everytime the surrounding element
* was resized, the subject is fired.
*
* Usage:
* `<div [osRezised]="mySubject">...content...</div>`
*/
@Directive({
selector: '[osResized]'
})
export class ResizedDirective implements OnInit {
@Input()
public osResized: Subject<void>;
/**
* Old width, to check, if the width has actually changed.
*/
private oldWidth: number;
/**
* Old height, to check, if the height has actually changed.
*/
private oldHeight: number;
public constructor(private element: ElementRef) {}
/**
* Inits the ResizeSensor. triggers initial size change.
*/
public ngOnInit(): void {
// tslint:disable-next-line:no-unused-expression
new ResizeSensor(this.element.nativeElement, x => this.onSizeChanged());
this.onSizeChanged();
}
/**
* The size has changed. Check, if the size actually hs changed. If so,
* trigger the given subject.
*/
private onSizeChanged(): void {
const newWidth = this.element.nativeElement.clientWidth;
const newHeight = this.element.nativeElement.clientHeight;
if (newWidth === this.oldWidth && newHeight === this.oldHeight) {
return;
}
this.oldWidth = newWidth;
this.oldHeight = newHeight;
if (this.osResized) {
this.osResized.next();
}
}
}

View File

@ -1,5 +1,5 @@
import { ProjectableBaseModel } from '../base/projectable-base-model';
import { Speaker, SpeakerState } from './speaker';
import { BaseModel } from '../base/base-model';
/**
* The representation of the content object for agenda items. The unique combination
@ -24,7 +24,7 @@ export const itemVisibilityChoices = [
* Representations of agenda Item
* @ignore
*/
export class Item extends ProjectableBaseModel {
export class Item extends BaseModel<Item> {
public id: number;
public item_number: string;
public title: string;

View File

@ -1,13 +1,13 @@
import { AgendaInformation } from './agenda-information';
import { ProjectableBaseModel } from './projectable-base-model';
import { Searchable } from './searchable';
import { SearchRepresentation } from '../../../core/services/search.service';
import { BaseModel } from './base-model';
/**
* A base model for models, that can be content objects in the agenda. Provides title and navigation
* information for the agenda.
*/
export abstract class AgendaBaseModel extends ProjectableBaseModel implements AgendaInformation, Searchable {
export abstract class AgendaBaseModel extends BaseModel<AgendaBaseModel> implements AgendaInformation, Searchable {
/**
* A model that can be a content object of an agenda item.
* @param collectionString

View File

@ -1,27 +0,0 @@
import { BaseModel } from './base-model';
import { Projectable } from './projectable';
export abstract class ProjectableBaseModel extends BaseModel<ProjectableBaseModel> implements Projectable {
/**
* A model which can be projected. This class give basic implementation for the projector.
*
* @param collectionString
* @param verboseName
* @param input
*/
protected constructor(collectionString: string, verboseName: string, input?: any) {
super(collectionString, verboseName, input);
}
/**
* This is a Dummy, which should be changed if the projector gets implemented.
*/
public project(): void {}
/**
* @returns the projector title.
*/
public getProjectorTitle(): string {
return this.getTitle();
}
}

View File

@ -1,14 +0,0 @@
/**
* Interface for every model, that should be projectable.
*/
export interface Projectable {
/**
* Should return the title for the projector.
*/
getProjectorTitle(): string;
/**
* Dummy. I don't know how the projctor system will be, so this function may change
*/
project(): void;
}

View File

@ -1,10 +1,10 @@
import { ProjectableBaseModel } from '../base/projectable-base-model';
import { BaseModel } from '../base/base-model';
/**
* Representation of a countdown
* @ignore
*/
export class Countdown extends ProjectableBaseModel {
export class Countdown extends BaseModel<Countdown> {
public id: number;
public description: string;
public default_time: number;

View File

@ -1,10 +1,10 @@
import { ProjectableBaseModel } from '../base/projectable-base-model';
import { BaseModel } from '../base/base-model';
/**
* Representation of a projector message.
* @ignore
*/
export class ProjectorMessage extends ProjectableBaseModel {
export class ProjectorMessage extends BaseModel<ProjectorMessage> {
public id: number;
public message: string;

View File

@ -1,24 +1,104 @@
import { BaseModel } from '../base/base-model';
/**
* A projectorelement must have a name and optional attributes.
* error is listed here, because this might be set by the server, if
* something is wrong and I want you to be sensible about this.
*/
export interface ProjectorElement {
/**
* The name of the element.
*/
name: string;
/**
* An optional error. If this is set, this element is invalid, so
* DO NOT read additional data (name is save).
*/
error?: string;
/**
* Additional data.
*/
[key: string]: any;
}
/**
* Multiple elements.
*/
export type ProjectorElements = ProjectorElement[];
/**
* A projectiondefault
*/
export interface ProjectionDefault {
id: number;
name: string;
display_name: string;
projector_id: number;
}
/**
* Representation of a projector. Has the nested property "projectiondefaults"
* @ignore
*/
export class Projector extends BaseModel<Projector> {
public id: number;
public elements: Object;
public elements: ProjectorElements;
public scale: number;
public scroll: number;
public name: string;
public blank: boolean;
public width: number;
public height: number;
public projectiondefaults: Object[];
public projectiondefaults: ProjectionDefault[];
public constructor(input?: any) {
super('core/projector', 'Projector', input);
}
/**
* Returns true, if there is an element with the given name (and optionally
* an id). If the id is given, the element to search MUST have this id.
*
* @param name The name of the element
* @param id The optional id to check.
* @returns true, if there is at least one element with the given name (and id).
*/
public isElementShown(name: string, id?: number): boolean {
return this.elements.some(element => element.name === name && (!id || element.id === id));
}
/**
* Removes all elements, that do not have `stable=true`.
*/
public removeAllNonStableElements(): void {
this.elements = this.elements.filter(element => element.stable);
}
/**
* Adds the given element to the projectorelements
*
* @param element The element to add.
*/
public addElement<T extends ProjectorElement>(element: T): void {
this.elements.push(element);
}
/**
* Removes elements given by the name and optional id. If no id is given
* all elements with a matching name are removed.
*
* If an id is given, ut the element dies not specify an id, it will be removed.
*
* @param name The name to search
* @param id The optional id to search.
*/
public removeElementByNameAndId(name: string, id?: number): void {
this.elements = this.elements.filter(
element => element.name !== name || (!id && !element.id && element.id !== id)
);
}
public getTitle(): string {
return this.name;
}

View File

@ -1,12 +1,12 @@
import { File } from './file';
import { ProjectableBaseModel } from '../base/projectable-base-model';
import { Searchable } from '../base/searchable';
import { BaseModel } from '../base/base-model';
/**
* Representation of MediaFile. Has the nested property "File"
* @ignore
*/
export class Mediafile extends ProjectableBaseModel implements Searchable {
export class Mediafile extends BaseModel<Mediafile> implements Searchable {
public id: number;
public title: string;
public mediafile: File;

View File

@ -13,6 +13,8 @@ import { MotionPoll } from './motion-poll';
* @ignore
*/
export class Motion extends AgendaBaseModel {
public static COLLECTIONSTRING = 'motions/motion';
public id: number;
public identifier: string;
public title: string;
@ -45,7 +47,7 @@ export class Motion extends AgendaBaseModel {
public last_modified: string;
public constructor(input?: any) {
super('motions/motion', 'Motion', input);
super(Motion.COLLECTIONSTRING, 'Motion', input);
}
/**

View File

@ -1,12 +1,14 @@
import { ProjectableBaseModel } from '../base/projectable-base-model';
import { Searchable } from '../base/searchable';
import { SearchRepresentation } from '../../../core/services/search.service';
import { BaseModel } from '../base/base-model';
/**
* Representation of a user in contrast to the operator.
* @ignore
*/
export class User extends ProjectableBaseModel implements Searchable {
export class User extends BaseModel<User> implements Searchable {
public static COLLECTIONSTRING = 'users/user';
public id: number;
public username: string;
public title: string;
@ -25,7 +27,7 @@ export class User extends ProjectableBaseModel implements Searchable {
public default_password: string;
public constructor(input?: any) {
super('users/user', 'Participant', input);
super(User.COLLECTIONSTRING, 'Participant', input);
}
public get full_name(): string {

View File

@ -26,7 +26,8 @@ import {
MatBadgeModule,
MatStepperModule,
MatTabsModule,
MatBottomSheetModule
MatBottomSheetModule,
MatSliderModule
} from '@angular/material';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material';
@ -73,6 +74,9 @@ import { OsSortBottomSheetComponent } from './components/os-sort-filter-bar/os-s
import { FilterMenuComponent } from './components/os-sort-filter-bar/filter-menu/filter-menu.component';
import { LogoComponent } from './components/logo/logo.component';
import { C4DialogComponent, CopyrightSignComponent } from './components/copyright-sign/copyright-sign.component';
import { ProjectorButtonComponent } from './components/projector-button/projector-button.component';
import { ProjectionDialogComponent } from './components/projection-dialog/projection-dialog.component';
import { ResizedDirective } from './directives/resized.directive';
/**
* Share Module for all "dumb" components and pipes.
@ -121,6 +125,7 @@ import { C4DialogComponent, CopyrightSignComponent } from './components/copyrigh
MatButtonToggleModule,
MatStepperModule,
MatTabsModule,
MatSliderModule,
DragDropModule,
TranslateModule.forChild(),
RouterModule,
@ -160,6 +165,7 @@ import { C4DialogComponent, CopyrightSignComponent } from './components/copyrigh
MatRadioModule,
MatButtonToggleModule,
MatStepperModule,
MatSliderModule,
DragDropModule,
NgxMatSelectSearchModule,
FileDropModule,
@ -179,7 +185,10 @@ import { C4DialogComponent, CopyrightSignComponent } from './components/copyrigh
OsSortFilterBarComponent,
LogoComponent,
CopyrightSignComponent,
C4DialogComponent
C4DialogComponent,
ProjectorButtonComponent,
ProjectionDialogComponent,
ResizedDirective
],
declarations: [
PermsDirective,
@ -199,7 +208,10 @@ import { C4DialogComponent, CopyrightSignComponent } from './components/copyrigh
FilterMenuComponent,
LogoComponent,
CopyrightSignComponent,
C4DialogComponent
C4DialogComponent,
ProjectorButtonComponent,
ProjectionDialogComponent,
ResizedDirective
],
providers: [
{ provide: DateAdapter, useClass: OpenSlidesDateAdapter },

View File

@ -66,7 +66,7 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
throw new Error(
`The content object (${agendaItem.content_object.collection}, ${
agendaItem.content_object.id
}) of item ${agendaItem.id} is not a BaseProjectableModel.`
}) of item ${agendaItem.id} is not a AgendaBaseModel.`
);
}
}

View File

@ -22,8 +22,8 @@
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- slector column -->
<ng-container matColumnDef="selector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="checkbox-cell"></mat-header-cell>
<mat-cell *matCellDef="let assignment" class="checkbox-cell">
<mat-header-cell *matHeaderCellDef mat-sort-header class="icon-cell"></mat-header-cell>
<mat-cell *matCellDef="let assignment" class="icon-cell">
<mat-icon>{{ isSelected(assignment) ? 'check_circle' : '' }}</mat-icon>
</mat-cell>
</ng-container>

View File

@ -0,0 +1,47 @@
import { Projectable } from './projectable';
import { BaseViewModel } from './base-view-model';
import { ProjectorOptions } from './projector-options';
/**
* Base view class for projectable models.
*/
export abstract class BaseProjectableModel extends BaseViewModel implements Projectable {
/**
* Per default, a slide does not have any options
*
* @override
*/
public getProjectorOptions(): ProjectorOptions {
return [];
}
/**
* @override
*/
public abstract getProjectionDefaultName(): string;
/**
* The id should match the model's id.
*
* @override
*/
public getIdForSlide(): number {
return this.id;
}
/**
* A model s return the collection string
*
* @override
*/
public abstract getNameForSlide(): string;
/**
* Per default a model is a non-stable element.
*
* @override
*/
public isStableSlide(): boolean {
return false;
}
}

View File

@ -0,0 +1,32 @@
import { ProjectorOptions } from './projector-options';
import { Displayable } from 'app/shared/models/base/displayable';
/**
* Interface for every model, that should be projectable.
*/
export interface Projectable extends Displayable {
/**
* All options for the slide
*/
getProjectorOptions(): ProjectorOptions;
/**
* The projection default name for the slide
*/
getProjectionDefaultName(): string;
/**
* The (optional) id for the slide
*/
getIdForSlide(): number | null;
/**
* The slide's name
*/
getNameForSlide(): string;
/**
* The stable attribute for the slide.
*/
isStableSlide(): boolean;
}

View File

@ -0,0 +1,32 @@
export interface ProjectorDecisionOption {
key: string;
displayName: string;
default: string;
}
export interface ProjectorChoiceOption extends ProjectorDecisionOption {
choices: { value: string; displayName: string }[];
}
export type ProjectorOption = ProjectorDecisionOption | ProjectorChoiceOption;
export type ProjectorOptions = ProjectorOption[];
export function isProjectorDecisionOption(object: any): object is ProjectorDecisionOption {
const option = <ProjectorDecisionOption>object;
return (
option.key !== undefined &&
option.displayName !== undefined &&
option.default !== undefined &&
(<ProjectorChoiceOption>object).choices === undefined
);
}
export function isProjectorChoiceOption(object: any): object is ProjectorChoiceOption {
const option = <ProjectorChoiceOption>object;
return (
option.key !== undefined &&
option.displayName !== undefined &&
option.default !== undefined &&
option.choices !== undefined
);
}

View File

@ -1,17 +1,11 @@
import { AppConfig } from '../base/app-config';
import { Projector } from '../../shared/models/core/projector';
import { Countdown } from '../../shared/models/core/countdown';
import { ChatMessage } from '../../shared/models/core/chat-message';
import { ProjectorMessage } from '../../shared/models/core/projector-message';
import { Tag } from '../../shared/models/core/tag';
export const CommonAppConfig: AppConfig = {
name: 'common',
models: [
{ collectionString: 'core/projector', model: Projector },
{ collectionString: 'core/chat-message', model: ChatMessage },
{ collectionString: 'core/countdown', model: Countdown },
{ collectionString: 'core/projector-message', model: ProjectorMessage },
{ collectionString: 'core/tag', model: Tag }
],
mainMenuEntries: [

View File

@ -9,7 +9,7 @@ export const ConfigAppConfig: AppConfig = {
route: '/settings',
displayName: 'Settings',
icon: 'settings',
weight: 700,
weight: 1300,
permission: 'core.can_manage_config'
}
]

View File

@ -55,8 +55,8 @@
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- Selector Column -->
<ng-container matColumnDef="selector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="checkbox-cell"></mat-header-cell>
<mat-cell *matCellDef="let item" class="checkbox-cell" (click)="selectItem(item, $event)">
<mat-header-cell *matHeaderCellDef mat-sort-header class="icon-cell"></mat-header-cell>
<mat-cell *matCellDef="let item" class="icon-cell" (click)="selectItem(item, $event)">
<mat-icon>{{ isSelected(item) ? 'check_circle' : '' }}</mat-icon>
</mat-cell>
</ng-container>

View File

@ -15,7 +15,7 @@
<!-- Prefix field -->
<mat-form-field>
<input formControlName="prefix" matInput placeholder="{{'Prefix' | translate}}" required>
<mat-hint *ngIf="!createForm.controls.name.valid">
<mat-hint *ngIf="!createForm.controls.prefix.valid">
<span translate>Required</span>
</mat-hint>
</mat-form-field>

View File

@ -30,7 +30,7 @@
</mat-card-content>
<mat-card-actions>
<button mat-button (click)="create()">
<span translate>Save</span>
<span translate>Create</span>
</button>
<button mat-button (click)="commentSectionToCreate = null">
<span translate>Cancel</span>

View File

@ -160,7 +160,7 @@
<os-personal-note *ngIf="!editMotion" [motion]="motion"></os-personal-note>
<ng-container *ngTemplateOutlet="motionLogTemplate"></ng-container>
</div>
<div class="desktop-right ">
<div class="desktop-right">
<!-- Content -->
<mat-card [ngClass]="editMotion ? 'os-form-card' : 'os-card'">
<ng-container *ngTemplateOutlet="contentTemplate"></ng-container>

View File

@ -30,6 +30,14 @@
</mat-cell>
</ng-container>
<!-- Projector column -->
<ng-container matColumnDef="projector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="icon-cell">Projector</mat-header-cell>
<mat-cell *matCellDef="let motion" class="icon-cell">
<os-projector-button [object]="motion"></os-projector-button>
</mat-cell>
</ng-container>
<!-- identifier column -->
<ng-container matColumnDef="identifier">
<mat-header-cell *matHeaderCellDef mat-sort-header>Identifier</mat-header-cell>

View File

@ -34,15 +34,16 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
/**
* Use for minimal width. Please note the 'selector' row for multiSelect mode,
* to be able to display an indicator for the state of selection
* TODO: Remove projector, if columnsToDisplayFullWidth is used..
*/
public columnsToDisplayMinWidth = ['identifier', 'title', 'state', 'speakers'];
public columnsToDisplayMinWidth = ['projector', 'identifier', 'title', 'state', 'speakers'];
/**
* Use for maximal width. Please note the 'selector' row for multiSelect mode,
* to be able to display an indicator for the state of selection
* TODO: Needs vp.desktop check
*/
public columnsToDisplayFullWidth = ['identifier', 'title', 'state', 'speakers'];
public columnsToDisplayFullWidth = ['projector', 'identifier', 'title', 'state', 'speakers'];
/**
* Value of the configuration variable `motions_statutes_enabled` - are statutes enabled?

View File

@ -1,16 +1,17 @@
import { BaseModel } from '../../../shared/models/base/base-model';
import { BaseViewModel } from '../../base/base-view-model';
import { BaseProjectableModel } from 'app/site/base/base-projectable-model';
import { Category } from '../../../shared/models/motions/category';
import { MotionComment } from '../../../shared/models/motions/motion-comment';
import { Item } from 'app/shared/models/agenda/item';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { Motion } from '../../../shared/models/motions/motion';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { MotionComment } from '../../../shared/models/motions/motion-comment';
import { PersonalNoteContent } from 'app/shared/models/users/personal-note';
import { User } from '../../../shared/models/users/user';
import { ViewMotionCommentSection } from './view-motion-comment-section';
import { Workflow } from '../../../shared/models/motions/workflow';
import { WorkflowState } from '../../../shared/models/motions/workflow-state';
import { ProjectorOptions } from 'app/site/base/projector-options';
/**
* The line numbering mode for the motion detail view.
@ -39,7 +40,7 @@ export enum ChangeRecoMode {
* Provides "safe" access to variables and functions in {@link Motion}
* @ignore
*/
export class ViewMotion extends BaseViewModel {
export class ViewMotion extends BaseProjectableModel {
protected _motion: Motion;
protected _category: Category;
protected _submitters: User[];
@ -436,6 +437,30 @@ export class ViewMotion extends BaseViewModel {
return this.amendment_paragraphs.length > 0;
}
public getProjectorOptions(): ProjectorOptions {
return [
{
key: 'mode',
displayName: 'Mode',
default: 'original',
choices: [
{ value: 'original', displayName: 'Original' },
{ value: 'changed', displayName: 'Changed' },
{ value: 'diff', displayName: 'Diff' },
{ value: 'agreed', displayName: 'Agreed' }
]
}
];
}
public getProjectionDefaultName(): string {
return 'motions';
}
public getNameForSlide(): string {
return Motion.COLLECTIONSTRING;
}
/**
* Duplicate this motion into a copy of itself
*/

View File

@ -0,0 +1,5 @@
<div id="container" #container [osResized]="resizeSubject">
<div id="projector" [ngStyle]="projectorStyle">
<os-projector [projector]="projector"></os-projector>
</div>
</div>

View File

@ -0,0 +1,14 @@
#container {
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: #222;
display: flex;
align-items: center;
}
#projector {
width: 500px;
margin: auto auto;
min-width: 1px;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FullscreenProjectorComponent } from './fullscreen-projector.component';
import { E2EImportsModule } from 'e2e-imports.module';
import { ProjectorModule } from '../../projector.module';
describe('FullscreenProjectorComponent', () => {
let component: FullscreenProjectorComponent;
let fixture: ComponentFixture<FullscreenProjectorComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule, ProjectorModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FullscreenProjectorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
fit('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,139 @@
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { AuthService } from 'app/core/services/auth.service';
import { ActivatedRoute } from '@angular/router';
import { Subject } from 'rxjs';
import { OperatorService } from 'app/core/services/operator.service';
import { ViewProjector } from 'app/site/projector/models/view-projector';
import { Size } from 'app/site/projector/size';
import { ProjectorRepositoryService } from 'app/site/projector/services/projector-repository.service';
/**
* The fullscreen projector. Bootstraps OpenSlides, gets the requested projector,
* holds the projector component and fits it to the screen.
*
* DO NOT use this component in the site!
*/
@Component({
selector: 'os-fullscreen-projector',
templateUrl: './fullscreen-projector.component.html',
styleUrls: ['./fullscreen-projector.component.scss']
})
export class FullscreenProjectorComponent implements OnInit {
// TODO: isLoading, canSeeProjector and other issues must be displayed!
public isLoading = true;
public canSeeProjector = false;
/**
* The id of the projector given by the url.
*/
public projectorId: number;
/**
* The projector from the datastore.
*/
public projector: ViewProjector | null;
/**
* Saves the projectorsize. It's used to check, if the size has changed
* on a projector update.
*/
private oldProjectorSize: Size = { width: 0, height: 0 };
/**
* This subject fires, if the container changes it's size.
*/
public resizeSubject = new Subject<void>();
/**
* Used to give the projector the right size.
*/
public projectorStyle = {
width: '100px' // Some default. Will be overwritten soon.
};
/**
* The container to get the window size.
*/
@ViewChild('container')
private containerElement: ElementRef;
/**
* Constructor. Updates the projector dimensions on a resize.
*
* @param auth
* @param route
* @param operator
* @param repo
*/
public constructor(
auth: AuthService, // Needed tro trigger loading of OpenSlides. Starts the Bootup process.
private route: ActivatedRoute,
private operator: OperatorService,
private repo: ProjectorRepositoryService
) {
this.resizeSubject.subscribe(() => {
this.updateProjectorDimensions();
});
}
/**
* Get the projector id from the URL. Loads the projector.
* Subscribes to the operator to get his/her permissions.
*/
public ngOnInit(): void {
this.route.params.subscribe(params => {
this.loadProjector(parseInt(params.id, 10) || 1);
this.isLoading = false;
});
this.operator.getObservable().subscribe(() => {
this.canSeeProjector = this.operator.hasPerms('projector.can_see');
});
}
/**
* Loads the projector.
*
* @param projectorId: The projector id for the projector to load.
*/
private loadProjector(projectorId: number): void {
this.projectorId = projectorId;
// TODO: what happens on delete?
// Watches the projector. Update the container size, if the projector size changes.
this.repo.getViewModelObservable(this.projectorId).subscribe(projector => {
this.projector = projector;
if (
projector &&
(projector.width !== this.oldProjectorSize.width || projector.height !== this.oldProjectorSize.height)
) {
this.oldProjectorSize.width = projector.height;
this.oldProjectorSize.height = projector.height;
this.updateProjectorDimensions();
}
});
}
/**
* Fits the projector into the container.
*/
private updateProjectorDimensions(): void {
if (!this.containerElement || !this.projector) {
return;
}
const projectorAspectRatio = this.projector.width / this.projector.height;
const windowAspectRatio =
this.containerElement.nativeElement.offsetWidth / this.containerElement.nativeElement.offsetHeight;
if (projectorAspectRatio >= windowAspectRatio) {
// full width
this.projectorStyle.width = `${this.containerElement.nativeElement.offsetWidth}px`;
} else {
// full height
const width = Math.floor(this.containerElement.nativeElement.offsetHeight * projectorAspectRatio);
this.projectorStyle.width = `${width}px`;
}
}
}

View File

@ -0,0 +1,41 @@
<os-head-bar [nav]="false" [goBack]="true">
<!-- Title -->
<div class="title-slot">
<h2 translate>{{ projector?.name | translate }}</h2>
</div>
</os-head-bar>
<div class="content-container">
<div class="column-left">
<div id="projector">
<os-projector [projector]="projector"></os-projector>
</div>
</div>
<div class="column-right">
<div class="control-group">
{{ projector?.scroll }}
<button type="button" mat-icon-button (click)="scroll(scrollScaleDirection.Up)">
<mat-icon>arrow_upward</mat-icon>
</button>
<button type="button" mat-icon-button (click)="scroll(scrollScaleDirection.Down)">
<mat-icon>arrow_downward</mat-icon>
</button>
<button type="button" mat-icon-button (click)="scroll(scrollScaleDirection.Reset)">
<mat-icon>refresh</mat-icon>
</button>
</div>
<hr>
<div class="control-group">
{{ projector?.scale }}
<button type="button" mat-icon-button (click)="scale(scrollScaleDirection.Up)">
<mat-icon>zoom_in</mat-icon>
</button>
<button type="button" mat-icon-button (click)="scale(scrollScaleDirection.Down)">
<mat-icon>zoom_out</mat-icon>
</button>
<button type="button" mat-icon-button (click)="scale(scrollScaleDirection.Reset)">
<mat-icon>refresh</mat-icon>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,20 @@
#projector {
width: 100%; /*1000px;*/
}
.column-left {
display: inline-block;
padding-top: 20px;
width: 70%;
padding-right: 25px;
}
.column-right {
padding-top: 20px;
min-width: calc(30% - 25px);
float: right;
}
.control-group {
text-align: center;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { ProjectorDetailComponent } from './projector-detail.component';
import { ProjectorModule } from '../../projector.module';
describe('ProjectorDetailComponent', () => {
let component: ProjectorDetailComponent;
let fixture: ComponentFixture<ProjectorDetailComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule, ProjectorModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectorDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,71 @@
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { ProjectorRepositoryService, ScrollScaleDirection } from '../../services/projector-repository.service';
import { ViewProjector } from '../../models/view-projector';
import { BaseViewComponent } from 'app/site/base/base-view';
/**
* The projector detail view.
*/
@Component({
selector: 'os-projector-detail',
templateUrl: './projector-detail.component.html',
styleUrls: ['./projector-detail.component.scss']
})
export class ProjectorDetailComponent extends BaseViewComponent implements OnInit {
/**
* The projector to show.
*/
public projector: ViewProjector;
public scrollScaleDirection = ScrollScaleDirection;
/**
* @param titleService
* @param translate
* @param matSnackBar
* @param repo
* @param route
*/
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private repo: ProjectorRepositoryService,
private route: ActivatedRoute
) {
super(titleService, translate, matSnackBar);
}
/**
* Gets the projector and subscribes to it.
*/
public ngOnInit(): void {
super.setTitle('Projector');
this.route.params.subscribe(params => {
const projectorId = parseInt(params.id, 10) || 1;
this.repo.getViewModelObservable(projectorId).subscribe(projector => (this.projector = projector));
});
}
/**
* Change the scroll
* @param direction The direction to send.
*/
public scroll(direction: ScrollScaleDirection): void {
this.repo.scroll(this.projector, direction).then(null, this.raiseError);
}
/**
* Change the scale
* @param direction The direction to send.
*/
public scale(direction: ScrollScaleDirection): void {
this.repo.scale(this.projector, direction).then(null, this.raiseError);
}
}

View File

@ -0,0 +1,101 @@
<os-head-bar [nav]="true" [mainButton]="true" (mainEvent)="onPlusButton()">
<!-- Title -->
<div class="title-slot">
<h2 translate>Projectors</h2>
</div>
<!-- Menu -->
<div class="menu-slot">
<div *osPerms="'core.can_manage_projector'">
<button type="button" mat-icon-button [matMenuTriggerFor]="ellipsisMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</div>
</os-head-bar>
<mat-card *ngIf="projectorToCreate">
<mat-card-title translate>New Projector</mat-card-title>
<mat-card-content>
<form [formGroup]="createForm" (keydown)="keyDownFunction($event)">
<p>
<mat-form-field>
<input formControlName="name" matInput placeholder="{{'Name' | translate}}" required>
<mat-hint *ngIf="!createForm.controls.name.valid">
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</p>
</form>
</mat-card-content>
<mat-card-actions>
<button mat-button (click)="create()">
<span translate>Create</span>
</button>
<button mat-button (click)="projectorToCreate = null">
<span translate>Cancel</span>
</button>
</mat-card-actions>
</mat-card>
<div id="card-wrapper">
<mat-card class="projector-card" *ngFor="let projector of projectors">
<mat-card-title>
<a [routerLink]="['/projector-site/detail', projector.id]">
{{ projector.name }}
</a>
<div class="card-actions">
<button mat-icon-button *ngIf="editId !== projector.id" (click)=onEditButton(projector)>
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button *ngIf="editId === projector.id" (click)=onCancelButton(projector)>
<mat-icon>close</mat-icon>
</button>
<button mat-icon-button *ngIf="editId === projector.id" (click)=onSaveButton(projector)>
<mat-icon>save</mat-icon>
</button>
<button mat-icon-button mat-button (click)=onDeleteButton(projector)>
<mat-icon>delete</mat-icon>
</button>
</div>
</mat-card-title>
<mat-card-content>
<p>TODO: projector</p>
<ng-container *ngIf="editId === projector.id">
<form [formGroup]="updateForm" (keydown)="keyDownFunction($event, projector)">
<p>
<!-- Name field -->
<mat-form-field>
<input formControlName="name" matInput placeholder="{{'Name' | translate}}" required>
<mat-hint *ngIf="!createForm.controls.name.valid">
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</p><p>
<!-- Aspect ratio field -->
<mat-radio-group formControlName="aspectRatio" [name]="projector.id">
<mat-radio-button *ngFor="let ratio of aspectRatiosKeys" [value]="ratio">
{{ ratio }}
</mat-radio-button>
</mat-radio-group>
</p><p>
<mat-slider [thumbLabel]="true" formControlName="width" min="800" max="3840" step="10"></mat-slider>
{{ updateForm.value.width }}
</p>
</form>
</ng-container>
</mat-card-content>
</mat-card>
</div>
<mat-menu #ellipsisMenu="matMenu">
<button mat-menu-item>
<mat-icon>note</mat-icon>
<span translate>Projector messages</span>
</button>
<button mat-menu-item>
<mat-icon>alarm</mat-icon>
<span translate>Countdowns</span>
</button>
</mat-menu>

View File

@ -0,0 +1,13 @@
#card-wrapper {
margin-top: 10px;
.projector-card {
margin: 20px;
width: 300px;
display: inline-block;
}
.card-actions {
float: right;
}
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectorListComponent } from './projector-list.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { ProjectorModule } from '../../projector.module';
describe('ProjectorListComponent', () => {
let component: ProjectorListComponent;
let fixture: ComponentFixture<ProjectorListComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule, ProjectorModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectorListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,225 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { ProjectorRepositoryService } from '../../services/projector-repository.service';
import { ViewProjector } from '../../models/view-projector';
import { Projector } from 'app/shared/models/core/projector';
import { BaseViewComponent } from 'app/site/base/base-view';
import { MatSnackBar } from '@angular/material';
import { PromptService } from 'app/core/services/prompt.service';
/**
* All supported aspect rations for projectors.
*/
const aspectRatios: { [ratio: string]: number } = {
'4:3': 4 / 3,
'16:9': 16 / 9,
'16:10': 16 / 10
};
/**
* List for all projectors.
*/
@Component({
selector: 'os-projector-list',
templateUrl: './projector-list.component.html',
styleUrls: ['./projector-list.component.scss']
})
export class ProjectorListComponent extends BaseViewComponent implements OnInit {
/**
* This member is set, if the user is creating a new projector.
*/
public projectorToCreate: Projector | null;
/**
* The create form.
*/
public createForm: FormGroup;
/**
* The update form. Will be refreahed for each projector. Just one update
* form can be shown per time.
*/
public updateForm: FormGroup;
/**
* The id of the currently edited projector.
*/
public editId: number | null = null;
/**
* All aspect ratio keys/strings for the UI.
*/
public aspectRatiosKeys: string[];
/**
* All projectors.
*/
public projectors: ViewProjector[];
/**
* Constructor. Initializes all forms.
*
* @param titleService
* @param translate
* @param matSnackBar
* @param repo
* @param formBuilder
* @param promptService
*/
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private repo: ProjectorRepositoryService,
private formBuilder: FormBuilder,
private promptService: PromptService
) {
super(titleService, translate, matSnackBar);
this.aspectRatiosKeys = Object.keys(aspectRatios);
this.createForm = this.formBuilder.group({
name: ['', Validators.required]
});
this.updateForm = this.formBuilder.group({
name: ['', Validators.required],
aspectRatio: ['', Validators.required],
width: [0, Validators.required]
});
}
/**
* Watches all projectors.
*/
public ngOnInit(): void {
super.setTitle('Projectors');
this.repo.getViewModelListObservable().subscribe(projectors => (this.projectors = projectors));
}
/**
* Opens the create form.
*/
public onPlusButton(): void {
if (!this.projectorToCreate) {
this.projectorToCreate = new Projector();
this.createForm.setValue({ name: '' });
}
}
/**
* Creates the comment section from the create form.
*/
public create(): void {
if (this.createForm.valid && this.projectorToCreate) {
this.projectorToCreate.patchValues(this.createForm.value as Projector);
this.repo.create(this.projectorToCreate).then(() => (this.projectorToCreate = null), this.raiseError);
}
}
/**
* Event on Key Down in update or create form.
*
* @param event the keyboard event
* @param the current view in scope
*/
public keyDownFunction(event: KeyboardEvent, projector?: ViewProjector): void {
if (event.key === 'Enter' && event.shiftKey) {
if (projector) {
this.onSaveButton(projector);
} else {
this.create();
}
}
if (event.key === 'Escape') {
if (projector) {
this.onCancelButton(projector);
} else {
this.projectorToCreate = null;
}
}
}
/**
* Calculates the aspect ratio of the given projector.
* If no matching ratio is found, the first ratio is returned.
*
* @param projector The projector to check
* @returns the found ratio key.
*/
public getAspectRatioKey(projector: ViewProjector): string {
const ratio = projector.width / projector.height;
const RATIO_ENVIRONMENT = 0.05;
const foundRatioKey = Object.keys(aspectRatios).find(key => {
const value = aspectRatios[key];
return value >= ratio - RATIO_ENVIRONMENT && value <= ratio + RATIO_ENVIRONMENT;
});
if (!foundRatioKey) {
return Object.keys(aspectRatios)[0];
} else {
return foundRatioKey;
}
}
/**
* Starts editing for the given projector.
*
* @param projector The projector to edit
*/
public onEditButton(projector: ViewProjector): void {
if (this.editId !== null) {
return;
}
this.editId = projector.id;
this.updateForm.reset();
this.updateForm.patchValue({
name: projector.name,
aspectRatio: this.getAspectRatioKey(projector),
width: projector.width
});
}
/**
* Cancels the current editing.
* @param projector the projector
*/
public onCancelButton(projector: ViewProjector): void {
if (projector.id !== this.editId) {
return;
}
this.editId = null;
}
/**
* Saves the projector
*
* @param projector The projector to save.
*/
public onSaveButton(projector: ViewProjector): void {
if (projector.id !== this.editId || !this.updateForm.valid) {
return;
}
const updateProjector: Partial<Projector> = {
name: this.updateForm.value.name,
width: this.updateForm.value.width,
height: Math.round(this.updateForm.value.width / aspectRatios[this.updateForm.value.aspectRatio])
};
this.repo.update(updateProjector, projector);
this.editId = null;
}
/**
* Delete the projector.
*
* @param projector The projector to delete
*/
public async onDeleteButton(projector: ViewProjector): Promise<void> {
const content = this.translate.instant('Delete') + ` ${projector.name}?`;
if (await this.promptService.open('Are you sure?', content)) {
this.repo.delete(projector).then(null, this.raiseError);
}
}
}

View File

@ -0,0 +1,17 @@
<div id="container" [osResized]="resizeSubject" [ngStyle]="containerStyle" #container>
<div id="projector" [ngStyle]="projectorStyle">
<div class="header" [ngStyle]="headerFooterStyle" *ngIf="enableHeaderAndFooter">
<div *ngIf="enableTitle">
Header Title
</div>
</div>
<div *ngFor="let slide of slides" class="content">
<os-slide-container [slideData]="slide" [scroll]="scroll" [scale]="scale"></os-slide-container>
</div>
<div class="footer" [ngStyle]="headerFooterStyle" *ngIf="enableHeaderAndFooter">
Footer
</div>
</div>
</div>

View File

@ -0,0 +1,36 @@
#container {
background-color: lightgoldenrodyellow;
position: relative;
#projector {
position: absolute;
top: 0;
left: 0;
transform-origin: left top;
overflow: hidden;
.header {
position: absolute;
top: 0;
left: 0;
color: white;
width: 100%;
height: 50px;
z-index: 1;
}
.content {
width: 100%;
position: absolute;
top: 0;
left: 0;
}
.footer {
position: absolute;
color: white;
width: 100%;
height: 50px;
bottom: 0;
z-index: 1;
}
}
}

View File

@ -1,7 +1,8 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectorComponent } from './projector.component';
import { E2EImportsModule } from '../../../e2e-imports.module';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { ProjectorModule } from '../../projector.module';
describe('ProjectorComponent', () => {
let component: ProjectorComponent;
@ -9,8 +10,7 @@ describe('ProjectorComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [ProjectorComponent]
imports: [E2EImportsModule, ProjectorModule]
}).compileComponents();
}));

View File

@ -0,0 +1,236 @@
import { Component, Input, ViewChild, ElementRef, OnDestroy } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from 'app/base.component';
import { ViewProjector } from '../../models/view-projector';
import { ProjectorDataService, SlideData } from '../../services/projector-data.service';
import { Subscription, Subject } from 'rxjs';
import { ProjectorRepositoryService } from '../../services/projector-repository.service';
import { ConfigService } from 'app/core/services/config.service';
import { Size } from '../../size';
/**
* THE projector. Cares about scaling and the right size and resolution.
* Watches the given projector and creates slide-containers for each projectorelement.
*/
@Component({
selector: 'os-projector',
templateUrl: './projector.component.html',
styleUrls: ['./projector.component.scss']
})
export class ProjectorComponent extends BaseComponent implements OnDestroy {
/**
* The current projector id.
*/
private projectorId: number | null = null;
/**
* The projector. Accessors are below.
*/
private _projector: ViewProjector;
@Input()
public set projector(projector: ViewProjector) {
this._projector = projector;
// check, if ID changed:
const newId = projector ? projector.id : null;
if (this.projectorId !== newId) {
this.projectorIdChanged(this.projectorId, newId);
this.projectorId = newId;
}
// Update scaling, if projector is set.
if (projector) {
const oldSize: Size = { ...this.currentProjectorSize };
this.currentProjectorSize.height = projector.height;
this.currentProjectorSize.width = projector.width;
if (
oldSize.height !== this.currentProjectorSize.height ||
oldSize.width !== this.currentProjectorSize.width
) {
this.updateScaling();
}
}
}
public get projector(): ViewProjector {
return this._projector;
}
/**
* The current projector size. This is for checking,
* if the size actually has changed.
*/
private currentProjectorSize: Size = { width: 0, height: 0 };
/**
* Ths subscription to the projectordata.
*/
private dataSubscription: Subscription;
/**
* The container element. THis is neede to get the size of the element,
* in which the projector must fit and be scaled to.
*/
@ViewChild('container')
private containerElement: ElementRef;
/**
* Dynamic style attributes for the projector.
*/
public projectorStyle: {
transform?: string;
width: string;
height: string;
'background-color': string;
} = {
width: '0px',
height: '0px',
'background-color': 'white'
};
/**
* Dynamic style attributes for the header and footer.
*/
public headerFooterStyle: { 'background-color': string; color: string } = {
'background-color': 'blue',
color: 'white'
};
/**
* Dynamic style attributes for the container.
*/
public containerStyle: { height?: string } = {};
/**
* All slides to show on this projector
*/
public slides: SlideData<object>[] = [];
/**
* The scroll for this projector. 0 is the default.
*/
public scroll = 0;
/**
* The scale for this projector. 0 is the default.
*/
public scale = 0;
/**
* The subscription to the projector.
*/
private projectorSubscription: Subscription;
/**
* A subject that fires, if the container is resized.
*/
public resizeSubject = new Subject<void>();
// Some settings for the view from the config.
public enableHeaderAndFooter = true;
public enableTitle = true;
public enableLogo = true;
/**
* Listen to all related config variables. Register the resizeSubject.
*
* @param titleService
* @param translate
* @param projectorDataService
* @param projectorRepository
* @param configService
*/
public constructor(
titleService: Title,
translate: TranslateService,
private projectorDataService: ProjectorDataService,
private projectorRepository: ProjectorRepositoryService,
private configService: ConfigService
) {
super(titleService, translate);
// Get all important config variables.
this.configService
.get<boolean>('projector_enable_header_footer')
.subscribe(val => (this.enableHeaderAndFooter = val));
this.configService.get<boolean>('projector_enable_title').subscribe(val => (this.enableTitle = val));
this.configService
.get<string>('projector_header_fontcolor')
.subscribe(val => (this.headerFooterStyle.color = val));
this.configService
.get<string>('projector_header_backgroundcolor')
.subscribe(val => (this.headerFooterStyle['background-color'] = val));
this.configService.get<boolean>('projector_enable_logo').subscribe(val => (this.enableLogo = val));
this.configService
.get<string>('projector_background_color')
.subscribe(val => (this.projectorStyle['background-color'] = val));
// Watches for resizing of the container.
this.resizeSubject.subscribe(() => {
if (this.containerElement) {
this.updateScaling();
}
});
}
/**
* Scales the projector to the right format.
*/
private updateScaling(): void {
if (
!this.containerElement ||
this.currentProjectorSize.width === 0 ||
this.containerElement.nativeElement.offsetWidth === 0
) {
return;
}
const scale = this.containerElement.nativeElement.offsetWidth / this.currentProjectorSize.width;
if (isNaN(scale)) {
return;
}
this.projectorStyle.transform = 'scale(' + scale + ')';
this.projectorStyle.width = this.currentProjectorSize.width + 'px';
this.projectorStyle.height = this.currentProjectorSize.height + 'px';
this.containerStyle.height = Math.round(scale * this.currentProjectorSize.height) + 'px';
}
/**
* Called, if the projector id changes.
*/
private projectorIdChanged(from: number, to: number): void {
// Unsubscribe form data and projector subscriptions.
if (this.dataSubscription) {
this.dataSubscription.unsubscribe();
}
if (this.projectorSubscription) {
this.projectorSubscription.unsubscribe();
}
if (to > 0) {
if (from > 0) {
this.projectorDataService.projectorClosed(from);
}
this.dataSubscription = this.projectorDataService
.getProjectorObservable(to)
.subscribe(data => (this.slides = data || []));
this.projectorSubscription = this.projectorRepository.getViewModelObservable(to).subscribe(projector => {
this.scroll = projector.scroll;
this.scale = projector.scale;
});
} else if (!to && from > 0) {
// no new projector
this.projectorDataService.projectorClosed(from);
}
}
/**
* Deregister the projector from the projectordataservice.
*/
public ngOnDestroy(): void {
if (this.projectorId > 0) {
this.projectorDataService.projectorClosed(this.projectorId);
}
}
}

View File

@ -0,0 +1 @@
<div id="slide" [ngStyle]="slideStyle"><ng-container #slide></ng-container></div>

View File

@ -0,0 +1,3 @@
::ng-deep #slide {
z-index: 5;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SlideContainerComponent } from './slide-container.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { ProjectorModule } from '../../projector.module';
describe('SlideContainerComponent', () => {
let component: SlideContainerComponent;
let fixture: ComponentFixture<SlideContainerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule, ProjectorModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SlideContainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,157 @@
import { Component, Input, ViewChild, ViewContainerRef, ComponentRef } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from 'app/base.component';
import { SlideData } from '../../services/projector-data.service';
import { DynamicSlideLoader } from 'app/slides/services/dynamic-slide-loader.service';
import { BaseSlideComponent } from 'app/slides/base-slide-component';
import { SlideOptions } from 'app/slides/slide-manifest';
import { ConfigService } from 'app/core/services/config.service';
/**
* Container for one slide. Cares about the position (scale, scroll) in the projector,
* and loading of slides.
*/
@Component({
selector: 'os-slide-container',
templateUrl: './slide-container.component.html',
styleUrls: ['./slide-container.component.scss']
})
export class SlideContainerComponent extends BaseComponent {
private previousSlideName: string;
@ViewChild('slide', { read: ViewContainerRef })
private slide: ViewContainerRef;
private slideRef: ComponentRef<BaseSlideComponent<object>>;
/**
* The data for this slide. Will be accessed below.
*/
private _slideData: SlideData<object>;
@Input()
public set slideData(data: SlideData<object>) {
// If there is no ata or an error, clear and exit.
if (!data || data.error) {
// clear slide container:
if (this.slide) {
this.slide.clear();
}
if (data.error) {
console.error(data.error);
}
return;
}
this._slideData = data;
if (this.previousSlideName !== data.element.name) {
this.slideChanged(data.element.name);
this.previousSlideName = data.element.name;
}
this.setDataForComponent();
}
public get slideData(): SlideData<object> {
return this._slideData;
}
/**
* The current projector scroll.
*/
private _scroll: number;
/**
* Updates the slideStyle, when the scroll changes.
*/
@Input()
public set scroll(value: number) {
this._scroll = value;
this.updateScroll();
}
/**
* Update the slideStyle, when the scale changes.
*/
@Input()
public set scale(value: number) {
if (this.slideOptions.scaleable) {
value *= 10;
value += 100;
this.slideStyle['font-size'] = `${value}%`;
} else {
this.slideStyle['font-size'] = '100%';
}
}
/**
* The current slideoptions.
*/
private slideOptions: SlideOptions = { scaleable: false, scrollable: false };
/**
* Styles for scaling and scrolling.
*/
public slideStyle: { 'font-size': string; 'margin-top': string } = {
'font-size': '100%',
'margin-top': '50px'
};
/**
* Variable, if the projector header is enabled.
*/
private headerEnabled = true;
public constructor(
titleService: Title,
translate: TranslateService,
private dynamicSlideLoader: DynamicSlideLoader,
private configService: ConfigService
) {
super(titleService, translate);
this.configService.get<boolean>('projector_enable_header_footer').subscribe(val => {
this.headerEnabled = val;
});
}
/**
* Updates the 'margin-top' attribute in the slide styles.
*/
private updateScroll(): void {
if (this.slideOptions.scrollable) {
let value = this._scroll;
value *= -50;
if (this.headerEnabled) {
value += 50; // Default offset for the header
}
this.slideStyle['margin-top'] = `${value}px`;
} else {
this.slideStyle['margin-top'] = '0px';
}
}
/**
* Loads the slides via the dynamicSlideLoader. Creates the slide components and provide the slide data to it.
*
* @param slideName The slide to load.
*/
private slideChanged(slideName: string): void {
this.slideOptions = this.dynamicSlideLoader.getSlideOptions(slideName);
this.dynamicSlideLoader.getSlideFactory(slideName).then(slideFactory => {
this.slide.clear();
this.slideRef = this.slide.createComponent(slideFactory);
this.setDataForComponent();
});
}
/**
* "injects" the slide data into the slide component.
*/
private setDataForComponent(): void {
if (this.slideRef && this.slideRef.instance) {
this.slideRef.instance.data = this.slideData;
}
}
}

View File

@ -0,0 +1,51 @@
import { BaseViewModel } from '../../base/base-view-model';
import { Projector, ProjectorElements } from 'app/shared/models/core/projector';
export class ViewProjector extends BaseViewModel {
private _projector: Projector;
public get projector(): Projector {
return this._projector ? this._projector : null;
}
public get id(): number {
return this.projector ? this.projector.id : null;
}
public get name(): string {
return this.projector ? this.projector.name : null;
}
public get elements(): ProjectorElements {
return this.projector ? this.projector.elements : null;
}
public get height(): number {
return this.projector ? this.projector.height : null;
}
public get width(): number {
return this.projector ? this.projector.width : null;
}
public get scale(): number {
return this.projector ? this.projector.scale : null;
}
public get scroll(): number {
return this.projector ? this.projector.scroll : null;
}
public constructor(projector?: Projector) {
super();
this._projector = projector;
}
public getTitle(): string {
return this.name;
}
public updateValues(projector: Projector): void {
console.log('Update projector TODO with vals:', projector);
}
}

View File

@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ProjectorListComponent } from './components/projector-list/projector-list.component';
import { ProjectorDetailComponent } from './components/projector-detail/projector-detail.component';
const routes: Routes = [
{
path: 'list',
component: ProjectorListComponent
},
{
path: 'detail/:id',
component: ProjectorDetailComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ProjectorRoutingModule {}

View File

@ -0,0 +1,22 @@
import { AppConfig } from '../base/app-config';
import { Projector } from 'app/shared/models/core/projector';
import { Countdown } from 'app/shared/models/core/countdown';
import { ProjectorMessage } from 'app/shared/models/core/projector-message';
export const ProjectorAppConfig: AppConfig = {
name: 'projector',
models: [
{ collectionString: 'core/projector', model: Projector },
{ collectionString: 'core/countdown', model: Countdown },
{ collectionString: 'core/projector-message', model: ProjectorMessage }
],
mainMenuEntries: [
{
route: '/projector-site/list',
displayName: 'Projector',
icon: 'videocam',
weight: 700,
permission: 'core.can_see_projector'
}
]
};

View File

@ -0,0 +1,13 @@
import { ProjectorModule } from './projector.module';
describe('ProjectorModule', () => {
let projectorModule: ProjectorModule;
beforeEach(() => {
projectorModule = new ProjectorModule();
});
it('should create an instance', () => {
expect(projectorModule).toBeTruthy();
});
});

View File

@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProjectorRoutingModule } from './projector-routing.module';
import { SharedModule } from '../../shared/shared.module';
import { ProjectorComponent } from './components/projector/projector.component';
import { ProjectorListComponent } from './components/projector-list/projector-list.component';
import { ProjectorDetailComponent } from './components/projector-detail/projector-detail.component';
import { SlideContainerComponent } from './components/slide-container/slide-container.component';
import { FullscreenProjectorComponent } from './components/fullscreen-projector/fullscreen-projector.component';
@NgModule({
imports: [CommonModule, ProjectorRoutingModule, SharedModule],
declarations: [
ProjectorComponent,
ProjectorListComponent,
ProjectorDetailComponent,
SlideContainerComponent,
FullscreenProjectorComponent
]
})
export class ProjectorModule {}

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { E2EImportsModule } from '../../../../e2e-imports.module';
import { ProjectorDataService } from './projector-data.service';
describe('ProjectorDataService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [ProjectorDataService]
});
});
it('should be created', inject([ProjectorDataService], (service: ProjectorDataService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,103 @@
import { Injectable } from '@angular/core';
import { WebsocketService } from 'app/core/services/websocket.service';
import { Observable, BehaviorSubject } from 'rxjs';
import { ProjectorElement } from 'app/shared/models/core/projector';
export interface SlideData<T = object> {
data: T;
element: ProjectorElement;
error?: string;
}
export type ProjectorData = SlideData[];
interface AllProjectorData {
[id: number]: ProjectorData;
}
/**
* This service handles the websocket connection for the projector data.
* Each projector instance registers itself by calling `getProjectorObservable`.
* A projector should deregister itself, when the component is destroyed.
*/
@Injectable({
providedIn: 'root'
})
export class ProjectorDataService {
/**
* Counts the open projector instances per projector id.
*/
private openProjectorInstances: { [id: number]: number } = {};
/**
* Holds the current projector data for each projector.
*/
private currentProjectorData: { [id: number]: BehaviorSubject<ProjectorData | null> } = {};
/**
* Constructor.
*
* @param websocketService
*/
public constructor(private websocketService: WebsocketService) {
// TODO: On reconnect, we do need to re-inform the server about all needed projectors. This also
// updates our projector data, which is great!
this.websocketService.getOberservable('projector').subscribe((update: AllProjectorData) => {
Object.keys(update).forEach(_id => {
const id = parseInt(_id, 10);
if (this.currentProjectorData[id]) {
this.currentProjectorData[id].next(update[id]);
}
});
});
}
/**
* Gets an observable for the projector data.
*
* @param projectorId The requested projector
* @return an observable for the projector data of the given projector.
*/
public getProjectorObservable(projectorId: number): Observable<ProjectorData | null> {
// Count projectors.
if (!this.openProjectorInstances[projectorId]) {
this.openProjectorInstances[projectorId] = 1;
if (!this.currentProjectorData[projectorId]) {
this.currentProjectorData[projectorId] = new BehaviorSubject<ProjectorData | null>(null);
}
} else {
this.openProjectorInstances[projectorId]++;
}
// Projector opened the first time.
if (this.openProjectorInstances[projectorId] === 1) {
this.updateProjectorDataSubscription();
}
return this.currentProjectorData[projectorId].asObservable();
}
/**
* Unsubscribe data from the server, if the last projector was closed.
*
* @param projectorId the projector.
*/
public projectorClosed(projectorId: number): void {
if (this.openProjectorInstances[projectorId]) {
this.openProjectorInstances[projectorId]--;
}
if (this.openProjectorInstances[projectorId] === 0) {
this.updateProjectorDataSubscription();
this.currentProjectorData[projectorId].next(null);
}
}
/**
* Gets initial data and keeps reuesting data.
*/
private updateProjectorDataSubscription(): void {
const allActiveProjectorIds = Object.keys(this.openProjectorInstances)
.map(id => parseInt(id, 10))
.filter(id => this.openProjectorInstances[id] > 0);
this.websocketService.send('listenToProjectors', { projector_ids: allActiveProjectorIds });
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { ProjectorRepositoryService } from './projector-repository.service';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('GroupRepositoryService', () => {
beforeEach(() =>
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [ProjectorRepositoryService]
})
);
it('should be created', inject([ProjectorRepositoryService], (service: ProjectorRepositoryService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,115 @@
import { Injectable } from '@angular/core';
import { BaseRepository } from '../../base/base-repository';
import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service';
import { DataSendService } from '../../../core/services/data-send.service';
import { DataStoreService } from '../../../core/services/data-store.service';
import { Identifiable } from '../../../shared/models/base/identifiable';
import { ViewProjector } from '../models/view-projector';
import { Projector } from '../../../shared/models/core/projector';
import { HttpService } from 'app/core/services/http.service';
/**
* Directions for scale and scroll requests.
*/
export enum ScrollScaleDirection {
Up = 'up',
Down = 'down',
Reset = 'reset'
}
/**
* Manages all projector instances.
*/
@Injectable({
providedIn: 'root'
})
export class ProjectorRepositoryService extends BaseRepository<ViewProjector, Projector> {
/**
* Constructor calls the parent constructor
*
* @param DS The DataStore
* @param mapperService Maps collection strings to classes
* @param dataSend sending changed objects
* @param http
*/
public constructor(
DS: DataStoreService,
mapperService: CollectionStringModelMapperService,
private dataSend: DataSendService,
private http: HttpService
) {
super(DS, mapperService, Projector);
}
/**
* Creates a new projector. Adds the clock as default, stable element
*/
public async create(projectorData: Partial<Projector>): Promise<Identifiable> {
const projector = new Projector();
projector.patchValues(projectorData);
projector.elements = [{ name: 'core/clock', stable: true }];
return await this.dataSend.createModel(projector);
}
/**
* Updates a projector.
*/
public async update(projectorData: Partial<Projector>, viewProjector: ViewProjector): Promise<void> {
const projector = new Projector();
projector.patchValues(viewProjector.projector);
projector.patchValues(projectorData);
await this.dataSend.updateModel(projector);
}
/**
* Deletes a given projector.
*
* @param projector
*/
public async delete(projector: ViewProjector): Promise<void> {
await this.dataSend.deleteModel(projector.projector);
}
public createViewModel(projector: Projector): ViewProjector {
return new ViewProjector(projector);
}
/**
* Scroll the given projector.
*
* @param projector The projector to scroll
* @param direction The direction.
*/
public async scroll(projector: ViewProjector, direction: ScrollScaleDirection): Promise<void> {
this.controlView(projector, direction, 'scroll');
}
/**
* Scale the given projector.
*
* @param projector The projector to scale
* @param direction The direction.
*/
public async scale(projector: ViewProjector, direction: ScrollScaleDirection): Promise<void> {
this.controlView(projector, direction, 'scale');
}
/**
* Controls the view of a projector.
*
* @param projector The projector to control.
* @param direction The direction
* @param action The action. Can be scale or scroll.
*/
private async controlView(
projector: ViewProjector,
direction: ScrollScaleDirection,
action: 'scale' | 'scroll'
): Promise<void> {
this.http.post(`/rest/core/projector/${projector.id}/control_view`, {
action: action,
direction: direction
});
}
}

View File

@ -0,0 +1,4 @@
export interface Size {
width: number;
height: number;
}

View File

@ -50,6 +50,10 @@ const routes: Routes = [
{
path: 'history',
loadChildren: './history/history.module#HistoryModule'
},
{
path: 'projector-site',
loadChildren: './projector/projector.module#ProjectorModule'
}
],
canActivateChild: [AuthGuard]

View File

@ -71,17 +71,6 @@
<span>{{ entry.displayName | translate }}</span>
</a>
</span>
<a
[@navItemAnim]
*osPerms="'core.can_see_projector'"
mat-list-item
routerLink="/projector"
routerLinkActive="active"
(click)="mobileAutoCloseNav()"
>
<mat-icon>videocam</mat-icon>
<span translate>Projector</span>
</a>
<mat-divider></mat-divider>
<a
[@navItemAnim]

View File

@ -22,12 +22,20 @@
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- Selector column -->
<ng-container matColumnDef="selector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="checkbox-cell"></mat-header-cell>
<mat-cell *matCellDef="let user" (click)="selectItem(user, $event)" class="checkbox-cell">
<mat-header-cell *matHeaderCellDef mat-sort-header class="icon-cell"></mat-header-cell>
<mat-cell *matCellDef="let user" (click)="selectItem(user, $event)" class="icon-cell">
<mat-icon>{{ isSelected(user) ? 'check_circle' : '' }}</mat-icon>
</mat-cell>
</ng-container>
<!-- Projector column -->
<ng-container matColumnDef="projector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="icon-cell">Projector</mat-header-cell>
<mat-cell *matCellDef="let user" class="icon-cell">
<os-projector-button [object]="user"></os-projector-button>
</mat-cell>
</ng-container>
<!-- name column -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell>

View File

@ -226,7 +226,8 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
* @returns column definition
*/
public getColumnDefinition(): string[] {
const columns = ['name', 'group', 'presence'];
// TODO: no projector in mobile view.
const columns = ['projector', 'name', 'group', 'presence'];
if (this.isMultiSelect) {
return ['selector'].concat(columns);
}

View File

@ -1,9 +1,9 @@
import { BaseViewModel } from '../../base/base-view-model';
import { User } from '../../../shared/models/users/user';
import { Group } from '../../../shared/models/users/group';
import { BaseModel } from '../../../shared/models/base/base-model';
import { BaseProjectableModel } from 'app/site/base/base-projectable-model';
export class ViewUser extends BaseViewModel {
export class ViewUser extends BaseProjectableModel {
private _user: User;
private _groups: Group[];
@ -105,6 +105,18 @@ export class ViewUser extends BaseViewModel {
this._groups = groups;
}
public getProjectionDefaultName(): string {
return 'users';
}
public getNameForSlide(): string {
return User.COLLECTIONSTRING;
}
public isStableSlide(): boolean {
return true;
}
/**
* required by BaseViewModel. Don't confuse with the users title.
*/

View File

@ -0,0 +1,21 @@
import { SlideManifest } from './slide-manifest';
/**
* Here, all slides has to be registered.
*/
export const allSlides: SlideManifest[] = [
{
slideName: 'motions/motion',
path: 'motions/motion',
loadChildren: './slides/motions/motion/motions-motion-slide.module#MotionsMotionSlideModule',
scaleable: true,
scrollable: true
},
{
slideName: 'users/user',
path: 'users/user',
loadChildren: './slides/users/user/users-user-slide.module#UsersUserSlideModule',
scaleable: false,
scrollable: false
}
];

View File

@ -0,0 +1,16 @@
import { Input } from '@angular/core';
import { SlideData } from 'app/site/projector/services/projector-data.service';
/**
* Every slide has to extends this base class. It forces the slides
* to have an input for the slidedata.
*/
export abstract class BaseSlideComponent<T extends object> {
/**
* Each slide must take slide data.
*/
@Input()
public data: SlideData<T>;
public constructor() {}
}

View File

@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/compiler/src/core';
import { Type } from '@angular/core';
import { SharedModule } from 'app/shared/shared.module';
import { SLIDE } from './slide-token';
import { BaseSlideComponent } from './base-slide-component';
/**
* Generates the configuration for a slide module.
*
* @param slideComponent The component
* @return the Module configuration fo rthe slide module.
*/
export function makeSlideModule<T extends BaseSlideComponent<object>>(slideComponent: Type<T>): NgModule {
return {
imports: [CommonModule, SharedModule],
declarations: [slideComponent],
providers: [{ provide: SLIDE, useValue: slideComponent }],
entryComponents: [slideComponent]
};
}

View File

@ -0,0 +1,3 @@
export interface MotionsMotionSlideData {
test: string;
}

View File

@ -0,0 +1,4 @@
<div>
Motion Slide
<h1>TEST</h1>
</div>

View File

@ -0,0 +1,3 @@
div {
background-color: red;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MotionsMotionSlideComponent } from './motions-motion-slide.component';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('MotionsMotionSlideComponent', () => {
let component: MotionsMotionSlideComponent;
let fixture: ComponentFixture<MotionsMotionSlideComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [MotionsMotionSlideComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MotionsMotionSlideComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { BaseSlideComponent } from 'app/slides/base-slide-component';
import { MotionsMotionSlideData } from './motions-motion-slide-model';
@Component({
selector: 'os-motions-motion-slide',
templateUrl: './motions-motion-slide.component.html',
styleUrls: ['./motions-motion-slide.component.scss']
})
export class MotionsMotionSlideComponent extends BaseSlideComponent<MotionsMotionSlideData> implements OnInit {
public constructor() {
super();
}
public ngOnInit(): void {
console.log('Hello from motion slide');
}
}

View File

@ -0,0 +1,13 @@
import { MotionsMotionSlideModule } from './motions-motion-slide.module';
describe('MotionsMotionSlideModule', () => {
let motionsMotionSlideModule: MotionsMotionSlideModule;
beforeEach(() => {
motionsMotionSlideModule = new MotionsMotionSlideModule();
});
it('should create an instance', () => {
expect(motionsMotionSlideModule).toBeTruthy();
});
});

View File

@ -0,0 +1,7 @@
import { NgModule } from '@angular/core';
import { MotionsMotionSlideComponent } from './motions-motion-slide.component';
import { makeSlideModule } from 'app/slides/base-slide-module';
@NgModule(makeSlideModule(MotionsMotionSlideComponent))
export class MotionsMotionSlideModule {}

View File

@ -0,0 +1,78 @@
import { Injectable, Inject, Injector, NgModuleFactoryLoader, ComponentFactory, Type } from '@angular/core';
import { SlideManifest, SlideOptions } from '../slide-manifest';
import { SLIDE } from '../slide-token';
import { SLIDE_MANIFESTS } from '../slide-manifest';
import { BaseSlideComponent } from '../base-slide-component';
/**
* Cares about loading slides dynamically.
*/
@Injectable()
export class DynamicSlideLoader {
public constructor(
@Inject(SLIDE_MANIFESTS) private manifests: SlideManifest[],
private loader: NgModuleFactoryLoader,
private injector: Injector
) {}
/**
* Searches the manifest for the given slide name.
*
* TODO: Improve by loading all manifests in an object with the
* slide name as keys in the constructor. It's just a lookup here, then.
*
* @param slideName The slide to look up.
* @returns the slide's manifest.
*/
private getManifest(slideName: string): SlideManifest {
const manifest = this.manifests.find(m => m.slideName === slideName);
if (!manifest) {
throw new Error(`Could not find slide for "${slideName}"`);
}
return manifest;
}
/**
* Get slide options for a given slide.
*
* @param slideName The slide
* @returns SlideOptions for the requested slide.
*/
public getSlideOptions(slideName: string): SlideOptions {
return this.getManifest(slideName);
}
/**
* Asynchronically load the slide's component factory, which is used to create
* the slide component.
*
* @param slideName The slide to search.
*/
public async getSlideFactory<T extends BaseSlideComponent<object>>(
slideName: string
): Promise<ComponentFactory<T>> {
const manifest = this.getManifest(slideName);
// Load the module factory.
return this.loader.load(manifest.loadChildren).then(ngModuleFactory => {
// create the module
const moduleRef = ngModuleFactory.create(this.injector);
// Get the slide provided by the SLIDE-injectiontoken.
let dynamicComponentType: Type<T>;
try {
// Read from the moduleRef injector and locate the dynamic component type
dynamicComponentType = moduleRef.injector.get(SLIDE);
} catch (e) {
console.log(
'The module for Slide "' + slideName + '" is not configured right: Make usage of makeSlideModule.'
);
throw e;
}
// Resolve this component factory
return moduleRef.componentFactoryResolver.resolveComponentFactory<T>(dynamicComponentType);
});
}
}

Some files were not shown because too many files have changed in this diff Show More