forma-embedded-view-sdk
Version:
The Forma Embedded View SDK is a JavaScript library for creating custom extensions in Autodesk Forma (previously Spacemaker).
452 lines (451 loc) • 16.3 kB
JavaScript
import { AnalysisApi } from "./analysis.js";
import { AreaMetricsApi } from "./areaMetrics.js";
import { AuthApi, FormaAuthProvider } from "./auth/auth.js";
import { ColorbarApi } from "./colorbar.js";
import { ElementsApi } from "./elements.js";
import { ExperimentalApi } from "./experimental/index.js";
import { ExtensionsApi } from "./extensions.js";
import { GeneratorsApi } from "./generators.js";
import { GeoDataApi } from "./geodata.js";
import { GeometryApi } from "./geometry.js";
import { HeightObserver } from "./height-observer.js";
import { IframeMessenger, } from "./iframe-messenger.js";
import { IntegrateApi } from "./integrate.js";
import { LibraryApi } from "./library.js";
import { PredictiveAnalysisApi } from "./predictive-analysis.js";
import { ProjectApi } from "./project.js";
import { ProposalApi } from "./proposal.js";
import { QueuedPubSub } from "./queued-pub-sub.js";
import { CameraApi } from "./scene/camera.js";
import { DesignToolApi } from "./scene/design-tool.js";
import { RenderApi } from "./scene/render.js";
import { SunApi } from "./scene/sun.js";
import { TerrainApi } from "./scene/terrain.js";
import { SelectionApi } from "./selection.js";
import { version } from "./version.js";
export const defaultAllowedOrigins = [
// EU Prod
"https://app.autodeskforma.com",
/^https:\/\/local\.autodeskforma\.com:/,
// US Prod
"https://app.autodeskforma.eu",
/^https:\/\/local\.autodeskforma\.eu:/,
// EU Chaos
"https://app.spacemakerai.eu",
/^https:\/\/local\.spacemakerai\.eu:/,
// Future Autodesk Regions.
// E.g. forma.aus.autodesk.com.
// See https://wiki.autodesk.com/x/sAxTew for more information.
/^https:\/\/(local\.)?forma(\.(stg|dev|sbx|int|uat|qa|prd))?(\.[a-z]{3})?\.autodesk\.com(:|$)/,
];
export function checkAllowedOrigin(origin, allowedOrigins) {
return allowedOrigins.some((allowedOrigin) => typeof allowedOrigin === "string"
? allowedOrigin === origin
: allowedOrigin.test(origin));
}
export class EmbeddedViewSdk {
config;
source = window.parent;
#iframeMessenger;
#authProvider;
origin;
/**
* Experimental APIs are subject to change at any time without notice.
*
* Do not rely on experimental APIs in production code.
*/
experimental;
analysis;
extensions;
elements;
generators;
geometry;
integrateElements;
library;
project;
proposal;
camera;
sun;
terrain;
render;
selection;
areaMetrics;
predictiveAnalysis;
designTool;
auth;
colorbar;
geoData;
#customRequestHandlers = new Map();
#customEventHandlers = new Map();
#customSubscribeHandlers = new Map();
#heightObserver = new HeightObserver({
onHeight: (height) => {
this.#iframeMessenger
.sendRequest("resizeIframeHeight", {
height,
})
.catch((err) => {
console.log("failed to resize iframe", err);
});
},
});
#messagePorts = new QueuedPubSub();
constructor(config) {
this.config = config;
this.origin = config?.origin ?? EmbeddedViewSdk.getHostOrigin();
const allowedOrigins = config?.allowedOrigins ?? defaultAllowedOrigins;
if (!checkAllowedOrigin(this.origin, allowedOrigins)) {
throw new Error(`Unsupported origin: ${this.origin}`);
}
this.#iframeMessenger = new IframeMessenger({
sourceOrigin: this.origin,
source: this.source,
requestResolver: (action) => this.#customRequestHandlers.get(action),
eventResolver: (action) => this.#customEventHandlers.get(action),
subscribeResolver: (name) => this.#customSubscribeHandlers.get(name),
debug: config?.debug,
});
this.#iframeMessenger.connect();
void this.#iframeMessenger.sendEvent("sdk-version", {
version,
});
window.addEventListener("beforeunload", () => {
// Let the host know that we are unloading.
this.#iframeMessenger.disconnect();
});
window.addEventListener("focus", () => {
// Let the host know that we are focused.
void this.#iframeMessenger.sendEvent("window-focused");
});
this.analysis = new AnalysisApi(this.#iframeMessenger);
this.extensions = new ExtensionsApi(this.#iframeMessenger);
this.elements = new ElementsApi(this.#iframeMessenger);
this.generators = new GeneratorsApi(this.#iframeMessenger);
this.geometry = new GeometryApi(this.#iframeMessenger);
this.integrateElements = new IntegrateApi(this.#iframeMessenger);
this.library = new LibraryApi(this.#iframeMessenger);
this.project = new ProjectApi(this.#iframeMessenger);
this.proposal = new ProposalApi(this.#iframeMessenger);
this.camera = new CameraApi(this.#iframeMessenger);
this.sun = new SunApi(this.#iframeMessenger);
this.terrain = new TerrainApi(this.#iframeMessenger);
this.render = new RenderApi(this.#iframeMessenger);
this.selection = new SelectionApi(this.#iframeMessenger);
this.areaMetrics = new AreaMetricsApi(this.#iframeMessenger);
this.predictiveAnalysis = new PredictiveAnalysisApi(this.#iframeMessenger);
this.designTool = new DesignToolApi(this.#iframeMessenger);
this.colorbar = new ColorbarApi(this.#iframeMessenger);
this.geoData = new GeoDataApi(this.#iframeMessenger);
this.experimental = new ExperimentalApi(this.#iframeMessenger);
this.#authProvider = new FormaAuthProvider(this.#iframeMessenger, this.#heightObserver);
this.auth = new AuthApi(this.#authProvider);
this.#customRequestHandlers.set("receive-message-port", (payload) => {
this.#messagePorts.publish(payload);
});
}
async ping() {
return await this.#iframeMessenger.sendRequest("ping");
}
/**
* @hidden
* @internal
*/
getIframeMessenger() {
return this.#iframeMessenger;
}
getProjectId() {
const projectId = new URLSearchParams(window.location.search).get("projectId");
if (!projectId) {
throw new Error("Missing query parameter: projectId");
}
return projectId;
}
getExtensionId() {
const extensionId = new URLSearchParams(window.location.search).get("extensionId");
if (!extensionId) {
throw new Error("Missing query parameter: extensionId");
}
return extensionId;
}
#isRegion(region) {
if (!["EMEA", "US"].includes(region)) {
return false;
}
return true;
}
/**
* Get the region for which the current Forma project is hosted.
* If you're using the Forma API, you need to use this to determine
* the `X-Ads-Region` header value. The possible values are `US` and `EMEA`
* @returns "EMEA" or "US"
*/
getRegion() {
const region = new URLSearchParams(window.location.search).get("region");
if (!region || !this.#isRegion(region)) {
throw new Error("Missing query param or not supported region provided");
}
return region;
}
/**
* Get the preferred presentation unit system for the project.
* It is important to note that all programmatic interfaces operate with metric units.
* This value should be used to determine the default unit system for the UI.
* If this value is imperial, you should convert and dispaly results in imperial units in the UI.
*/
async getPresentationUnitSystem() {
return this.#iframeMessenger.sendRequest("unit-system/get-presentation-unit-system");
}
/**
* Check for access to perform edit operations in the current project.
*
* @example
* ```js
* const canEdit = await Forma.getCanEdit()
* if (canEdit) {
* await Forma.proposal.addElement({ urn })
* } else {
* console.log("User need to have collaborator or admin role to add elements")
* }
* ```
*/
async getCanEdit() {
return await this.#iframeMessenger.sendRequest("access/can-edit");
}
/**
* Check for access to view the current project's hub
* @example
* ```js
* const canViewHub = await Forma.getCanViewHub()
* if (canViewHub) {
* const projectData = await Forma.project.get()
* await Forma.extensions.storage.getTextObject({
* key: "some-key",
* authcontext: projectData.hubId
* })
* }
* ```
*/
async getCanViewHub() {
return await this.#iframeMessenger.sendRequest("access/can-view-hub");
}
/**
* Check for access to edit the current project's hub
* @example
* ```js
* const canViewHub = await Forma.getCanEditHub()
* if (canEditHub) {
* const projectData = await Forma.project.get()
* await Forma.extensions.storage.setObject({
* key: "some-key",
* authcontext: projectData.hubId,
* data: "some awesome data"
* })
* }
* ```
*/
async getCanEditHub() {
return await this.#iframeMessenger.sendRequest("access/can-edit-hub");
}
/**
* Retrieve the embedded view ID used to identify this embedded view.
*
* You can set a custom embedded view ID when dynamically opening
* an embedded view inside a floating panel.
*/
getEmbeddedViewId() {
const embeddedViewId = new URLSearchParams(window.location.search).get("embeddedViewId");
if (!embeddedViewId) {
throw new Error("Missing query parameter: embeddedViewId");
}
return embeddedViewId;
}
static getHostOrigin() {
const origin = new URLSearchParams(window.location.search).get("origin");
if (!origin) {
throw new Error("Missing query parameter: origin");
}
return origin;
}
/**
* @hidden
* @internal
*
* @remarks
* This method allows only one handler to be registered per action.
* Registering a new handler for the same action will replace the existing handler.
*/
setRequestHandler(name, handler) {
this.#customRequestHandlers.set(name, handler);
}
/**
* @hidden
* @internal
*/
deleteRequestHandler(name) {
return this.#customRequestHandlers.delete(name);
}
/**
* @hidden
* @internal
*
* @remarks
* This method allows only one handler to be registered per action.
* Registering a new handler for the same action will replace the existing handler.
*/
setEventHandler(name, handler) {
this.#customEventHandlers.set(name, handler);
}
/**
* @hidden
* @internal
*/
deleteEventHandler(name) {
return this.#customEventHandlers.delete(name);
}
/**
* @hidden
* @internal
*
* @remarks
* This method allows only one handler to be registered per action.
* Registering a new handler for the same action will replace the existing handler.
*/
setSubscribeHandler(name, handler) {
this.#customSubscribeHandlers.set(name, handler);
}
/**
* @hidden
* @internal
*/
deleteSubscribeHandler(name) {
return this.#customSubscribeHandlers.delete(name);
}
/**
* @hidden
* @internal
*/
sendRequest(action, payload, transfer) {
return this.#iframeMessenger.sendRequest(action, payload, transfer);
}
/**
* @hidden
* @internal
*/
sendEvent(action, payload, transfer) {
return this.#iframeMessenger.sendEvent(action, payload, transfer);
}
/**
* @hidden
* @internal
*/
createSubscription(name, handler, options) {
return this.#iframeMessenger.createSubscription(name, handler, options);
}
/**
* Open another embedded view in a floating panel.
*
* The embedded view will be owned by the current embedded view,
* and automatically closed when the current embedded view is closed.
*
* To wait for the embedded view to be ready or being closed, listen to
* the relevant events via {@link onEmbeddedViewStateChange}
* before invoking this method. Methods such as {@link createMessagePort}
* will automatically wait for the embedded view to be ready.
*
* If the URL is invalid or the embedded view cannot be initialized,
* the state of the embedded view will remain open and not connected,
* until the user closes the panel which triggers the closed state.
*
* @experimental
*/
async openFloatingPanel(options) {
await this.#iframeMessenger.sendRequest("open-floating-panel", options);
}
/**
* Close an embedded view belonging to this extension.
*
* Currently only floating panels can be closed.
*
* If the embedded view is not open this will still resolve successfully.
*
* @experimental
*/
async closeEmbeddedView(options) {
await this.#iframeMessenger.sendRequest("close-embedded-view", options);
}
/**
* Listen to when the state of an embedded view belonging to the current
* extension changes.
*
* @experimental
*/
async onEmbeddedViewStateChange(handler) {
return this.#iframeMessenger.createSubscription("on-embedded-view-state-change", handler);
}
/**
* Register a handler to be called when the embedded view is closing.
* It will be called before the embedded view is removed from the DOM.
* The handler must resolve before the timeout duration.
*
* @example
* const { unsubscribe } = Forma.onEmbeddedViewClosing(async ({ timeoutDuration }) => {
* await new Promise((resolve) => setTimeout(() => {
* console.log(`The embedded view will be removed from DOM within ${timeoutDuration}ms`)
* resolve(null)
* }, 5000))
* })
*
* // Later, when you want to stop listening for changes:
* unsubscribe()
*
* @param handler function to be called when the embedded view is closing. The handler must resolve before the timeout duration.
* @returns { unsubscribe: () => void } object with an `unsubscribe` method to stop subscribing to the event
*
* @remarks
* This will not function when the user closes the tab or browser gracefully.
* To ensure robustness, please also handle these events.
* Learn more at [Window: beforeunload event](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event).
*
* @experimental
*/
onEmbeddedViewClosing(handler) {
this.setRequestHandler("on-embedded-view-closing", async (payload) => {
await handler(payload);
});
return {
unsubscribe: this.deleteSubscribeHandler.bind(this, "on-embedded-view-closing"),
};
}
/**
* Create a MessagePort that can be used to communicate directly with
* another embedded view belonging to the current extension.
*
* The other embedded view must have called the {@link onMessagePort} method
* during initialization for this to succeeed.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API
* @experimental
*/
async createMessagePort(options) {
const channel = new MessageChannel();
await this.#iframeMessenger.sendRequest("create-message-port", {
embeddedViewId: options.embeddedViewId,
portName: options.portName,
port: channel.port2,
}, [channel.port2]);
return channel.port1;
}
/**
* Receive a MessagePort initiated from {@link createMessagePort}.
*
* The first time this is called, the handler will receive any
* queued message ports.
*
* @returns A function that can be used to unsubscribe.
*
* @experimental
*/
onMessagePort(handler) {
return this.#messagePorts.subscribe(handler);
}
}