ucap-electron/projects/notify-window/src/lib/services/notify-window.service.ts

498 lines
14 KiB
TypeScript
Raw Normal View History

2020-03-27 17:41:40 +09:00
import url from 'url';
import fse from 'fs-extra';
import log from 'electron-log';
import { WebContentsChannel } from '@ucap/electron-core';
import { AnimationQueue } from '../utils/animation-queue';
import {
NotifyWindowOptions,
DefaultNotifyWindowOptions
} from '../models/notify-window-options';
import { screen, BrowserWindow, ipcMain, IpcMainEvent, shell } from 'electron';
import { NotifyWindow } from '../models/notify-window';
import { NotifyWindowEventType } from '../types/event.type';
import { Channel } from '../types/channel.type';
const onClickNotifyWindow = 'onClickNotifyWindow';
const onCloseNotifyWindow = 'onCloseNotifyWindow';
interface ENPoint {
x: number;
y: number;
}
interface ENDimension {
width: number;
height: number;
}
class BrowserWindowPooler {
private readonly inactiveWindows: BrowserWindow[];
constructor(private readonly minSize: number) {
this.minSize = 0 > this.minSize ? 0 : this.minSize;
this.inactiveWindows = [];
}
getLength() {
return this.inactiveWindows.length;
}
push(...items: BrowserWindow[]): number {
const length = this.inactiveWindows.push(...items);
if (this.minSize < length) {
this.start();
}
return length;
}
pop(): BrowserWindow {
if (!this.inactiveWindows || 0 === this.inactiveWindows.length) {
return undefined;
}
return this.inactiveWindows.pop();
}
closeAll() {
while (this.inactiveWindows.length > 0) {
const w = this.inactiveWindows.pop();
w.close();
}
}
private start() {
setTimeout(() => {
while (this.minSize < this.inactiveWindows.length) {
const w = this.inactiveWindows.pop();
w.close();
}
}, 3000);
}
}
export class NotifyWindowService {
private animationQueue: AnimationQueue;
private customOptions: NotifyWindowOptions;
private nextInsertPosition: ENPoint;
private totalDimension: ENDimension;
private firstPosition: ENPoint;
private lowerRightCornerPosition: ENPoint;
private maxVisibleNotifications: number;
private activeNotifications: BrowserWindow[];
private browserWindowPooler: BrowserWindowPooler;
private notificationQueue: NotifyWindow[];
private closedNotifications: Map<number, boolean>;
private latestId: number;
private templateUrl: string;
constructor(options?: NotifyWindowOptions) {
this.customOptions = {
...DefaultNotifyWindowOptions
};
if (!!options) {
this.customOptions = {
...this.customOptions,
...options
};
}
this.setup();
this.setupEvents();
}
setOptions(options: NotifyWindowOptions) {
if (!!options) {
this.customOptions = {
...this.customOptions,
...options
};
}
this.calcDimensions();
}
getOptions(): NotifyWindowOptions {
return this.customOptions;
}
setTemplatePath(templatePath: string) {
if (!!templatePath) {
this.customOptions.templatePath = templatePath;
this.updateTemplatePath();
}
}
getTemplatePath(): string {
if (!this.templateUrl) {
this.updateTemplatePath();
}
return this.templateUrl;
}
notify(notification: NotifyWindow): number {
notification.id = this.latestId++;
this.animationQueue.push({
context: this,
func: this.showNotification,
args: [notification]
});
return notification.id;
}
dispose(): void {
this.animationQueue.clear();
this.activeNotifications.forEach(window => window.close());
this.browserWindowPooler.closeAll();
}
closeAll(): void {
this.animationQueue.clear();
this.activeNotifications.forEach(window => window.close());
this.browserWindowPooler.closeAll();
this.setup();
}
private setup(): void {
this.nextInsertPosition = { x: 0, y: 0 };
this.totalDimension = { width: 0, height: 0 };
this.firstPosition = { x: 0, y: 0 };
this.activeNotifications = [];
this.browserWindowPooler = new BrowserWindowPooler(
this.getOptions().browserWindowPool.min
);
this.notificationQueue = [];
this.closedNotifications = new Map();
this.latestId = 0;
this.animationQueue = new AnimationQueue();
const display = screen.getPrimaryDisplay();
this.lowerRightCornerPosition = {
x: display.bounds.x + display.workArea.x + display.workAreaSize.width,
y: display.bounds.y + display.workArea.y + display.workAreaSize.height
};
this.calcDimensions();
this.maxVisibleNotifications = Math.floor(
display.workAreaSize.height / this.totalDimension.height
);
this.maxVisibleNotifications =
this.getOptions().browserWindowPool.max < this.maxVisibleNotifications
? this.getOptions().browserWindowPool.max
: this.maxVisibleNotifications;
}
private setupEvents(): void {
const self = this;
ipcMain.on(
Channel.close,
(event: IpcMainEvent, windowId: number, notification: NotifyWindow) => {
const onClose = self.buildCloseNotification(
BrowserWindow.fromId(windowId),
notification
);
self.buildCloseNotificationSafely(onClose)('close');
}
);
ipcMain.on(
Channel.click,
(event: IpcMainEvent, windowId: number, notification: NotifyWindow) => {
if (!!notification.url) {
shell.openExternal(notification.url);
}
const notificationWindow = BrowserWindow.fromId(windowId);
if (notificationWindow && notificationWindow[onClickNotifyWindow]) {
const onClose = self.buildCloseNotification(
BrowserWindow.fromId(windowId),
notification
);
notificationWindow[onClickNotifyWindow]({
type: NotifyWindowEventType.Click,
id: notification.id,
close: self.buildCloseNotificationSafely(onClose)
});
delete notificationWindow[onClickNotifyWindow];
}
}
);
}
private calcDimensions() {
this.totalDimension = {
width: this.customOptions.width + this.customOptions.padding,
height: this.customOptions.height + this.customOptions.padding
};
this.firstPosition = {
x: this.lowerRightCornerPosition.x - this.totalDimension.width,
y: this.lowerRightCornerPosition.y - this.totalDimension.height
};
this.nextInsertPosition = {
x: this.firstPosition.x,
y: this.firstPosition.y
};
}
private calcInsertPosition() {
if (this.activeNotifications.length < this.maxVisibleNotifications) {
this.nextInsertPosition.y =
this.lowerRightCornerPosition.y -
this.totalDimension.height * (this.activeNotifications.length + 1);
}
}
private updateTemplatePath() {
try {
fse.statSync(this.customOptions.templatePath).isFile();
this.templateUrl = url.format({
pathname: this.customOptions.templatePath,
protocol: 'file:',
slashes: true
});
} catch (e) {
log.error(
'electron-notify: Could not find template ("' +
this.customOptions.templatePath +
'").'
);
log.error(
'electron-notify: To use a different template you need to correct the config.templatePath or simply adapt config.htmlTemplate'
);
}
}
private showNotification(notification: NotifyWindow): Promise<any> {
const self = this;
return new Promise<any>((resolve, reject) => {
if (this.activeNotifications.length < this.maxVisibleNotifications) {
self.getWindow().then(notificationWindow => {
self.calcInsertPosition();
notificationWindow.setPosition(
self.nextInsertPosition.x,
self.nextInsertPosition.y
);
self.activeNotifications.push(notificationWindow);
const displayTime = !!notification.displayTime
? notification.displayTime
: self.customOptions.displayTime;
let timeoutId: any;
const onClose = self.buildCloseNotification(
notificationWindow,
notification,
() => timeoutId
);
const onCloseNotificationSafely = self.buildCloseNotificationSafely(
onClose
);
timeoutId = setTimeout(() => {
if (notificationWindow.isDestroyed()) {
return;
}
onCloseNotificationSafely('timeout');
}, displayTime);
if (!!notification.onShow) {
notification.onShow({
type: NotifyWindowEventType.Show,
id: notification.id,
close: onCloseNotificationSafely
});
}
if (!!notification.onClick) {
notificationWindow[onClickNotifyWindow] = notification.onClick;
} else {
delete notificationWindow[onClickNotifyWindow];
}
if (!!notification.onClose) {
notificationWindow[onCloseNotifyWindow] = notification.onClose;
} else {
delete notificationWindow[onCloseNotifyWindow];
}
notificationWindow.webContents.send(
Channel.browserWindowSetContents,
notification
);
notificationWindow.showInactive();
resolve(notificationWindow);
});
} else {
self.notificationQueue.push(notification);
resolve();
}
});
}
private buildCloseNotification(
notificationWindow: BrowserWindow,
notification: NotifyWindow,
timeoutIdFunc?: () => number
) {
const self = this;
return (e: NotifyWindowEventType): Promise<void> => {
if (notificationWindow.isDestroyed()) {
return;
}
if (self.closedNotifications.has(notification.id)) {
self.closedNotifications.delete(notification.id);
return new Promise<void>(resolve => {
resolve();
});
} else {
self.closedNotifications.set(notification.id, true);
}
if (!!notificationWindow[onCloseNotifyWindow]) {
notificationWindow[onCloseNotifyWindow]({
type: e,
id: notification.id
});
delete notificationWindow[onCloseNotifyWindow];
}
notificationWindow.webContents.send(Channel.reset);
if (!!timeoutIdFunc) {
clearTimeout(timeoutIdFunc());
}
const i = self.activeNotifications.indexOf(notificationWindow);
self.activeNotifications.splice(i, 1);
self.browserWindowPooler.push(notificationWindow);
notificationWindow.hide();
self.checkForQueuedNotifications();
return self.moveOneDown(i);
};
}
private buildCloseNotificationSafely(
onClose: (e: NotifyWindowEventType) => any
) {
const self = this;
return (reason: any) => {
if (!reason) {
reason = 'closedByAPI';
}
self.animationQueue.push({
context: self,
func: onClose,
args: [reason]
});
};
}
private checkForQueuedNotifications(): void {
if (
0 < this.notificationQueue.length &&
this.activeNotifications.length < this.maxVisibleNotifications
) {
this.animationQueue.push({
context: this,
func: this.showNotification,
args: [this.notificationQueue.shift()]
});
}
}
private getWindow(): Promise<BrowserWindow> {
const slef = this;
return new Promise<BrowserWindow>((resolve, reject) => {
if (0 < slef.browserWindowPooler.getLength()) {
resolve(slef.browserWindowPooler.pop());
} else {
const windowProperties = slef.customOptions.defaultWindow;
windowProperties.width = slef.customOptions.width;
windowProperties.height = slef.customOptions.height;
const notificationWindow = new BrowserWindow({
...windowProperties,
title: 'Notification'
});
notificationWindow.setVisibleOnAllWorkspaces(true);
notificationWindow.loadURL(slef.getTemplatePath());
notificationWindow.webContents.on(
WebContentsChannel.DidFinishLoad,
() => {
// Done
notificationWindow.webContents.send(
Channel.loadConfig,
slef.customOptions
);
resolve(notificationWindow);
}
);
notificationWindow.webContents.on(
WebContentsChannel.DevtoolsOpened,
() => {
notificationWindow.webContents.closeDevTools();
}
);
}
});
}
private moveOneDown(startPos: number): Promise<void> {
const self = this;
return new Promise<void>(async (resolve, reject) => {
if (startPos >= self.activeNotifications.length || -1 === startPos) {
resolve();
return;
}
const aryNotificationPos: number[] = [];
for (let i = startPos; i < self.activeNotifications.length; i++) {
aryNotificationPos.push(i);
}
await Promise.all(
aryNotificationPos.map(async index => {
await self.moveNotificationAnimation(index);
})
);
resolve();
});
}
private moveNotificationAnimation(index: number): Promise<void> {
const self = this;
return new Promise<void>((resolve, reject) => {
const notificationWindow = self.activeNotifications[index];
const newY =
self.lowerRightCornerPosition.y -
self.totalDimension.height * (index + 1);
const startY = notificationWindow.getPosition()[1];
const step = (newY - startY) / self.customOptions.animationSteps;
let curStep = 1;
const animationInterval = setInterval(() => {
// Abort condition
if (curStep === self.customOptions.animationSteps) {
notificationWindow.setPosition(self.firstPosition.x, newY);
clearInterval(animationInterval);
return resolve();
}
// Move one step down
notificationWindow.setPosition(
self.firstPosition.x,
Math.trunc(startY + curStep * step)
);
curStep++;
}, self.customOptions.animationStepMs);
});
}
}