From 819752df9c5c47af0d1e0eb5b79892c4bab241fa Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Mon, 6 Mar 2023 16:20:32 +0000 Subject: [PATCH 1/2] Example of a portal-based React CodeMirror widget --- src/editor/codemirror/CodeMirror.tsx | 53 +++++++-- .../codemirror/reactWidgetExtension.tsx | 103 ++++++++++++++++++ 2 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 src/editor/codemirror/reactWidgetExtension.tsx diff --git a/src/editor/codemirror/CodeMirror.tsx b/src/editor/codemirror/CodeMirror.tsx index 31d155dd1..3fad4903a 100644 --- a/src/editor/codemirror/CodeMirror.tsx +++ b/src/editor/codemirror/CodeMirror.tsx @@ -12,7 +12,15 @@ import { lineNumbers, ViewUpdate, } from "@codemirror/view"; -import { useEffect, useMemo, useRef } from "react"; +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import { useIntl } from "react-intl"; import { lineNumFromUint8Array } from "../../common/text-util"; import useActionFeedback from "../../common/use-action-feedback"; @@ -40,6 +48,7 @@ import { languageServer } from "./language-server/view"; import { lintGutter } from "./lint/lint"; import { codeStructure } from "./structure-highlighting"; import themeExtensions from "./themeExtensions"; +import { reactWidgetExtension } from "./reactWidgetExtension"; interface CodeMirrorProps { className?: string; @@ -52,6 +61,20 @@ interface CodeMirrorProps { parameterHelpOption: ParameterHelpOption; } +interface PortalContent { + dom: HTMLElement; + content: ReactNode; +} + +/** + * Creates a React portal for a CodeMirror dom element (e.g. for a widget) and + * returns a clean-up function to call when the widget is destroyed. + */ +export type PortalFactory = ( + dom: HTMLElement, + content: ReactNode +) => () => void; + /** * A React component for CodeMirror 6. * @@ -100,6 +123,13 @@ const CodeMirror = ({ [fontSize, codeStructureOption, parameterHelpOption] ); + const [portals, setPortals] = useState([]); + const portalFactory: PortalFactory = useCallback((dom, content) => { + const portal = { dom, content }; + setPortals((portals) => [...portals, portal]); + return () => setPortals((portals) => portals.filter((p) => p !== portal)); + }, []); + useEffect(() => { const initializing = !viewRef.current; if (initializing) { @@ -118,6 +148,7 @@ const CodeMirror = ({ extensions: [ notify, editorConfig, + reactWidgetExtension(portalFactory), // Extension requires external state. dndSupport({ sessionSettings, setSessionSettings }), // Extensions only relevant for editing: @@ -172,6 +203,9 @@ const CodeMirror = ({ parameterHelpOption, uri, apiReferenceMap, + portals, + portalFactory, + setPortals, ]); useEffect(() => { // Do this separately as we don't want to destroy the view whenever options needed for initialization change. @@ -260,13 +294,16 @@ const CodeMirror = ({ }, [routerState, setRouterState]); return ( -
+ <> +
+ {portals.map(({ content, dom }) => createPortal(content, dom))} + ); }; diff --git a/src/editor/codemirror/reactWidgetExtension.tsx b/src/editor/codemirror/reactWidgetExtension.tsx new file mode 100644 index 000000000..bdaef3e0a --- /dev/null +++ b/src/editor/codemirror/reactWidgetExtension.tsx @@ -0,0 +1,103 @@ +import { Button, HStack, Text } from "@chakra-ui/react"; +import { EditorState, Extension, StateField } from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + WidgetType, +} from "@codemirror/view"; +import { useCallback } from "react"; +import { supportedLanguages, useSettings } from "../../settings/settings"; +import { PortalFactory } from "./CodeMirror"; + +/** + * An example react component that we use inside a CodeMirror widget as + * a proof of concept. + */ +const ExampleReactComponent = () => { + // This is a weird thing to do in a CodeMirror widget but proves the point that + // we can use React features to communicate with the rest of the app. + const [settings, setSettings] = useSettings(); + const handleClick = useCallback(() => { + let { languageId } = settings; + while (languageId === settings.languageId) { + languageId = + supportedLanguages[ + Math.floor(Math.random() * supportedLanguages.length) + ].id; + } + setSettings({ + ...settings, + languageId, + }); + }, [settings, setSettings]); + return ( + + + Current language: {settings.languageId} + + ); +}; + +/** + * This widget will have its contents rendered by the code in CodeMirror.tsx + * which it communicates with via the portal factory. + */ +class ExampleReactBlockWidget extends WidgetType { + private portalCleanup: (() => void) | undefined; + + constructor(private createPortal: PortalFactory) { + super(); + } + + toDOM() { + const dom = document.createElement("div"); + this.portalCleanup = this.createPortal(dom, ); + return dom; + } + + destroy(dom: HTMLElement): void { + if (this.portalCleanup) { + this.portalCleanup(); + } + } + + ignoreEvent() { + return true; + } +} + +/** + * A toy extension that creates a wiget after the first line. + */ +export const reactWidgetExtension = ( + createPortal: PortalFactory +): Extension => { + const decorate = (state: EditorState) => { + // Just put a widget at the start of the document. + // A more interesting example would look at the cursor (selection) and/or syntax tree. + const endOfFirstLine = state.doc.lineAt(0).to; + const widget = Decoration.widget({ + block: true, + widget: new ExampleReactBlockWidget(createPortal), + side: 1, + }); + return Decoration.set(widget.range(endOfFirstLine)); + }; + + const stateField = StateField.define({ + create(state) { + return decorate(state); + }, + update(widgets, transaction) { + if (transaction.docChanged) { + return decorate(transaction.state); + } + return widgets.map(transaction.changes); + }, + provide(field) { + return EditorView.decorations.from(field); + }, + }); + return [stateField]; +}; From 2575e708e28081f534d4718e9ed1d951821e7d1a Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 6 Mar 2024 20:10:14 +0000 Subject: [PATCH 2/2] Illustrate accessing the view --- src/editor/codemirror/reactWidgetExtension.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/editor/codemirror/reactWidgetExtension.tsx b/src/editor/codemirror/reactWidgetExtension.tsx index bdaef3e0a..e4702e0f8 100644 --- a/src/editor/codemirror/reactWidgetExtension.tsx +++ b/src/editor/codemirror/reactWidgetExtension.tsx @@ -10,11 +10,17 @@ import { useCallback } from "react"; import { supportedLanguages, useSettings } from "../../settings/settings"; import { PortalFactory } from "./CodeMirror"; +interface ExampleReactComponentProps { + view: EditorView; +} + /** * An example react component that we use inside a CodeMirror widget as * a proof of concept. */ -const ExampleReactComponent = () => { +const ExampleReactComponent = ({ view }: ExampleReactComponentProps) => { + console.log("We have access to the view here", view); + // This is a weird thing to do in a CodeMirror widget but proves the point that // we can use React features to communicate with the rest of the app. const [settings, setSettings] = useSettings(); @@ -50,9 +56,12 @@ class ExampleReactBlockWidget extends WidgetType { super(); } - toDOM() { + toDOM(view: EditorView) { const dom = document.createElement("div"); - this.portalCleanup = this.createPortal(dom, ); + this.portalCleanup = this.createPortal( + dom, + + ); return dom; }