NAV

Lucid Extension API

Introduction

The Lucid Extension API allows developers to build, package, and deploy extensions to Lucid's core products, like Lucidchart and Lucidspark.

The Extension API allows you to deliver packages including any combination of:

  • Shape libraries including rich shapes driven by data and formulas
  • Data connectors for integrations not offered directly by Lucid
  • Editor extensions that run custom code directly inside Lucidchart and other products

The Extension API includes two npm modules, lucid-package and lucid-extension-sdk. You can see ongoing release notes for these packages here.

Glossary

An extension package, sometimes shortened to package, is an installable collection of shape libraries, data connectors, editor extensions, and other settings and content.

An editor extension is JavaScript code executed in a core Lucid product, which using the lucid-extension-sdk library can interact with both the UI of the application and the content currently loaded.

A data connector is a collection of webhook endpoints that translate between external representations of data and Lucid's representation. Data connectors are bi-directional, allowing for data updates to flow from external sources into Lucid documents and from Lucid documents back to their external sources. A data connector must provide a URL at which it can respond to requests made by Lucid's servers.

Getting started

Unlocking Developer Features

Two tools are needed to develop with the Extension API: the developer menu and the developer portal. At this time, the Extension API is in beta and these tools are not enabled by default for Lucid users.

To get access to these tools, fill out this short questionnaire. Lucid will respond via email in 1-2 business days with instructions for using these tools. Note that certain accounts may not be eligible for the Extension API Beta.

While the Extension API has robust functionality, the Beta tag was added to highlight Lucid may need to make backwards incompatible changes to the API in preparation for a general launch. Information you provide in the questionnaire will help Lucid identify who we must notify when/if such changes occur.

As you develop on the API, if something is not working as expected, please let us know in our developer community. Or if there is a feature you’d like but are not seeing, please let us know in our feedback form. Thank you for building with us!

Create a new package

An extension package is a set of code, content, and configuration for Lucid products that is installable by individual Lucid users or by a Lucid account admin for use by their entire account. The lucid-package CLI is provided as a convenience to help you quickly create, test, and bundle your extension packages.

To get started, in a directory that will contain your extension packages:

npm install lucid-package npx lucid-package create my-new-package-name

Add an editor extension

An editor extension is a piece of custom code that executes inside a Lucid product such as Lucidchart or Lucidspark. The lucid-package CLI provides a quick-start template to help you get up and running immediately.

To add a new editor extension to your package:

cd my-new-package-name npx lucid-package create-editor-extension my-extension-name

You may create multiple extensions in the same package.

Debug your editor extension

You don't need to bundle, upload, and install your extension in order to run it and make sure it works. The following command will start webpack on your code in --watch mode, and start up a local HTTP server that Lucid products can connect to, to load your latest code.

To start the debug server:

npx lucid-package test-editor-extension my-extension-name

You can then enable loading of your local code in the Developer menu in Lucidchart by clicking "Load local extension". The page will refresh and your editor extension code will run. Note: "Load local extension" is not supported in Safari.

The main entry point to your new editor extension is in editorextensions/my-extension-name/src/extension.ts. This is configurable in this editor extension's webpack.config.js file. Experiment by changing code in that file and refreshing your browser tab to reload it.

For all published editor extensions, and by default for this debug server as well, your code runs in a sandboxed JavaScript VM for security. However, this makes debugging difficult. If you turn on the "Debug local extensions (no sandbox)" option in the Developer menu, your code will be run via a scoped eval, allowing you to use the standard browser debugging tools to examine and step through your code.

We recommend that you do all final validation of your editor extension with the normal sandbox enabled, however, as you may have inadvertently used features not allowed in the sandbox that won't work once you release your extension. For example, editor extensions are not allowed to directly access any browser DOM APIs.

Debugging multiple editor extensions together

You can watch and serve multiple editor extensions in the same package at the same time.

To start the debug server for multiple editor extensions:

npx lucid-package test-editor-extension my-extension-name my-other-extension-name my-third-extension-name

Bundle your package for upload

First, you should create your package on the Lucid Developer Portal. Clicking on your extension should take you to a page with a URL in the form https://lucid.app/developer#/packages/<UUID>. Copy this UUID and paste it into the "id" field of your my-new-package-name/manifest.json.

Then, once your editor extension (and other extension package content) works the way you want, you can bundle it for upload to the Lucid Developer Portal:

npx lucid-package bundle

This will create the file package.zip which is ready for upload to the Lucid Developer Dashboard.

Developer guide

Introduction

This developer guide will walk you through common scenarios with the Extension API, linking out to specific documentation for the features used along the way.

Read and write document content

When a user opens a Lucid product, the content they are viewing is a document. The document contains all of the information about what is displayed, as well as any data that may be used in that content.

The extension SDK provides a mechanism for reading and writing the content of the current document through a series of proxy classes.

Pages

A document consists of one or more pages. In Lucidchart, a document may include more than one page, while in Lucidspark, there is always a single page. A page contains any number of items which may be blocks, lines, or groups.

You can access the list of all pages through the DocumentProxy class, or get a reference to the currently-displayed page through the Viewport class:

import {DocumentProxy, EditorClient, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const document = new DocumentProxy(client); const viewport = new Viewport(client); client.registerAction('listPages', () => { for (const [pageId, page] of document.pages) { console.log( pageId, page.getTitle(), viewport.getCurrentPage() === page ? 'active' : '' ); } });

Items

The content on a page consists of blocks, lines, and groups, which are all kinds of items.

All items (and in fact other elements such as pages or the document itself), have a set of properties that can be read and written using their .properties collection:

function dumpProperties(page:PageProxy) { for(const [blockId, block] of page.allBlocks) { console.log('Block of class ' + block.getClassName() + ' (' + blockId + '):') for(const [propertyName, propertyValue] of block.properties) { console.log(propertyName, propertyValue); } } }

While this allows you access to all the underlying properties of an item, it is not typesafe and should only be used when more specific methods are not available. For example, a BlockProxy has methods to directly read its class name as a string, its rotation as a number, and so on.

Items may also have data fields and/or whole data records associated with them. You can read more about associating data with items here.

Blocks

A block is a single shape on a page. Different types of blocks are specified by their class name.

Creating a block

Not all block classes have their code loaded on all documents, so before adding a block to a page, you must make sure that the block class has been loaded.

Once you have loaded the block class, you can add it to the page like this:

async function createProcessBlock(page:PageProxy, x:number, y:number) { await client.loadBlockClasses(['ProcessBlock']); const block = page.addBlock({ className:'ProcessBlock', boundingBox:{ x, y, w:200, h:160 } }); block.textAreas.set('Text', 'The new shape'); }

You only need to load a block class once, so you may also structure your code this way to avoid needing to have async functions any time you create a block:

const client = new EditorClient(); function createProcessBlock(page:PageProxy, x:number, y:number) { const block = page.addBlock({ className:'ProcessBlock', boundingBox:{ x, y, w:200, h:160 } }); block.textAreas.set('Text', 'The new shape'); } async function init() { await client.loadBlockClasses(['ProcessBlock']); const menu = new Menu(client); menu.addMenuItem({...}); } init();

By avoiding adding any UI (like menu items, custom panels, etc) until your needed block classes are loaded, you can then operate synchronously in the rest of your code.

Class specific functionality

While all properties of all blocks are available through .properties, many kinds of blocks have properties specific to them that may not be obvious how to use.

When you get a BlockProxy class instance representing one block, it may actually be a subclass of BlockProxy depending on its class name. For example, ERD blocks are returned as ERDBlockProxy which has specific methods to read the fields specified on the ERD shape:

function dumpERD(page: PageProxy) { for (const [blockId, block] of page.allBlocks) { if (block instanceof ERDBlockProxy) { console.log('ERD block: ' + block.getName()); for(const field of block.getFields()) { console.log('Field: ' + field.getName() + ': ' + field.getType()); } } } }

Identifying custom shapes

If you use custom shapes in your extension, those blocks will be instances of CustomBlockProxy. This class allows you to easily check if you’re working with one of your custom shapes:

function findInstancesOfMyShape(page: PageProxy) { for (const [blockId, block] of page.allBlocks) { if (block instanceof CustomBlockProxy) { if (block.isFromStencil('my-library', 'my-shape')) { console.log('Found custom shape "my-shape": ' + block.id); } } } }

You can also extend CustomBlockProxy with a class that provides behavior specific to individual custom shapes. For example, if you create a custom shape that has a user-editable text area named TextContent, you could write the following class:

export class MyCustomBlock extends CustomBlockProxy { public static library = 'my-shape-library'; public static shape = 'my-custom-shape'; public getTextContent() { const taName = this.getStencilTextAreaName('TextContent'); if (!taName) { return ''; } return this.textAreas.get(taName); } public setTextContent(text: string) { const taName = this.getStencilTextAreaName('TextContent'); if (!taName) { return ''; } return this.textAreas.set(taName, text); } } CustomBlockProxy.registerCustomBlockClass(MyCustomBlock);

When you call CustomBlockProxy.registerCustomBlockClass, it makes it so that your custom class is used for any block created from the shape you specified in library and shape. So you can write code like this:

const client = new EditorClient(); const document = new DocumentProxy(client); client.registerAction('log-custom-text-content', () => { for(const [pageId, page] of document.pages) { for(const [blockId, block] of page.blocks) { if(block instanceof MyCustomBlock) { console.log(block.getTextContent()); } } } });

Text on blocks

Many classes of blocks have one or more text area. These text areas can be enumerated, read, and written using textAreas. The text is provided as plain text in this object.

function changeText(page: PageProxy) { for (const [blockId, block] of page.allBlocks) { for (const [textAreaKey, plainText] of block.textAreas) { block.textAreas.set(textAreaKey, plainText + ' (changed)'); } } }

You can read or write text styles on text areas using textStyles. The entries have the same keys as textAreas. When you read a text area's style, you will be returned an object describing styles that are common across the entire text area. For example, if a single word is bolded, you will not get [TextMarkupNames.Bold]: true in the result. If there are conflicting styles like that, the default style value will be returned (in the case of Bold, you will get false).

Changing text styles is asynchronous and must be awaited to be sure it is complete. This is because changing the font (using TextMarkupNames.Family or TextMarkupNames.Bold or TextMarkupNames.Italic) may require a network request to find the appropriate font.

import {TextMarkupNames, EditorClient, Menu, MenuType, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('toggle-bold', async () => { for (const item of viewport.getSelectedItems()) { for (const ta of item.textAreas.keys()) { const oldStyle = item.textStyles.get(ta); await item.textStyles.set(ta, { [TextMarkupNames.Bold]: !oldStyle[TextMarkupNames.Bold], }); } } }); menu.addMenuItem({ label: 'Toggle bold', action: 'toggle-bold', menuType: MenuType.Main, });

Positioning

Blocks (and other items) have a bounding box you can read with .getBoundingBox(). For blocks, this bounding box is the unrotated bounding box of the shape—that is, the bounding box it would occupy if its rotation were set to 0.

Moving or resizing a block is often subject to constraints. For example, some blocks have a minimum width or height, or do not allow resizing on one axis. Other blocks have side effects when moved, such as moving the other items along with a magnetized container.

For this reason, you cannot directly set the BoundingBox property on a block. Instead, you must use the offset method or one of the utility methods that calls it, such as setBoundingBox or setLocation.

To properly position a block, it may be necessary to determine the user's focus on the board. To achieve this, you can utilize viewport.getVisibleRect() to obtain the current viewport's location, and then create the block relative to the user's current viewing position:

function createProcessBlock(page:PageProxy, viewport:Viewport, dx:number, dy:number) { const {x, y} = viewport.getVisibleRect(); const block = page.addBlock({ className:'ProcessBlock', boundingBox:{ x + dx, y + dy, w:200, h:160 } }); block.textAreas.set('Text', 'The new shape'); }

Lines

A line is a connector with two endpoints. Each endpoint may or may not be connected to a block, or to another line.

Creating a line

To create a line, you specify each endpoint as either a free-floating endpoint (just x/y coordinates), a block-connected endpoint, or a line-connected endpoint.

function connectBlocks(block1: BlockProxy, block2: BlockProxy) { block1.getPage().addLine({ endpoint1: { connection: block1, linkX: 0.5, linkY: 1, }, endpoint2: { connection: block2, linkX: 0.5, linkY: 0, }, }); }

Text on lines

Any line can have any number of text areas on it. Each text area consists of its text, a position along the line (from 0 to 1), and a number specifying which side of the line the text should appear on.

function dumpLineText(page: PageProxy) { for (const [lineId, line] of page.lines) { for (const [key, text] of line.textAreas) { const position = line.getTextAreaPosition(key); if (position) { if (position.side == 0) { console.log('Text on line: ' + text); } else if (position.side == -1) { console.log('Text to left of line: ' + text); } else if (position.side == 1) { console.log('Text to right of line: ' + text); } } } } }

You can add or remove text with addTextArea and deleteTextArea.

As with blocks, you can read and write text style with textStyles.

Groups

A group is a set of other items, which acts in many ways like a single block.

On PageProxy and GroupProxy, if you use .blocks you will get only the blocks whose immediate parent is that page or group. If you use .allBlocks you will get all blocks within that page or group, at any level of grouping. The same pattern applies for .lines and .groups.

Adding menu items

One common way for end users to trigger editor extension functionality is through menu items, which can be added either to the main menu or the context menu.

Menu items trigger named actions, which must be registered with EditorClient.registerAction. Named actions may also return values, and can be used as callbacks to determine when a menu item should be visible or enabled.

For example, the following code registers a processBlocksSelected action that returns true if only ProcessBlock shapes are selected, then passes that action's name as the visibleAction when creating a menu item in the Edit menu that will turn the selected shapes red. This example also adds a menu item to the context menu, re-using the same actions.

const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('processBlocksSelected', () => { const selection = viewport.getSelectedItems(); return ( selection.length > 0 && selection.every((item) => item instanceof BlockProxy && item.getClassName() === 'ProcessBlock') ); }); client.registerAction('makeSelectionRed', () => { for (const item of viewport.getSelectedItems()) { item.properties.set('FillColor', '#ff0000ff'); } }); menu.addMenuItem({ label: 'Turn red', action: 'makeSelectionRed', menuType: MenuType.Main, location: MenuLocation.Edit, visibleAction: 'processBlocksSelected', }); menu.addMenuItem({ label: 'Turn red', action: 'makeSelectionRed', menuType: MenuType.Context, visibleAction: 'processBlocksSelected', });

Standard modals

While you can build completely custom modals, many interactions with the user are simple enough to use pre-built modals provided by the Extension API.

Alert

An alert modal presents plain text to the user with a single button to acknowledge that message. You can configure the alert with a custom title (which defaults to the name of your extension) and/or custom text for the OK button.

The alert method returns a Promise that resolves to true if the user clicks the OK button, or false if the user dismisses the modal in some other way.

const client = new EditorClient(); client.alert('This is a message');

Alert

Confirm

A confirm modal presents plain text to the user with an OK and Cancel button. You can configure the confirm modal with a custom title (which defaults to the name of your extension) and/or custom text for the OK and Cancel buttons.

The confirm method returns a Promise that resolves to true if the user clicks the OK button, or false if the user dismisses the modal in some other way.

const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('makeSelectionRed', async () => { if (await client.confirm('Do you wish to turn the selected blocks red?', undefined, 'Yes', 'No')) { for (const item of viewport.getSelectedItems()) { item.properties.set('FillColor', '#ff0000ff'); } } }); menu.addMenuItem({ label: 'Turn red', action: 'makeSelectionRed', menuType: MenuType.Main, location: MenuLocation.Edit, });

Confirm

Custom file upload

Menu items can be specified as prompting a file upload. To do this, you first register a named action for receiving the uploaded file content using EditorClient.registerFileUploadAction. This callback will receive an array of files that were selected by the user, including the file names and content as text. Optionally, the content may also be included as a Uint8Array if your menu item requests that binary be included.

const client = new EditorClient(); const menu = new Menu(client); client.registerFileUploadAction('logFileContent', (files: FileUploadData[]) => { console.log(files); for (const file of files) { console.log(file.fileName); console.log(file.text); if (file.binary) { console.log(file.binary); } } }); menu.addMenuItem({ label: 'Upload file for processing', menuType: MenuType.Main, file: { action: 'logFileContent', accept: 'text/csv', singleFileOnly: true, binary: true, }, });

Custom shapes

Use this command inside an extension package directory:

npx lucid-package create-shape-library <name>

This will create a shape library with one custom shape in it (just a rectangle) and a default library.manifest.

More detail on creating your custom shapes can be found at custom shapes.

Components of a Shape Definition

A shape definition describes the components needed to render a shape. At the most basic level, a shape definition provides the geometry of the shape, which allows Lucidchart to produce path data. More complex shapes can contain sub-shapes that each have their own unique geometry, and sub-shapes of their own.

Shape definitions are defined in /shapes

Custom UI

The Extension API allows you to display custom UI in a sandboxed iframe. The UI displayed in these iframes can communicate with your extension code via asynchronous message passing. You can define custom UI by extending the Modal or Panel class.

To display a simple modal, extend the Modal class, passing configuration to the super constructor:

import {EditorClient, Menu, MenuType, Modal} from 'lucid-extension-sdk'; class HelloWorldModal extends Modal { constructor(client: EditorClient) { super(client, { title: 'Hello world', width: 400, height: 300, content: 'Hello from a modal', }); } } const client = new EditorClient(); const menu = new Menu(client); client.registerAction('hello', () => { const modal = new HelloWorldModal(client); modal.show(); }); menu.addMenuItem({ label: 'Say Hello', action: 'hello', menuType: MenuType.Main, });

Hello World modal

Panel Hello World

To add a simple panel to the right dock, extend the Panel class, passing configuration to the super constructor. Note that in this case, the HelloWorldPanel is constructed during the initial execution of the extension code, since it will live for the duration of the editor session, unlike the HelloWorldModal above that is used and then discarded when it is closed.

The iconUrl specified here can be any image, but it will be displayed as 24x24 CSS pixels. Consider using a base64-encoded image URL for this icon to avoid any image loading delay.

import {EditorClient, Panel, PanelLocation} from 'lucid-extension-sdk'; class HelloWorldPanel extends Panel { constructor(client: EditorClient) { super(client, { title: 'Hello world', content: 'Hello from a panel', iconUrl: 'https://cdn-cashy-static-assets.lucidchart.com/marketing/images/LucidSoftwareFavicon.png', location: PanelLocation.RightDock, }); } } const client = new EditorClient(); const panel = new HelloWorldPanel(client);

Hello World panel

Persistent panels

Lucid may destroy the panel iframe and recreate it when needed for optimal application performance. Therefore, if you are developing an extension such as a video conference tool where your code needs to run continuously in the background, you can prevent this behavior by using the persist: true option in conjunction with location: PanelLocation.RightDock:

class PersistentPanel extends Panel { constructor(client: EditorClient) { super(client, { title: 'Hello world', content: 'Hello from a panel', iconUrl: 'https://cdn-cashy-static-assets.lucidchart.com/marketing/images/LucidSoftwareFavicon.png', location: PanelLocation.RightDock, persist: true, }); } }

Specifying content

The content string passed to either a Modal or Panel constructor is set as the full content of the iframe. That means it will typically include an <html> tag. The iframe is sandboxed, but allows the following:

This means your content may include scripts, external images, and so forth. Each extension is loaded on a unique origin, so the allow-same-origin permission means you get access to browser APIs like localStorage and IndexedDB.

One easy way to package HTML content into your extension is by using a TypeScript import as a string. By default, a new editor extension includes a resources directory with the following resource.d.ts:

declare module '*.html' { const content: string; export default content; }

This indicates that any .html file in that directory will be treated as a string when imported. The tsconfig.json file references that file as well so the compiler knows to treat those files correctly:

"files":[ "resources/resource.d.ts", ],

This allows you to import the whole HTML file and use it as the content string like this:

import {EditorClient, Modal} from 'lucid-extension-sdk'; import importHtml from '../resources/import.html'; export class ImportModal extends Modal { constructor(client: EditorClient) { super(client, { title: 'Import a thing', width: 600, height: 400, content: importHtml, }); } }

External content

Although you can, you do not need to bundle the entire UI application into the content of a Modal or Panel. Your HTML content can refer to JavaScript and CSS resources that you host elsewhere.

If you wish to serve the entire iframe from an external URL, you can easily navigate there when the frame loads by passing in something like this for content:

undefined
export class ContentFromElsewhereModal extends Modal { constructor(client: EditorClient) { super(client, { title: 'Loading up something else', width: 600, height: 400, content: '<script>location.href="https://www.example.com";</script>', }); } }

Communicating with custom UI

You can communicate with your UI components via message passing. To send a message to your iframe code, call sendMessage on your Modal or Panel subclass. You can pass any JSON-serializable value, and that value will be sent to your iframe with Window.postMessage. You can listen for these messages using the normal browser API, with your message being stored in the event object's .data.

window.addEventListener('message', (e) => { console.log(e.data); });

To send a message from your iframe code to your Modal or Panel class instance, call parent.postMessage like this:

parent.postMessage({ a: 'hello', b: 'world' }, '*');

Any message you pass into parent.postMessage will be received in your class's implementation of messageFromFrame. For example, the following code has the message logged to the console and then closes the modal or panel:

protected messageFromFrame(message: JsonSerializable): void { console.log(message['a']); console.log(message['b']); this.hide(); }

Lucid styles

You can utilize similar styles of elements that appear in Lucid products in your custom UI by linking the stylesheet at https://lucid.app/public-styles.css, which provides the most up to date styles.

Currently you can style buttons and text fields with Lucid's style patterns.

<html> <head> <link type="text/css" rel="stylesheet" href="https://lucid.app/public-styles.css"> </head> <body> <button class="lucid-styling primary">primary</button> <button class="lucid-styling secondary">secondary</button> <button class="lucid-styling tertiary">tertiary</button> <input type="text" class="lucid-styling" > </body> </html>

Lucid styles example

Using Angular

Complex custom UI built directly with HTML and Javascript can be difficult to write and maintain. There are a number of popular frameworks you can use to make UI development more scalable. Angular is one popular option. This section will walk you through building a simple Angular application that is displayed in a custom panel.

Step 1: Create the editor extension

Start by creating a new empty extension package, and adding an editor extension to that package:

npx lucid-package create ngtest cd ngtest npx lucid-package create-editor-extension with-cool-ui

Step 2: Create the Angular app and start the dev server on it

Now we will use the Angular CLI to create a new Angular application called rightpanel inside the with-cool-ui editor extension. Do these steps in a separate console, as you'll want to leave the ng serve process running separately from the lucid-package dev server.

cd ngtest mkdir node_modules npm install @angular/cli webpack-shell-plugin-next inliner cd editorextensions/with-cool-ui npx ng new rightpanel cd rightpanel npx ng serve # Leave this running while developing

Step 3: Replace webpack.config.js for the editor extension

Here we will use webpack-shell-plugin-next (which we installed in Step 2) to prepare the Angular app for use in both development and release modes.

onWatchRun will run whenever you start up npx lucid-package test-editor-extension. Here, we are reading the main HTML file from the Angular dev server (ng serve) that we started running in Step 2. We are making sure all of the URLs in that file are absolute (by prepending http://localhost:4200 to them) so that they will resolve correctly in our new panel's iframe. We then write that resulting HTML out to a file in the resources directory so we can read that text in our extension code.

onBeforeNormalRun will run whenever you build your package for deployment with npx lucid-package bundle. Here, we run a full ng build inside the rightpanel directory, then use the inliner command we installed in Step 2 to combine all of the compiled Angular assets into a single HTML file. While we could do this same operation for onWatchRun, it is much slower than allowing ng serve to directly provide the code during development.

const path = require('path'); const WebpackShellPluginNext = require('webpack-shell-plugin-next'); const angularTargets = [{name: 'rightpanel', port: 4200}]; module.exports = { entry: './src/extension.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, { test: /[\\\/]resources[\\\/]/, use: 'raw-loader', exclude: /\.json$/, }, ], }, resolve: { extensions: ['.ts', '.js'], }, output: { filename: 'bin/extension.js', path: __dirname, }, plugins: [ new WebpackShellPluginNext({ //When doing a watch build, run "ng serve" and update the html file to prefix http://localhost:4200/ to all the resource URLs onWatchRun: { scripts: angularTargets.map( (target) => `curl http://localhost:${target.port} | ` + `sed -E "s/(src|href)=\\"/\\\\1=\\"http:\\/\\/localhost:${target.port}\\//gi" > ` + `resources/${target.name}.html`, ), blocking: true, }, //When doing a full build, run "ng build" and then inline all the final assets into the html file onBeforeNormalRun: { scripts: angularTargets.map( (target) => `cd ${target.name} && ` + `npx ng build && ` + `sed s/module/text\\\\/javascript/gi dist/${target.name}/index.html > dist/${target.name}/index2.html && ` + `cd dist/${target.name} && npx inliner < index2.html > ../../../resources/${target.name}.html`, ), blocking: true, }, }), ], mode: 'development', };

Step 4: Use the Angular app in a Panel

Update src/extension.ts:

import {EditorClient, Panel, PanelLocation} from 'lucid-extension-sdk'; import html from '../resources/rightpanel.html'; const client = new EditorClient(); export class RightPanel extends Panel { private static icon = 'https://lucid.app/favicon.ico'; constructor(client: EditorClient) { super(client, { title: 'From Angular', content: html, location: PanelLocation.RightDock, iconUrl: RightPanel.icon, }); } } const rightPanel = new RightPanel(client);

Step 5: Run the Lucid dev server

Make sure your ng serve process is running (see Step 2) before doing this step, or your panel may not work. Remember that running test-editor-extension will trigger the onWatchRun script that generates the correct HTML for the panel to work.

npx lucid-package test-editor-extension with-cool-ui

Step 6: Write your Angular app

With both the ng serve and lucid-package test-editor-extension dev servers running, the dev cycle for updating the Angular app is just editing its code and then reloading that iframe. For modals, that means closing and reopening the modal; for panels it means switching to the normal context panel and back. No need to reload the whole editor.

If you want to share classes or other code from your extension to your UI, then just add the following in rightpanel/tsconfig.json:

{ ... "compilerOptions": { ... "paths": { "@extension/*": ["../src/*"] },

Then you will be able to import, for example, from with-cool-ui/src/sharedthing.ts like this:

import {SharedThing, SharedClass} from '@extension/sharedthing';

Remember, of course, that just because you're sharing code doesn't mean you're in a shared runtime. You still have to send serializable messages back and from from your UI project like this. You could easily make a simple Angular injectable that receives messages that you can use in your UI components:

import {Injectable} from '@angular/core'; @Injectable() export class DataFromExtension { public ids: string[] = []; constructor() { //Listen for lists of selected item IDs window.addEventListener('message', (event) => { if (event.data['ids']) { this.ids = event.data['ids']; } }); //Once we're ready to receive those messages, ask the extension to refresh our data parent.postMessage('refresh', '*'); } }

You can add something like this to your Panel class to keep your UI updated any time the current selection changes:

export class RightPanel extends Panel { private readonly viewport = new Viewport(this.client); constructor(client: EditorClient) { //... this.viewport.hookSelection(() => this.sendStateToFrame()); } private sendStateToFrame() { this.sendMessage({ ids: this.viewport.getSelectedItems().map((i) => i.id), }); } //When the app is loaded, it will send a message asking for an update. protected messageFromFrame(message: any) { if (message === 'refresh') { this.sendStateToFrame(); } } }

Using React

Similarly, you can use React to make custom UIs. This section will walk you through building a simple React application that is displayed in a custom panel.

Step 1: Create the editor extension

Start by creating a new empty extension package, and adding an editor extension to that package:

npx lucid-package create reacttest cd reacttest npx lucid-package create-editor-extension with-cool-ui

Step 2: Create the React app and start the dev server on it

Now we will use Create React App to bootstrap a new React application called rightpanel inside the with-cool-ui editor extension. Do these steps in a separate console, as you'll want to leave the npm start process running separately from the lucid-package dev server.

cd reacttest mkdir node_modules npm install webpack-shell-plugin-next inliner cd editorextensions/with-cool-ui/ npx create-react-app rightpanel --template typescript cd rightpanel npm start # Leave this running while developing

Step 3: Replace webpack.config.js for the editor extension

Here we will use webpack-shell-plugin-next (which we installed in Step 2) to prepare the React app for use in both development and release modes.

onWatchRun will run whenever you start up npx lucid-package test-editor-extension. Here, we are reading the main HTML file from the React dev server (npm start) that we started running in Step 2. We are making sure all of the URLs in that file are absolute (by prepending http://localhost:3000 to them) so that they will resolve correctly in our new panel's iframe. We then write that resulting HTML out to a file in the resources directory so we can read that text in our extension code.

onBeforeNormalRun will run whenever you build your package for deployment with npx lucid-package bundle. Here, we run a full npm run build inside the rightpanel directory, then use the inliner command we installed in Step 2 to combine all of the compiled React assets into a single HTML file. While we could do this same operation for onWatchRun, it is much slower than allowing npm start to directly provide the code during development.

const path = require('path'); const WebpackShellPluginNext = require('webpack-shell-plugin-next'); const reactTargets = [{name: 'rightpanel', port: 3000}]; module.exports = { entry: './src/extension.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, { test: /[\\\/]resources[\\\/]/, use: 'raw-loader', exclude: /\.json$/, }, ], }, resolve: { extensions: ['.ts', '.js'], }, output: { filename: 'bin/extension.js', path: __dirname, }, plugins: [ new WebpackShellPluginNext({ //When doing a watch build, run "npm start" and update the html file to prefix http://localhost:3000/ to all the resource URLs onWatchRun: { scripts: reactTargets.map( (target) => `curl http://localhost:${target.port} | ` + `sed -E "s/(src|href)=\\"/\\\\1=\\"http:\\/\\/localhost:${target.port}\/gi" > ` + `resources/${target.name}.html`, ), blocking: true, }, // When doing a full build, run "npm run build" and then inline all the final assets into the html file onBeforeNormalRun: { scripts: reactTargets.map( (target) => `cd ${target.name} && ` + `npm run build && ` + `sed -E "s/(src|href)=\\"\\//\\1=\\"\/gi" build/index.html > build/index2.html &&` + `cd build && npx inliner < index2.html > ../../resources/${target.name}.html` ), blocking: true, }, }), ], mode: 'development', };

Step 4: Use the React app in a Panel

Update src/extension.ts:

import {EditorClient, Panel, PanelLocation, Viewport} from 'lucid-extension-sdk'; import html from '../resources/rightpanel.html'; const client = new EditorClient(); export class RightPanel extends Panel { private static icon = 'https://lucid.app/favicon.ico'; constructor(client: EditorClient) { super(client, { title: 'From React', content: html, location: PanelLocation.RightDock, iconUrl: RightPanel.icon, }); } } const rightPanel = new RightPanel(client);

Step 5: Run the Lucid dev server

Make sure your npm start process is running (see Step 2) before doing this step, or your panel may not work. Remember that running test-editor-extension will trigger the onWatchRun script that generates the correct HTML for the panel to work.

npx lucid-package test-editor-extension with-cool-ui

Step 6: Write your React app

With both the npm start and lucid-package test-editor-extension dev servers running, the dev cycle for updating the React app is just editing its code and then reloading that iframe. For modals, that means closing and reopening the modal; for panels it means switching to the normal context panel and back. No need to reload the whole editor.

You might observe that the static assets generated by Create React App are not being loaded properly. This issue arises because our onWatchRun script was not able to prepend http://localhost:3000 to urls created after root.render(). While you can manually prepend http://localhost:3000 in your application during development, we recommend to use absolute urls or data urls. This is because in production, we won't be able to serve your static assets.

In order to share classes or other code between your extension and UI, you will need to install either craco or react-app-rewired. These tools allow you to override the default webpack settings used by Create React App.

cd rightpanel npm install @craco/craco

Then create a file called craco.config.js in the rightpanel directory:

const path = require('path'); module.exports = { webpack: { alias: { '@extension': path.resolve(__dirname, '../src'), }, configure: webpackConfig => { const scopePluginIndex = webpackConfig.resolve.plugins.findIndex( ({ constructor }) => constructor && constructor.name === 'ModuleScopePlugin' ); webpackConfig.resolve.plugins.splice(scopePluginIndex, 1); return webpackConfig; } }, };

Then create a file called tsconfig.paths.json in the same directory:

{ "compilerOptions": { "paths": { "@extension/*": ["../src/*"] } } }

In tsconfig.json:

{ ... "extends": "./tsconfig.paths.json",

Then you should change all react-scripts in package.json into craco:

{ ... "scripts": { "start": "craco start", "build": "craco build", "test": "craco test", "eject": "craco eject" }

Then you will be able to import, for example, from with-cool-ui/src/sharedthing.ts like this:

import {SharedThing, SharedClass} from '@extension/sharedthing';

Remember, of course, that just because you're sharing code doesn't mean you're in a shared runtime. You still have to send serializable messages back and from from your UI project like this. You could easily add a eventListener in the useEffect hook in your UI components:

import React, { useEffect, useState } from 'react'; import './App.css'; function App() { const [ids, setIds] = useState([]); const handleMessage = (event: MessageEvent<any>) => { if (event.data['ids']) { setIds(event.data['ids']); } } useEffect(() => { window.addEventListener('message', handleMessage); //Once we're ready to receive those messages, ask the extension to refresh our data window.parent.postMessage('refresh', '*'); return () => { window.removeEventListener('message', handleMessage); }; }, []); return ( <div className="App"> <div>selected Ids: {ids}</div> </div> ); }

You can add something like this to your Panel class to keep your UI updated any time the current selection changes:

export class RightPanel extends Panel { private readonly viewport = new Viewport(this.client); constructor(client: EditorClient) { //... this.viewport.hookSelection(() => this.sendStateToFrame()); } private sendStateToFrame() { this.sendMessage({ ids: this.viewport.getSelectedItems().map((i) => i.id), }); } //When the app is loaded, it will send a message asking for an update. protected messageFromFrame(message: any) { if (message === 'refresh') { this.sendStateToFrame(); } } }

Drag and drop blocks from a panel

You can add controls to your custom panels that allow users to drag and drop a new block from your panel onto the canvas. This example will build on the above instructions on using Angular to build custom UI.

Custom drag and drop

Use Viewport.startDraggingNewBlock from the Panel

In the RightPanel class we created above, we can start listening for messages from our Angular app.

We will be sending a few possible messages

  • drag to indicate the user has started dragging out of the panel
  • pointermove indicating the user is dragging the content over the canvas at a particular location
  • pointerup indicating the user has dropped the content on the canvas at a particular location
  • cancelDrag indicating the user is no longer dragging content from the panel

We will also send a message from the extension to the panel's Angular app: dragDone indicating the user has successfully dropped the shape onto the canvas, or has otherwise cancelled the operation (e.g. by pressing Escape).

The code of the RightPanel looks like this:

export class RightPanel extends Panel { // ... protected async messageFromFrame(message: any) { if (message == 'drag') { const maybeBlock = await this.viewport.startDraggingNewBlock({ className: 'ProcessBlock', boundingBox: {x: 0, y: 0, w: 200, h: 200}, properties: {'Text': 'Custom Text'}, }); if (maybeBlock) { maybeBlock.properties.set('FillColor', '#ff00ffff'); } this.sendMessage('dragDone'); } else if (message.message == 'pointermove') { this.viewport.dragPointerMove(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'pointerup') { this.viewport.dragPointerUp(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message == 'cancelDrag') { this.viewport.cancelDraggingNewBlock(); } } }

You can see that startDraggingNewBlock returns a Promise that resolves to either the newly created block itself, or undefined if the operation was cancelled. You can use this to make changes to the new block (or carry out any other operation you need to perform) as soon as the block is dropped on the canvas.

Here, we are just creating a new standard block type, but this operation works just as well with custom shapes, like this:

export class RightPanel extends Panel { // ... private scoreBarDefinition:BlockDefinition|undefined; constructor(client: EditorClient) { // ... this.client.getCustomShapeDefinition('shapes', 'score-bar').then(def => { this.scoreBarDefinition = def; }); } //When the app is loaded, it will send a message asking for an update. protected async messageFromFrame(message: any) { if (message == 'drag') { if (this.scoreBarDefinition) { const maybeBlock = await this.viewport.startDraggingNewBlock(this.scoreBarDefinition); if (maybeBlock) { maybeBlock.properties.set('FillColor', '#ff00ffff'); } } this.sendMessage('dragDone'); } else if (message.message == 'pointermove') { this.viewport.dragPointerMove(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'pointerup') { this.viewport.dragPointerUp(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message == 'cancelDrag') { this.viewport.cancelDraggingNewBlock(); } } }

Write the Angular component

Writing a well-behaved drag and drop source requires some care. For this example, we want to have all of the following behaviors:

  • The element they drag should move with the mouse cursor when they start dragging.
  • The element they drag should disappear from the panel when they move onto the canvas.
  • The element they drag should move back to its original location if the user completes or cancels the drag in any way.

Here is some sample code performing all of these operations with a simple div as the dragged element.

app.component.html:

<div class="drag" (pointerdown)="pointerDown($event)" > Drag me </div>

app.component.less:

div.drag { width: 100px; height: 100px; border: 4px solid red; }

app.component.ts:

import {Component} from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.less'], }) export class AppComponent { private pointerDownEvent: PointerEvent | undefined; //As of the last pointer event, is the (captured) pointer outside the iframe's bounds? private pointerIsOut = false; private documentPointerUp = (e: PointerEvent) => { if(this.pointerIsOut) { //Notify the editor that it needs to simulate canvas pointer events parent.postMessage({message:'pointerup', x:e.pageX - window.scrollX, y:e.pageY - window.scrollY}, '*'); } stopDrag(); }; private isInsideFrame(e: PointerEvent) { const x = e.pageX - window.scrollX; const y = e.pageY - window.scrollY; return x >= 0 && x <= window.innerWidth && y >= 0 && y <= window.innerHeight; } private documentPointerMove = (e: PointerEvent) => { const isInside = this.isInsideFrame(e); if(!this.pointerIsOut && !isInside) { this.startCanvasDrag(); } else if(this.pointerIsOut && isInside) { //If the pointer has re-entered the iframe while dragging, tell the extension to //cancel the ongoing interaction for dragging the new block out. this.stopCanvasDrag(); } this.pointerIsOut = !isInside; //While dragging the HTML element around, move it around //with relative positioning to keep it attached to the pointer cursor. const target = this.pointerDownEvent?.target; if (this.pointerDownEvent && target instanceof HTMLElement) { target.style.position = 'relative'; target.style.top = e.pageY - this.pointerDownEvent.pageY + 'px'; target.style.left = e.pageX - this.pointerDownEvent.pageX + 'px'; } if(isInside) { //Defense in depth: If somehow the pointer buttons have been released and the user //is moving the pointer over this iframe again, cancel any ongoing drag operation. if (e.buttons == 0) { this.stopDrag(); } } else { //Notify the editor that it needs to simulate canvas pointer events parent.postMessage({message:'pointermove', x:e.pageX - window.scrollX, y:e.pageY - window.scrollY}, '*'); } }; //If we have asked the extension to start a drag-new-block interaction, we need //to listen for a message indicating that interaction has completed (either //successfully or not) in order to reset our drag/drop state entirely. private windowMessage = (e) => { if (e.data === 'dragDone') { stopDrag(); } }; private startCanvasDrag() { parent.postMessage({message: 'drag'}, '*'); window.addEventListener('message', this.windowMessage); } private stopCanvasDrag() { window.removeEventListener('message', this.windowMessage); parent.postMessage({message:'cancelDrag'}, '*'); } //Start listening for pointer events on this iframe to implement drag & drop. private startDrag() { window.document.addEventListener('pointerup', this.documentPointerUp); window.document.addEventListener('pointermove', this.documentPointerMove); } //Cancel drag & drop, and reset the DOM back to how it began. private stopDrag() { const target = this.pointerDownEvent?.target; if (this.pointerDownEvent && target instanceof HTMLElement) { target.style.position = 'static'; target.style.top = ''; target.style.left = ''; this.pointerDownEvent = undefined; } window.document.removeEventListener('pointerup', this.documentPointerUp); window.document.removeEventListener('pointermove', this.documentPointerMove); this.stopCanvasDrag(); } public pointerDown(e: PointerEvent) { //Store the event that started the drag, as a coordinate anchor. this.pointerDownEvent = e; this.pointerIsOut = false; this.startDrag(); } }

Working with data on documents

Add custom data fields to a shape

The easiest and simplest way to add data to a shape is to set custom shape data fields. You can do this by reading and writing from shapeData:

block.shapeData.set('Value', 50); const value = block.shapeData.get('Value');

How to create data collections

If you want to have data sets shared between Lucidchart and your own systems, you'll want to have those data sets live independent of the shapes on the canvas. From editor extensions, you can create new collections of data that work like other imported data sets.

These data collections (think of each one as a spreadsheet or database table with a known schema) are organized into data sources. One data source typically represents one set of collections that are all related, e.g. "all the data from this one custom file import".

You can create a data source, collection, and data items like this. Note the second parameter to addDataSource—this is information about where the data came from. You can read this from your extension code in order to find the data source for the import you're interested in working with:

const client = new EditorClient(); const data = new DataProxy(client); function addData() { const source = data.addDataSource('stuff', {'from': 'computer'}); const collection = source.addCollection('things', { fields: [ {name: 'id', type: ScalarFieldTypeEnum.NUMBER}, {name: 'name', type: ScalarFieldTypeEnum.STRING}, ], primaryKey: ['id'], }); collection.patchItems({ added: [ {'id': 1, 'name': 'Ben Dilts'}, {'id': 2, 'name': 'James Judd'}, {'id': 3, 'name': 'Ryan Stringham'}, ], }); } function findData() { for (const [key, source] of data.dataSources) { if (source.getSourceConfig()['from'] === 'computer') { for (const [collectionId, collection] of source.collections) { if (collection.getName() === 'things') { return collection; } } } } return undefined; }

How to associate a record with a shape on-canvas

Once you have a data source and collection with the data you want to use, you can associate an entire record with a shape on-canvas by using a reference key. Reference keys are displayed in Lucidchart near the custom shape data fields.

To create a reference key, you need the ID of the collection and the primary key of the data item you want to link.

block.setReferenceKey('thing', { collectionId: collection.id, primaryKey: '1', readonly: true, });

The primary keys of data items are accessible both as the keys on the collection.items, as well as on each item itself:

for(const [primaryKey, item] of collection.items) { assert(item.primaryKey === primaryKey); }

Using reference keys to data items is the best long-term way to associate upstream data with a shape in a diagram. This is how we associate source data with shapes in all first-party integrations.

Enabling graphics for shapes connected to data

Once you have shapes connected to data it may be helpful to display the state of the imported data with respect to the external data source. For example, a sync with the external data source may have failed in which case it is desirable to show the user an error state on shapes linked to this data.

Extension required modal

Some shapes may display an icon indicating the state of the data connected to it by default, however, for most shapes, this functionality needs to be enabled by the extension.

const position: BadgeEnumPosition = { horizontalPos: HorizontalBadgePos.LEFT, verticalPos: VerticalBadgePos.BOTTOM, layer: BadgeLayerPos.INSIDE, responsive: BadgeResponsiveness.STACK, }; block.setDataSyncStateIconPosition(position);

List local data changes

Often, data imported from a third-party source, either using a standard Lucid feature like CSV or Google Sheets import, or via a custom data connector, is editable by the end user. If the data is edited in any way--by editing data-bound text fields, using the Data Panel or other UI, or even via the extension API--those changes are tracked until they are synced back to the original data source.

To access the list of locally-changed data items on a collection, if any, call CollectionProxy.getLocalChanges. That method returns undefined if the collection is not configured to track local changes separately from the initially-imported data.

Here is some simple code that will examine the currently-selected shape for linked data, then log a full list of all local changes to the associated data collection to the console. If you import an org chart, then make edits to that data by moving people around the organization, adding new people, or removing some people, the dump-changes action defined in this example would provide a log of all of your changes in the console.

import {EditorClient, Menu, MenuType, Viewport, CollectionProxy} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('dump-changes', async () => { for (const item of viewport.getSelectedItems()) { // Look for a data reference associated with a data collection const collectionId = item.referenceKeys.first()?.collectionId; if (collectionId) { // Validate that the reference is a "branch", meaning it is tracking // local changes independent of the original imported data. const branch = new CollectionProxy(collectionId, client); if (branch.getBranchedFrom()) { const changes = branch.getLocalChanges(); if (changes) { console.log('ADDED'); for (const item of changes.getAddedItems()) { console.log(item.primaryKey); for (const [key, val] of item.fields) { console.log(key, val); } } console.log('DELETED'); for (const item of changes.getDeletedItems()) { console.log(item.primaryKey); for (const [key, val] of item.fields) { console.log(key, val); } } console.log('CHANGED'); for (const item of changes.getChangedItems()) { console.log(item.primaryKey); for (const key of item.changedFields) { console.log(key, item.original.fields.get(key), item.fields.get(key)); } } } } } } }); menu.addMenuItem({ label: 'Dump changes', action: 'dump-changes', menuType: MenuType.Main, });

Manage text

Text can exist on both blocks and lines. Reading and writing the text content itself can be done through the textAreas property on either a LineProxy or BlockProxy. For example, to read all the text off the current selection and then replace it with Hello World:

import {EditorClient, Menu, MenuType, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('hello', async () => { for(const item of viewport.getSelectedItems()) { for(const [key, plainText] of item.textAreas) { console.log('Old value: ' + plainText); item.textAreas.set(key, 'Hello world'); } } }); menu.addMenuItem({ label: 'Hello World', action: 'hello', menuType: MenuType.Main, });

While blocks typically have a set number of text areas on them, lines have any number of text areas, and those text areas can be freely moved around by the user. You can add new text areas, read and write their position on the line, and delete them.

const first = line.addTextArea('Start Left', {location: 0, side: -1}); const second = line.addTextArea('Middle', {location: 0.5, side: 0}); const third = line.addTextArea('End Right', {location: 1, side: 1}); console.log(line.getTextAreaPosition(first)); line.setTextAreaPosition(first, {location: 0.25, side: 0}); line.deleteTextArea(third);

The location of a line text area is a number between 0 and 1, where 0 places the text at the first endpoint of the line, and 1 places the text at the second endpoint of the line.

The side of a line text area places the text on top of the line if it is 0, to the left of the line (when looking from the first toward the second endpoint) if it is -1, and to the right of the line if it is 1.

Bootstrap data for documents created via API

Our public API for creating a document supports the ability to include bootstrap data that's readable by a specific editor extension.

Alongside the other data you send in the body of the POST, you can include extensionBootstrapData which specifies the extension package that the data is intended for, the name of the editor extension in that package that should read this particular bootstrap data, a minimum version number for that package to be allowed to read the data, and then the data payload itself.

For example, a full create document request body could look like this:

{ "title": "new document title", "product": "lucidchart", "extensionBootstrapData": { "packageId": "74672098-cf36-492c-b8e6-2c4233549cd3", "extensionName": "sheets-adapter", "minimumVersion": "1.4.0", "data": { "a": 1, "b": 2 } } }

Because you must specify a packageId on bootstrap data, remember to specify id in your package manifest.

You can then access and process this bootstrap data as follows from your editor extension code:

client.processAndClearBootstrapData((data) => { //here, data would be {a:1, b:2} });

In that bootstrap callback, you can operate on the data immediately, or you can do asynchronous operations. If the callback returns a Promise (or you mark it async), the bootstrap data won't be cleared off the document until that promise successfully resolves.

To verify that bootstrap data processing works, you can directly set the "Bootstrap" property on the document and then refresh the page:

new DocumentProxy(client).properties.set('Bootstrap', { 'PackageId': '74672098-cf36-492c-b8e6-2c4233549cd3', 'ExtensionName': 'sheets-adapter', 'MinimumVersion': '1.4.0', 'Data': { 'a': 1, 'b': 2, }, });

Marking an extension as required for a document

The processAndClearBootstrapData function also allows you to pass in an optional boolean value that, if true, marks the document as requiring the extension. Once marked, if the extension is not installed the user will be notified about the extension being required on document load.

client.processAndClearBootstrapData((data) => {}, true);

Extension required modal

The minimum extension version required by document is the minimumVersion provided in the request body when creating the document.

Only extensions with bootstrap data available to them will be allowed to mark themselves as required for a document.

User

By adding the USER_INFO scope to your editor extension definition in manifest.json, you can utilize the UserProxy class to access user information:

import {EditorClient, UserProxy} from 'lucid-extension-sdk'; const client = new EditorClient(); const user = new UserProxy(client); user.id;

Using OAuth APIs

You may need to access OAuth-protected APIs from your editor extensions in order to communicate with other systems or sources of data. In order to do this, you need to:

  1. Configure the OAuth provider in your manifest.json
  2. Upload your extension package to the Developer Portal, and enter the extension ID in your manifest.json
  3. Install your extension package for your own user account
  4. Enter your OAuth application credentials (client ID and client secret) on the Developer Portal
  5. Call EditorClient.oauthXhr from your editor extension

Note that while these steps are all mandatory to using an OAuth provider in your extension package once installed, you can also use an OAuth provider in development mode only by following these steps instead:

  1. Configure the OAuth provider in your manifest.json
  2. Create a file <providerName>.credentials.local in the root of your package, containing a JSON object with clientId and clientSecret keys
  3. Call EditorClient.oauthXhr from your editor extension
  4. Start the local dev server with npx lucid-package test-editor-extension <extensionName>

Configure the OAuth provider

Currently, only OAuth 2.0 authorization via the authorization code grant flow or client credentials grant flow is supported.

Your package's manifest.json file can optionally include an array of oauthProviders which must contain the following:

Field Description Example
name A short name by which to reference this OAuth provider sheets
title The name displayed to the user to describe this OAuth provider Google Sheets
grantType A GrantType type authorizationCode
tokenUrl The URL for OAuth token exchange https://oauth2.googleapis.com/token
authorizationUrl The URL for the OAuth user authorization flow https://accounts.google.com/o/oauth2/auth?access_type=offline&prompt=consent
usePkce A boolean flag for authorization code providers. Defaults to false. If true, the OAuth user authorization flow will use PKCE. Learn more here: https://oauth.net/2/pkce/ false
scopes An array of OAuth scopes to request ["https://www.googleapis.com/auth/userinfo.email","https://www.googleapis.com/auth/spreadsheets"]
domainWhitelist An array of domains you may make requests to for this provider ["https://sheets.googleapis.com","https://www.googleapis.com"]
clientAuthentication A ClientAuthentication type basic
helpUrl A URL to a help article to be shown in Lucid when prompting to redirect to your OAuth flow (max length 255 characters) https://www.example.com/lucid-help
faviconUrl A URL to a favicon to show in Lucid when prompting to redirect to your OAuth flow. Rendered at 24px by 24px. (max length 255 characters) https://www.example.com/lucid-favicon.png
Grant Types

This configures the OAuth grant type for the OAuth 2.0 authorization flow.

Type Description
authorizationCode The Authorization Code grant type is used by confidential and public clients to exchange an authorization code for an access token. Learn more
clientCredentials The Client Credentials grant type is used by clients to obtain an access token outside of the context of a user. Learn more
Client Authentication Types

This configures how the client ID & client secret should be sent to the OAuth provider when exchanging the authorization code for an OAuth access token.

Type Description
basic Sends client credentials as Basic Auth header
clientParameters Sends client credentials in body

Upload your extension package

The access tokens (and refresh tokens, if available) granted to the user by the OAuth provider are not provided to your editor extension code to use directly. Instead, they are encrypted and stored on Lucid's servers according to our data retention and privacy policies.

Because of this, you must have a version of your extension package installed in order to use OAuth APIs, even in debug and development mode.

Go to the Lucid Developer Portal and create a new extension package project, if you have not already. Copy the extension package's ID into the id field in your manifest.json. This will allow your code to be recognized as being associated with that project even when run in debug mode.

Produce the package.zip for your extension with this command

npx lucid-package bundle

and upload that package.zip file as a new version of your extension.

Configure Lucid's redirect url in OAuth Provider Settings

Many OAuth2 APIs require that developers specifically authorize a redirect url before it can be used with their OAuth2 client. Your Lucid extension will use the following redirect url:

https://extensibility.lucid.app/packages/<packageId>/oauthProviders/<oauthProviderName>/authorized

If required by your OAuth provider, configure your client to authorize this redirect url. You will likely have to do this manually in your OAuth client settings.

Enter application credentials

On your extension package's details page on the Developer Portal, a section labeled "OAuth Providers" will appear. It will show that your credentials are missing for your new provider. Click through and enter the client ID and client secret you received from the OAuth provider when setting up your application with them.

These credentials are associated with your extension package's ID and the name you gave the OAuth provider. If you update your extension later with a new version, you do not need to re-enter these credentials.

Install your extension

Click on the version number of your extension that you just uploaded, and click the button to install the extension package for yourself. This will mark this version as installed and provide a place for OAuth tokens to be stored during your testing.

If you add more OAuth providers later, you need to make sure that you have a version of your extension package installed that includes all the providers you intend to use.

Call the API with EditorClient

In your editor extension, you can request an OAuth API call like this:

const client = new EditorClient(); const result = await client.oauthXhr('sheets', { url: 'https://www.googleapis.com/oauth2/v1/userinfo', method: 'GET', });

The first parameter to oauthXhr is the name of the OAuth provider you specified in your extension package's manifest. The second is in the same format as normal network requests through EditorClient—you can specify url, method, data, headers, and timeoutMs.

If the user has not yet received an OAuth token from that provider, they will be prompted to authorize with them, and then the requested API call will be made. If the user does not successfully authorize, then oauthXhr will behave as if it received a 401 UNAUTHORIZED response from the provider.

You can also request an OAuth API call using the asyncOAuthXhr command. The request is enqueued to eventually execute. The request may be attempted multiple times with an overall timeout of 120 seconds whereas the oauthXhr command will timeout in 10 seconds. asyncOAuthXhr takes the same parameters as oauthXhr.

Hook text editing

Your extension can react to a user editing text in a number of ways.

  • Prevent a user from editing a certain text area
  • Allow the user to edit a text area, but prevent their edit after the fact (knowing what they typed)
  • Allow the user to edit a text area, but replace the value they typed with another

To hook text editing, use the Viewport class's hookTextEdit method like this:

import {EditorClient, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const viewport = new Viewport(client); viewport.hookTextEdit(async (item, textAreaKey) => { if (item.properties.get(textAreaKey) === 'Deny') { return false; } if (item.properties.get(textAreaKey) === 'Numeric') { return (newValue) => { if (String(+newValue) === newValue) { return true; } else if (isNaN(+newValue)) { return false; } else { return String(+newValue); } }; } return true; });

This example watches text editing and does the following:

  • If the text the user is trying to edit currently says Deny then text editing is prevented by returning false from the hook
  • If the text the user is trying to edit currently says Numeric then the text editing is allowed, but
    • if the final value is not a number, the edit is reverted, and
    • if the final value is not in the default number format, it will be replaced (e.g. +14.0 -> 14).

Both the text editing hook and the callback to be run when editing is complete can be async. However, be cautious with this as you will cause the user to wait until your Promise resolves before continuing their work.

Your extension can capture pasting URLs from the clipboard to produce a custom block with embedded data. Your extension will register a domain pattern and a callback block that will be called automatically when the user pastes a matching URL onto the canvas.

From the URL you can return an UnfurlDetails object which will describe the content of the newly created block. Lucid will take that information and populate a new block on the canvas. You can provide an optional async followup to perform additional computation and update the block as needed.

Created blocks can have one or multiple preview images that a user can browse through.

Example embed menu

You can also provide an iframe for a user to view expanded content.

Example Iframe

Listen for a URL by calling registerUnfurlHandler:

import EditorClient from 'lucid-extension-sdk'; const client = new EditorClient(); private async performUnfurl(url: string): Promise<UnfurlDetails | undefined> { const idRegex = /^https:\/\/my\.url\.com\/\w+\/d\/([a-zA-Z0-9-_]+)/; const id = idRegex.exec(url)?.[1] if (id) { try { return { providerName: 'myUrlCompany', providerFaviconUrl: 'my.url.com/logo', unfurlTitle: 'Block title', previewImageUrl: 'my.url.com/preview?url=' + url, }; } catch (error) { console.log(error); } } return undefined; } public async performAfterUnfurlCallback(blockProxy: LinkUnfurlBlockProxy, url: string) { const [fileKey, frameId] = parseLink(url); // for example if (!fileKey || !frameId) { return undefined; } const nameOfFrame = await getNameOfFrame(fileKey, frameId); // for example if (nameOfFrame) { blockProxy.setTitle(nameOfFrame); } blockProxy.setIframe({ iframeUrl: 'https://www.my.url.com/embed?embed_host=astra&url=' + url, aspectRatio: UnfurlIframeAspectRatio.Square, }); return; } client.registerUnfurlHandler('my.url.com', { unfurlCallback: async (url: string) => performUnfurl(url), afterUnfurlCallback: async (blockProxy, url) => performAfterUnfurlCallback(blockProxy, url), });

Domain URL

The domain is required and will be checked against any URLs pasted to the canvas. If the domain matches, your callback will be run. If it doesn't match, then other unfurl handlers will be called so multiple extensions can be running and watching for URLs at the same time. Domains of multiple extensions cannot overlap.

The domain supports subdomains and wildcards in the subdomains. For example:

domain.com my.domain.com *.domain.com

We do not support anything after the domain or wildcards in the domain itself. So we DO NOT support domain.com/something or d*main.com

Unfurl Callback

unfurlCallback is a required parameter that will be called with the pasted url. Your block should return an UnfurlDetails object to produce a block or undefined to do nothing. This call must run quickly in case any long computations need to be run in the followup async call. The purpose is to get the minimal information to display the new shape.

After Unfurl Callback

If you have additional, longer work that you need to do for an unfurl you may provide an asynchronous callback named afterUnfurlCallback that will run after the block is generated on the canvas. You will get a LinkUnfurlBlockProxy which will let you update the title, thumbnail and preview URLs, and the details iframe.

Package Settings

Extension packages can specify settings to be configured by the end user who installs the package. The values of these settings are shared for all users of each installation of your package (typically all users on the Lucid account).

Settings entries in manifest.json

The package manifest file can optionally include an array of settings, each of which has the following fields:

name A string uniquely identifying this setting within this package
label A human-readable string label for this setting, which will be used in the default UI for configuring your package
description A longer description for the setting, to be displayed alongside the label where appropriate
type The type of the setting; currently the only legal value is "string"

For example, if you wanted to allow the user to be able to configure a subdomain of your app to connect to for data, you might add the following to your manifest:

{ "settings": [{ "name": "subdomain", "label": "MyApp Subdomain", "description": "The subdomain of your instance of MyApp. If you log in to MyApp at company.example.com, you should enter 'company' for your MyApp Subdomain.", "type": "string" }] }

Accessing the configured values

You can query the user's configured values for your package's settings from your editor extension code, check their permissions to edit those values (only users with permissions to manage the extension installation can edit setting values), and even prompt the user to edit their settings:

import {EditorClient, Menu, MenuType} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); client.registerAction('import-data', async () => { let settings = await client.getPackageSettings(); if (!settings.get('subdomain')) { if (await client.canEditPackageSettings()) { await client.alert( 'You have not configured a MyApp subdomain. You will now be prompted to complete that configuration.', ); await client.showPackageSettingsModal(); settings = await client.getPackageSettings(); if (!settings.get('subdomain')) { return; } } else { client.alert( 'Your account has not configured a MyApp subdomain. Talk with your Lucid account administrator to complete configuration of the MyApp integration.', ); } } // Do whatever you need with the configured settings }); menu.addMenuItem({ label: 'Import data', action: 'import-data', menuType: MenuType.Main, });

In the future, settings will be editable by the installer in their account settings area. They may also become part of the package installation flow. For now, you must programmatically prompt the user to enter settings values in-product as shown above.

Lucid card integrations

Lucidspark allows rich data-driven integrations for task management systems. A Lucid card integration allows some or all of this functionality:

  • Importing tasks from a task management system as cards on a Lucidspark board
  • Creating new cards on a Lucidspark board that sync back to the task management system as new tasks
  • Converting existing shapes, like sticky notes, to cards that sync back as new tasks
  • Syncing changes made to cards in Lucidspark back to the task management system
  • Syncing changes made in the task management system to any connected Lucidspark board

Building a Lucid card integration requires the following steps:

  • In an editor extension, extend LucidCardIntegration and implement the functionality you want to support
  • Define an OAuth provider in your package manifest to allow API calls to the data source
  • Build a publicly-routable data connector that can read and write from the data source, and include configuration for it in your package manifest

This section of the developer guide will walk you through building a very simple Lucid card integration.

Extend LucidCardIntegration

This section describes how to implement a card integration without any actual data connector backing it. The following section, Connect To Data, will walk you through what adjustments you'll typically make to this sample code to make it work with real data.

In a new package, create a new editor extension, and write a new class that extends LucidCardIntegration. Construct it, and register it with LucidCardIntegrationRegistry.

import {EditorClient, LucidCardIntegration, LucidCardIntegrationRegistry} from 'lucid-extension-sdk'; const client = new EditorClient(); class ExampleLucidCardIntegration extends LucidCardIntegration { //We will fill this in soon } LucidCardIntegrationRegistry.addCardIntegration(client, new ExampleLucidCardIntegration(client));

Basic configuration

These fields are required for every Lucid card integration:

label - The name of the integration, e.g. "Asana"

itemLabel - What to call one data item, e.g. "Task" or "Asana task"

itemsLabel - What to call multiple data items, e.g. "Tasks" or "Asana tasks"

iconUrl - A URL (a data URI is fine) pointing to an icon representing the integration. This will be displayed at up to 32x32 CSS pixels in size.

dataConnectorName - The name of the data connector for this card integration, as specified in the package manifest

textStyle - Optionally, styles to be applied to all text on any cards created by this integration

class ExampleLucidCardIntegration extends LucidCardIntegration { public label = 'Example'; public itemLabel = 'Example task'; public itemsLabel = 'Example tasks'; public iconUrl = 'https://cdn-cashy-static-assets.lucidchart.com/marketing/images/LucidSoftwareFavicon.png'; public dataConnectorName = 'example'; public textStyle = { [TextMarkupNames.Size]: 24, [TextMarkupNames.Italic]: true, }; }

Field configuration

Card integrations import some subset of all available fields on the data source. Many task management systems can have hundreds of fields, and it may be impractical or not useful to import all of them. To that end, card integrations allow the end user to configure which fields should be imported.

Every card integration must provide a fieldConfiguration object with the following members:

getAllFields - Given a data source already imported on this document by this card integration, return a list of all fields that could be eligible for import. The data source is provided because the list of fields available in case producing the list of available fields is easier with access to the imported data.

onSelectedFieldsChange - Optionally, you may provide a callback for code you want to run after the user adjusts which fields should be imported. This is usually not needed, as data will automatically be re-requested from your data connector if the list of requested fields changes.

class ExampleLucidCardIntegration extends LucidCardIntegration { // ... public fieldConfiguration = { getAllFields: async (dataSource: DataSourceProxy) => { return ['id', 'name', 'due', 'complete', 'cost']; } }; }

Card shape configuration

Lucid card integrations must provide the default configuration to use for the appearance of cards imported or created by the integration.

As with the getAllFields callback described above, the getDefaultConfig callback accepts a DataSourceProxy as a parameter, indicating which data connection the configuration is for, in case you need access to the imported data to determine the list of fields to display.

The following information can be provided:

cardConfig.fieldNames - An array of field names that should be displayed as text fields on the card shapes on-canvas

cardConfig.fieldStyles - Optionally, text style overrides that should be applied for specific fields, on top of the default textStyle described in Basic configuration.

cardConfig.fieldDisplaySettings - Information about fields that should be displayed as data graphics on card shapes

cardDetailsPanelConfig.fields - An array of fields that has been imported and should be shown in the card details panel, with a name and optionally a locked boolean indicating whether the field cannot be unchecked from the list of fields to import

class ExampleLucidCardIntegration extends LucidCardIntegration { // ... public getDefaultConfig = async (dataSource: DataSourceProxy): Promise<CardIntegrationConfig> => { return { cardConfig: { fieldNames: ['name'], fieldStyles: new Map([ ['name', { [TextMarkupNames.Bold]: true, }], ]), fieldDisplaySettings: new Map([ [ 'complete', { stencilConfig: { displayType: FieldDisplayType.BasicTextBadge, horizontalPosition: HorizontalBadgePos.LEFT, backgroundColor: '=IF(@complete, "#b8dedc", "#fff1aa")', valueFormula: '=IF(@complete, "Complete", "Incomplete")', }, }, ], [ 'id', { stencilConfig: { displayType: FieldDisplayType.SquareImageBadge, valueFormula: '="https://cdn-cashy-static-assets.lucidchart.com/marketing/images/LucidSoftwareFavicon.png"', onClickHandlerKey: OnClickHandlerKeys.OpenBrowserWindow, linkFormula: '=CONCATENATE("https://www.example.com/data/", @id)', horizontalPosition: HorizontalBadgePos.RIGHT, tooltipFormula: '="Open at example.com"', backgroundColor: '#00000000', }, }, ], [ 'due', { stencilConfig: { displayType: FieldDisplayType.DateBadge, tooltipFormula: '=@due', }, }, ], ]), }, cardDetailsPanelConfig: { fields: [ { name: 'name', locked: true, }, {name: 'complete', locked: true}, {name: 'due'}, ], }, }; }; }

Field display settings

For fields included in fieldDisplaySettings, a variety of display options are available by selecting a value for stencilConfig.displayType.

In each case, only the displayType is strictly required. The other options that you can set on these display configuration objects are as follows:

backgroundColor

For displayType values that support it, you may provide an override for the background color for this data. If this is not provided, the background color will default to the background color of the card itself, darkened 5%.

This may be provided as a literal color hex, e.g. "#00ff00ff" or as a formula by starting the string with "=", e.g. "=darken("#ffffff", 0.5)"

valueFormula

If specified, the result of this formula (executed in the context of the data item associated with this card) will be used instead of the raw field value when creating the data graphic. This can be useful if, for example, you want to convert an ID into a URL.

tooltipFormula

If specified, the result of this formula (executed in the context of the data item associated with this card) will be used as a tooltip when the user hovers the cursor over this data graphic.

horizontalPosition

Each display type has its default location on the card. You can override those default locations by setting these values.

verticalPosition

Each display type has its default location on the card. You can override those default locations by setting these values.

onClickHandlerKey

If specified, what behavior should happen when the user clicks on the data graphic generated via the above displayType?

linkFormula

If onClickHandlerKey is OpenBrowserWindow, this formula calculates the URL to open.

Import modal callbacks

Most Lucid card integrations will allow users to import data from their data source using the standard card import modal. To configure that modal for search and import, specify the importModal member of your LucidCardIntegration. You will provide three asynchronous callbacks that specify the set of search fields the user can fill out, perform the search based on those values, and finally import the data selected as new cards on-canvas. There is a fourth optional callback that can be used on the setup process.

getSearchFields callback

The getSearchFields callback returns a list of fields to display on the search modal for the user to search or filter the available data. The callback takes all the entered search field values so far as a parameter, in case you want to return different search fields based on values entered in the form so far, e.g. to offer a dropdown to select a project based on the workspace already selected.

The following example displays a simple text search box, plus a dropdown to filter to either "completed" or "not completed" tasks, defaulting to filtering to "not completed". The constraint for that dropdown for MAX_VALUE of 1 indicates that only one option can be selected. Without that constraint, the user would be able to select multiple options in the dropdown.

class ExampleLucidCardIntegration extends LucidCardIntegration { // ... public importModal = { getSearchFields: async ( searchSoFar: Map<string, SerializedFieldType>, ): Promise<ExtensionCardFieldDefinition[]> => { return [ { name: 'search', label: 'Search', type: ScalarFieldTypeEnum.STRING, }, { name: 'complete', label: 'Status', type: ScalarFieldTypeEnum.BOOLEAN, constraints: [{type: FieldConstraintType.MAX_VALUE, value: 1}], default: false, options: [ {label: 'Completed', value: true}, {label: 'Not Completed', value: false}, ], }, ]; }, // Stub these out for now search: () => { throw 'Not implemented'; }, import: () => { throw 'Not implemented'; }, }; }

At this point, you can register your card integration and see it appear in the Lucidspark UI.

LucidCardIntegrationRegistry.addCardIntegration( client, new ExampleLucidCardIntegration(client) );

At the bottom of the left toolbar, click the ellipses to open a menu, and scroll to the bottom. You'll see your new integration listed there. Click the pin icon on the right to pin the integration to your toolbar.

Card integration ellipses menu

If you click your integration's icon in the toolbar, you'll see a standard flyout appear. Because you've specified importModal on your LucidCardIntegration subclass, a button appears to perform an import.

Card integration import flyout

If you click that button, you'll see the import modal appear, with the search and status fields displayed, with the default value in the status field.

There's an error displayed because the search callback just throws an error.

Card integration import error

Note: If you want to provide a human-readable error as the result of a search or other callback, you can use throw new HumanReadableError('The message'). That error message will be displayed as the red text on the modal. Any other uncaught error or rejected promise will result in a generic error message as shown above.

Supported search field types

The following examples are all currently-supported search field types.

Plain text input

{ name: 'search', label: 'Search', type: ScalarFieldTypeEnum.STRING, }

Simple single-select dropdown

{ name: 'complete', label: 'Status', //Note: May be NUMBER or STRING as well, depending on the "value" set on options below type: ScalarFieldTypeEnum.BOOLEAN, constraints: [{type: FieldConstraintType.MAX_VALUE, value: 1}], options: [ {label: 'Completed', value: true}, {label: 'Not Completed', value: false}, ], }

Simple multi-select dropdown

{ name: 'complete', label: 'Status', //Note: May be NUMBER or STRING as well, depending on the "value" set on options below type: ScalarFieldTypeEnum.BOOLEAN, options: [ {label: 'Completed', value: true}, {label: 'Not Completed', value: false}, ], }

Dropdown where options are loaded lazily

Use the MAX_VALUE constraint to specify single-select or multi-select dropdown, as above.

const optionsCallback = LucidCardIntegrationRegistry.registerFieldOptionsCallback(client, async () => { return [ {label: 'Completed', value: true}, {label: 'Not Completed', value: false}, ]; }); // ... { name: 'complete', label: 'Status', type: ScalarFieldTypeEnum.BOOLEAN, options: optionsCallback, }

Dropdown where options are queried as the user types a search term

Use the MAX_VALUE constraint to specify single-select or multi-select dropdown, as above.

const searchCallback = LucidCardIntegrationRegistry.registerFieldSearchCallback(client, async (searchText) => { return [ {label: 'Tabitha Ross', value:'Tabitha Ross'}, {label: 'Stanley Browning', value:'Stanley Browning'}, {label: 'Randall Lucas', value:'Randall Lucas'}, {label: 'Kira Ellis', value:'Kira Ellis'}, {label: 'Dale Bauer', value:'Dale Bauer'}, {label: 'Itzel Knight', value:'Itzel Knight'}, {label: 'Sage Beltran', value:'Sage Beltran'}, {label: 'Iris Ponce', value:'Iris Ponce'}, {label: 'Gisselle Conway', value:'Gisselle Conway'}, {label: 'Emely Williams', value:'Emely Williams'}, {label: 'Elena Arias', value:'Elena Arias'}, {label: 'Sarahi Aguirre', value:'Sarahi Aguirre'}, ].filter(one => one.label.includes(searchText)); }), // ... { name: 'owner', label: 'Owner', type: ScalarFieldTypeEnum.STRING, search: searchCallback, }

search callback

The next step is to implement the actual search functionality based on the values the user has entered into the search fields you specified above.

Typically, this will include making OAuth API calls to an outside source of data. In this simple example, we will just hard-code some search results.

The search callback returns a CollectionDefinition including a schema and data items representing the search results. It also returns a list of fields to display as columns in the user-visible data table in the search modal.

class ExampleLucidCardIntegration extends LucidCardIntegration { // ... public importModal = { // ... search: async ( fields: Map<string, SerializedFieldType>, ): Promise<{ partialImportMetadata?: {collectionId: string; syncDataSourceId?: string}; data: CollectionDefinition; fields: ExtensionCardFieldDefinition[]; }> => { const complete = fields.get('complete') as boolean | undefined; const search = fields.get('search') as string | undefined; const data = [ { 'id': 'task1', 'name': 'The first task', 'complete': false, }, { 'id': 'task2', 'name': 'The second task', 'complete': true, }, { 'id': 'task3', 'name': 'The third task', 'complete': true, }, ].filter((one) => { if (complete !== undefined && one.complete !== complete) { return false; } if (search !== undefined && !one.name.includes(search)) { return false; } return true; }); return { data: { schema: { fields: [ {name: 'id', type: ScalarFieldTypeEnum.STRING}, {name: 'name', type: ScalarFieldTypeEnum.STRING, mapping: [SemanticKind.Title]}, {name: 'complete', type: ScalarFieldTypeEnum.BOOLEAN}, ], primaryKey: ['id'], }, items: new Map( data.map((one) => [ //The key for a data item is its primary key field, JSON-stringified. JSON.stringify(one.id), one, ]), ), }, fields: [ { name: 'name', label: 'Name', type: ScalarFieldTypeEnum.STRING, }, { name: 'complete', label: 'Completed', type: ScalarFieldTypeEnum.BOOLEAN, }, ], }; }, // ... }; }

Now, you can use the text search and dropdown to filter those three hard-coded tasks in the import modal.

Card integration import search

The optional partialImportMetadata that you can return from the search callback will be covered later.

import callback

Now that you've allowed the user to find the tasks they want to import, you need to provide the callback that actually performs the import once the user selects one or more of those tasks and clicks Import.

The import callback accepts as parameters a list of primary keys selected by the user from your CollectionDefinition returned from your search callback, as well as the set of field values entered by the user in the search form (in case you need that information in order to perform the import, e.g. the user selecting which server to query).

When the callback returns, the import should be complete, and the imported data should exist in a collection on the current document. This will typically involve calling an action on a data connector, which performs the actual import by querying the data source and then converting that data into a format consumable by Lucid.

However, in order to provide as simple an example as possible, we will simply create a collection and directly enter the data from the editor extension itself. Warning: Using this approach instead of a data connector will make it nearly impossible to build a two-way data sync that works well with multiple users.

class ExampleLucidCardIntegration extends LucidCardIntegration { // ... public importModal = { // ... import: async ( primaryKeys: string[], searchFields: Map<string, SerializedFieldType>, ): Promise<{collection: CollectionProxy; primaryKeys: string[]}> => { const data = new DataProxy(client); const source = data.dataSources.find((source) => source.getSourceConfig()['from'] === 'example') || data.addDataSource('example', {'from': 'example'}); const collection = source.collections.find((collection) => collection.getName() === 'tasks') || source.addCollection('tasks', { fields: [ {name: 'id', type: ScalarFieldTypeEnum.STRING}, {name: 'name', type: ScalarFieldTypeEnum.STRING, mapping: [SemanticKind.Title]}, {name: 'complete', type: ScalarFieldTypeEnum.BOOLEAN}, {name: 'due', type: [ScalarFieldTypeEnum.DATEONLY, ScalarFieldTypeEnum.NULL]}, ], primaryKey: ['id'], }); const added = [ { 'id': 'task1', 'name': 'The first task', 'complete': false, 'due': {'isoDate': '2025-01-01'}, }, { 'id': 'task2', 'name': 'The second task', 'complete': true, 'due': null, }, { 'id': 'task3', 'name': 'The third task', 'complete': true, 'due': null, }, ] //Only insert the ones selected by the user .filter((one) => primaryKeys.includes(JSON.stringify(one['id']))) //Only insert ones not already in the collection .filter((one) => !collection.items.get(JSON.stringify(one['id'])).exists()); collection.patchItems({added}); return {collection, primaryKeys: added.map((one) => JSON.stringify(one['id']))}; }, // ... }; }

Now, if you import the first task, you will see the card appear on-canvas, configured based on the getDefaultConfig callback we specified before.

Card integration import card

onSetup callback

During the import modal setup, if you want to add any custom setup code, you can do by implementing this optional callback.

It is going to be called everytime right after the modal is displayed and before the first call to getSearchFields.

Add card callbacks

Some Lucid card integrations will allow users to create new card data directly within Lucidspark. To configure that behavior, specify the addCard member of your LucidCardIntegration.

You will provide one callback the specifies the fields a user may (or must) enter in order to create a new card. You will also provide a second callback that creates the card data, given values the user entered in the fields you specified.

getInputFields callback

The getInputFields callback works just like the getSearchFields callback in importModal, and supports all the same input types.

A simple form that requires the user to enter a task name, and optionally allows them to specify a due date, looks like this:

class ExampleLucidCardIntegration extends LucidCardIntegration { // ... public addCard = { getInputFields: async ( inputSoFar: Map<string, SerializedFieldType>, ): Promise<ExtensionCardFieldDefinition[]> => { return [ { name: 'name', label: 'Task name', type: ScalarFieldTypeEnum.STRING, constraints: [{type: FieldConstraintType.REQUIRED}], }, { name: 'due', label: 'Due on', type: ScalarFieldTypeEnum.DATEONLY, }, ]; }, // Stub this out for now createCardData: () => { throw new HumanReadableError('Not implemented'); }, }; }

At this point, the user can now access this form in two ways.

First, a new button appears in the standard card integration flyout.

Card integration create flyout

Which, when clicked, opens your form.

Card integration create flyout part 2

Second, the user can now select one or more shapes on their board and convert them into cards on your integration via a menu (or context menu) option.

Card integration create menu

The text of the shape will automatically be used to fill the first available text field in your form.

Card integration create menu part 2

createCardData callback

When the user fills in your form and clicks Create, the createCardData callback is called with the form input values as its parameter.

Usually, this callback will invoke a data action on a data connector, which will create the data via an OAuth API and then perform an import of that newly-created data.

However, to provide the simplest possible example, we will just directly create a data item in the same collection we do our "import" on. Warning: Using this approach instead of a data connector will make it nearly impossible to build a two-way data sync that works well with multiple users.

This callback works just like import callback from the importModal configuration above, but only returns a single primary key instead of an array of them.

class ExampleLucidCardIntegration extends LucidCardIntegration { // ... public addCard = { // ... createCardData: async ( input: Map<string, SerializedFieldType>, ): Promise<{collection: CollectionProxy; primaryKey: string}> => { const data = new DataProxy(client); const source = data.dataSources.find((source) => source.getSourceConfig()['from'] === 'example') || data.addDataSource('example', {'from': 'example'}); const collection = source.collections.find((collection) => collection.getName() === 'tasks') || source.addCollection('tasks', { fields: [ {name: 'id', type: ScalarFieldTypeEnum.STRING}, {name: 'name', type: ScalarFieldTypeEnum.STRING, mapping: [SemanticKind.Title]}, {name: 'complete', type: ScalarFieldTypeEnum.BOOLEAN}, {name: 'due', type: [ScalarFieldTypeEnum.DATEONLY, ScalarFieldTypeEnum.NULL]}, ], primaryKey: ['id'], }); const added = [ { 'id': 'task' + (collection.items.size + 1), 'name': input.get('name'), 'complete': false, 'due': input.get('due'), }, ]; collection.patchItems({added}); return {collection, primaryKey: JSON.stringify(added[0]['id'])}; }, }; }

At this point, you should be able to convert one or more shapes on your Lucidspark board to cards in our example integration. You should also be able to create new cards directly from the standard card integration flyout.

Connect To Data

In order to connect your card integration to real data, you'll first need to configure OAuth authorization parameters for your integration. Read the section on Using OAuth APIs to get started.

A card integration typically accesses data in two ways:

  1. Direct OAuth API calls from extension code during the search and import steps
  2. Invoking data actions on a data connector to finalize imports

Add direct API calls

Any time you're accessing data in order to populate the import or create card UI, you can do so by making OAuth-authorized calls directly with EditorClient.oauthXhr. For example, if you need to allow a user to filter tasks during import based on a list of tags, you might use EditorClient.oauthXhr to request the list of tags to display as options in the return value of getSearchFields.

You can also use direct API access in this way to return results from the search callback. A simple search callback might look like this:

search: async ( fields: Map<string, SerializedFieldType>, ): Promise<{ data: CollectionDefinition; fields: ExtensionCardFieldDefinition[]; }> => { const workspaceId = fields.get('workspace') as string | undefined; const response = await client.oauthXhr('exampleApi', { 'https://www.example.com/api/tasks?workspaceId=' + workspaceId, method: 'GET', responseFormat: 'utf8', }); const tasks = JSON.parse(response.responseText); return { data: { schema: { fields: [ {name: 'id', type: ScalarFieldTypeEnum.STRING}, {name: 'name', type: ScalarFieldTypeEnum.STRING, mapping: [SemanticKind.Title]}, {name: 'assignee', type: ScalarFieldTypeEnum.STRING}, {name: 'completed', type: ScalarFieldTypeEnum.BOOLEAN}, {name: 'dueOn', type: ScalarFieldTypeEnum.DATEONLY}, ], primaryKey: ['id'], }, items: new Map( tasks.map((task) => [ JSON.stringify(task.id), { 'id': task.id, 'name': task.name, 'assignee': task.assignee?.name, 'completed': task.completed, 'dueOn': task.due_on ? { 'isoDate': task.due_on } : undefined, }, ]), ), }, fields: [ { name: 'name', label: 'Name', type: ScalarFieldTypeEnum.STRING, }, { name: 'assignee', label: 'Assignee', type: ScalarFieldTypeEnum.STRING, }, { name: 'completed', label: 'Completed', type: ScalarFieldTypeEnum.BOOLEAN, }, { name: 'dueOn', label: 'Due', type: ScalarFieldTypeEnum.STRING, }, ], }; }

Here we're querying a simple OAuth API using the workspace field selected by the user in the search form, and returning a simple collection of those tasks to display.

Add calls to your data connector

Once you need to add real data to the document that will be shared with other collaborators on the same Lucid document, you should do so using your data connector.

The use of a data connector allows your data to be read and written by any number of collaborators simultaneously, while making sure specific changes are attributed to (and executed with the OAuth credentials of) the user initiating those changes, even in unusual circumstances like a user making a change and then losing connectivity before performing the actual API calls.

It is technically possible to create a data source, collections, and data items directly in the import callback. In fact, it might make sense to do that during early prototyping in order to get something working quickly. However, it will result in code that is fragile to certain race conditions and edge cases.

You can learn more about data connectors here.

Connect the import callback to data

There are two key methods that you'll use to interact with your data connector, EditorClient.performDataAction and EditorClient.awaitDataImport.

performDataAction invokes an action on your data connector, and you'll use it each time you either import data. You can pass whatever information is necessary to perform the action as the flowData parameter to that method.

awaitDataImport allows you to wait for your data connector to create any data you expect it to. You specify the sync ID of the data source and collection that should be created, along with a list of primary keys you're waiting for.

For example, a simple import callback might look like this:

import: async ( primaryKeys: string[], searchFields: Map<string, SerializedFieldType>, ): Promise<{collection: CollectionProxy; primaryKeys: string[]}> => { await this.editorClient.performDataAction({ dataConnectorName: 'exampleApi', //Name of the data connector syncDataSourceIdNonce: 'example-tasks-source', //Sync ID to use for this data source (can be anything) actionName: 'Import', //Name of the action to invoke actionData: { //actionData--this will be passed along to your data connector code workspaceId: searchFields.get('workspace'), taskIds: primaryKeys.map((pk) => JSON.parse(pk)) }, asynchronous: true, //This data action is expected to set data on this document }); //Wait for the data connector to create the 'example-task-source' data source, //the 'Tasks' collection within it, and this specific list of primary keys const collection = await this.editorClient.awaitDataImport('exampleApi', 'example-tasks-source', 'Tasks', primaryKeys); //Tell the import flow to create cards for these imported data records return {collection, primaryKeys}; },

Connect the createCardData callback to data

Any attempted changes to data from within Lucid, including adding a new data record, should be sent back to the data source through your data connector. This helps avoid any edge cases or race conditions with multiple editors. The common flow for creating a new card works like this:

Create card flow

A simple createCardData callback looks like this:

createCardData: async ( input: Map<string, SerializedFieldType>, ): Promise<{collection: CollectionProxy; primaryKey: string}> => { let collection: CollectionProxy; try { //Check if the Tasks collection has already been created in the example-tasks-source data source, //with a 1ms timeout. If the collection doesn't exist yet, an exception will be thrown. collection = await this.editorClient.awaitDataImport('exampleApi', 'example-tasks-source', 'Tasks', [], 1); } catch { //No collection exists yet. Ask the data connector to perform an import of an empty list of tasks, //just to get the collection created along with whatever other metadata you might need to set up there. await this.editorClient.performDataAction({ dataConnectorName: 'exampleApi', syncDataSourceIdNonce: 'example-tasks-source', actionName: 'Import', actionData: {taskIds: []}, asynchronous: true, }); //And now wait for that empty import to complete, getting a reference to the resulting collection. collection = await this.editorClient.awaitDataImport('exampleApi', 'example-tasks-source', `Tasks`, []); } //Add a single record const primaryKeys = collection.patchItems({ added: [ { 'name': input.get('name'), 'dueOn': input.get('due'), }, ], }); if (primaryKeys.length != 1) { throw new Error('Failed to add new card data'); } return {collection, primaryKey: primaryKeys[0]}; }

Examples

The source code for a card integration based on the guide above can be found here. This can serve as a template for you to build a card integration.

For a more thorough example, you can look at the source code for Lucid's Asana Cards extension here. You can find the extension here if you want to try it out.

Connecting to external data

You may need to access data from a source not already supported by Lucid. In order to do this, you have two choices:

  1. Fetch data using EditorClient.oauthXhr and add it to the document by creating data sources and collections in your extension as described here.
  2. Create a data connector which can access and manage data in response to requests from Lucid's servers, and add the data connector to your extension.

For simple integrations, the first approach may be entirely sufficient. However, if you want your data to update when changes are made to it in Lucid documents, or if you want updates that happen in the data's source to be automatically reflected in Lucid documents, then you'll need to create a data connector.

A data connector is a collection of callback endpoints that translate between external representations of data and Lucid's representation. Data connectors are bidirectional, allowing for data updates to flow from external sources into Lucid documents and from Lucid documents back to their external sources. A data connector must provide at least one URL at which it can respond to requests made by Lucid's servers.

When Lucid makes a request to your data connector, it will come in the form of a data action. Data actions are triggered either explicitly by code in your extension, or automatically based on user interactions with Lucid documents. Each data action has a name, a callback url suffix, an OAuth token for accessing the data on behalf of the requesting user, and other information that is relevant to fulfilling the user's request.

To add a data connector to your extension you must:

  1. Declare your data connector in your manifest.json.
  2. Implement your data connector.
  3. Expose a URL Lucid's servers can use to make requests to your data connector.

Declare your data connector

To declare a data connector, include an array of dataConnectors in your manifest.json. Each data connector must contain the following:

Field Description Example
name Then name you will use to refer to this data connector in your extension DemoDataConnector
oauthProviderName The name of an OAuth provider defined in your mainfest.json asana
callbackBaseUrl The base url Lucid will send callback events to https://www.example.com/
dataActions A mapping of the data actions supported by this data connector to the url suffix that should be appended to the callbackBaseUrl when making requests for that type of data action. {"Import" : "import"}

Any time you add or update declarations for any data connectors, you will need to package and upload your manifest, then install the extension for yourself again before your data connector can be used by your extension.

Implement your data connector

As an example, let's start with an "Import" data action which instructs the data connector what data to import onto a document.

To trigger an "Import" data action from your extension, call performDataAction on the EditorClient in lucid-extension-sdk:

/demo-extension-package/editorextensions/demo-extension/src/index.ts
const client = new EditorClient(); client.performDataAction({ dataConnectorName: 'DemoDataConnector', actionName: 'Import', actionData: {'requestedItems': ['id-1', 'id-2']}, asynchronous: true, });

If your manifest.json defines callbackBaseUrl = https://www.example.com/ and dataActions = {"Import" : "import"}, Lucid will make a POST request to https://www.example.com/import with a data action as the request body. The data action will include {'requestedItems': ['id-1', 'id-2']} in its data, and because the action was called with asynchronous: true it will also include a token that can be used to POST the requested data back to the Lucid. Additionally, the data action will include an OAuth access token that can be used to access the data on behalf of the user who triggered the data action. The access token can be found in the body of the data action under action.context.userCredential as well as in the Authorization header of the request.

For your convenience, the lucid-extension-sdk provides helpful wrappers for implementing your data connector that will handle request signature validation, data action routing, and more. The data connector itself can be defined using the DataConnector class. Request handling for data actions can be added by calling either defineAsynchronousAction, or defineAction on the Data connector, and providing the implementation for data actions with a specified name:

/demo-extension-package/data-connector/src/index.ts
new DataConnector(new DataConnectorClient(cryptoDependencies)).defineAsynchronousAction('Import', async (action) => { const client = action.client; // <- an authorized client for sending data back to the document const actionName = action.name; // <- "Import" const actionData = action.data; // <- {'requestedItems': ['id-1', 'id-2']} const userCredential = action.context.userCredential; // <- the OAuth access token for the user who triggered the data action });

To add a new collection to the document containing the requested items, you must first define what the data will look like using a schema. For this example, we will use data of the following type:

/demo-extension-package/data-connector/src/index.ts
type MyItemType = {id: string; name: string; age: number; isSingle: boolean};

You can define the schema for your data by calling declareSchema and specifying the types for each of the fields in your data:

/demo-extension-package/data-connector/src/index.ts
const myCollectionSchema = declareSchema({ primaryKey: ['id'], fields: { 'id': {type: ScalarFieldTypeEnum.STRING}, 'name': {type: ScalarFieldTypeEnum.STRING}, 'age': {type: ScalarFieldTypeEnum.NUMBER}, 'isSingle': {type: ScalarFieldTypeEnum.BOOLEAN}, }, }); // Infer the TS type: type MyItemType = ItemType<typeof myCollectionSchema.example>; let dataItem: MyItemType = {id: 'id-1', name: 'John', age: 30, isSingle: true};

To fulfill the import request, retrieve the data that was requested, then use the authorized client provided to post the data back to the document:

/demo-extension-package/data-connector/src/index.ts
const makeDataConnector( client: DataConnectorClient, ) => { return new DataConnector(client).defineAsynchronousAction('Import', async (action) => { const itemsToAdd: MyItemType[] = [ {id: 'id-1', name: 'John Doe', age: 30, isSingle: true}, {id: 'id-2', name: 'Jane Doe', age: 31, isSingle: true}, ]; action.client.update({ dataSourceName: 'Demo Data Source', collections: { 'My Collection': { schema: { fields: myCollectionSchema.array, primaryKey: myCollectionSchema.primaryKey.elements, }, patch: { items: myCollectionSchema.fromItems(itemsToAdd), }, }, }, }); }); }

Field Labels

When fields are displayed in the card details panels, or in other visualizations, the field name will be used as the label for the field. You can specify the labels that will be used by providing a custom label as part of the schema while adding a collection. This is done by adding a collection to a data source that has been imported. Include the fields from the source you want to display, specify which field is the primary key, then add field labels for any field names you want to override.

source.addCollection('track-tickets', { fields: [ {name: 'id', type: ScalarFieldTypeEnum.STRING}, {name: 'description', type: ScalarFieldTypeEnum.STRING}, {name: 'assigned', type: [ScalarFieldTypeEnum.STRING, ScalarFieldTypeEnum.NULL]}, {name: 'state', type: [ScalarFieldTypeEnum.STRING, ScalarFieldTypeEnum.NULL]}, ], primaryKey: ['id'], fieldLabels: { 'id': 'Ticket', 'description': 'Description', 'assigned': 'Assigned', 'state': 'State', }, });

Expose a URL for your data connector

The final step is to use your data connector to handle incoming requests by calling dataConnector.runAction:

const response = await dataConnector.runAction(requestUrl, requestHeaders, requestBodyAsJson);

As an example, this code sets up a simple development server using node express that will accept and respond to requests made to your data connector:

serve.js
import {DataConnectorClient} from 'lucid-extension-sdk'; import {makeDataConnector} from './dataconnector'; import * as express from 'express'; import * as crypto from 'crypto'; const client = new DataConnectorClient({Buffer, crypto}); const dataConnector = makeDataConnector(client); dataConnector.runDebugServer({express});

node ./serve.js

You will need to expose the URL you are using to accept requests publicly so that Lucid's servers can send it requests.

Request Signing

For your security, Lucid provides a request signature header (X-RSA-Signature) on all requests sent to data connectors. The signature can be used by data connectors to validate that incoming requests are legitimate traffic coming from Lucid. The signature is a concatenation of the request body and the query parameters. Lucid signs requests using the RS384 algorithm. A full list of Lucid's public keys can be found here in jwks format, and individual keys can be found by their IDs in PEM format at lucid.app/.well-known/pem/TPCP.

Validation

If you use DataConnector from the lucid-extension-sdk to implement your data connector, request validation is performed automatically for you.

If you would like to validate the requests yourself, you can follow this example for Node.js:

const crypto = require('crypto'); const parts = request.uri.split('?') const params = parts.length > 0 ? parts[1] : ""; const nonce = request.headers['X-Lucid-RSA-Nonce']; const signature = Buffer.from(request.headers["X-Lucid-Signature"], "base64"); const data = Buffer.from(body + nonce + params); const verified = crypto.verify("SHA384", data, LUCID_PUBLIC_KEY, signature);

Examples

For a more thorough example, you can look at the source code for Lucid's Asana Cards extension here. You can find the extension here if you want to try it out.

i18n

Any string internationalization or localization must happen inside your extension code. As a convenience, the extension SDK provides access to a full-featured i18n library. This section describes how to use this library, but you are welcome to use any i18n mechanism that works well for you.

Providing and using i18n strings

There is an i18n object in the global namespace that provides a few methods you'll commonly use:

i18n.getLanguage Returns the language code currently selected by the user in Lucid, e.g. "en" or "es-LA"
i18n.setData Adds new strings to the currently active i18n dictionary, and updates the active language
i18n.get Gets a string from the currently active i18n dictionary and performs interpolations as necessary

Here is a minimal example of using the library:

const language = i18n.getLanguage(); // Set the English strings as fallbacks, in case other languages // don't have all the strings translated yet i18n.setData({ "a": "ay", "b": "bee", }, language); // If the language is set to another of the languages we // have a dictionary for, set those strings as replacements if(language == "es-LA") { i18n.setData({ "a": "ah", "b": "bay", }, language); } // Later... const client = new EditorClient(); client.alert(i18n.get("a"));

Use JSON files for string dictionaries

Inlining your i18n strings into your source code isn't a scalable solution. Instead, you can put your dictionaries into .json files under your resources directory. For example, if you add the files resources/i18n/en.json, resources/i18n/es-LA.json, etc., your code could look like this instead:

import en from '../resources/i18n/en.json'; import de from '../resources/i18n/de.json'; import esLa from '../resources/i18n/es-LA.json'; const language = i18n.getLanguage(); i18n.setData(en, language); if (language == 'de') { i18n.setData(de, language); } else if (language == 'es-LA') { i18n.setData(esLa, language); }

In order to import JSON files like this, you'll need to make sure your resources/resource.d.ts file specifies their type:

declare module '*.json' { const content: Record<string, string>; export default content; }

You must also have exclude: /\.json$/ in the raw-loader rule for the resources directory in webpack.config.js.

String interpolation

You can mark sections of your string to be replaced with curly braces. For example,

i18n.setData( { 'title': 'Send an email to {name}', }, i18n.getLanguage(), ); // "Send an email to Ben" console.log(i18n.get('title', {'name': 'Ben'}));

Pluralization

count is a reserved string interpolation id, to be used only for pluralization.

You can provide entries in your i18n dictionary that support pluralization by appending (in most languages) .one or .other to the key.

For example,

i18n.setData( { 'title.one': 'Send an email to {count} person', 'title.other': 'Send an email to {count} people', }, i18n.getLanguage(), ); // "Send an email to 1 person" console.log(i18n.get('title', {'count': 1})); // "Send an email to 4 people" console.log(i18n.get('title', {'count': 4}));

You can learn more about pluralization rules on this site from the Unicode CLDR Project. You can see a full list of which pluralization rules you should specify for every possible language here.

For convenience, here is the list of pluralization suffixes you should provide (only for strings interpolating a count) for each language supported by Lucid:

Language Suffixes
Chinese other
Dutch one, other
English one, other
French one, other
German one, other
Italian one, other
Japanese other
Korean one, other
Polish one, other
Portuguese one, other
Russian one, few, many
Spanish one, other
Swedish one, other

Wrapped strings

You can "wrap" sections of your i18n strings in markup, often used for producing HTML links and other simple markup.

i18n.setData( { 'message': 'Send an email to <w0>{name}</w0>', }, i18n.getLanguage(), ); // Send an email to <a href="mailto:ben@example.com">Ben</a> console.log(i18n.get( 'message', {'name': 'Ben'}, ['<a href="mailto:ben@example.com">{}</a>'] ));

Here, we're replacing the first wrapping tag w0 with the email link, with the content of w0 being placed where the {} is in the replacement text. If you need to do multiple wrapping tags in the same i18n string, number them w0, w1, w2, etc.

Supported languages

i18n.getLanguage() will tell you the user's currently-selected language in Lucid. This will be one of the following values:

Language Code
Chinese (simplified) zh-CN
Chinese (traditional) zh-TW
Dutch nl
English en
French fr-FR
German de
Italian it-IT
Japanese ja
Korean ko
Polish pl-PL
Portuguese pt-BR
Russian ru
Spanish es-LA
Swedish sv-SE

lucid-package CLI

lucid-package is a command-line interface for creating and managing extension packages. The getting started section goes over the very basics of using lucid-package to create and test an extension.

To install lucid-package, just use npm install lucid-package. It is then directly executable with npx, for example npx lucid-package create.

This section describes each command available in the lucid-package CLI in detail. For a quick start using the CLI, see Getting started.

build-editor-extension

npx lucid-package build-editor-extension <name>

After creating an editor extension as part of your extension package, build-editor-extension compiles that extension in release mode, performing type checking and producing the final bin/extension.js file that is included as part of the final bundle you upload to the developer dashboard.

This command is not necessary to use directly, as the bundle command already compiles your extensions, but may be useful as a troubleshooting tool.

bundle

npx lucid-package bundle

The bundle command compiles the editor extensions and custom shape libraries in the current extension package and produces the final package.zip file that you upload to the Developer Portal as a version of your extension.

Multiple environments

The bundle command will default to bundling your package defined with the manifest.json. However, you can provide an environment parameter to define which manifest you would like to mixin when bundling. The values in that manifest will override values in manifest.json.

npx lucid-package bundle --env=staging

This example will mix in the values from manifest.staging.json and use that in the environment scoped package package-staging.zip.

create-editor-extension

npx lucid-package create-editor-extension <name>

This command creates a new directory with the given name, with a very simple editor extension inside it. Your manifest.json file is also updated to refer to this new editor extension.

You should adjust the product and scopes in the new extensions entry in the manifest to match your needs. You should request the minimum scopes required to implement your editor extension.

create-shape-library

npx lucid-package create-shape-library <name>

This command creates a shape library in a new directory with the given name, and adds a reference to that shape library in manifest.json. You can then test your shape library using the test-shape-libraries or test-editor-extension command.

For more information, see the section Custom shapes in the developer guide.

create-image-shape-library

npx lucid-package create-image-shape-library <name> <image-path>

This command creates a shape library from a directory of images. Every file in the target folder will become a new shape with a corresponding image. Width and height for each image will be read in automatically.

You can add the tag --aspectRatio which will lock the aspect ratio for all shapes. If you want to manually set the width and height use --width x --height y.

You can then test normally with test-shape-libraries or test-editor-extension command.

create

npx lucid-package create <extension-name>

Use lucid-package create to create a new Lucid extension in a new directory. This command creates a directory with the name you pass in as the extension's name, and creates and empty Lucid extension there.

test-editor-extension

npx lucid-package test-editor-extension <name>

This starts a local HTTP server that watches the given editor extension's source code and provides a debug-compiled version of it to the Lucid product specified for that editor extension in manifest.json.

See Getting started for more information on debugging editor extensions.

This command also serves all shape libraries in your extension package as well, as often those shape libraries are needed by the editor extensions to work correctly. See test-shape-libraries for more details.

test-shape-libraries

npx lucid-package test-shape-libraries

This starts a local HTTP server that watches this extension's shape library source code and provides a debug-compiled version of it to the Lucid product specified for those shape libraries in manifest.json.

While running this command, all shape libraries in this extension are automatically activated and loaded into the shape toolbox. This is not the case when an end user installs your extension, but is provided as a convenience to make it easier and faster to test changes to shape libraries.

Changes to shape code should be reflected almost immediately, without a need to refresh the browser.

update-sdk

npx lucid-package update-sdk

This command updates the NPM dependency of each of your editor extensions to the latest published version of lucid-extension-sdk. You can also do this yourself by going into the directory of each editor extension and running npm install lucid-extension-sdk@latest

Release notes

lucid-extension-sdk

NPM package

0.0.140

You can now extend CustomBlockProxy for custom shapes defined in your extension package, and that class will automatically be used instead of CustomBlockProxy whenever you get a reference to a block of that type.

For more details, see Identifying custom shapes.

0.0.134

You can now programmatically examine data changes made locally that have not yet been synced back to the original source.

0.0.106

i18n support was just added to extensions, with the recommendation being to store your strings in JSON files under resources/i18n inside your editor extensions. The default webpack.config.js previously had all files inside /resources/ using the raw-loader, which is incompatible with JSON.

If you want to add i18n to an editor extension created before this update, make sure you add exclude: /\.json$/ to the raw-loader entry in module.exports.module.rules in webpack.config.js, e.g.

module.exports = { entry: './src/extension.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, { test: /[\\\/]resources[\\\/]/, use: 'raw-loader', exclude: /\.json$/, // <- Newly added }, ], }, resolve: { extensions: ['.ts', '.js'], }, output: { filename: 'bin/extension.js', path: __dirname, }, mode: 'development', };

You also need to make sure resources/resource.d.ts specifies the type of your i18n files:

declare module '*.json' { const content: Record<string, string>; export default content; }

0.0.84

You can now check which Lucid product is currently loaded with EditorClient.getProduct.

0.0.27

Custom panels can now be placed into the toolbox area on the left of Lucidchart by specifying PanelLocation.ContentDock.

0.0.26

If a data URL is provided as an image fill style for a block, that image will be uploaded to Lucid's image service on the current user's account to optimize performance.

0.0.25

Extensions can now read, set, or clear simple static icon data graphics on shapes using BlockProxy.getSimpleStaticDataGraphic and BlockProxy.setSimpleStaticDataGraphic. For example:

const client = new EditorClient(); const viewport = new Viewport(client); client.registerAction('toggleIcon', () => { const selection = viewport.getSelectedItems(); for (const block of selection) { if (block instanceof BlockProxy) { const icon = block.getSimpleStaticDataGraphic(); if (icon) { block.setSimpleStaticDataGraphic(undefined); } else { block.setSimpleStaticDataGraphic({ icon: { set: DataGraphicIconSets.STATUS_ICONS, index: 0, }, position: { horizontalPos: HorizontalBadgePos.RIGHT, verticalPos: VerticalBadgePos.TOP, layer: BadgeLayerPos.EDGE, responsive: BadgeResponsiveness.STACK, }, color: '#ff0000', }); } } } });

0.0.24

Extensions can now easily read, set, or clear drop shadows on shapes using BlockProxy.getShadow and BlockProxy.setShadow.

0.0.21

Custom panels can now allow users to drag and drop blocks from the custom panel onto the current page. See the new section Drag and drop blocks from a panel for details.

0.0.20

Extension can now mark themselves as required for a document. See the new section Marking an extension as required for a document for details.

0.0.15

Menu items can now prompt the user to select files to be read by your extension. See the new section Custom file upload in the Developer Guide.

0.0.14

The EditorClient class now has new alert and confirm methods. See the new section on standard modals in the Developer Guide.

0.0.13

The Viewport class added the ability to hook the selection changing.

0.0.12

The Viewport class added the ability to hook text editing. See the new section Hook Text Editing in the Developer Guide.

0.0.11

The DocumentProxy class added methods to hook creating new items (blocks, lines, and groups) on the document.

The BlockProxy class added a linkText method that connects a field value on one of the block's reference keys to one of the block's text areas, so that editing that text area also writes the change back to the linked data record.

0.0.10

The LineProxy class added methods to add text areas as well as read and write the position of existing text areas on the line. See the new section on Managing text in the Developer Guide.

0.0.9

The Panel class was added, allowing the creation of custom UI in the right dock area of Lucidchart. See the new section on Custom UI in the Developer Guide. In order to use this new functionality you need to add the CUSTOM_UI scope to your editor extension definition in manifest.json.

0.0.8

The EditorClient class now has a new method, oauthXhr, for making OAuth-protected API calls. See the new section on Using OAuth APIs in the Developer Guide.

lucid-package

NPM package

0.0.31

When using lucid-package bundle, you may now provide an environment param lucid-package bundle --env=staging. Your package will now be bundled with the overrides defined in manifest.staging.json and generate a package-staging.zip.

0.0.29

When using lucid-package test-editor-extension, you may now provide a manifest.local.json alongside your existing manifest.json that overrides values in the manifest.

For example, if you want to use a different callbackBaseUrl in your first dataConnector while debugging locally, your manifest.local.json file would look like this:

{ "dataConnectors": [ { "callbackBaseUrl": "https://debug-data-connector.localhost/connector-name/" } ] }

This allows you to always keep production configuration values in your manifest.json file while still allowing you the flexibility to override those values during development.

0.0.26

The OAuth 2.0 client credentials grant type is now supported.

0.0.24

OAuth providers can now be used in development mode without a need to bundle and install the package. Add a JSON file named &lt;provider name&gt;.credentials.local at the root of your package, containing a JSON object with clientId and clientSecret keys. This file will be used to allow OAuth requests to be made whenever your extension is being served with lucid-package test-editor-extension.

0.0.23

New packages created with the CLI now include a .gitignore file by default.

0.0.20

Custom shape libraries may now include multiple entries in the library manifest referring to the same shape definition file. Shape entries in the shape library manifest may now optionally include a "key" to be used to differentiate between them when calling EditorClient.getCustomShapeDefinition.

0.0.14

Dev server is more fault-tolerant while starting up.

0.0.13

Bugfix: Dev server now sends the correct package ID and version for shape libraries.

0.0.11

Editor extension manifests may now specify the CUSTOM_UI scope, which allows for the creation of custom panels in the right dock in Lucidchart.

0.0.9

Package manifests may now specify product in each entry of editorExtensions. This can currently be either "chart" or "spark", and defaults to "chart" if omitted.

0.0.8

Package manifests may now include two additional fields, id and oauthProviders.

If specified, id must match the ID of the extension on the Developer Portal, or uploading a new version of the package will fail. This serves as protection against developers accidentally uploading a package to the wrong project.

oauthProviders is configuration for OAuth providers that editor extensions may make API calls to. See the new section on Using OAuth APIs in the Developer Guide.

For specifying bootstrap data on documents created via the Lucid OAuth API, you must now use the package id and editor extension name specified in your manifest even during development, rather than specifying __local__.

When doing local development of an editor extension, if you already have another version of that extension installed, the local code will be loaded instead of the installed version.