How to inline edit markdown in Workshop ? / Rich text edition

I would like to edit markdown, while still being able to preview the markdown as it gets written. In other words, I would like to have a rich text editor widget in Workshop with a live preview of the final formatting of the text.

1 Like

You should develop a widget as part of a widget set, by following: https://www.palantir.com/docs/foundry/custom-widgets/create/

Here is an example custom widget, which is a full-featured Markdown editor (powered by MDXEditor). It supports rich formatting, bidirectional parameter updates, text-selection tracking, and a custom add object reference button that lets users insert clickable links to Ontology objects directly in their markdown.

Project Structure

repo/
β”œβ”€β”€ index.html                  # HTML entry point
β”œβ”€β”€ vite.config.ts              # Vite + widget manifest plugin
β”œβ”€β”€ package.json
β”œβ”€β”€ tsconfig.json
└── src/
    β”œβ”€β”€ main.tsx                # React root + FoundryWidget provider
    β”œβ”€β”€ main.config.ts          # Widget parameter & event definitions
    β”œβ”€β”€ main.css                # Global / MDXEditor overrides
    β”œβ”€β”€ client.ts               # OSDK client setup
    β”œβ”€β”€ context.ts              # Typed widget context hook
    β”œβ”€β”€ types/
    β”‚   └── mdast.ts            # MDast node type definitions
    β”œβ”€β”€ utils/
    β”‚   └── textCleanup.ts      # Whitespace normalization
    └── components/
        β”œβ”€β”€ Widget.tsx           # Main editor component
        β”œβ”€β”€ Widget.css
        β”œβ”€β”€ ObjectReferenceComponent.tsx / .css
        β”œβ”€β”€ InsertObjectLinkButton.tsx / .css
        └── ErrorBoundary.tsx / .css

Widget Configuration β€” Parameters & Events

This is the contract between the widget and Workshop. Every parameter can be bound to a Workshop variable, and every event can trigger Workshop actions.

A few choices:

  • inputText vs outputText: Separating them lets Workshop push new content without immediately overwriting the user’s draft. The widget shows an β€œUpdate available” banner instead.
  • objectReferenceClick event outputs both the object type and primary key, so Workshop can open a detail panel, navigate, or trigger an action on the referenced object.
  • insertObjectType / insertPrimaryKey / insertObjectLabel: These are write parameters β€” Workshop sets them (e.g., from a search result), and the widget consumes them when the user clicks β€œInsert Object Link” in the toolbar.
// src/main.config.ts
import { defineConfig } from "@osdk/widget.client";

export default defineConfig({
  id: "markdownEditor",
  name: "Markdown Editor",
  description:
    "A markdown editor widget using MDXEditor with custom object reference support",
  type: "workshop",
  parameters: {
    inputText: {
      displayName: "Input Text",
      type: "string",
    },
    outputText: {
      displayName: "Output Text",
      type: "string",
    },
    selectedText: {
      displayName: "Selected Text",
      type: "string",
    },
    clickedObjectType: {
      displayName: "Clicked Object Type",
      type: "string",
    },
    clickedPrimaryKey: {
      displayName: "Clicked Primary Key",
      type: "string",
    },
    insertObjectType: {
      displayName: "Insert Object Type",
      type: "string",
    },
    insertPrimaryKey: {
      displayName: "Insert Primary Key",
      type: "string",
    },
    insertObjectLabel: {
      displayName: "Insert Object Label",
      type: "string",
    },
  },
  events: {
    buttonClick: {
      displayName: "Button Click",
      parameterUpdateIds: ["outputText"],
    },
    textEdit: {
      displayName: "Text Edit",
      parameterUpdateIds: ["outputText"],
    },
    enterPressed: {
      displayName: "Enter Pressed",
      parameterUpdateIds: ["outputText"],
    },
    textSelected: {
      displayName: "Text Selected",
      parameterUpdateIds: ["selectedText"],
    },
    objectReferenceClick: {
      displayName: "Object Reference Click",
      parameterUpdateIds: ["clickedObjectType", "clickedPrimaryKey"],
    },
  },
});

Bootstrapping the Widget

Entry point (src/main.tsx)

// src/main.tsx
import "./main.css";
import "@mdxeditor/editor/style.css";

import { FoundryWidget } from "@osdk/widget.client-react";
import { createRoot } from "react-dom/client";
import MainConfig from "./main.config.js";
import { Widget } from "./components/Widget.js";
import { ErrorBoundary } from "./components/ErrorBoundary.js";

const root = document.getElementById("root")!;

createRoot(root).render(
  <ErrorBoundary>
    <FoundryWidget config={MainConfig}>
      <Widget />
    </FoundryWidget>
  </ErrorBoundary>,
);

The widget fills its container (100vh) and makes the editor toolbar sticky at the top with the editable area scrolling below:

/* src/main.css β€” key overrides for MDXEditor */
/* Reset and base styles */
*, *::before, *::after {
  box-sizing: border-box;
}

html, body, #root {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
}

body {
  font-family: system-ui, -apple-system, sans-serif;
}

/* MDXEditor fullscreen styling */
.mdxeditor {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.mdxeditor-toolbar {
  flex-shrink: 0;
  border-bottom: 1px solid #e0e0e0;
  background-color: #f9f9f9;
  padding: 8px;
}

.mdxeditor-root-contenteditable {
  flex: 1;
  overflow: auto;
  padding: 16px;
}

.editor-content {
  width: 100%;
  min-height: 100%;
  outline: none;
  font-size: 15px;
  line-height: 1.6;
}
/* Markdown content styling */
.editor-content h1 {
  font-size: 2em;
  margin: 0.5em 0;
  font-weight: 600;
}

.editor-content h2 {
  font-size: 1.5em;
  margin: 0.5em 0;
  font-weight: 600;
}

.editor-content h3 {
  font-size: 1.25em;
  margin: 0.5em 0;
  font-weight: 600;
}

.editor-content p {
  margin: 0.5em 0;
}

.editor-content ul,
.editor-content ol {
  margin: 0.5em 0;
  padding-left: 2em;
  list-style-position: inside;
}

.editor-content li {
  margin: 0.25em 0;
}

/* Ensure lists inside tables display correctly */
.editor-content table ul,
.editor-content table ol {
  padding-left: 1.5em;
  list-style-position: inside;
}

.editor-content code {
  background-color: #f0f0f0;
  padding: 2px 6px;
  border-radius: 3px;
  font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
  font-size: 0.9em;
}

.editor-content pre {
  background-color: #f5f5f5;
  padding: 12px;
  border-radius: 4px;
  overflow-x: auto;
  margin: 1em 0;
}

.editor-content pre code {
  background: none;
  padding: 0;
}

.editor-content blockquote {
  border-left: 4px solid #d0d0d0;
  padding-left: 1em;
  margin: 1em 0;
  color: #666;
}

.editor-content table {
  border-collapse: collapse;
  width: 100%;
  margin: 1em 0;
}

.editor-content table th,
.editor-content table td {
  border: 1px solid #ddd;
  padding: 8px;
  text-align: left;
}

.editor-content table th {
  background-color: #f5f5f5;
  font-weight: 600;
}

.editor-content a {
  color: #0078d4;
  text-decoration: none;
}

.editor-content a:hover {
  text-decoration: underline;
}

Note the ErrorBoundary wrapping everything β€” important because a crash inside a custom widget iframe is otherwise a blank white rectangle with no feedback.

Typed context hook (src/context.ts)

// src/context.ts
import { useFoundryWidgetContext } from "@osdk/widget.client-react";
import type MainConfig from "./main.config.js";

export const useWidgetContext = useFoundryWidgetContext.withTypes<typeof MainConfig>();

This gives us fully typed access to parameters.values.inputText, emitEvent("textEdit", ...), etc. throughout the component tree.

OSDK client (src/client.ts)

// src/client.ts
import { $ontologyRid } from "@custom-widget/sdk";
import { createClient } from "@osdk/client";

/**
 * OSDK client instance for accessing Ontology data.
 *
 * Configured to use widgets-auth authentication and the current origin.
 * This client can be used to load objects, execute actions, and call functions
 * defined in the Ontology.
 */
export const client = createClient(window.location.origin, $ontologyRid, () =>
  Promise.resolve("widgets-auth")
);

The "widgets-auth" token provider is the standard approach for custom widgets β€” the Foundry iframe host handles actual authentication.

The Core Widget Component

The Widget.tsx file is the heart of this project. Here’s an annotated breakdown of the most important pieces.
A few points:

  • The banner pattern is important UX β€” you don’t want to silently overwrite what a user is typing. Hence if the input changes while text is already loaded, a banner will show up to ask the user if they want to β€œoverwrite” their current changes
  • Every keystroke pushes the cleaned markdown back to Workshop via the textEdit event.
  • Text selected is tracked and exposed as a variable
import React, { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useWidgetContext } from "../context.js";
import { 
  MDXEditor, 
  headingsPlugin, 
  listsPlugin, 
  quotePlugin, 
  thematicBreakPlugin,
  markdownShortcutPlugin,
  linkPlugin,
  linkDialogPlugin,
  imagePlugin,
  tablePlugin,
  codeBlockPlugin,
  codeMirrorPlugin,
  diffSourcePlugin,
  frontmatterPlugin,
  directivesPlugin,
  toolbarPlugin,
  UndoRedo,
  BoldItalicUnderlineToggles,
  CreateLink,
  InsertTable,
  InsertThematicBreak,
  ListsToggle,
  BlockTypeSelect,
  CodeToggle,
  type MDXEditorMethods
} from '@mdxeditor/editor';
import { ObjectReferenceComponent } from "./ObjectReferenceComponent.js";
import { InsertObjectLinkButton } from "./InsertObjectLinkButton.js";
import { normalizeWhitespace } from "../utils/textCleanup.js";
import type { MDastNode } from "../types/mdast.js";
import "./Widget.css";

/**
 * Main Widget component - A markdown editor with custom object reference support.
 * 
 * Features:
 * - Rich markdown editing via MDXEditor
 * - Custom directives for object references
 * - Bidirectional parameter updates with Workshop
 * - Update banner for external content changes
 * - Text selection tracking
 */
export const Widget: React.FC = () => {
  const { parameters, emitEvent } = useWidgetContext();
  const { inputText, outputText, insertObjectType, insertPrimaryKey, insertObjectLabel } = parameters.values;
  
  console.log('[Widget] Component render - insert parameters:', {
    insertObjectType,
    insertPrimaryKey,
    insertObjectLabel,
    parameterState: parameters.state
  });
  
  // Editor state
  const [currentText, setCurrentText] = useState("");
  const [isLoaded, setIsLoaded] = useState(false);
  
  // Update banner state
  const [pendingInputText, setPendingInputText] = useState<string | undefined>(undefined);
  const [showUpdateBanner, setShowUpdateBanner] = useState(false);
  
  // Refs for DOM and API access
  const mdxRef = useRef<MDXEditorMethods>(null);
  const lastInputTextRef = useRef<string | undefined>(undefined);
  
  // Stable refs for event handlers to prevent memory leaks
  const currentSelectionRef = useRef<string>("");
  const handleKeyPressRef = useRef<((e: KeyboardEvent) => void) | null>(null);
  
  // Refs for insert parameters to avoid recreating plugins
  const insertObjectTypeRef = useRef<string | undefined>(undefined);
  const insertPrimaryKeyRef = useRef<string | undefined>(undefined);
  const insertObjectLabelRef = useRef<string | undefined>(undefined);

  /**
   * Update insert parameter refs whenever they change
   */
  useEffect(() => {
    console.log('[Widget] Updating insert parameter refs:', {
      insertObjectType,
      insertPrimaryKey,
      insertObjectLabel
    });
    insertObjectTypeRef.current = insertObjectType;
    insertPrimaryKeyRef.current = insertPrimaryKey;
    insertObjectLabelRef.current = insertObjectLabel;
  }, [insertObjectType, insertPrimaryKey, insertObjectLabel]);

  /**
   * Initialize content when parameters load
   */
  useEffect(() => {
    console.log('[Widget] Parameters initialization effect:', {
      state: parameters.state,
      inputText,
      outputText,
      isLoaded,
      lastInputTextRef: lastInputTextRef.current
    });
    
    if (parameters.state === "loaded") {
      const content = inputText || outputText || "";
      console.log('[Widget] Loading content:', content);
      setCurrentText(content);
      
      // Only set lastInputTextRef on very first load
      if (lastInputTextRef.current === undefined) {
        lastInputTextRef.current = inputText;
        console.log('[Widget] Set initial lastInputTextRef:', inputText);
      }
      
      setIsLoaded(true);
      console.log('[Widget] Widget loaded');
    }
  }, [parameters.state, inputText, outputText]);

  /**
   * Show update banner when inputText changes externally
   */
  useEffect(() => {
    console.log('[Widget] Input text change effect:', {
      isLoaded,
      inputText,
      lastInputTextRef: lastInputTextRef.current,
      shouldShowBanner: isLoaded && inputText !== undefined && inputText !== lastInputTextRef.current
    });
    
    if (isLoaded && inputText !== undefined && inputText !== lastInputTextRef.current) {
      console.log('[Widget] Input text changed externally, showing update banner');
      setPendingInputText(inputText);
      setShowUpdateBanner(true);
    }
  }, [inputText, isLoaded]);

  /**
   * Handle text changes from the editor
   */
  const handleTextChange = useCallback((newText: string) => {
    console.log('[Widget] Text changed in editor, length:', newText.length);
    const cleanedText = normalizeWhitespace(newText);
    setCurrentText(cleanedText);
    emitEvent("textEdit", {
      parameterUpdates: {
        outputText: cleanedText,
      },
    });
  }, [emitEvent]);

  /**
   * Handle mouse up for MDXEditor text selection (via document listener)
   */
  useEffect(() => {
    console.log('[Widget] Mouse up effect setup, isLoaded:', isLoaded);
    if (!isLoaded) return;

    const handleDocumentMouseUp = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      
      // Only handle if inside the editor content area
      if (!target.closest('.editor-content')) {
        return;
      }
      
      const selection = window.getSelection();
      
      if (selection && selection.toString()) {
        const selectedText = selection.toString();
        
        if (selectedText && selectedText !== currentSelectionRef.current) {
          console.log('[Widget] Text selected:', selectedText);
          currentSelectionRef.current = selectedText;
          emitEvent("textSelected", {
            parameterUpdates: {
              selectedText: selectedText,
            },
          });
        }
      }
    };

    document.addEventListener('mouseup', handleDocumentMouseUp);
    console.log('[Widget] Mouse up listener attached');
    
    return () => {
      document.removeEventListener('mouseup', handleDocumentMouseUp);
      console.log('[Widget] Mouse up listener removed');
    };
  }, [isLoaded, emitEvent]);

  /**
   * Update the keyboard event handler ref with current values
   * This approach prevents memory leaks from frequently changing dependencies
   */
  useEffect(() => {
    console.log('[Widget] Keyboard handler ref updated with currentText length:', currentText.length);
    handleKeyPressRef.current = (event: KeyboardEvent) => {
      const target = event.target as HTMLElement;
      
      // Skip if inside a dialog, popover, or form (e.g., link dialog)
      if (target.closest('[role="dialog"]') || 
          target.closest('[data-radix-popper-content-wrapper]') ||
          target.closest('form') ||
          target.tagName === 'INPUT') {
        return;
      }
      
      if (target && target.closest('.mdxeditor') && event.key === 'Enter' && !event.shiftKey) {
        const selection = window.getSelection();
        
        if (selection && selection.anchorNode) {
          const editorElement = document.querySelector('.mdxeditor');
          
          if (editorElement && editorElement.contains(selection.anchorNode)) {
            console.log('[Widget] Enter key pressed in editor');
            emitEvent("enterPressed", {
              parameterUpdates: {
                outputText: currentText,
              },
            });
          }
        }
      }
    };
  }, [emitEvent, currentText]);

  /**
   * Attach and detach keyboard event listener
   * Uses stable handler reference to prevent memory leaks
   */
  useEffect(() => {
    console.log('[Widget] Keyboard listener effect, isLoaded:', isLoaded);
    if (!isLoaded) return;

    const stableHandler = (event: KeyboardEvent) => {
      handleKeyPressRef.current?.(event);
    };

    document.addEventListener('keydown', stableHandler);
    console.log('[Widget] Keyboard listener attached');

    return () => {
      document.removeEventListener('keydown', stableHandler);
      console.log('[Widget] Keyboard listener removed');
    };
  }, [isLoaded]);

  /**
   * Handle Done button click
   */
  const handleButtonClick = useCallback(() => {
    console.log('[Widget] Done button clicked');
    emitEvent("buttonClick", {
      parameterUpdates: {
        outputText: currentText,
      },
    });
  }, [emitEvent, currentText]);

  /**
   * Apply pending input text update
   */
  const handleApplyUpdate = useCallback(() => {
    console.log('[Widget] Applying input text update');
    if (pendingInputText !== undefined) {
      lastInputTextRef.current = pendingInputText;
      setCurrentText(pendingInputText);

      // Force update MDXEditor content via ref
      if (mdxRef.current) {
        mdxRef.current.setMarkdown(pendingInputText);
        console.log('[Widget] MDXEditor content updated');
      }

      // Update outputText to match inputText
      emitEvent("textEdit", {
        parameterUpdates: {
          outputText: pendingInputText,
        },
      });

      setPendingInputText(undefined);
      setShowUpdateBanner(false);
    }
  }, [pendingInputText, emitEvent]);

  /**
   * Dismiss the update banner without applying changes
   */
  const handleDismissUpdate = useCallback(() => {
    console.log('[Widget] Dismissing update banner');
    if (pendingInputText !== undefined) {
      // Update the tracking ref to prevent showing the banner again
      lastInputTextRef.current = pendingInputText;
    }
    
    setPendingInputText(undefined);
    setShowUpdateBanner(false);
  }, [pendingInputText]);

  /**
   * Handle clicks on object reference directives
   */
  const handleObjectReferenceClick = useCallback((objectType: string, primaryKey: string) => {
    console.log('[Widget] Object reference clicked:', { objectType, primaryKey });
    emitEvent("objectReferenceClick", {
      parameterUpdates: {
        clickedObjectType: objectType,
        clickedPrimaryKey: primaryKey,
      },
    });
  }, [emitEvent]);

  /**
   * Memoized directive editor component
   */
  const ObjectReferenceEditor = useCallback(({ mdastNode }: { mdastNode: MDastNode }) => {
    const objectType = mdastNode.attributes?.objectType ?? '';
    const primaryKey = mdastNode.attributes?.primaryKey ?? '';
    const label = mdastNode.children?.[0]?.value ?? 'Reference';

    return (
      <ObjectReferenceComponent
        objectType={objectType}
        primaryKey={primaryKey}
        onClick={handleObjectReferenceClick}
      >
        {label}
      </ObjectReferenceComponent>
    );
  }, [handleObjectReferenceClick]);

  /**
   * Memoized directive descriptors for MDXEditor
   */
  const directiveDescriptors = useMemo(
    () => [
      {
        name: 'objectreference',
        type: 'textDirective' as const,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        testNode(node: any) {
          return node.name === 'objectreference';
        },
        attributes: ['objectType', 'primaryKey'],
        hasChildren: true,
        Editor: ObjectReferenceEditor,
      },
    ],
    [ObjectReferenceEditor]
  );

  /**
   * Insert an object reference directive at cursor position
   */
  const handleInsertObjectLink = useCallback(() => {
    // Use refs to get current values
    const currentInsertObjectType = insertObjectTypeRef.current;
    const currentInsertPrimaryKey = insertPrimaryKeyRef.current;
    const currentInsertObjectLabel = insertObjectLabelRef.current;
    
    console.log('[Widget] Insert object link called with:', {
      currentInsertObjectType,
      currentInsertPrimaryKey,
      currentInsertObjectLabel
    });
    
    if (!currentInsertObjectType || !currentInsertPrimaryKey || !currentInsertObjectLabel) {
      console.warn('[Widget] Cannot insert object link - missing parameters:', {
        currentInsertObjectType,
        currentInsertPrimaryKey,
        currentInsertObjectLabel
      });
      return;
    }

    const directiveText = `:objectreference[${currentInsertObjectLabel}]{objectType="${currentInsertObjectType}" primaryKey="${currentInsertPrimaryKey}"}`;
    console.log('[Widget] Directive text to insert:', directiveText);

    if (mdxRef.current && typeof mdxRef.current.insertMarkdown === 'function') {
      try {
        mdxRef.current.insertMarkdown(directiveText);
        console.log('[Widget] Object link inserted successfully');
      } catch (error) {
        console.error('[Widget] Failed to insert markdown:', error);
        // Fallback: append to end of content
        const newText = currentText + '\n' + directiveText + '\n';
        setCurrentText(newText);
        
        if (mdxRef.current) {
          mdxRef.current.setMarkdown(newText);
        }
        
        emitEvent("textEdit", {
          parameterUpdates: {
            outputText: newText,
          },
        });
      }
    }
  }, [currentText, emitEvent]);

  /**
   * Memoized toolbar contents
   */
  const ToolbarContents = useCallback(() => {
    // Use refs to get current values for disabled check
    const isDisabled = !insertObjectTypeRef.current || !insertPrimaryKeyRef.current || !insertObjectLabelRef.current;
    console.log('[Widget] ToolbarContents render - button disabled:', isDisabled, {
      insertObjectType: insertObjectTypeRef.current,
      insertPrimaryKey: insertPrimaryKeyRef.current,
      insertObjectLabel: insertObjectLabelRef.current
    });
    
    return (
      <>
        <UndoRedo />
        <BoldItalicUnderlineToggles />
        <BlockTypeSelect />
        <CodeToggle />
        <CreateLink />
        <InsertTable />
        <InsertThematicBreak />
        <ListsToggle />
        <div className="toolbar-actions">
          <InsertObjectLinkButton
            onClick={handleInsertObjectLink}
            disabled={isDisabled}
          />
          <button
            onClick={handleButtonClick}
            className="toolbar-done-button"
          >
            Done
          </button>
        </div>
      </>
    );
  },
    [handleInsertObjectLink, handleButtonClick]
  );

  /**
   * Memoized plugins array for MDXEditor
   * Note: We intentionally exclude directiveDescriptors and toolbarContents from dependencies
   * because recreating plugins causes the editor to lose state (including link dialog state).
   * The refs ensure the callbacks always have access to current values.
   */
  const plugins = useMemo(
    () => [
      headingsPlugin(),
      listsPlugin(),
      quotePlugin(),
      thematicBreakPlugin(),
      markdownShortcutPlugin(),
      linkPlugin(),
      linkDialogPlugin({
        linkAutocompleteSuggestions: []
      }),
      imagePlugin(),
      tablePlugin(),
      codeBlockPlugin({ defaultCodeBlockLanguage: 'javascript' }),
      codeMirrorPlugin({
        codeBlockLanguages: { js: 'JavaScript', css: 'CSS', txt: 'text', tsx: 'TypeScript', ts: 'TypeScript' }
      }),
      diffSourcePlugin(),
      frontmatterPlugin(),
      directivesPlugin({ directiveDescriptors }),
      toolbarPlugin({ toolbarContents: ToolbarContents }),
    ],
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

  // Loading state
  if (parameters.state === "loading" || parameters.state === "not-started" || !isLoaded) {
    return (
      <div className="widget-loading">
        Loading editor...
      </div>
    );
  }

  return (
    <div className="widget-container">
      {/* Update banner */}
      {showUpdateBanner && (
        <div className="update-banner">
          <span className="update-banner-message">
            The input text has been updated. Apply changes?
          </span>
          <div className="update-banner-actions">
            <button
              onClick={handleApplyUpdate}
              className="update-banner-apply"
            >
              Apply
            </button>
            <button 
              onClick={handleDismissUpdate}
              className="update-banner-dismiss"
            >
              Dismiss
            </button>
          </div>
        </div>
      )}

      {/* Editor area */}
      <div className="editor-container">
        <div className="editor-container-full-height">
          <MDXEditor
            ref={mdxRef}
            markdown={currentText}
            onChange={handleTextChange}
            placeholder="Enter your markdown content here..."
            plugins={plugins}
            contentEditableClassName="editor-content"
          />
        </div>
      </div>
    </div>
  );
};

The Widget CSS is below, including the banner:

/* src/components/Widget.css */
/* Widget container */
.widget-container {
  width: 100%;
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* Loading state */
.widget-loading {
  width: 100%;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: system-ui, sans-serif;
}

/* Update banner */
.update-banner {
  padding: 12px 16px;
  background-color: #fff3cd;
  border-bottom: 1px solid #ffc107;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.update-banner-message {
  font-size: 14px;
  color: #856404;
}

.update-banner-actions {
  display: flex;
  gap: 8px;
}

.update-banner-apply {
  padding: 6px 12px;
  background-color: #ffc107;
  color: #000;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  font-weight: 500;
}

.update-banner-apply:hover {
  background-color: #e0a800;
}

.update-banner-dismiss {
  padding: 6px 12px;
  background-color: transparent;
  color: #856404;
  border: 1px solid #856404;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  font-weight: 500;
}

.update-banner-dismiss:hover {
  background-color: rgba(133, 100, 4, 0.1);
}

/* Editor area */
.editor-container {
  flex: 1;
  overflow: auto;
  width: 100%;
}

.editor-container-full-height {
  height: 100%;
}

/* Toolbar buttons */
.toolbar-actions {
  margin-left: auto;
  display: flex;
  gap: 8px;
  align-items: center;
}

.toolbar-done-button {
  padding: 6px 12px;
  background-color: #0078d4;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  font-weight: 500;
}

.toolbar-done-button:hover {
  background-color: #005a9e;
}

Note: adding normal links (not object reference) seem to have an issue in this current implementation. The button in the toolbar opens a popup which does not add the links when the form is filled. Unclear why.

The most unique part of the widget is how object references are rendered as links. MDXEditor supports generic directives β€” we define a custom objectreference text directive. The "directiveDescriptors is what is registered to the MDXEditor to render links in a custom way.

Example of reference:

:objectreference[Employee Name]{objectType="Employee" primaryKey="emp-123"}

The rendering component is:

// src/components/ObjectReferenceComponent.tsx
import React from "react";
import "./ObjectReferenceComponent.css";

/**
 * ObjectReferenceComponent - Renders a clickable object reference in the markdown editor.
 * 
 * This component displays as a styled inline button that emits click events
 * when the user interacts with an object reference directive.
 */
interface ObjectReferenceProps {
  objectType: string;
  primaryKey: string;
  children: React.ReactNode;
  onClick: (objectType: string, primaryKey: string) => void;
}

export const ObjectReferenceComponent: React.FC<ObjectReferenceProps> = ({
  objectType,
  primaryKey,
  children,
  onClick,
}) => {
  const handleClick = (e: React.MouseEvent) => {
    e.preventDefault();
    onClick(objectType, primaryKey);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      onClick(objectType, primaryKey);
    }
  };

  return (
    <button
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      className="object-reference"
      title={`Object: ${objectType}, Key: ${primaryKey}`}
      type="button"
    >
      {children}
    </button>
  );
};

Styled as an inline pill/button:

/* src/components/ObjectReferenceComponent.css */
.object-reference {
  color: #0078d4;
  text-decoration: none;
  cursor: pointer;
  border: none;
  border-bottom: 1px solid #0078d4;
  padding: 2px 4px;
  border-radius: 2px;
  background-color: #f0f8ff;
  font: inherit;
  display: inline;
}

.object-reference:hover,
.object-reference:focus {
  background-color: #e6f3ff;
}

The logic to add a reference from the toolbar is defined below.

The parent’s Workshop sets the insertObjectType, insertPrimaryKey, and insertObjectLabel parameters. The toolbar button calls mdxRef.current.insertMarkdown(...):

// src/components/InsertObjectLinkButton.tsx
import React from "react";
import "./InsertObjectLinkButton.css";

/**
 * InsertObjectLinkButton - Toolbar button for inserting object reference directives.
 * 
 * This button is disabled when the required parameters (objectType, primaryKey, label)
 * are not provided from Workshop.
 */
interface InsertObjectLinkButtonProps {
  onClick: () => void;
  disabled?: boolean;
}

export const InsertObjectLinkButton: React.FC<InsertObjectLinkButtonProps> = ({ 
  onClick, 
  disabled = false 
}) => {
  console.log('[InsertObjectLinkButton] Render - disabled:', disabled);
  
  const handleClick = () => {
    console.log('[InsertObjectLinkButton] Button clicked, disabled:', disabled);
    onClick();
  };
  
  return (
    <button
      type="button"
      onClick={handleClick}
      disabled={disabled}
      className="insert-object-link-button"
      title={disabled ? 'Set insert parameters in Workshop to enable' : 'Insert object reference at cursor'}
    >
      <span>πŸ”—</span>
      <span>Insert Object Link</span>
    </button>
  );
};

with its CSS:

.insert-object-link-button {
  padding: 6px 12px;
  border: none;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 500;
  display: flex;
  align-items: center;
  gap: 4px;
}

.insert-object-link-button:enabled {
  background-color: #0078d4;
  color: white;
  cursor: pointer;
}

.insert-object-link-button:enabled:hover {
  background-color: #005a9e;
}

.insert-object-link-button:disabled {
  background-color: #e0e0e0;
  color: #999;
  cursor: not-allowed;
}

Utility and Errors

MDXEditor sometimes produces non-breaking spaces and HTML entities. This utility cleans them up for consistent storage:

// src/utils/textCleanup.ts
export function normalizeWhitespace(text: string): string {
  return text
    .replace(/&#x20;/g, ' ')    // HTML hex entity for space
    .replace(/&nbsp;/g, ' ')    // Named entity for non-breaking space
    .replace(/\u00A0/g, ' ')    // Unicode non-breaking space (U+00A0)
    .replace(/&#xA0;/g, ' ')    // HTML hex entity for nbsp
    .replace(/&#160;/g, ' ');   // HTML decimal entity for nbsp
}

A class-based ErrorBoundary wraps the entire widget to catch render errors gracefully instead of showing a blank iframe:

// src/components/ErrorBoundary.tsx
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
    console.error('[ErrorBoundary] Caught error:', error, errorInfo);
  }

  render(): React.ReactNode {
    if (this.state.hasError) {
      return (
        <div className="error-boundary-container">
          <h2 className="error-boundary-title">Something went wrong</h2>
          <p className="error-boundary-message">
            The widget encountered an error and could not render properly.
          </p>
          <details className="error-boundary-details">
            <summary>Error Details</summary>
            <pre>{this.state.error?.message}{'\n\n'}{this.state.error?.stack}</pre>
          </details>
        </div>
      );
    }
    return this.props.children;
  }
}
1 Like

Update here - Workshop has released a markdown mode on the text input widget that supports a rich text editing experience.

4 Likes