From 68c34fcb622bb2bb2736b7ee6191a2eaae6e1e9a Mon Sep 17 00:00:00 2001 From: Jason Siefken Date: Sat, 9 Dec 2023 23:39:50 -0500 Subject: [PATCH] Cleanup when the CodeMirror component is unloaded --- packages/codemirror/src/CodeMirror.tsx | 495 +++--------------- .../codemirror/src/extensions/lsp/plugin.tsx | 23 +- .../codemirror/src/extensions/lsp/worker.ts | 15 +- packages/codemirror/src/extensions/theme.ts | 39 ++ packages/codemirror/src/index.ts | 1 + packages/codemirror/src/test-main.tsx | 53 +- .../src/Viewer/renderers/codeEditor-ts.tsx | 5 +- packages/lsp-tools/src/dev-site.tsx | 4 +- packages/parser/src/dev-site.tsx | 4 +- .../src/language-server/index.ts | 11 + 10 files changed, 169 insertions(+), 481 deletions(-) create mode 100644 packages/codemirror/src/extensions/theme.ts create mode 100644 packages/codemirror/src/index.ts diff --git a/packages/codemirror/src/CodeMirror.tsx b/packages/codemirror/src/CodeMirror.tsx index 2cd079eb0..73e7b27a5 100644 --- a/packages/codemirror/src/CodeMirror.tsx +++ b/packages/codemirror/src/CodeMirror.tsx @@ -1,456 +1,81 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React from "react"; +import { EditorSelection, Extension } from "@codemirror/state"; +import ReactCodeMirror, { EditorView } from "@uiw/react-codemirror"; +import { syntaxHighlightingExtension } from "./extensions/syntax-highlighting"; +import { tabExtension } from "./extensions/tab"; import { - EditorState, - Transaction, - StateEffect, - EditorSelection, -} from "@codemirror/state"; -import { selectLine, deleteLine, cursorLineUp } from "@codemirror/commands"; -import { EditorView, keymap, Command } from "@codemirror/view"; -import { basicSetup } from "codemirror"; -import { styleTags, tags as t } from "@lezer/highlight"; -import { lineNumbers } from "@codemirror/view"; -import { - LRLanguage, - LanguageSupport, - syntaxTree, - indentNodeProp, - foldNodeProp, -} from "@codemirror/language"; -import { completeFromSchema } from "@codemirror/lang-xml"; -import { parser } from "@doenet/parser"; -import { doenetSchema } from "@doenet/static-assets"; + lspPlugin, + uniqueLanguageServerInstance, +} from "./extensions/lsp/plugin"; +import { colorTheme } from "./extensions/theme"; export function CodeMirror({ - setInternalValueTo, - onBeforeChange, + value, + onChange, onCursorChange, readOnly, onBlur, onFocus, - paddingBottom, }: { - setInternalValueTo: string; - onBeforeChange: (str: string) => void; + value: string; + onChange?: (str: string) => void; onCursorChange?: (selection: EditorSelection) => any; readOnly?: boolean; onBlur?: () => void; onFocus?: () => void; - paddingBottom?: string; }) { - if (readOnly === undefined) { - readOnly = false; - } - - let colorTheme = EditorView.theme({ - "&": { - color: "var(--canvastext)", - //backgroundColor: "var(--canvas)", - }, - ".cm-content": { - caretColor: "#0e9", - borderDownColor: "var(--canvastext)", - }, - ".cm-editor": { - caretColor: "#0e9", - backgroundColor: "var(--canvas)", - }, - "&.cm-focused .cm-cursor": { - backgroundColor: "var(--lightBlue)", - borderLeftColor: "var(--canvastext)", - }, - "&.cm-focused .cm-selectionBackground, ::selection": { - backgroundColor: "var(--mainGray)", - }, - "&.cm-focused": { - color: "var(--canvastext)", - }, - "cm-selectionLayer": { - backgroundColor: "var(--mainGreen)", - }, - ".cm-gutters": { - backgroundColor: "var(--mainGray)", - color: "black", - border: "none", - }, - ".cm-activeLine": { - backgroundColor: "var(--mainGray)", - color: "black", - }, - }); - - let [editorConfig, setEditorConfig] = useState({ - matchTag: false, - }); - let view = useRef(null); - let parent = useRef(null); - const [count, setCount] = useState(0); - - const changeFunc = useCallback((tr: Transaction) => { - if (tr.selection && onCursorChange) { - onCursorChange(tr.selection); - } - if (tr.docChanged) { - let strOfDoc = tr.state.sliceDoc(); - onBeforeChange(strOfDoc); - return true; - } - return false; - //trust in the system - //eslint-disable-next-line - }, []); - - //Make sure readOnly takes affect - //TODO: Do this is a smarter way - async await? - if (readOnly && view.current?.state?.facet(EditorView.editable)) { - const disabledExtensions = [ - EditorView.editable.of(false), - lineNumbers(), - ]; - view.current.dispatch({ - effects: StateEffect.reconfigure.of(disabledExtensions), - }); - } - - //Fires when the editor losses focus - const onBlurExtension = EditorView.domEventHandlers({ - blur() { - if (onBlur) { - onBlur(); - } - }, - }); - - //Fires when the editor receives focus - const onFocusExtension = EditorView.domEventHandlers({ - focus() { - if (onFocus) { - onFocus(); - } - }, - }); - - //tabs = 2 spaces - const tab = " "; - const tabCommand: Command = ({ state, dispatch }) => { - dispatch( - state.update(state.replaceSelection(tab), { - scrollIntoView: true, - annotations: Transaction.userEvent.of("input"), - }), - ); - return true; - }; - - const tabExtension = keymap.of([ - { - key: "Tab", - run: tabCommand, - }, - ]); - - const copyCommand: Command = ({ state, dispatch }) => { - if (state.selection.main.empty) { - selectLine({ state: state, dispatch: dispatch }); - document.execCommand("copy"); - } else { - document.execCommand("copy"); - } - return true; - }; - - const copyExtension = keymap.of([ - { - key: "Mod-x", - run: copyCommand, - }, - ]); - - const cutCommand: Command = ({ state, dispatch }) => { - //if the selection is empty - if (state.selection.main.empty && view.current) { - selectLine({ state: state, dispatch: dispatch }); - document.execCommand("copy"); - if ( - state.doc.lineAt(state.selection.main.from).number !== - state.doc.lines - ) { - deleteLine(view.current); - cursorLineUp(view.current); - } else { - deleteLine(view.current); - } - } else { - document.execCommand("copy"); - dispatch( - state.update(state.replaceSelection(""), { - scrollIntoView: true, - annotations: Transaction.userEvent.of("input"), - }), - ); - } - return true; - }; - const cutExtension = keymap.of([ - { - key: "Mod-x", - run: cutCommand, - }, - ]); - - const doenetExtensions = useMemo( - () => [ - basicSetup, - doenet(doenetSchema), - EditorView.lineWrapping, - colorTheme, - tabExtension, - cutExtension, - copyExtension, - onBlurExtension, - onFocusExtension, - EditorState.changeFilter.of(changeFunc), - - // XXX This type appears to be incorrect, but I am not sure what this function is doing... - // @ts-ignore - EditorView.updateListener.of(changeFunc), - ], - [changeFunc], - ); - - const matchTag = useCallback( - (tr: Transaction) => { - const cursorPos = tr.newSelection.main.from; - //if we may be closing an OpenTag - if ( - tr.annotation(Transaction.userEvent) == "input" && - tr.newDoc.sliceString(cursorPos - 1, cursorPos) === ">" - ) { - //check to see if we are actually closing an OpenTag - let node = syntaxTree(tr.state).resolve(cursorPos, -1); - if (node.name !== "OpenTag") { - return tr; - } - //first node is the StartTag - let tagNameNode = node.firstChild?.nextSibling; - let tagName = tr.newDoc.sliceString( - tagNameNode?.from || 0, - tagNameNode?.to, - ); - - //an inefficient hack to make it so the modified document is saved directly after tagMatch - let tra = tr.state.update({ - changes: { - from: cursorPos, - insert: ""), - }, - sequential: true, - }); - changeFunc(tra); - - return [ - tr, - { - changes: { - from: cursorPos, - insert: ""), - }, - sequential: true, - }, - ]; - } else { - return tr; - } - }, - [changeFunc], + // Only one language server runs for all documents, so we specify a document id to keep different instances different. + const [documentId, _] = React.useState(() => + Math.floor(Math.random() * 100000).toString(), ); - const state = EditorState.create({ - doc: setInternalValueTo, - extensions: doenetExtensions, - }); - - useEffect(() => { - if (view.current !== null && parent.current !== null) { - // console.log(">>>changing setInternalValueTo to", setInternalValueTo); - let tr = view.current.state.update({ - changes: { - from: 0, - to: view.current.state.doc.length, - insert: setInternalValueTo, - }, - }); - view.current.dispatch(tr); - } - }, [setInternalValueTo]); - - useEffect(() => { - if (view.current === null && parent.current !== null) { - view.current = new EditorView({ state, parent: parent.current }); - - if (readOnly && view.current.state.facet(EditorView.editable)) { - //Force a refresh - setCount((old) => { - return old + 1; - }); + React.useEffect(() => { + return () => { + // We need to clean up the document on the language server. If the document + // was read-only, the language server wasn't loaded so there is nothing to do. + if (readOnly) { + return; } - } - }); - - useEffect(() => { - if (view.current !== null && parent.current !== null) { - if (readOnly && view.current.state.facet(EditorView.editable)) { - // console.log(">>>read only has been set, changing"); - //NOTE: WHY DOESN'T THIS WORK? - const disabledExtensions = [ - EditorView.editable.of(false), - lineNumbers(), - ]; - view.current.dispatch({ - effects: StateEffect.reconfigure.of(disabledExtensions), - }); - } else if ( - !readOnly && - !view.current.state.facet(EditorView.editable) - ) { - // console.log(">>>read only has been turned off, changing"); - view.current.dispatch({ - effects: StateEffect.reconfigure.of(doenetExtensions), - }); - if (editorConfig.matchTag) { - view.current.dispatch({ - effects: StateEffect.appendConfig.of( - EditorState.transactionFilter.of(matchTag), - ), - }); - } - } - } - //annoying that editorConfig is a dependency, but no real way around it - }, [ - doenetExtensions, - setInternalValueTo, - matchTag, - readOnly, - editorConfig.matchTag, - ]); - - //TODO any updates would force an update of each part of the config. - //Doesn't matter since there's only one toggle at the moment, but could cause unnecessary work later - useEffect(() => { - // console.log(">>>config update") - if (editorConfig.matchTag) { - view.current?.dispatch({ - effects: StateEffect.appendConfig.of( - EditorState.transactionFilter.of(matchTag), - ), - }); - } else { - view.current?.dispatch({ - //this will also need to change when more options are added, as this paves all of the added extensions. - effects: StateEffect.reconfigure.of(doenetExtensions), - }); - } - }, [editorConfig, matchTag, doenetExtensions]); - - let divStyle: React.CSSProperties = {}; - - if (paddingBottom) { - divStyle.paddingBottom = paddingBottom; + const uri = `file:///${documentId}.doenet`; + uniqueLanguageServerInstance.closeDocument(uri); + }; + }, [documentId, readOnly]); + + const extensions: Extension[] = [ + syntaxHighlightingExtension, + colorTheme, + EditorView.lineWrapping, + ]; + if (!readOnly) { + extensions.push(tabExtension); + extensions.push(lspPlugin(documentId)); } - //should rewrite using compartments once a more formal config component is established return ( - <> -
- - ); -} - -let parserWithMetadata = parser.configure({ - props: [ - indentNodeProp.add({ - //fun (unfixable?) glitch: If you modify the document and then create a newline before enough time has passed for a new parse (which is often < 50ms) - //the indent wont have time to update and you're going right back to the left side of the screen. - Element(context) { - let closed = /^\s*<\//.test(context.textAfter); - // console.log("youuuhj",context.state.doc.lineAt(context.node.from)) - return ( - context.lineIndent(context.node.from) + - (closed ? 0 : context.unit) - ); - }, - "OpenTag CloseTag SelfClosingTag"(context) { - if (context.node.firstChild?.name == "TagName") { - return context.column(context.node.from); + { + if (onChange) { + onChange(update.state.doc.toString()); } - return context.column(context.node.from) + context.unit; - }, - }), - foldNodeProp.add({ - Element(subtree) { - let first = subtree.firstChild; - let last = subtree.lastChild; - if (!first || first.name != "OpenTag") return null; - return { - from: first.to, - to: last?.name == "CloseTag" ? last.from : subtree.to, - }; - }, - }), - styleTags({ - AttributeValue: t.string, - Text: t.content, - TagName: t.tagName, - MismatchedCloseTag: t.invalid, - "StartTag StartCloseTag EndTag SelfCloseEndTag": t.angleBracket, - "MismatchedCloseTag/TagName": [t.tagName, t.invalid], - "MismatchedCloseTag/StartCloseTag": t.invalid, - AttributeName: t.propertyName, - Is: t.definitionOperator, - "EntityReference CharacterReference": t.character, - Comment: t.blockComment, - Macro: t.macroName, - }), - ], -}); - -const doenetLanguage = LRLanguage.define({ - parser: parserWithMetadata, - languageData: { - commentTokens: { block: { open: "" } }, - indentOnInput: /^\s*<\/$/, - }, -}); - -// TODO: not sure what this is for, but it isn't referenced anywhere. -// If we need this functionality, we have to rework it -// given that view is no longer a global variable. -// export function codeMirrorFocusAndGoToEnd() { -// view.current.focus(); -// view.current.dispatch( -// view.current.state.update({ -// selection: { anchor: view.current.state.doc.length }, -// }), -// { scrollIntoView: true }, -// ); -// } - -const doenet = ( - conf: Partial & { attributes?: [] } = {}, -) => - new LanguageSupport( - doenetLanguage, - doenetLanguage.data.of({ - autocomplete: completeFromSchema( - conf.elements || [], - conf.attributes || [], - ), - }), + }} + onUpdate={(viewUpdate) => { + for (const tr of viewUpdate.transactions) { + if (tr.selection && onCursorChange) { + onCursorChange(tr.selection); + } + } + }} + onBlur={() => onBlur && onBlur()} + onFocus={() => onFocus && onFocus()} + height="100%" + extensions={extensions} + /> ); +} diff --git a/packages/codemirror/src/extensions/lsp/plugin.tsx b/packages/codemirror/src/extensions/lsp/plugin.tsx index 6fbee0815..f02b33c40 100644 --- a/packages/codemirror/src/extensions/lsp/plugin.tsx +++ b/packages/codemirror/src/extensions/lsp/plugin.tsx @@ -2,21 +2,21 @@ // BSD 3-Clause License // Copyright (c) 2021, Mahmud Ridwan // All rights reserved. -// +// // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: -// +// // * Redistributions of source code must retain the above copyright notice, this // list of conditions and the following disclaimer. -// +// // * Redistributions in binary form must reproduce the above copyright notice, // this list of conditions and the following disclaimer in the documentation // and/or other materials provided with the distribution. -// +// // * Neither the name of the library nor the names of its // contributors may be used to endorse or promote products derived from // this software without specific prior written permission. -// +// // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -28,7 +28,6 @@ // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - import { renderToString } from "react-dom/server"; import React from "react"; import { @@ -80,7 +79,7 @@ const lspSeverityToCmSeverity = { } as const; // One language server is shared across all plugin instances -const languageServer = new LSP(); +export const uniqueLanguageServerInstance = new LSP(); export class LSPPlugin implements PluginValue { documentId: string; @@ -106,13 +105,15 @@ export class LSPPlugin implements PluginValue { if (value === this.value) { return; } - await languageServer.updateDocument(this.uri, value); + await uniqueLanguageServerInstance.updateDocument(this.uri, value); this.pollForDiagnostics(); this.value = value; } async pollForDiagnostics() { - this.diagnosticsPromise = languageServer.getDiagnostics(this.uri); + this.diagnosticsPromise = uniqueLanguageServerInstance.getDiagnostics( + this.uri, + ); this.diagnostics = await this.diagnosticsPromise; this.processDiagnostics(); } @@ -171,7 +172,7 @@ export class LSPPlugin implements PluginValue { LSPCompletionTriggerKind.Invoked; let triggerCharacter: string | undefined; const precedingTriggerCharacter = - languageServer.completionTriggers.includes( + uniqueLanguageServerInstance.completionTriggers.includes( line.text[pos - line.from - 1], ); if (!explicit && precedingTriggerCharacter) { @@ -187,7 +188,7 @@ export class LSPPlugin implements PluginValue { return null; } const position = offsetToPos(state.doc, pos); - const result = await languageServer.getCompletionItems( + const result = await uniqueLanguageServerInstance.getCompletionItems( this.uri, { line: position.line, character: position.character }, { diff --git a/packages/codemirror/src/extensions/lsp/worker.ts b/packages/codemirror/src/extensions/lsp/worker.ts index a35e112cb..51e36803f 100644 --- a/packages/codemirror/src/extensions/lsp/worker.ts +++ b/packages/codemirror/src/extensions/lsp/worker.ts @@ -61,8 +61,21 @@ export class LSP { }); } - async updateDocument(uri: string, text: string) { + async closeDocument(uri: string) { if (!this.lspConn) { + await this.init(); + await this.closeDocument(uri); + return; + } + await this.lspConn.textDocumentClosed({ + textDocument: { + uri, + }, + }); + } + + async updateDocument(uri: string, text: string) { + if (!this.lspConn || !this.versionCounter[uri]) { await this.initDocument(uri, text); return; } diff --git a/packages/codemirror/src/extensions/theme.ts b/packages/codemirror/src/extensions/theme.ts new file mode 100644 index 000000000..796271db4 --- /dev/null +++ b/packages/codemirror/src/extensions/theme.ts @@ -0,0 +1,39 @@ +import { EditorView } from "@codemirror/view"; + +export const colorTheme = EditorView.theme({ + "&": { + color: "var(--canvastext)", + height: "100%", + //backgroundColor: "var(--canvas)", + }, + ".cm-content": { + caretColor: "#0e9", + borderDownColor: "var(--canvastext)", + }, + ".cm-editor": { + caretColor: "#0e9", + backgroundColor: "var(--canvas)", + }, + "&.cm-focused .cm-cursor": { + backgroundColor: "var(--lightBlue)", + borderLeftColor: "var(--canvastext)", + }, + "&.cm-focused .cm-selectionBackground, ::selection": { + backgroundColor: "var(--mainGray)", + }, + "&.cm-focused": { + color: "var(--canvastext)", + }, + "cm-selectionLayer": { + backgroundColor: "var(--mainGreen)", + }, + ".cm-gutters": { + backgroundColor: "var(--mainGray)", + color: "black", + border: "none", + }, + ".cm-activeLine": { + backgroundColor: "var(--mainGray)", + color: "black", + }, +}); diff --git a/packages/codemirror/src/index.ts b/packages/codemirror/src/index.ts new file mode 100644 index 000000000..baf36ad17 --- /dev/null +++ b/packages/codemirror/src/index.ts @@ -0,0 +1 @@ +export * from "./CodeMirror"; diff --git a/packages/codemirror/src/test-main.tsx b/packages/codemirror/src/test-main.tsx index 540cb6b19..82fe6042a 100644 --- a/packages/codemirror/src/test-main.tsx +++ b/packages/codemirror/src/test-main.tsx @@ -5,13 +5,22 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { CodeMirror2 } from "./CodeMirror2"; +import { CodeMirror } from "./CodeMirror"; -ReactDOM.createRoot(document.getElementById("root")!).render( - - {}} - value={` +ReactDOM.createRoot(document.getElementById("root")!).render(); + +function App() { + const [viewVisible, setViewVisible] = React.useState(true); + + return ( + + + {viewVisible && ( + {}} + value={`

Use this to test DoenetML. Some text & @@ -25,27 +34,17 @@ ReactDOM.createRoot(document.getElementById("root")!).render( `} - onBlur={ - () => {} //console.log("blur") - } - onFocus={ - () => {} //console.log("focus") - } - onCursorChange={(e) => console.log("cursor change", e)} - /> - { - {}} + onBlur={() => console.log("blur")} + onFocus={() => console.log("focus")} + onCursorChange={(e) => console.log("cursor change", e)} + /> + )} +

Read only view below
+ {}} value={`

foo

`} - onBlur={ - () => {} //console.log("blur") - } - onFocus={ - () => {} - //console.log("focus") - } readOnly={true} /> - } -
, -); +
+ ); +} diff --git a/packages/doenetml/src/Viewer/renderers/codeEditor-ts.tsx b/packages/doenetml/src/Viewer/renderers/codeEditor-ts.tsx index b3adcb897..cc7e088f1 100644 --- a/packages/doenetml/src/Viewer/renderers/codeEditor-ts.tsx +++ b/packages/doenetml/src/Viewer/renderers/codeEditor-ts.tsx @@ -281,7 +281,7 @@ export default React.memo(function CodeEditor(props) {
readOnly={SVs.disabled} onBlur={() => { @@ -297,8 +297,7 @@ export default React.memo(function CodeEditor(props) { onFocus={() => { // console.log(">>codeEditor FOCUS!!!!!") }} - onBeforeChange={onEditorChange} - paddingBottom={paddingBottom} + onChange={onEditorChange} /> {errorsAndWarnings} diff --git a/packages/lsp-tools/src/dev-site.tsx b/packages/lsp-tools/src/dev-site.tsx index 66eed0964..59090fd7c 100644 --- a/packages/lsp-tools/src/dev-site.tsx +++ b/packages/lsp-tools/src/dev-site.tsx @@ -141,10 +141,10 @@ function App() { >
{ + onChange={(val) => { setDoenetSource(val); }} - setInternalValueTo={INITIAL_DOENET_SOURCE} + value={INITIAL_DOENET_SOURCE} onCursorChange={(selection) => { const range = selection.ranges[0]; if (!range) { diff --git a/packages/parser/src/dev-site.tsx b/packages/parser/src/dev-site.tsx index 42172feb4..f2b766f1b 100644 --- a/packages/parser/src/dev-site.tsx +++ b/packages/parser/src/dev-site.tsx @@ -89,10 +89,10 @@ function App() { >
{ + onChange={(val) => { setDoenetSource(val); }} - setInternalValueTo={INITIAL_DOENET_SOURCE} + value={INITIAL_DOENET_SOURCE} />