@paperbits/core
Version:
Paperbits core components.
680 lines (551 loc) • 22.4 kB
text/typescript
import * as _ from "lodash";
import * as ko from "knockout";
import * as Arrays from "@paperbits/common/arrays";
import * as Html from "@paperbits/common/html";
import template from "./defaultViewManager.html";
import "@paperbits/common/extensions";
import { Bag, Keys } from "@paperbits/common";
import { EventManager, Events, GlobalEventHandler } from "@paperbits/common/events";
import { IComponent, View, ViewManager, ICommand, ViewManagerMode, IHighlightConfig, IContextCommandSet, ISplitterConfig, Toast, IContextCommand } from "@paperbits/common/ui";
import { Router } from "@paperbits/common/routing";
import { DragSession } from "@paperbits/common/ui/draggables";
import { IWidgetBinding } from "@paperbits/common/editing";
import { Component, OnMounted, OnDestroyed } from "@paperbits/common/ko/decorators";
import { RoleModel, BuiltInRoles } from "@paperbits/common/user";
import { DesignerUserService } from "./designerUserService";
import { ViewStack } from "@paperbits/common/ui/viewStack";
import { ISettingsProvider } from "@paperbits/common/configuration";
declare let uploadDialog: HTMLInputElement;
const defaultEditorWidth = 320;
export class DefaultViewManager implements ViewManager {
private contextualCommandsBag: Bag<IContextCommandSet> = {};
private pointerX: number;
private pointerY: number;
public readonly designTime: ko.Observable<boolean>;
public readonly previewable: ko.Observable<boolean>;
public readonly block: ko.Computed<boolean>;
public readonly journey: ko.ObservableArray<View>;
public readonly journeyName: ko.Computed<string>;
public readonly toasts: ko.ObservableArray<Toast>;
public readonly primaryToolboxVisible: ko.Observable<boolean>;
public readonly activeView: ko.Observable<View>;
public readonly contextualCommands: ko.ObservableArray<IContextCommandSet>;
public readonly highlightedElement: ko.Observable<IHighlightConfig>;
public readonly splitterElement: ko.Observable<ISplitterConfig>;
public readonly selectedElement: ko.Observable<IHighlightConfig>;
public readonly selectedElementContextualEditor: ko.Observable<IContextCommandSet>;
public readonly viewport: ko.Observable<string>;
public readonly rolesScope: ko.ObservableArray<RoleModel>;
public readonly host: ko.Observable<IComponent>;
public readonly shutter: ko.Observable<boolean>;
public readonly dragSession: ko.Observable<DragSession>;
public readonly locale: ko.Observable<string>;
public readonly canPreview: ko.Computed<boolean>;
public readonly canGoBack: ko.Computed<boolean>;
public readonly websitePreviewEnabled: ko.Observable<boolean>;
public readonly activeLayer: ko.Observable<string>;
public mode: ViewManagerMode;
public hostDocument: Document;
private previousMode: ViewManagerMode;
private previousHost: IComponent;
constructor(
private readonly eventManager: EventManager,
private readonly globalEventHandler: GlobalEventHandler,
private readonly designerUserService: DesignerUserService,
private readonly settingsProvider: ISettingsProvider,
private readonly router: Router,
private readonly viewStack: ViewStack
) {
this.designTime = ko.observable(false);
this.previewable = ko.observable(true);
this.activeLayer = ko.observable();
this.block = ko.computed(() => {
return this.designTime() && this.previewable();
});
this.mode = ViewManagerMode.selecting;
this.toasts = ko.observableArray<Toast>();
this.journey = ko.observableArray<View>();
this.journeyName = ko.pureComputed<string>(() => {
if (this.journey().length === 0) {
return null;
}
return this.journey()[0].heading;
});
this.activeView = ko.observable<View>();
this.contextualCommands = ko.observableArray<IContextCommandSet>([]);
this.highlightedElement = ko.observable<IHighlightConfig>();
this.splitterElement = ko.observable<ISplitterConfig>();
this.selectedElement = ko.observable<IHighlightConfig>();
this.selectedElementContextualEditor = ko.observable<IContextCommandSet>();
this.viewport = ko.observable<string>("xl");
this.locale = ko.observable<string>("en-us");
this.rolesScope = ko.observableArray([BuiltInRoles.anonymous]);
this.host = ko.observable<IComponent>();
this.shutter = ko.observable(true);
this.dragSession = ko.observable();
this.primaryToolboxVisible = ko.observable(false);
this.websitePreviewEnabled = ko.observable(false);
this.canPreview = ko.pureComputed<boolean>(() => this.websitePreviewEnabled() && this.host()?.name === "page-host");
this.canGoBack = ko.pureComputed<boolean>(() => ["style-guide", "layout-host"].includes(this.host()?.name));
}
public async initialize(): Promise<void> {
this.globalEventHandler.addDragEnterListener(this.hideToolboxes.bind(this));
this.globalEventHandler.addDragDropListener(this.onDragEnd.bind(this));
this.globalEventHandler.addDragEndListener(this.onDragEnd.bind(this));
this.globalEventHandler.addDragLeaveScreenListener(this.showToolboxes.bind(this));
this.eventManager.addEventListener(Events.VirtualDragEnd, this.onDragEnd.bind(this));
this.router.addRouteChangeListener(this.onRouteChange.bind(this));
this.globalEventHandler.appendDocument(document);
this.eventManager.addEventListener("onTopLevelEscape", this.onEscape.bind(this));
this.eventManager.addEventListener("onKeyDown", this.onKeyDown.bind(this));
this.eventManager.addEventListener("onKeyUp", this.onKeyUp.bind(this));
const websitePreviewEnabled = await this.settingsProvider.getSetting<boolean>("features/preview");
this.websitePreviewEnabled(websitePreviewEnabled || false);
}
public onHostDocumentCreated(hostDocument: Document): void {
this.hostDocument = hostDocument;
this.hostDocument.addEventListener(Events.MouseMove, this.onPointerMove, true);
}
public onHostDocumentDisposed(): void {
this.hostDocument = null;
this.hostDocument.removeEventListener(Events.MouseMove, this.onPointerMove, true);
}
private onKeyDown(event: KeyboardEvent): void {
if (event.ctrlKey && event.key === Keys.F10) {
if (this.mode === ViewManagerMode.preview) {
this.disablePreviewMode();
}
else {
this.enablePreviewMode();
}
}
if (this.getActiveView()) {
return;
}
if (!event.ctrlKey && !event.metaKey) {
return;
}
this.designTime(false);
this.clearSelection();
}
private onKeyUp(event: KeyboardEvent): void {
if (this.getActiveView()) {
return;
}
if (event.ctrlKey || event.metaKey) {
return;
}
if (this.mode === ViewManagerMode.preview) {
return;
}
this.designTime(true);
}
public setHost(component: IComponent, canGoBack: boolean = false): void {
const currentComponent = this.host();
this.previousHost = canGoBack ? currentComponent : null;
if (currentComponent && currentComponent.name === component.name && !currentComponent.params) {
return;
}
this.clearContextualCommands();
this.host(component);
this.previewable(component.name !== "style-guide");
}
private onPointerMove(event: PointerEvent): void {
this.pointerX = event.clientX;
this.pointerY = event.clientY;
}
public getPointerPosition(): any {
return { x: this.pointerX, y: this.pointerY };
}
public getHost(): IComponent {
return this.host();
}
public getDesignerDocument(): Document {
return document;
}
public getHostDocument(): Document {
return this.hostDocument;
}
private onRouteChange(): void {
this.clearContextualCommands();
this.closeView();
}
public getCurrentJourney(): string {
return this.journeyName();
}
public addToast(title: string, content: string, commands?: ICommand[]): Toast {
const toast = new Toast(title, content, "info", null, commands);
this.toasts.push(toast);
return toast;
}
public removeToast(toast: Toast): void {
this.toasts.splice(this.toasts().indexOf(toast), 1);
}
public notifySuccess(title: string, content: string): Toast {
const toast = new Toast(title, content, "success");
this.toasts.push(toast);
this.scheduleToastRemoval(toast);
return toast;
}
public notifyInfo(title: string, content: string, commands?: ICommand[]): Toast {
const toast = new Toast(title, content, "info", null, commands);
this.toasts.push(toast);
this.scheduleToastRemoval(toast);
return toast;
}
public notifyError(title: string, content: string): Toast {
const toast = new Toast(title, content, "error");
this.toasts.push(toast);
this.scheduleToastRemoval(toast, 15000);
return toast;
}
public notifyProgress(promise: Promise<any>, title: string, content: string): Toast {
const toast = new Toast(title, content);
this.toasts.push(toast);
if (promise["progress"]) {
promise["progress"](toast.progress);
}
promise.then(() => {
toast.progress(100);
}).catch(() => {
toast.state("error");
}).finally(() => {
this.scheduleToastRemoval(toast);
});
return toast;
}
public updateJourneyComponent(view: View): void {
let journey = this.journey();
const existingComponent = journey.find(c => { return c.component.name === view.component.name; });
if (existingComponent) {
journey = journey.splice(0, journey.indexOf(existingComponent));
}
journey.push(view);
this.journey(journey);
}
public clearJourney(): void {
this.journey([]);
this.activeView(null);
this.viewStack.clear();
}
public hideToolboxes(): void {
this.journey([]);
this.primaryToolboxVisible(false);
if (this.mode !== ViewManagerMode.preview) {
this.mode = ViewManagerMode.dragging;
}
this.clearContextualCommands();
}
public showToolboxes(): void {
this.primaryToolboxVisible(true);
this.mode = ViewManagerMode.selecting;
}
public openViewAsWorkshop(view: View): void {
this.viewStack.clear();
this.clearContextualCommands();
this.updateJourneyComponent(view);
this.mode = ViewManagerMode.configure;
}
/**
* Deletes specified view and all views after it in the stack.
* @param view View
*/
public closeWorkshop(view: View | string): void {
const journey = this.journey();
let viewToClose;
if (typeof view === "string") {
viewToClose = journey.find(x => x.component.name === view);
}
else {
viewToClose = view;
}
const indexOfClosingView = journey.indexOf(viewToClose);
if (indexOfClosingView < 0) {
return;
}
journey.splice(indexOfClosingView);
this.journey(journey);
this.mode = ViewManagerMode.selecting;
}
private scheduleToastRemoval(toast: Toast, displayDuration: number = 8000): void {
setTimeout(() => {
this.toasts(_.without(this.toasts(), toast));
}, displayDuration);
}
public openUploadDialog(...accept: string[]): Promise<File[]> {
uploadDialog.accept = accept?.join(",");
uploadDialog.click();
return new Promise<File[]>((resolve) => {
uploadDialog.onchange = () => {
const selectedFiles = Arrays.coerce<File>(uploadDialog.files);
uploadDialog.value = "";
resolve(selectedFiles);
};
});
}
public openViewAsPopup(view: View): void {
this.viewStack.clear();
if (this.activeView() === view) {
return;
}
if (!view.name) {
view.name = `${view.component.name}-view`;
}
view.hitTest = (el) => { // TODO: Move to bindingHandler
return !!Html.closest(el, (x: HTMLElement) =>
(x.getAttribute && !!x.getAttribute(Html.Attributes.ContentEditable)) || // TODO: Move hitTest check to text editor
(x?.classList && Arrays.coerce(x.classList).includes("toolbox")));
};
view.close = () => this.closeView();
if (view.component.params) {
view.component.params.onClose = () => this.closeView();
}
if (!view.resizing) {
view.resizing = {
directions: "vertically horizontally",
initialWidth: defaultEditorWidth
}
}
else {
if (typeof view.resizing === "string" && view.name !== "text-block-editor") {
view.resizing = {
directions: view.resizing,
initialWidth: defaultEditorWidth
};
}
}
if (!view.positioning) {
view.positioning = {
top: 20,
left: 150
}
}
this.clearContextualCommands();
this.closeView();
this.activeView(view);
this.mode = ViewManagerMode.configure;
if (view.component.name === "text-block-editor") {
this.designTime(false); // Review: It's here for text editor
}
this.viewStack.pushView(view);
}
public getActiveView(): View {
return this.activeView();
}
public onEscape(): void {
const host = this.host();
if (this.viewStack.getViews().length === 0 && this.journey().length > 0) {
const journey = this.journey();
journey.pop();
this.journey(journey);
return;
}
if (!this.getActiveView() && this.journey().length === 0 && host && host.name !== "page-host") {
this.setHost({ name: "page-host" }); // TODO: Get host type by current route.
}
}
public closeEditors(): void {
this.closeView();
this.clearJourney();
}
public openWidgetEditor(binding: IWidgetBinding<any, any>): void {
if (!binding.editor) {
return;
}
let editorComponentName: string;
if (typeof binding.editor === "string") {
editorComponentName = binding.editor;
}
else {
const registration = Reflect.getMetadata("paperbits-component", binding.editor);
editorComponentName = registration?.name;
}
const view: View = {
name: `${binding.name}-editor`,
component: {
name: editorComponentName,
definition: binding.editor,
binder: binding.editorComponentBinder,
params: {
model: binding.model,
onChange: binding.applyChanges
}
},
scrolling: "editorScrolling" in binding ? <boolean>binding.editorScrolling : true,
heading: binding.displayName,
resizing: <string>binding.editorResizing,
returnFocusTo: document.getElementById("contentEditor")
};
this.openViewAsPopup(view);
this.eventManager.dispatchEvent(Events.HintRequest, {
key: "fe7c",
content: `You can resize and move almost every editor, and it will remember its size and position.`
});
}
public closeView(): void {
if (this.mode === ViewManagerMode.preview) {
return;
}
const view = this.activeView();
if (view) {
this.viewStack.removeView(view);
if (view.onClose) {
view.onClose();
}
}
this.activeView(null);
this.eventManager.dispatchEvent("onViewClose");
this.clearContextualCommands();
this.mode = ViewManagerMode.selecting;
this.primaryToolboxVisible(true);
this.designTime(true);
}
public setContextualCommands(widgetName: string, contextualCommandSet: IContextCommandSet): void {
this.contextualCommandsBag[widgetName] = contextualCommandSet;
const commands = Object.keys(this.contextualCommandsBag).map(key => this.contextualCommandsBag[key]);
this.contextualCommands(commands);
}
public removeContextualCommands(editorName: string): void {
if (!this.contextualCommandsBag[editorName]) {
return;
}
delete this.contextualCommandsBag[editorName];
const editors = Object.keys(this.contextualCommandsBag).map(key => this.contextualCommandsBag[key]);
this.contextualCommands(editors);
}
public clearContextualCommands(): void {
if (this.mode === ViewManagerMode.configure) {
return;
}
this.contextualCommandsBag = {};
this.contextualCommands([]);
this.highlightedElement(null);
this.setSplitter(null);
this.selectedElement(null);
this.selectedElementContextualEditor(null);
if (this.mode !== ViewManagerMode.preview) {
this.designTime(true);
this.mode = ViewManagerMode.selecting;
}
}
public setHighlight(config: IHighlightConfig): void {
if (this.mode === ViewManagerMode.preview) {
return;
}
this.highlightedElement(null);
this.setSplitter(null);
this.highlightedElement(config);
}
public setSplitter(config: ISplitterConfig): void {
if (this.mode === ViewManagerMode.preview) {
return;
}
this.splitterElement(null);
this.splitterElement(config);
}
public setSelectedElement(config: IHighlightConfig, contextualCommand: IContextCommandSet): void {
if (this.mode === ViewManagerMode.preview) {
return;
}
this.clearContextualCommands();
this.closeView();
this.selectedElement(null);
this.selectedElement(config);
this.selectedElementContextualEditor(contextualCommand);
if (this.mode !== ViewManagerMode.configure) {
this.mode = ViewManagerMode.selected;
}
this.clearJourney();
}
public clearSelection(): void {
this.selectedElement(null);
}
public getSelectedElement(): IHighlightConfig {
return this.selectedElement();
}
public setViewport(viewport: string): void {
this.clearContextualCommands();
this.viewport(viewport);
}
public getViewport(): string {
return this.viewport();
}
public setViewRoles(roles: RoleModel[]): void {
const roleKeys = roles.map(role => role.key);
this.rolesScope(roles);
this.designerUserService.setUserRoles(roleKeys);
this.eventManager.dispatchEvent("onUserRoleChange", roleKeys);
}
public getViewRoles(): RoleModel[] {
return this.rolesScope();
}
public setShutter(): void {
this.previousMode = this.mode;
this.mode = ViewManagerMode.pause;
this.shutter(true);
}
public removeShutter(): void {
this.mode = this.previousMode;
this.shutter(false);
}
public pauseContextualCommands(...except: IContextCommand[]): void {
this.mode = ViewManagerMode.pause;
this.highlightedElement(null);
}
public resumeContextualCommands(): void {
this.mode = ViewManagerMode.selecting;
this.designTime(true);
this.clearContextualCommands();
}
public beginDrag(session: DragSession): void {
this.clearContextualCommands();
this.closeView();
this.dragSession(session);
this.hideToolboxes();
}
public getDragSession(): DragSession {
return this.dragSession();
}
public onDragEnd(): void {
if (this.mode !== ViewManagerMode.preview) {
this.showToolboxes();
}
this.highlightedElement(null);
this.selectedElement(null);
}
public enablePreviewMode(): void {
this.previousMode = this.mode;
this.clearJourney();
this.hideToolboxes();
this.designTime(false);
this.toasts().forEach(toast => {
this.removeToast(toast);
});
this.mode = ViewManagerMode.preview;
}
public disablePreviewMode(): void {
this.showToolboxes();
this.designTime(true);
this.mode = ViewManagerMode.selecting;
}
public returnToContentEditing(): void {
if (this.previousHost) {
this.setHost(this.previousHost);
}
this.designTime(true);
this.mode = ViewManagerMode.selecting;
}
public getActiveLayer(): string {
return this.activeLayer();
}
public setActiveLayer(layerName: string): void {
this.activeLayer(layerName);
}
public dispose(): void { }
}