Implement UI Website (#2087)

* Implement UI Website system

* Add UIWebsite documentation

* Implement review fixes

* Add getAll function on UIWebsite

Co-authored-by: Alexis Faizeau <a.faizeau@workadventu.re>
This commit is contained in:
Alexis Faizeau
2022-05-04 18:30:31 +02:00
committed by GitHub
parent d47aad2ead
commit 9c5fcd2fd8
20 changed files with 1514 additions and 16 deletions
+1
View File
@@ -5,3 +5,4 @@ dist/
*.sh
!templater.sh
/public/iframe_api.js
/public/es.js
+2 -2
View File
@@ -21,7 +21,7 @@
"lint-staged": "^12.3.7",
"npm-run-all": "^4.1.5",
"prettier": "^2.3.1",
"prettier-plugin-svelte": "^2.5.0",
"prettier-plugin-svelte": "^2.7.0",
"sass": "^1.49.7",
"svelte": "^3.46.3",
"svelte-check": "^2.1.0",
@@ -95,4 +95,4 @@
"yarn run pretty"
]
}
}
}
+17
View File
@@ -39,6 +39,7 @@ import type { RemotePlayerClickedEvent } from "./RemotePlayerClickedEvent";
import { isAddActionsMenuKeyToRemotePlayerEvent } from "./AddActionsMenuKeyToRemotePlayerEvent";
import type { ActionsMenuActionClickedEvent } from "./ActionsMenuActionClickedEvent";
import { isRemoveActionsMenuKeyFromRemotePlayerEvent } from "./RemoveActionsMenuKeyFromRemotePlayerEvent";
import { isCreateUIWebsiteEvent, isModifyUIWebsiteEvent, isUIWebsite } from "./ui/UIWebsite";
export interface TypedMessageEvent<T> extends MessageEvent {
data: T;
@@ -152,6 +153,10 @@ export const isIframeEventWrapper = z.union([
type: z.literal("modifyEmbeddedWebsite"),
data: isEmbeddedWebsiteEvent,
}),
z.object({
type: z.literal("modifyUIWebsite"),
data: isModifyUIWebsiteEvent,
}),
]);
export type IframeEvent = z.infer<typeof isIframeEventWrapper>;
@@ -261,6 +266,18 @@ export const iframeQueryMapTypeGuards = {
query: isMovePlayerToEventConfig,
answer: isMovePlayerToEventAnswer,
},
openUIWebsite: {
query: isCreateUIWebsiteEvent,
answer: isUIWebsite,
},
closeUIWebsite: {
query: z.string(),
answer: z.undefined(),
},
getUIWebsites: {
query: z.undefined(),
answer: z.array(isUIWebsite),
},
};
type IframeQueryMapTypeGuardsType = typeof iframeQueryMapTypeGuards;
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
const regexUnit = /-*\d+(px|em|%|cm|in|pc|pt|mm|ex|vw|vh|rem)|auto|inherit/;
// Parse the string to check if is a valid CSS unit (px,%,vw,vh...)
export const isUIWebsiteCSSValue = z.string().regex(regexUnit);
export type UIWebsiteCSSValue = z.infer<typeof isUIWebsiteCSSValue>;
export const isUIWebsiteMargin = z.object({
top: isUIWebsiteCSSValue.optional(),
bottom: isUIWebsiteCSSValue.optional(),
left: isUIWebsiteCSSValue.optional(),
right: isUIWebsiteCSSValue.optional(),
});
export type UIWebsiteMargin = z.infer<typeof isUIWebsiteMargin>;
export const isViewportPositionVertical = z.enum(["top", "middle", "bottom"]);
export type ViewportPositionVertical = z.infer<typeof isViewportPositionVertical>;
export const isViewportPositionHorizontal = z.enum(["left", "middle", "right"]);
export type ViewportPositionHorizontal = z.infer<typeof isViewportPositionHorizontal>;
export const isUIWebsitePosition = z.object({
vertical: isViewportPositionVertical,
horizontal: isViewportPositionHorizontal,
});
export type UIWebsitePosition = z.infer<typeof isUIWebsitePosition>;
export const isUIWebsiteSize = z.object({
height: isUIWebsiteCSSValue,
width: isUIWebsiteCSSValue,
});
export type UIWebsiteSize = z.infer<typeof isUIWebsiteSize>;
export const isCreateUIWebsiteEvent = z.object({
url: z.string(),
visible: z.optional(z.boolean()),
allowApi: z.optional(z.boolean()),
allowPolicy: z.optional(z.string()),
position: isUIWebsitePosition,
size: isUIWebsiteSize,
margin: isUIWebsiteMargin.optional(),
});
export type CreateUIWebsiteEvent = z.infer<typeof isCreateUIWebsiteEvent>;
export const isModifyUIWebsiteEvent = z.object({
id: z.string(),
url: z.string().optional(),
visible: z.boolean().optional(),
position: isUIWebsitePosition.optional(),
size: isUIWebsiteSize.optional(),
margin: isUIWebsiteMargin.optional(),
});
export type ModifyUIWebsiteEvent = z.infer<typeof isModifyUIWebsiteEvent>;
export const isUIWebsite = z.object({
id: z.string(),
url: z.string(),
visible: z.boolean(),
allowApi: z.boolean(),
allowPolicy: z.string(),
position: isUIWebsitePosition,
size: isUIWebsiteSize,
margin: isUIWebsiteMargin.optional(),
});
export type UIWebsite = z.infer<typeof isUIWebsite>;
+6
View File
@@ -35,6 +35,7 @@ import type { RemotePlayerClickedEvent } from "./Events/RemotePlayerClickedEvent
import { AddActionsMenuKeyToRemotePlayerEvent } from "./Events/AddActionsMenuKeyToRemotePlayerEvent";
import type { ActionsMenuActionClickedEvent } from "./Events/ActionsMenuActionClickedEvent";
import { RemoveActionsMenuKeyFromRemotePlayerEvent } from "./Events/RemoveActionsMenuKeyFromRemotePlayerEvent";
import { ModifyUIWebsiteEvent } from "./Events/ui/UIWebsite";
type AnswererCallback<T extends keyof IframeQueryMap> = (
query: IframeQueryMap[T]["query"],
@@ -112,6 +113,9 @@ class IframeListener {
private readonly _modifyEmbeddedWebsiteStream: Subject<ModifyEmbeddedWebsiteEvent> = new Subject();
public readonly modifyEmbeddedWebsiteStream = this._modifyEmbeddedWebsiteStream.asObservable();
private readonly _modifyUIWebsiteStream: Subject<ModifyUIWebsiteEvent> = new Subject();
public readonly modifyUIWebsiteStream = this._modifyUIWebsiteStream.asObservable();
private readonly iframes = new Set<HTMLIFrameElement>();
private readonly iframeCloseCallbacks = new Map<HTMLIFrameElement, (() => void)[]>();
private readonly scripts = new Map<string, HTMLIFrameElement>();
@@ -276,6 +280,8 @@ class IframeListener {
this._setTilesStream.next(iframeEvent.data);
} else if (iframeEvent.type == "modifyEmbeddedWebsite") {
this._modifyEmbeddedWebsiteStream.next(iframeEvent.data);
} else if (iframeEvent.type == "modifyUIWebsite") {
this._modifyUIWebsiteStream.next(iframeEvent.data);
} else if (iframeEvent.type == "registerMenu") {
const dataName = iframeEvent.data.name;
this.iframeCloseCallbacks.get(iframe)?.push(() => {
+338
View File
@@ -0,0 +1,338 @@
import {
CreateUIWebsiteEvent,
UIWebsiteCSSValue,
UIWebsiteMargin,
UIWebsitePosition,
UIWebsiteSize,
ViewportPositionHorizontal,
ViewportPositionVertical,
UIWebsite as UIWebsiteCore,
} from "../../Events/ui/UIWebsite";
import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "../IframeApiContribution";
class UIWebsitePositionInternal {
private readonly website: UIWebsite;
private _vertical: ViewportPositionVertical;
private _horizontal: ViewportPositionHorizontal;
constructor(uiWebsite: UIWebsite, position: UIWebsitePosition) {
this.website = uiWebsite;
this._vertical = position.vertical;
this._horizontal = position.horizontal;
}
public get vertical() {
return this._vertical;
}
public set vertical(vertical: ViewportPositionVertical) {
this._vertical = vertical;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.website.id,
position: {
vertical: this._vertical,
horizontal: this._horizontal,
},
},
});
}
public get horizontal() {
return this._horizontal;
}
public set horizontal(horizontal: ViewportPositionHorizontal) {
this._horizontal = horizontal;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.website.id,
position: {
vertical: this._vertical,
horizontal: this._horizontal,
},
},
});
}
}
class UIWebsiteSizeInternal {
private readonly website: UIWebsite;
private _height: UIWebsiteCSSValue;
private _width: UIWebsiteCSSValue;
constructor(uiWebsite: UIWebsite, size: UIWebsiteSize) {
this.website = uiWebsite;
this._height = size.height;
this._width = size.width;
}
public get height() {
return this._height;
}
public set height(height: UIWebsiteCSSValue) {
this._height = height;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.website.id,
size: {
height: this._height,
width: this._width,
},
},
});
}
public get width() {
return this._height;
}
public set width(width: UIWebsiteCSSValue) {
this._width = width;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.website.id,
size: {
height: this._height,
width: this._width,
},
},
});
}
}
class UIWebsiteMarginInternal {
private readonly website: UIWebsite;
private _top?: UIWebsiteCSSValue;
private _bottom?: UIWebsiteCSSValue;
private _left?: UIWebsiteCSSValue;
private _right?: UIWebsiteCSSValue;
constructor(uiWebsite: UIWebsite, margin: UIWebsiteMargin) {
this.website = uiWebsite;
this._top = margin.top;
this._bottom = margin.bottom;
this._left = margin.left;
this._right = margin.right;
}
public get top() {
return this._top;
}
public set top(top: UIWebsiteCSSValue | undefined) {
this._top = top;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.website.id,
margin: {
top: this._top,
},
},
});
}
public get bottom() {
return this._bottom;
}
public set bottom(bottom: UIWebsiteCSSValue | undefined) {
this._bottom = bottom;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.website.id,
margin: {
bottom: this._bottom,
},
},
});
}
public get left() {
return this._left;
}
public set left(left: UIWebsiteCSSValue | undefined) {
this._left = left;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.website.id,
margin: {
left: this._left,
},
},
});
}
public get right() {
return this._right;
}
public set right(right: UIWebsiteCSSValue | undefined) {
this._right = right;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.website.id,
margin: {
right: this._right,
},
},
});
}
}
export class UIWebsite {
public readonly id: string;
private _url: string;
private _visible: boolean;
private readonly _allowPolicy: string;
private readonly _allowApi: boolean;
private _position: UIWebsitePositionInternal;
private _size: UIWebsiteSizeInternal;
private _margin: UIWebsiteMarginInternal;
constructor(config: UIWebsiteCore) {
this.id = config.id;
this._url = config.url;
this._visible = config.visible ?? true;
this._allowPolicy = config.allowPolicy ?? "";
this._allowApi = config.allowApi ?? false;
this._position = new UIWebsitePositionInternal(this, config.position);
this._size = new UIWebsiteSizeInternal(this, config.size);
this._margin = new UIWebsiteMarginInternal(this, config.margin ?? {});
}
public get url() {
return this._url;
}
public set url(url: string) {
this._url = url;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.id,
url: this._url,
},
});
}
public get visible() {
return this._visible;
}
public set visible(visible: boolean) {
this._visible = visible;
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.id,
visible: this._visible,
},
});
}
public get allowPolicy() {
return this._allowPolicy;
}
public get allowApi() {
return this._allowApi;
}
public get position() {
return this._position;
}
public set position(position: UIWebsitePosition) {
this._position = new UIWebsitePositionInternal(this, position);
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.id,
position: {
vertical: this._position.vertical,
horizontal: this._position.horizontal,
},
},
});
}
public get size() {
return this._size;
}
public set size(size: UIWebsiteSize) {
this._size = new UIWebsiteSizeInternal(this, size);
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.id,
size: {
height: this._size.height,
width: this._size.width,
},
},
});
}
public get margin() {
return this._margin;
}
public set margin(margin: UIWebsiteMargin) {
this._margin = new UIWebsiteMarginInternal(this, margin);
sendToWorkadventure({
type: "modifyUIWebsite",
data: {
id: this.id,
margin: {
top: this._margin.top,
bottom: this._margin.bottom,
left: this._margin.left,
right: this._margin.right,
},
},
});
}
close() {
return queryWorkadventure({
type: "closeUIWebsite",
data: this.id,
});
}
}
export class UIWebsiteCommands extends IframeApiContribution<UIWebsiteCommands> {
callbacks = [];
async open(createUIWebsite: CreateUIWebsiteEvent): Promise<UIWebsite> {
const result = await queryWorkadventure({
type: "openUIWebsite",
data: createUIWebsite,
});
return new UIWebsite(result);
}
async getAll(): Promise<UIWebsite[]> {
const result = await queryWorkadventure({
type: "getUIWebsites",
data: undefined,
});
return result.map((current) => new UIWebsite(current));
}
}
export default new UIWebsiteCommands();
+6
View File
@@ -14,6 +14,8 @@ import {
isActionsMenuActionClickedEvent,
} from "../Events/ActionsMenuActionClickedEvent";
import { Observable, Subject } from "rxjs";
import type { UIWebsiteCommands } from "./Ui/UIWebsite";
import website from "./Ui/UIWebsite";
let popupId = 0;
const popups: Map<number, Popup> = new Map<number, Popup>();
@@ -283,6 +285,10 @@ export class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventure
actionMessages.set(actionMessage.uuid, actionMessage);
return actionMessage;
}
get website(): UIWebsiteCommands {
return website;
}
}
export default new WorkAdventureUiCommands();
+6
View File
@@ -39,6 +39,8 @@
import ActionsMenu from "./ActionsMenu/ActionsMenu.svelte";
import Lazy from "./Lazy.svelte";
import { showDesktopCapturerSourcePicker } from "../Stores/ScreenSharingStore";
import UiWebsiteContainer from "./UI/Website/UIWebsiteContainer.svelte";
import { uiWebsitesStore } from "../Stores/UIWebsiteStore";
let mainLayout: HTMLDivElement;
@@ -128,6 +130,10 @@
{#if hasEmbedScreen}
<EmbedScreensContainer />
{/if}
{#if $uiWebsitesStore}
<UiWebsiteContainer />
{/if}
</section>
<section id="main-layout-baseline">
@@ -0,0 +1,21 @@
<script lang="ts">
import { uiWebsitesStore } from "../../../Stores/UIWebsiteStore";
import UiWebsiteLayer from "./UIWebsiteLayer.svelte";
</script>
<div id="ui-website-container">
{#each $uiWebsitesStore.reverse() as uiWebsite (uiWebsite.id)}
<UiWebsiteLayer {uiWebsite} />
{/each}
</div>
<style lang="scss">
#ui-website-container {
position: absolute;
z-index: 180;
height: 100%;
width: 100%;
top: 0;
left: 0;
}
</style>
@@ -0,0 +1,66 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { UIWebsite } from "../../../Api/Events/ui/UIWebsite";
import { iframeListener } from "../../../Api/IframeListener";
export let uiWebsite: UIWebsite;
let main: HTMLDivElement;
const iframe = document.createElement("iframe");
$: {
iframe.id = `ui-website-${uiWebsite.id}`;
iframe.src = uiWebsite.url;
iframe.title = uiWebsite.url;
iframe.style.border = "0";
iframe.allow = uiWebsite.allowPolicy ?? "";
iframe.style.height = uiWebsite.size.height;
iframe.style.width = uiWebsite.size.width;
iframe.style.visibility = uiWebsite.visible ? "visible" : "hidden";
iframe.style.margin = uiWebsite.margin
? `${uiWebsite.margin.top ? uiWebsite.margin.top : "O"} ${
uiWebsite.margin.right ? uiWebsite.margin.right : "O"
} ${uiWebsite.margin.bottom ? uiWebsite.margin.bottom : "O"} ${
uiWebsite.margin.left ? uiWebsite.margin.left : "O"
}`
: "0";
}
onMount(() => {
main.appendChild(iframe);
if (uiWebsite.allowApi) {
iframeListener.registerIframe(iframe);
}
});
onDestroy(() => {
if (uiWebsite.allowApi) {
iframeListener.unregisterIframe(iframe);
}
});
</script>
<div
bind:this={main}
class="layer"
style:justify-content={uiWebsite.position.horizontal === "middle"
? "center"
: uiWebsite.position.horizontal === "right"
? "end"
: "start"}
style:align-items={uiWebsite.position.vertical === "middle"
? "center"
: uiWebsite.position.vertical === "bottom"
? "end"
: "top"}
/>
<style lang="scss">
.layer {
height: 100%;
width: 100%;
display: flex;
position: absolute;
}
</style>
+16
View File
@@ -102,6 +102,7 @@ import CancelablePromise from "cancelable-promise";
import { Deferred } from "ts-deferred";
import { SuperLoaderPlugin } from "../Services/SuperLoaderPlugin";
import { ErrorScreenMessage, PlayerDetailsUpdatedMessage } from "../../Messages/ts-proto-generated/protos/messages";
import { uiWebsiteManager } from "./UI/UIWebsiteManager";
export interface GameSceneInitInterface {
initPosition: PointInterface | null;
reconnecting: boolean;
@@ -1298,6 +1299,18 @@ ${escapedMessage}
return coWebsiteManager.closeCoWebsites();
});
iframeListener.registerAnswerer("openUIWebsite", (websiteConfig) => {
return uiWebsiteManager.open(websiteConfig);
});
iframeListener.registerAnswerer("getUIWebsites", () => {
return uiWebsiteManager.getAll();
});
iframeListener.registerAnswerer("closeUIWebsite", (websiteId) => {
return uiWebsiteManager.close(websiteId);
});
iframeListener.registerAnswerer("getMapData", () => {
return {
data: this.gameMap.getMap(),
@@ -1594,6 +1607,9 @@ ${escapedMessage}
iframeListener.unregisterAnswerer("getCoWebsites");
iframeListener.unregisterAnswerer("setPlayerOutline");
iframeListener.unregisterAnswerer("setVariable");
iframeListener.unregisterAnswerer("openUIWebsite");
iframeListener.unregisterAnswerer("getUIWebsites");
iframeListener.unregisterAnswerer("closeUIWebsite");
this.sharedVariablesManager?.close();
this.embeddedWebsiteManager?.close();
@@ -0,0 +1,94 @@
import { get } from "svelte/store";
import { CreateUIWebsiteEvent, ModifyUIWebsiteEvent, UIWebsite } from "../../../Api/Events/ui/UIWebsite";
import { iframeListener } from "../../../Api/IframeListener";
import { v4 as uuidv4 } from "uuid";
import { uiWebsitesStore } from "../../../Stores/UIWebsiteStore";
class UIWebsiteManager {
constructor() {
iframeListener.modifyUIWebsiteStream.subscribe((websiteEvent: ModifyUIWebsiteEvent) => {
const website = get(uiWebsitesStore).find((currentWebsite) => currentWebsite.id === websiteEvent.id);
if (!website) {
throw new Error(`Could not find ui website with the id "${websiteEvent.id}" in your map`);
}
if (websiteEvent.url) {
website.url = websiteEvent.url;
}
if (websiteEvent.visible !== undefined) {
website.visible = websiteEvent.visible;
}
if (websiteEvent.position) {
if (websiteEvent.position.horizontal) {
website.position.horizontal = websiteEvent.position.horizontal;
}
if (websiteEvent.position.vertical) {
website.position.vertical = websiteEvent.position.vertical;
}
}
if (websiteEvent.size) {
if (websiteEvent.size.height) {
website.size.height = websiteEvent.size.height;
}
if (websiteEvent.size.width) {
website.size.width = websiteEvent.size.width;
}
}
if (websiteEvent.margin) {
website.margin = {};
if (websiteEvent.margin.top !== undefined) {
website.margin.top = websiteEvent.margin.top;
}
if (websiteEvent.margin.bottom !== undefined) {
website.margin.bottom = websiteEvent.margin.bottom;
}
if (websiteEvent.margin.left !== undefined) {
website.margin.left = websiteEvent.margin.left;
}
if (websiteEvent.margin.right !== undefined) {
website.margin.right = websiteEvent.margin.right;
}
}
});
}
public open(websiteConfig: CreateUIWebsiteEvent): UIWebsite {
const newWebsite: UIWebsite = {
...websiteConfig,
id: uuidv4(),
visible: websiteConfig.visible ?? true,
allowPolicy: websiteConfig.allowPolicy ?? "",
allowApi: websiteConfig.allowApi ?? false,
};
uiWebsitesStore.add(newWebsite);
return newWebsite;
}
public getAll(): UIWebsite[] {
return get(uiWebsitesStore);
}
public close(websiteId: string) {
const uiWebsite = get(uiWebsitesStore).find((currentWebsite) => currentWebsite.id === websiteId);
if (!uiWebsite) {
return;
}
uiWebsitesStore.remove(uiWebsite);
}
}
export const uiWebsiteManager = new UIWebsiteManager();
+20
View File
@@ -0,0 +1,20 @@
import { writable } from "svelte/store";
import { UIWebsite } from "../Api/Events/ui/UIWebsite";
function createUIWebsiteStore() {
const { subscribe, update, set } = writable(Array<UIWebsite>());
set(Array<UIWebsite>());
return {
subscribe,
add: (uiWebsite: UIWebsite) => {
update((currentArray) => [...currentArray, uiWebsite]);
},
remove: (uiWebsite: UIWebsite) => {
update((currentArray) => currentArray.filter((currentWebsite) => currentWebsite.id !== uiWebsite.id));
},
};
}
export const uiWebsitesStore = createUIWebsiteStore();
+4 -4
View File
@@ -2239,10 +2239,10 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier-plugin-svelte@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/prettier-plugin-svelte/-/prettier-plugin-svelte-2.5.0.tgz#7922534729f7febe59b4c56c3f5360539f0d8ab1"
integrity sha512-+iHY2uGChOngrgKielJUnqo74gIL/EO5oeWm8MftFWjEi213lq9QYTOwm1pv4lI1nA61tdgf80CF2i5zMcu1kw==
prettier-plugin-svelte@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/prettier-plugin-svelte/-/prettier-plugin-svelte-2.7.0.tgz#ecfa4fe824238a4466a3497df1a96d15cf43cabb"
integrity sha512-fQhhZICprZot2IqEyoiUYLTRdumULGRvw0o4dzl5jt0jfzVWdGqeYW27QTWAeXhoupEZJULmNoH3ueJwUWFLIA==
prettier@^2.0.2:
version "2.5.1"