UNPKG

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
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); } }