From c45fefe1615ac80437705cce3ab0386e0726f406 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 10 Oct 2023 17:35:58 +0200 Subject: [PATCH 1/4] add inline code blocks --- packages/engine/src/executor.ts | 4 +- packages/frame/src/Frame.tsx | 47 +++- packages/frame/src/MonacoBlockContent.tsx | 207 --------------- .../LanguageSelector.module.css | 0 .../src/{ => codeblocks}/LanguageSelector.tsx | 0 .../frame/src/codeblocks/MonacoCodeBlock.tsx | 95 +++++++ .../{ => codeblocks}/MonacoElement.module.css | 8 + .../src/{ => codeblocks}/MonacoElement.tsx | 247 +++++++++++++++--- .../frame/src/codeblocks/MonacoInlineCode.tsx | 88 +++++++ .../frame/src/codeblocks/MonacoNodeView.tsx | 148 +++++++++++ .../MonacoProsemirrorHelpers.ts | 34 +-- .../MonacoSelection.module.css | 0 .../frame/src/codeblocks/blocknotehelpers.ts | 114 ++++++++ .../local/LocalExecutionHost.tsx | 6 +- 14 files changed, 723 insertions(+), 275 deletions(-) delete mode 100644 packages/frame/src/MonacoBlockContent.tsx rename packages/frame/src/{ => codeblocks}/LanguageSelector.module.css (100%) rename packages/frame/src/{ => codeblocks}/LanguageSelector.tsx (100%) create mode 100644 packages/frame/src/codeblocks/MonacoCodeBlock.tsx rename packages/frame/src/{ => codeblocks}/MonacoElement.module.css (85%) rename packages/frame/src/{ => codeblocks}/MonacoElement.tsx (53%) create mode 100644 packages/frame/src/codeblocks/MonacoInlineCode.tsx create mode 100644 packages/frame/src/codeblocks/MonacoNodeView.tsx rename packages/frame/src/{ => codeblocks}/MonacoProsemirrorHelpers.ts (94%) rename packages/frame/src/{ => codeblocks}/MonacoSelection.module.css (100%) create mode 100644 packages/frame/src/codeblocks/blocknotehelpers.ts diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 193a796da..acbcc89ee 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { autorun, runInAction } from "mobx"; +import { autorun, runInAction, trace } from "mobx"; import { TypeCellContext } from "./context.js"; import { installHooks } from "./hookDisposables.js"; import { Module } from "./modules.js"; @@ -104,7 +104,7 @@ export async function runModule( detectedLoop = true; throw new Error("loop detected (child run)"); } - // trace(false); + trace(false); if (initialRun) { // log.debug("engine initial run", cell.id); //, func + ""); } else { diff --git a/packages/frame/src/Frame.tsx b/packages/frame/src/Frame.tsx index 1ec26f1ad..0e270f27d 100644 --- a/packages/frame/src/Frame.tsx +++ b/packages/frame/src/Frame.tsx @@ -29,8 +29,8 @@ import { AsyncMethodReturns, connectToParent } from "penpal"; import ReactDOM from "react-dom"; import * as Y from "yjs"; import styles from "./Frame.module.css"; -import { MonacoBlockContent } from "./MonacoBlockContent"; import { RichTextContext } from "./RichTextContext"; +import { MonacoCodeBlock } from "./codeblocks/MonacoCodeBlock"; import SourceModelCompiler from "./runtime/compiler/SourceModelCompiler"; import { MonacoContext } from "./runtime/editor/MonacoContext"; import { ExecutionHost } from "./runtime/executor/executionHosts/ExecutionHost"; @@ -42,7 +42,8 @@ import { variables } from "@typecell-org/util"; import { RiCodeSSlashFill } from "react-icons/ri"; import { EditorStore } from "./EditorStore"; import { MonacoColorManager } from "./MonacoColorManager"; -import monacoStyles from "./MonacoSelection.module.css"; +import { MonacoInlineCode } from "./codeblocks/MonacoInlineCode"; +import monacoStyles from "./codeblocks/MonacoSelection.module.css"; import { setupTypecellHelperTypeResolver } from "./runtime/editor/languages/typescript/TypeCellHelperTypeResolver"; import { setupTypecellModuleTypeResolver } from "./runtime/editor/languages/typescript/TypeCellModuleTypeResolver"; import { setupNpmTypeResolver } from "./runtime/editor/languages/typescript/npmTypeResolver"; @@ -102,6 +103,19 @@ const originalItems = [ group: "Code", icon: , }, + { + name: "Inline", + execute: (editor: any) => { + // state.tr.replaceSelectionWith(dinoType.create({type})) + const node = editor._tiptapEditor.schema.node( + "inlineCode", + undefined, + editor._tiptapEditor.schema.text("export default "), + ); + const tr = editor._tiptapEditor.state.tr.replaceSelectionWith(node); + editor._tiptapEditor.view.dispatch(tr); + }, + }, ]; const slashMenuItems = [...originalItems]; @@ -299,11 +313,11 @@ export const Frame: React.FC = observer((props) => { }, content: `// @default-collapsed import * as doc from "${data.documentId}"; + export let ${varName} = doc.${data.blockVariable}; -// export default { -// block: doc.${data.blockVariable}, -// doc, -// }; +export let ${varName}Scope = doc; + +export default ${varName}; `, } as any, ); @@ -340,7 +354,20 @@ export let ${varName} = doc.${data.blockVariable}; default: "", }, }, - node: MonacoBlockContent, + node: MonacoCodeBlock, + }, + inlinecode: { + propSchema: { + language: { + type: "string", + default: "typescript", + }, + storage: { + type: "string", + default: "", + }, + }, + node: MonacoInlineCode, }, }, slashMenuItems, @@ -354,10 +381,14 @@ export let ${varName} = doc.${data.blockVariable}; }, }); - if (editor !== editorStore.current.editor) { + if (editorStore.current.editor !== editor) { editorStore.current.editor = editor as any; } + if (editorStore.current.executionHost !== tools.newExecutionHost) { + editorStore.current.executionHost = tools.newExecutionHost; + } + return (
diff --git a/packages/frame/src/MonacoBlockContent.tsx b/packages/frame/src/MonacoBlockContent.tsx deleted file mode 100644 index 663ef3591..000000000 --- a/packages/frame/src/MonacoBlockContent.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { BlockNoteEditor, createTipTapBlock } from "@blocknote/core"; -import { mergeAttributes } from "@tiptap/core"; -// import styles from "../../Block.module.css"; - -import { - NodeViewProps, - NodeViewWrapper, - ReactNodeViewRenderer, -} from "@tiptap/react"; -import { keymap } from "prosemirror-keymap"; -import { EditorState, Selection } from "prosemirror-state"; -import { EditorView, NodeView } from "prosemirror-view"; -import { MonacoElement } from "./MonacoElement"; - -function arrowHandler( - dir: "up" | "down" | "left" | "right" | "forward" | "backward", -) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (state: EditorState, dispatch: any, view: EditorView) => { - if (state.selection.empty && view.endOfTextblock(dir)) { - const side = dir === "left" || dir === "up" ? -1 : 1; - const $head = state.selection.$head; - const nextPos = Selection.near( - state.doc.resolve(side > 0 ? $head.after() : $head.before()), - side, - ); - // console.log("nextPos", nextPos.$head.parent.type.name); - if (nextPos.$head && nextPos.$head.parent.type.name === "codeblock") { - dispatch(state.tr.setSelection(nextPos)); - return true; - } - } - return false; - }; -} - -const arrowHandlers = keymap({ - ArrowLeft: arrowHandler("left"), - ArrowRight: arrowHandler("right"), - ArrowUp: arrowHandler("up"), - ArrowDown: arrowHandler("down"), -} as any); - -const ComponentWithWrapper = ( - props: NodeViewProps & { - block: any; - htmlAttributes: any; - selectionHack: any; - blockNoteEditor: BlockNoteEditor; - }, -) => { - const { htmlAttributes, ...restProps } = props; - return ( - - - - ); -}; - -// TODO: clean up listeners -export const MonacoBlockContent = createTipTapBlock<"codeblock", any>({ - name: "codeblock", - content: "inline*", - editable: true, - selectable: true, - whitespace: "pre", - code: true, - addAttributes() { - return { - language: { - default: "typescript", - parseHTML: (element) => element.getAttribute("data-language"), - renderHTML: (attributes) => { - return { - "data-language": attributes.language, - }; - }, - }, - storage: { - default: {}, - parseHTML: (_element) => ({}), - renderHTML: (attributes) => { - return { - // "data-language": attributes.language, - }; - }, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "code", - priority: 200, - node: "codeblock", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - "code", - mergeAttributes(HTMLAttributes, { - // class: styles.blockContent, - "data-content-type": this.name, - }), - ]; - }, - - addNodeView() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const BlockContent = (props: any) => { - // const Content = blockConfig.render; - - // Add props as HTML attributes in kebab-case with "data-" prefix - const htmlAttributes: Record = {}; - // for (const [attribute, value] of Object.entries(props.node.attrs)) { - // if (attribute in blockConfig.propSchema) { - // htmlAttributes[camelToDataKebab(attribute)] = value; - // } - // } - - // Gets BlockNote editor instance - const editor = this.options.editor; - // Gets position of the node - const pos = - typeof props.getPos === "function" ? props.getPos() : undefined; - // Gets TipTap editor instance - const tipTapEditor = editor._tiptapEditor; - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - // Get the block - const block = editor.getBlock(blockIdentifier); - - // console.log("ComponentWithWrapper"); - return ( - - ); - }; - // console.log("addnodeview"); - return (props) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (!(props.editor as any).contentComponent) { - // same logic as in ReactNodeViewRenderer - return {}; - } - const ret = ReactNodeViewRenderer(BlockContent, { - stopEvent: () => true, - })(props) as NodeView; - // manual hack, because tiptap React nodeviews don't support setSelection - ret.setSelection = (anchor, head) => { - // This doesn't work because the Tiptap react renderer doesn't properly support forwardref - // (ret as any).renderer.ref?.setSelection(anchor, head); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ret as any).renderer.updateProps({ - selectionHack: { anchor, head }, - }); - }; - - // disable contentdom, because we render the content ourselves in MonacoElement - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ret as any).contentDOMElement = undefined; - // ret.destroy = () => { - // console.log("destroy element"); - // // (ret as any).renderer.destroy(); - // }; - // This is a hack because tiptap doesn't support innerDeco, and this information is normally dropped - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const oldUpdated = ret.update!.bind(ret); - ret.update = (node, outerDeco, innerDeco) => { - // console.log("update"); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const retAsAny = ret as any; - let decorations = retAsAny.decorations; - if ( - retAsAny.decorations.decorations !== outerDeco || - retAsAny.decorations.innerDecorations !== innerDeco - ) { - // change the format of "decorations" to have both the outerDeco and innerDeco - decorations = { - decorations: outerDeco, - innerDecorations: innerDeco, - }; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return oldUpdated(node, decorations, undefined as any); - }; - return ret; - }; - }, - addProseMirrorPlugins() { - return [arrowHandlers]; - }, -}); diff --git a/packages/frame/src/LanguageSelector.module.css b/packages/frame/src/codeblocks/LanguageSelector.module.css similarity index 100% rename from packages/frame/src/LanguageSelector.module.css rename to packages/frame/src/codeblocks/LanguageSelector.module.css diff --git a/packages/frame/src/LanguageSelector.tsx b/packages/frame/src/codeblocks/LanguageSelector.tsx similarity index 100% rename from packages/frame/src/LanguageSelector.tsx rename to packages/frame/src/codeblocks/LanguageSelector.tsx diff --git a/packages/frame/src/codeblocks/MonacoCodeBlock.tsx b/packages/frame/src/codeblocks/MonacoCodeBlock.tsx new file mode 100644 index 000000000..1b70379c7 --- /dev/null +++ b/packages/frame/src/codeblocks/MonacoCodeBlock.tsx @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createTipTapBlock } from "@blocknote/core"; +import { mergeAttributes } from "@tiptap/core"; +// import styles from "../../Block.module.css"; + +import { keymap } from "prosemirror-keymap"; +import { EditorState, Selection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { MonacoNodeView } from "./MonacoNodeView"; + +function arrowHandler( + dir: "up" | "down" | "left" | "right" | "forward" | "backward", +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (state: EditorState, dispatch: any, view: EditorView) => { + if (state.selection.empty && view.endOfTextblock(dir)) { + const side = dir === "left" || dir === "up" ? -1 : 1; + const $head = state.selection.$head; + const nextPos = Selection.near( + state.doc.resolve(side > 0 ? $head.after() : $head.before()), + side, + ); + // console.log("nextPos", nextPos.$head.parent.type.name); + if (nextPos.$head && nextPos.$head.parent.type.name === "codeblock") { + dispatch(state.tr.setSelection(nextPos)); + return true; + } + } + return false; + }; +} + +const arrowHandlers = keymap({ + ArrowLeft: arrowHandler("left"), + ArrowRight: arrowHandler("right"), + ArrowUp: arrowHandler("up"), + ArrowDown: arrowHandler("down"), +} as any); + +// TODO: clean up listeners +export const MonacoCodeBlock = createTipTapBlock<"codeblock", any>({ + name: "codeblock", + content: "inline*", + editable: true, + selectable: true, + whitespace: "pre", + code: true, + addAttributes() { + return { + language: { + default: "typescript", + parseHTML: (element) => element.getAttribute("data-language"), + renderHTML: (attributes) => { + return { + "data-language": attributes.language, + }; + }, + }, + storage: { + default: {}, + parseHTML: (_element) => ({}), + renderHTML: (attributes) => { + return { + // "data-language": attributes.language, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "code", + priority: 200, + node: "codeblock", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "code", + mergeAttributes(HTMLAttributes, { + // class: styles.blockContent, + "data-content-type": this.name, + }), + ]; + }, + + addNodeView: MonacoNodeView(false), + addProseMirrorPlugins() { + return [arrowHandlers]; + }, +}); diff --git a/packages/frame/src/MonacoElement.module.css b/packages/frame/src/codeblocks/MonacoElement.module.css similarity index 85% rename from packages/frame/src/MonacoElement.module.css rename to packages/frame/src/codeblocks/MonacoElement.module.css index 4be6bb115..d10104ddd 100644 --- a/packages/frame/src/MonacoElement.module.css +++ b/packages/frame/src/codeblocks/MonacoElement.module.css @@ -35,11 +35,19 @@ .monacoContainer { height: 100%; } + .codeCellOutput { line-height: 0; position: relative; + padding: 10px; } +.codeCellOutput.inline { + display: inline-block; + vertical-align: top; + padding: 0; + padding-top: 5px; +} .codeCellOutput > * { line-height: normal; } diff --git a/packages/frame/src/MonacoElement.tsx b/packages/frame/src/codeblocks/MonacoElement.tsx similarity index 53% rename from packages/frame/src/MonacoElement.tsx rename to packages/frame/src/codeblocks/MonacoElement.tsx index 6fc9dec0c..60251cff0 100644 --- a/packages/frame/src/MonacoElement.tsx +++ b/packages/frame/src/codeblocks/MonacoElement.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { NodeViewProps } from "@tiptap/core"; +import { NodeViewProps } from "@tiptap/core"; import * as monaco from "monaco-editor"; import React, { useCallback, @@ -12,8 +12,19 @@ import React, { } from "react"; import { VscChevronDown, VscChevronRight } from "react-icons/vsc"; -import { BlockNoteEditor } from "@blocknote/core"; +import { + autoUpdate, + safePolygon, + size, + useDismiss, + useFloating, + useHover, + useInteractions, +} from "@floating-ui/react"; import { useResource } from "@typecell-org/util"; +import { RichTextContext } from "../RichTextContext"; +import { MonacoTypeCellCodeModel } from "../models/MonacoCodeModel"; +import { getMonacoModel } from "../models/MonacoModelManager"; import LanguageSelector from "./LanguageSelector"; import styles from "./MonacoElement.module.css"; import { @@ -23,16 +34,17 @@ import { textFromPMNode, } from "./MonacoProsemirrorHelpers"; import monacoStyles from "./MonacoSelection.module.css"; -import { RichTextContext } from "./RichTextContext"; -import { MonacoTypeCellCodeModel } from "./models/MonacoCodeModel"; -import { getMonacoModel } from "./models/MonacoModelManager"; + +export type MonacoElementProps = NodeViewProps & { + modelUri: monaco.Uri; + language: string; + setLanguage: (lang: string) => void; + selectionHack: any; + inline: boolean; +}; const MonacoElementComponent = function MonacoElement( - props: NodeViewProps & { - block: any; - selectionHack: any; - blockNoteEditor: any; - }, + props: MonacoElementProps, ) { const editorRef = useRef(); // const refa = useRef(Math.random()); @@ -40,15 +52,15 @@ const MonacoElementComponent = function MonacoElement( const models = useResource(() => { // console.log("create", props.block.id, refa.current); - const uri = monaco.Uri.parse( - `file:///!${context.documentId}/${props.block.id}.cell.tsx`, - ); - console.log("allocate model", uri.toString()); + // const uri = monaco.Uri.parse( + // `file:///!${context.documentId}/${props.block.id}.cell.tsx`, + // ); + // console.log("allocate model", props.modelUri.toString()); const model = getMonacoModel( textFromPMNode(props.node), - props.block.props.language, - uri, + props.language, + props.modelUri, monaco, ); @@ -84,11 +96,8 @@ const MonacoElementComponent = function MonacoElement( models.state.isUpdating = true; models.state.node = props.node; try { - if (props.block.props.language !== models.codeModel.language) { - monaco.editor.setModelLanguage( - models.model, - props.block.props.language, - ); + if (props.language !== models.codeModel.language) { + monaco.editor.setModelLanguage(models.model, props.language); } applyNodeChangesToMonaco(props.node, models.model); models.state.lastDecorations = applyDecorationsToMonaco( @@ -103,7 +112,7 @@ const MonacoElementComponent = function MonacoElement( } finally { models.state.isUpdating = false; } - }, [props.node, props.block, props.decorations, models]); + }, [props.node, props.language, props.decorations, models]); // useImperativeHandle( // ref, @@ -140,7 +149,7 @@ const MonacoElementComponent = function MonacoElement( }, [models.model, models.state, props.selectionHack]); const codeRefCallback = useCallback( - (el: HTMLDivElement) => { + (el: HTMLDivElement, onLayoutChange?: (newHeight: number) => void) => { const editor = editorRef.current; if (editor) { @@ -150,18 +159,19 @@ const MonacoElementComponent = function MonacoElement( editorRef.current = undefined; } else { // no need for new editor - return; + return undefined; } } if (!el) { - return; + return undefined; } console.log("create editor"); const newEditor = monaco.editor.create(el, { model: models.model, theme: "typecellTheme", + renderLineHighlight: props.inline ? "none" : "all", }); bindMonacoAndProsemirror( @@ -191,23 +201,69 @@ const MonacoElementComponent = function MonacoElement( }); newEditor.onDidContentSizeChange(() => { + console.log("content size", newEditor.getContentHeight()); const contentHeight = Math.min(500, newEditor.getContentHeight()); newEditor.layout({ height: contentHeight, - width: newEditor.getContainerDomNode()!.offsetWidth, + width: props.inline + ? newEditor.getContentWidth() + 50 + : newEditor.getContainerDomNode()!.offsetWidth, }); + + onLayoutChange?.(contentHeight); }); editorRef.current = newEditor; + return newEditor; }, - [models.model, models.state, props.editor.view, props.getPos], + [ + models.model, + models.state, + props.editor.view, + props.getPos, + props.inline, + editorRef, + ], + ); + + // useEffect(() => { + // console.log("mount main"); + // return () => { + // console.log("unmount main"); + // }; + // }, []); + + return props.inline ? ( + + ) : ( + ); +}; +const MonacoBlockElement = ( + props: NodeViewProps & { + inline: boolean; + setLanguage: (lang: string) => void; + language: string; + model: monaco.editor.IModel; + codeRefCallback?: (el: HTMLDivElement) => void; + }, +) => { const [codeVisible, setCodeVisible] = useState( () => props.node.textContent.startsWith("// @default-collapsed") === false, ); + const context = useContext(RichTextContext); + return (
{/* {props.toolbar && props.toolbar} */} { - (props.blockNoteEditor as BlockNoteEditor).updateBlock( - props.block, - { - props: { - language: lang, - }, - }, - ); + props.setLanguage(lang); }} /> -
+
)}
{context.executionHost.renderOutput( - models.model.uri.toString(), + props.model.uri.toString(), () => { // noop }, @@ -263,5 +314,125 @@ const MonacoElementComponent = function MonacoElement( ); }; +const MonacoInlineElement = ( + props: NodeViewProps & { + inline: boolean; + model: monaco.editor.IModel; + selectionHack: any; + codeRefCallback?: ( + el: HTMLDivElement, + onLayoutChange: (newHeight: number) => void, + ) => monaco.editor.IStandaloneCodeEditor | undefined; + }, +) => { + const [codeVisible, setCodeVisible] = useState(false); + const height = useRef(50); + const [editorFocus, setEditorFocused] = useState(false); + + const { refs, floatingStyles, context } = useFloating({ + open: codeVisible, + onOpenChange: setCodeVisible, + placement: "top-start", + whileElementsMounted: autoUpdate, + middleware: [ + size({ + apply(args) { + args.elements.floating.style.height = height.current + "px"; + // console.log("size"); + // debugger; + }, + }), + ], + }); + + const dismiss = useDismiss(context); + const hover = useHover(context, { + delay: { + open: 0, + close: 400, + }, + enabled: !codeVisible || !editorFocus, + handleClose: safePolygon(), + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + hover, + dismiss, + ]); + + const codeRef = useCallback( + (el: HTMLDivElement) => { + console.log(codeVisible); + const editor = props.codeRefCallback?.(el, (newHeight: number) => { + height.current = newHeight; + context.update(); + }); + if (editor) { + editor.onDidBlurEditorWidget(() => { + setCodeVisible(false); + setEditorFocused(false); + }); + editor.onDidFocusEditorWidget(() => { + setEditorFocused(true); + }); + setEditorFocused(editor.hasWidgetFocus()); + } + refs.setFloating(el); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.codeRefCallback, refs.setFloating, codeVisible], + ); + + useEffect(() => { + console.log("selectionHack effect", props.selectionHack); + if (!props.selectionHack) { + return; + } + + setCodeVisible(true); + }, [props.selectionHack]); + + // useEffect(() => { + // console.log("mount inline"); + // return () => { + // console.log("unmount inline"); + // }; + // }, []); + + const rtcontext = useContext(RichTextContext); + // debugger; + return ( + <> + {codeVisible && ( +
+ )} + + + {rtcontext.executionHost.renderOutput( + props.model.uri.toString(), + () => { + // noop + }, + )} + + + ); +}; + // TODO: check why this doesn't work export const MonacoElement = React.memo(MonacoElementComponent); diff --git a/packages/frame/src/codeblocks/MonacoInlineCode.tsx b/packages/frame/src/codeblocks/MonacoInlineCode.tsx new file mode 100644 index 000000000..e8531284a --- /dev/null +++ b/packages/frame/src/codeblocks/MonacoInlineCode.tsx @@ -0,0 +1,88 @@ +import { createTipTapBlock } from "@blocknote/core"; +import { mergeAttributes } from "@tiptap/core"; +// import styles from "../../Block.module.css"; + +import { keymap } from "prosemirror-keymap"; +import { EditorState, Selection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { MonacoNodeView } from "./MonacoNodeView"; + +function arrowHandler( + dir: "up" | "down" | "left" | "right" | "forward" | "backward", +) { + return (state: EditorState, dispatch: any, view: EditorView) => { + if (state.selection.empty) { + const side = dir === "left" || dir === "up" ? -1 : 1; + const $head = state.selection.$head; + + const nextPos = Selection.near( + state.doc.resolve(side > 0 ? $head.pos + 1 : $head.pos - 1), + side, + ); + + if (nextPos.$head && nextPos.$head.parent.type.name === "inlineCode") { + dispatch(state.tr.setSelection(nextPos)); + return true; + } + } + return false; + }; +} + +const arrowHandlers = keymap({ + ArrowLeft: arrowHandler("left"), + ArrowRight: arrowHandler("right"), + ArrowUp: arrowHandler("up"), + ArrowDown: arrowHandler("down"), +} as any); + +// TODO: clean up listeners +export const MonacoInlineCode = createTipTapBlock({ + name: "inlineCode", + // inline: true, + content: "inline*", + editable: true, + selectable: false, + parseHTML() { + return [ + { + tag: "inlineCode", + priority: 200, + node: "inlineCode", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "inlineCode", + mergeAttributes(HTMLAttributes, { + // class: styles.blockContent, + "data-content-type": this.name, + }), + 0, + ]; + }, + + addNodeView: MonacoNodeView(true), + addProseMirrorPlugins() { + return [arrowHandlers] as any; + }, +}); + +MonacoInlineCode.config.group = "inline" as any; +(MonacoInlineCode as any).config.inline = true as any; + +// export function smartBlock(block: any) { +// const entries = block.children.map((b: any) => { +// if (!b.content.length) { +// return undefined; +// } +// const props = b.content[0].text.split(":", 2); +// if (props.length !== 2) { +// return undefined; +// } +// return [props[0].trim(), props[1].trim()]; +// }); +// return Object.fromEntries(entries.filter((a) => !!a)); +// } diff --git a/packages/frame/src/codeblocks/MonacoNodeView.tsx b/packages/frame/src/codeblocks/MonacoNodeView.tsx new file mode 100644 index 000000000..3bce8150d --- /dev/null +++ b/packages/frame/src/codeblocks/MonacoNodeView.tsx @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// import styles from "../../Block.module.css"; + +import { BlockNoteEditor } from "@blocknote/core"; +import { + NodeViewProps, + NodeViewRenderer, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { uniqueId } from "@typecell-org/util"; +import { NodeView } from "prosemirror-view"; +import { useContext, useRef } from "react"; +import { uri } from "vscode-lib"; +import { RichTextContext } from "../RichTextContext"; +import { MonacoElement, MonacoElementProps } from "./MonacoElement"; +import { getBlockInfoFromPos } from "./blocknotehelpers"; + +const ComponentWithWrapper = ( + props: { htmlAttributes: any } & MonacoElementProps, +) => { + const { htmlAttributes, ...restProps } = props; + return ( + + + + ); +}; + +export function MonacoNodeView(inline: boolean) { + const nodeView: + | ((this: { + name: string; + options: any; + storage: any; + editor: any; + type: any; + parent: any; + }) => NodeViewRenderer) + | null = function () { + const BlockContent = (props: NodeViewProps & { selectionHack: any }) => { + const id = useRef(uniqueId.generateUuid()); + const context = useContext(RichTextContext); + const htmlAttributes: Record = {}; + + // Gets BlockNote editor instance + const editor: BlockNoteEditor = this.options.editor; + // Gets position of the node + const pos = + typeof props.getPos === "function" ? props.getPos() : undefined; + + if (!pos) { + return null; + } + + const tipTapEditor = editor._tiptapEditor; + // Gets parent blockContainer node + const blockContainer = getBlockInfoFromPos(tipTapEditor.state.doc, pos); + // Gets block identifier + const blockIdentifier = blockContainer.node.attrs.id; + // Get the block + const block = editor.getBlock(blockIdentifier); + + if (!block) { + return null; + } + + const suffix = inline ? "_" + id.current : ""; + const modelUri = uri.URI.parse( + `file:///!${context.documentId}/${block.id}${suffix}.cell.tsx`, + ); + + return ( + { + editor.updateBlock(block, { + props: { + language, + }, + }); + }} + {...props} + // ref={ref} + /> + ); + }; + // console.log("addnodeview"); + return (props) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(props.editor as any).contentComponent) { + // same logic as in ReactNodeViewRenderer + return {}; + } + const ret = ReactNodeViewRenderer(BlockContent, { + stopEvent: () => true, + })(props) as NodeView; + // manual hack, because tiptap React nodeviews don't support setSelection + ret.setSelection = (anchor, head) => { + // This doesn't work because the Tiptap react renderer doesn't properly support forwardref + // (ret as any).renderer.ref?.setSelection(anchor, head); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ret as any).renderer.updateProps({ + selectionHack: { anchor, head }, + }); + }; + + // disable contentdom, because we render the content ourselves in MonacoElement + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ret as any).contentDOMElement = undefined; + // ret.destroy = () => { + // console.log("destroy element"); + // // (ret as any).renderer.destroy(); + // }; + // This is a hack because tiptap doesn't support innerDeco, and this information is normally dropped + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const oldUpdated = ret.update!.bind(ret); + ret.update = (node, outerDeco, innerDeco) => { + // console.log("update"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const retAsAny = ret as any; + let decorations = retAsAny.decorations; + if ( + retAsAny.decorations.decorations !== outerDeco || + retAsAny.decorations.innerDecorations !== innerDeco + ) { + // change the format of "decorations" to have both the outerDeco and innerDeco + decorations = { + decorations: outerDeco, + innerDecorations: innerDeco, + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return oldUpdated(node, decorations, undefined as any); + }; + return ret; + }; + }; + return nodeView; +} diff --git a/packages/frame/src/MonacoProsemirrorHelpers.ts b/packages/frame/src/codeblocks/MonacoProsemirrorHelpers.ts similarity index 94% rename from packages/frame/src/MonacoProsemirrorHelpers.ts rename to packages/frame/src/codeblocks/MonacoProsemirrorHelpers.ts index 042909535..877003f07 100644 --- a/packages/frame/src/MonacoProsemirrorHelpers.ts +++ b/packages/frame/src/codeblocks/MonacoProsemirrorHelpers.ts @@ -7,7 +7,7 @@ function selectionDir( view: EditorView, pos: number, size: number, - dir: -1 | 1 + dir: -1 | 1, ) { const targetPos = pos + (dir < 0 ? 0 : size); const selection = Selection.near(view.state.doc.resolve(targetPos), dir); @@ -19,7 +19,7 @@ function getTransactionForSelectionUpdate( selection: monaco.Selection | null, model: monaco.editor.ITextModel | null, offset: number, - tr: Transaction + tr: Transaction, ) { if (selection && model) { const selFrom = model.getOffsetAt(selection.getStartPosition()) + offset; @@ -32,16 +32,16 @@ function getTransactionForSelectionUpdate( : selEnd, selection.getDirection() === monaco.SelectionDirection.LTR ? selEnd - : selFrom - ) + : selFrom, + ), ); } } // because node.textContent doesn't preserve newlines export function textFromPMNode(node: Node): string { - if (!node.isTextblock) { - throw new Error("not a text node"); + if (!node.isTextblock && !node.isInline) { + throw new Error("not a text or inline node"); } let text = ""; node.forEach((c) => { @@ -64,7 +64,7 @@ export function bindMonacoAndProsemirror( state: { isUpdating: boolean; node: Node; - } + }, ) { // const id = Math.random(); /** @@ -94,7 +94,7 @@ export function bindMonacoAndProsemirror( mon.getSelection(), mon.getModel(), offset, - tr + tr, ); try { view.dispatch(tr); @@ -134,12 +134,12 @@ export function bindMonacoAndProsemirror( tr.replaceWith( offset + change.rangeOffset, offset + change.rangeOffset + change.rangeLength, - view.state.schema.text(change.text.toString()) + view.state.schema.text(change.text.toString()), ); } else { tr.delete( offset + change.rangeOffset, - offset + change.rangeOffset + change.rangeLength + offset + change.rangeOffset + change.rangeLength, ); } // TODO: update offset? @@ -152,7 +152,7 @@ export function bindMonacoAndProsemirror( mon.getSelection(), mon.getModel(), offset, - tr + tr, ); } try { @@ -178,7 +178,7 @@ export function bindMonacoAndProsemirror( if (e.code === "Delete" || e.code === "Backspace") { if (state.node.textContent === "") { view.dispatch( - view.state.tr.deleteRange(getPos(), getPos() + state.node.nodeSize) + view.state.tr.deleteRange(getPos(), getPos() + state.node.nodeSize), ); view.focus(); return; @@ -227,7 +227,7 @@ export function bindMonacoAndProsemirror( */ export function applyNodeChangesToMonaco( node: Node, - model: monaco.editor.ITextModel + model: monaco.editor.ITextModel, ) { const newText = textFromPMNode(node); const curText = model.getValue(); @@ -256,7 +256,7 @@ export function applyNodeChangesToMonaco( { range: monaco.Range.fromPositions( model.getPositionAt(start), - model.getPositionAt(curEnd) + model.getPositionAt(curEnd), ), text: newText.slice(start, newEnd), }, @@ -278,7 +278,7 @@ export function applyDecorationsToMonaco( mon: monaco.editor.IStandaloneCodeEditor, lastDecorations: string[], headSelectionClassName: string, - selectionClassName: string + selectionClassName: string, ) { if (!decorations.innerDecorations) { return []; @@ -292,7 +292,7 @@ export function applyDecorationsToMonaco( const selectionDec = decorations.innerDecorations.local.find( (d) => d.spec.type === "selection" && - d.spec.clientID === cursorDec.spec.clientID + d.spec.clientID === cursorDec.spec.clientID, ); let start: monaco.Position; @@ -331,7 +331,7 @@ export function applyDecorationsToMonaco( start.lineNumber, start.column, end.lineNumber, - end.column + end.column, ), options: { className: diff --git a/packages/frame/src/MonacoSelection.module.css b/packages/frame/src/codeblocks/MonacoSelection.module.css similarity index 100% rename from packages/frame/src/MonacoSelection.module.css rename to packages/frame/src/codeblocks/MonacoSelection.module.css diff --git a/packages/frame/src/codeblocks/blocknotehelpers.ts b/packages/frame/src/codeblocks/blocknotehelpers.ts new file mode 100644 index 000000000..5d2a7de47 --- /dev/null +++ b/packages/frame/src/codeblocks/blocknotehelpers.ts @@ -0,0 +1,114 @@ +import { Node, NodeType } from "prosemirror-model"; + +export type BlockInfoWithoutPositions = { + id: string; + node: Node; + contentNode: Node; + contentType: NodeType; + numChildBlocks: number; +}; + +export type BlockInfo = BlockInfoWithoutPositions & { + startPos: number; + endPos: number; + depth: number; +}; + +/** + * Helper function for `getBlockInfoFromPos`, returns information regarding + * provided blockContainer node. + * @param blockContainer The blockContainer node to retrieve info for. + */ +export function getBlockInfo(blockContainer: Node): BlockInfoWithoutPositions { + const id = blockContainer.attrs["id"]; + const contentNode = blockContainer.firstChild!; + const contentType = contentNode.type; + const numChildBlocks = + blockContainer.childCount === 2 ? blockContainer.lastChild!.childCount : 0; + + return { + id, + node: blockContainer, + contentNode, + contentType, + numChildBlocks, + }; +} + +/** + * Retrieves information regarding the nearest blockContainer node in a + * ProseMirror doc, relative to a position. + * @param doc The ProseMirror doc. + * @param pos An integer position. + * @returns A BlockInfo object for the nearest blockContainer node. + */ +export function getBlockInfoFromPos(doc: Node, pos: number): BlockInfo { + // If the position is outside the outer block group, we need to move it to the + // nearest block. This happens when the collaboration plugin is active, where + // the selection is placed at the very end of the doc. + const outerBlockGroupStartPos = 1; + const outerBlockGroupEndPos = doc.nodeSize - 2; + if (pos <= outerBlockGroupStartPos) { + pos = outerBlockGroupStartPos + 1; + + while ( + doc.resolve(pos).parent.type.name !== "blockContainer" && + pos < outerBlockGroupEndPos + ) { + pos++; + } + } else if (pos >= outerBlockGroupEndPos) { + pos = outerBlockGroupEndPos - 1; + + while ( + doc.resolve(pos).parent.type.name !== "blockContainer" && + pos > outerBlockGroupStartPos + ) { + pos--; + } + } + + // This gets triggered when a node selection on a block is active, i.e. when + // you drag and drop a block. + if (doc.resolve(pos).parent.type.name === "blockGroup") { + pos++; + } + + const $pos = doc.resolve(pos); + + const maxDepth = $pos.depth; + let node = $pos.node(maxDepth); + let depth = maxDepth; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (depth < 0) { + throw new Error( + "Could not find blockContainer node. This can only happen if the underlying BlockNote schema has been edited.", + ); + } + + if (node.type.name === "blockContainer") { + break; + } + + depth -= 1; + node = $pos.node(depth); + } + + const { id, contentNode, contentType, numChildBlocks } = getBlockInfo(node); + + const startPos = $pos.start(depth); + const endPos = $pos.end(depth); + + return { + id, + node, + contentNode, + contentType, + numChildBlocks, + startPos, + endPos, + depth, + }; +} diff --git a/packages/frame/src/runtime/executor/executionHosts/local/LocalExecutionHost.tsx b/packages/frame/src/runtime/executor/executionHosts/local/LocalExecutionHost.tsx index 58158f883..727c1e15b 100644 --- a/packages/frame/src/runtime/executor/executionHosts/local/LocalExecutionHost.tsx +++ b/packages/frame/src/runtime/executor/executionHosts/local/LocalExecutionHost.tsx @@ -70,9 +70,9 @@ export default class LocalExecutionHost public renderOutput(modelPath: string) { return ( -
- -
+ //
+ + //
); } From b4539e7c26879f8ec9e2e7caa3d5548262e3989b Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 11 Oct 2023 13:03:09 +0200 Subject: [PATCH 2/4] fix multiple imports of same module --- .../documentRenderers/richtext/FrameHost.tsx | 33 +++++++------ packages/frame/src/Frame.tsx | 46 +++++++++++++------ .../src/frameInterop/HostBridgeMethods.ts | 5 +- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/packages/editor/src/app/documentRenderers/richtext/FrameHost.tsx b/packages/editor/src/app/documentRenderers/richtext/FrameHost.tsx index cb4e90da3..624082ba4 100644 --- a/packages/editor/src/app/documentRenderers/richtext/FrameHost.tsx +++ b/packages/editor/src/app/documentRenderers/richtext/FrameHost.tsx @@ -68,40 +68,47 @@ export function FrameHost(props: { processYjsMessage: async (message: ArrayBuffer) => { provider.onMessage(message, "penpal"); }, - registerTypeCellModuleCompiler: async (moduleName: string) => { - if (moduleManagers.has(moduleName)) { - console.warn("already has moduleManager for", moduleName); - return; - } + resolveModuleName: async (moduleName: string) => { if (!moduleName.startsWith("!")) { throw new Error("invalid module name"); } - const identifierStr = moduleName.substring(1); + + const identifier = parseIdentifier(moduleName.substring(1)); + const identifierStr = identifier.toString(); + return identifierStr; + }, + registerTypeCellModuleCompiler: async (identifierStr: string) => { const identifier = parseIdentifier(identifierStr); + if (moduleManagers.has(identifierStr)) { + console.warn("already has moduleManager for", identifierStr); + return identifierStr; + } + const provider = new DocumentResourceModelProvider( identifier, props.sessionStore, ); const forwarder = new ModelForwarder( - "modules/" + moduleName, + "modules/" + identifierStr, provider, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion connectionMethods.current!, ); - moduleManagers.set(moduleName, { provider, forwarder }); + + moduleManagers.set(identifierStr, { provider, forwarder }); await forwarder.initialize(); - return identifier.toString(); + return identifierStr; }, - unregisterTypeCellModuleCompiler: async (moduleName: string) => { - const moduleManager = moduleManagers.get(moduleName); + unregisterTypeCellModuleCompiler: async (identifierStr: string) => { + const moduleManager = moduleManagers.get(identifierStr); if (!moduleManager) { - console.warn("no moduleManager for", moduleName); + console.warn("no moduleManager for", identifierStr); return; } moduleManager.provider.dispose(); moduleManager.forwarder.dispose(); - moduleManagers.delete(moduleName); + moduleManagers.delete(identifierStr); }, }; diff --git a/packages/frame/src/Frame.tsx b/packages/frame/src/Frame.tsx index 0e270f27d..e57e55bd6 100644 --- a/packages/frame/src/Frame.tsx +++ b/packages/frame/src/Frame.tsx @@ -121,6 +121,10 @@ const slashMenuItems = [...originalItems]; export const Frame: React.FC = observer((props) => { const modelReceivers = useMemo(() => new Map(), []); + const subCompilers = useMemo( + () => new Map(), + [], + ); const connectionMethods = useRef>(); const editorStore = useRef(new EditorStore()); @@ -224,31 +228,45 @@ export const Frame: React.FC = observer((props) => { const newCompiler = new SourceModelCompiler(monaco); const resolver = new Resolver(async (moduleName) => { // How to resolve typecell modules (i.e.: `import * as nb from "!dALYTUW8TXxsw"`) - const subcompiler = new SourceModelCompiler(monaco); - - const modelReceiver = new ModelReceiver(); - // TODO: what if we have multiple usage of the same module? - modelReceivers.set("modules/" + moduleName, modelReceiver); - - modelReceiver.onDidCreateModel((model) => { - subcompiler.registerModel(model); - }); const fullIdentifier = // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await connectionMethods.current!.registerTypeCellModuleCompiler( - moduleName, - ); + await connectionMethods.current!.resolveModuleName(moduleName); - // register an alias for the module so that types resolve - // (e.g.: from "!dALYTUW8TXxsw" to "!typecell:typecell.org/dALYTUW8TXxsw") if ("!" + fullIdentifier !== moduleName) { + // register an alias for the module so that types resolve + // (e.g.: from "!dALYTUW8TXxsw" to "!typecell:typecell.org/dALYTUW8TXxsw") + monaco.languages.typescript.typescriptDefaults.addExtraLib( `export * from "!${fullIdentifier}";`, `file:///node_modules/@types/${moduleName}/index.d.ts`, ); } + let modelReceiver = modelReceivers.get("modules/" + fullIdentifier); + + if (modelReceiver) { + const subCompiler = subCompilers.get("modules/" + fullIdentifier); + if (!subCompiler) { + throw new Error("subCompiler not found"); + } + return subCompiler; + } + modelReceiver = new ModelReceiver(); + + modelReceivers.set("modules/" + fullIdentifier, modelReceiver); + + const subcompiler = new SourceModelCompiler(monaco); + modelReceiver.onDidCreateModel((model) => { + subcompiler.registerModel(model); + }); + + subCompilers.set("modules/" + fullIdentifier, subcompiler); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await connectionMethods.current!.registerTypeCellModuleCompiler( + fullIdentifier, + ); // TODO: dispose modelReceiver return subcompiler; }, editorStore.current); diff --git a/packages/shared/src/frameInterop/HostBridgeMethods.ts b/packages/shared/src/frameInterop/HostBridgeMethods.ts index 0182c17c7..6c2919c11 100644 --- a/packages/shared/src/frameInterop/HostBridgeMethods.ts +++ b/packages/shared/src/frameInterop/HostBridgeMethods.ts @@ -10,8 +10,9 @@ export type HostBridgeMethods = { * send the compiled javascript back to the iframe. It also keeps watching the TypeCell module for changes * and sends changes across the bridge. */ - registerTypeCellModuleCompiler: (moduleName: string) => Promise; - unregisterTypeCellModuleCompiler: (moduleName: string) => Promise; + resolveModuleName: (moduleName: string) => Promise; + registerTypeCellModuleCompiler: (identifierStr: string) => Promise; + unregisterTypeCellModuleCompiler: (identifierStr: string) => Promise; /** * Function for y-penpal From 038d9b63c1fdd88b774c08c55429838f97b6f5d9 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 11 Oct 2023 13:26:00 +0200 Subject: [PATCH 3/4] ModelOutput changes (computed default) --- .../executor/components/ModelOutput.ts | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/frame/src/runtime/executor/components/ModelOutput.ts b/packages/frame/src/runtime/executor/components/ModelOutput.ts index 409e121e6..816b9a11a 100644 --- a/packages/frame/src/runtime/executor/components/ModelOutput.ts +++ b/packages/frame/src/runtime/executor/components/ModelOutput.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { makeObservable, observable, runInAction } from "mobx"; +import { computed, makeObservable, observable, runInAction } from "mobx"; import { lifecycle } from "vscode-lib"; import { TypeVisualizer } from "../lib/exports"; // import { @@ -9,7 +9,9 @@ import { TypeVisualizer } from "../lib/exports"; export class ModelOutput extends lifecycle.Disposable { private autorunDisposer: (() => void) | undefined; + public value: any = undefined; + public _defaultValue: any = {}; public typeVisualizers = observable.map< string, { @@ -22,12 +24,64 @@ export class ModelOutput extends lifecycle.Disposable { makeObservable(this, { typeVisualizers: observable.ref, value: observable.ref, + _defaultValue: observable, + defaultValue: computed.struct, }); } + get defaultValue() { + return this._defaultValue; + } + + /** + * All keys except "default" are getters that retrieve the value from the main context, so we don't need + * to update them, as the value on the default context has already been updated. + * + * However, the "default" value is not available on the context, so we need to update it manually. + * We cache this via a computed mobx value in this.defaultValue + * + * TODO: maybe streamline this code with typecell-org/engine, and make other properties than default "computed" as well + */ async updateValue(newValue: any) { runInAction(() => { - this.value = newValue; + if (!this.value) { + this.value = {}; + } + // this.value = newValue; + // return; + if (newValue instanceof Error) { + this.value = newValue; + return; + } + let changed = false; + const oldKeys = Object.getOwnPropertyNames(this.value); + const newKeys = Object.getOwnPropertyNames(newValue); + + for (const key of newKeys) { + if (!oldKeys.includes(key)) { + // this.value[key] = newValue[key]; + changed = true; + } + } + for (const key of oldKeys) { + if (!newKeys.includes(key)) { + // delete this.value[key]; + changed = true; + } + } + + this._defaultValue = newValue.default; + + if (changed) { + if (Object.hasOwn(newValue, "default")) { + Object.defineProperty(newValue, "default", { + get: () => { + return this.defaultValue; + }, + }); + } + this.value = newValue; + } }); } @@ -47,7 +101,7 @@ export class ModelOutput extends lifecycle.Disposable { get visualizer() { return ctx[key]; }, - }) + }), ); } } From efcb5b5e26b436a45308522c41b4706df3310d5d Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 11 Oct 2023 14:30:18 +0200 Subject: [PATCH 4/4] add experimental helper methods --- package-lock.json | 1 + packages/frame/package.json | 3 +- packages/frame/src/EditorStore.ts | 102 +++++++++++++++++- .../src/runtime/executor/lib/exports.tsx | 90 ++++++++++++++-- packages/frame/tsconfig.json | 3 +- 5 files changed, 189 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8055bff2c..e8dee97b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16030,6 +16030,7 @@ "@typecell-org/util": "^0.0.3", "@typecell-org/y-penpal": "^0.0.3", "localforage": "^1.10.0", + "lodash.memoize": "^4.1.2", "lz-string": "^1.4.4", "mobx": "^6.2.0", "mobx-react-lite": "^3.2.0", diff --git a/packages/frame/package.json b/packages/frame/package.json index b88619bce..588799328 100644 --- a/packages/frame/package.json +++ b/packages/frame/package.json @@ -13,12 +13,13 @@ "@tiptap/react": "^2.0.4", "@floating-ui/react": "^0.25.1", "@syncedstore/yjs-reactive-bindings": "^0.5.1", + "lodash.memoize": "^4.1.2", + "mobx-utils": "^6.0.8", "localforage": "^1.10.0", "lz-string": "^1.4.4", "monaco-editor": "^0.35.0", "mobx": "^6.2.0", "mobx-react-lite": "^3.2.0", - "mobx-utils": "^6.0.8", "prosemirror-model": "^1.19.3", "prosemirror-view": "^1.31.7", "prosemirror-state": "^1.4.3", diff --git a/packages/frame/src/EditorStore.ts b/packages/frame/src/EditorStore.ts index 2ccfa34ae..111399b80 100644 --- a/packages/frame/src/EditorStore.ts +++ b/packages/frame/src/EditorStore.ts @@ -3,27 +3,53 @@ import { Block, BlockNoteEditor } from "@blocknote/core"; import { ObservableMap, action, + computed, makeObservable, observable, onBecomeObserved, reaction, runInAction, } from "mobx"; +import { ExecutionHost } from "./runtime/executor/executionHosts/ExecutionHost"; +import LocalExecutionHost from "./runtime/executor/executionHosts/local/LocalExecutionHost"; export class EditorStore { private readonly blockCache = new ObservableMap(); + + // TODO: hacky properties + /** @internal */ public editor: BlockNoteEditor | undefined; + /** @internal */ + public executionHost: LocalExecutionHost | undefined; + public topLevelBlocks: any; constructor() { makeObservable(this, { customBlocks: observable.shallow, add: action, delete: action, + topLevelBlocks: observable.ref, + }); + + onBecomeObserved(this, "topLevelBlocks", () => { + this.editor!.onEditorContentChange(() => { + runInAction(() => { + this.topLevelBlocks = this.editor!.topLevelBlocks.map((block) => + this.getBlock(block.id), + ); + }); + }); + this.topLevelBlocks = this.editor!.topLevelBlocks.map((block) => + this.getBlock(block.id), + ); }); } customBlocks = new Map(); + /** + * Add a custom block (slash menu command) to the editor + */ public add(config: any) { if (this.customBlocks.has(config.id)) { // already has block with this id, maybe loop of documents? @@ -32,10 +58,17 @@ export class EditorStore { this.customBlocks.set(config.id, config); } + /** + * Remove a custom block (slash menu command) from the editor + */ public delete(config: any) { this.customBlocks.delete(config.id); } + /** + * EXPERIMENTAL + * @internal + * */ public getBlock(id: string) { let block = this.blockCache.get(id); if (!block) { @@ -44,32 +77,97 @@ export class EditorStore { return undefined; } - block = new TypeCellBlock(id, this.editor!, () => { + block = new TypeCellBlock(id, this.editor!, this.executionHost!, () => { this.blockCache.delete(id); }); this.blockCache.set(id, block); } + const b = block; + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this; return { ...block.block, storage: block.storage, + get parent(): any { + const findParent = ( + searchId: string, + parentId: string | undefined, + children: Block[], + ): string | undefined => { + for (const child of children) { + if (child.id === searchId) { + return parentId; + } + const found = findParent(searchId, child.id, child.children); + if (found) { + return found; + } + } + return undefined; + }; + const parentId = findParent(id, undefined, that.editor!.topLevelBlocks); + if (!parentId) { + return undefined; + } + return that.getBlock(parentId); + }, + get context() { + return b.context; + }, }; } + /** + * EXPERIMENTAL + * + * @internal + * */ public get firstBlock() { return this.getBlock(this.editor!.topLevelBlocks[0].id); } } +/** + * EXPERIMENTAL + */ class TypeCellBlock { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore it's set using updatePropertiesFromEditorBlock block: Block; storage: Record = {}; - constructor(id: string, editor: BlockNoteEditor, onRemoved: () => void) { + get context() { + // TODO: hacky + const keys = [...this.executionHost.outputs.keys()].filter((key) => + key.includes(this.block.id), + ); + + if (!keys.length) { + return undefined; + } + // return undefined; + + // debugger; + const val = this.executionHost.outputs.get(keys[0])?.value; + if (val instanceof Error) { + return undefined; + } + return val; + // return Object.fromEntries( + // Object.getOwnPropertyNames(val).map((key) => [key, val[key]]), + // ); + } + + constructor( + id: string, + editor: BlockNoteEditor, + private readonly executionHost: ExecutionHost, + onRemoved: () => void, + ) { makeObservable(this, { block: observable.ref, + context: computed, storage: true, }); diff --git a/packages/frame/src/runtime/executor/lib/exports.tsx b/packages/frame/src/runtime/executor/lib/exports.tsx index 843c8d18d..c6d093cc4 100644 --- a/packages/frame/src/runtime/executor/lib/exports.tsx +++ b/packages/frame/src/runtime/executor/lib/exports.tsx @@ -1,13 +1,13 @@ import { RunContext } from "@typecell-org/engine"; import { CodeModel } from "@typecell-org/shared"; -import { autorun, computed, runInAction } from "mobx"; +import memoize from "lodash.memoize"; +import { autorun, comparer, computed, runInAction } from "mobx"; import { observer } from "mobx-react-lite"; -import { createTransformer } from "mobx-utils"; +import { computedFn, createTransformer } from "mobx-utils"; import { useEffect, useMemo } from "react"; import { EditorStore } from "../../../EditorStore"; import { AutoForm, AutoFormProps } from "./autoForm"; import { Input } from "./input/Input"; - /** * This is used in ../resolver/resolver.ts and exposes the "typecell" helper functions * (e.g.: typecell.Input) @@ -42,24 +42,101 @@ export default function getExposeGlobalVariables( editorStore.delete(completeConfig); }); }, - get firstBlock() { + /** + * EXPERIMENTAL + */ + getBlock(id: string): any { + return editorStore.getBlock(id); + }, + /** + * EXPERIMENTAL + */ + get firstBlock(): any { return editorStore.firstBlock; }, - get currentBlock() { + /** + * EXPERIMENTAL + */ + get currentBlock(): any { // TODO: this logic should be part of CodeModel / BasicCodeModel const id = forModelList[forModelList.length - 1].path; const parts = decodeURIComponent(id.replace("file:///", "")).split("/"); - const fileId = parts.pop()!; + const fileId = parts.pop()!.split("_")[0]; const blockId = fileId.replace(".cell.tsx", ""); return editorStore.getBlock(blockId)!; }, + /** + * EXPERIMENTAL + */ + findBlocks: computedFn( + (predicate: (block: any) => boolean) => { + const result: any[] = []; + function find(children: any[]) { + for (const block of children) { + const asBlock = editorStore.getBlock(block.id)!; + if (predicate(asBlock)) { + result.push(asBlock); + } + find(block.children); + } + } + find(editorStore.topLevelBlocks); + return result; + }, + { equals: comparer.structural }, + ), + /** + * EXPERIMENTAL + */ + findBlockUp(predicate: (block: any) => boolean): any { + function find(currentBlock: any): any { + const parent = currentBlock.parent; + let children = parent?.children; + if (!children) { + children = editorStore.topLevelBlocks; + } + + let foundSelf = false; + for (let i = children.length - 1; i >= 0; i--) { + const block = children[i]; + if (!foundSelf) { + if (block.id === currentBlock.id) { + foundSelf = true; + } + continue; + } + const asBlock = editorStore.getBlock(block.id)!; + if (predicate(asBlock)) { + return asBlock; + } + } + + return parent ? find(parent) : undefined; + } + return find(this.currentBlock); + }, + /** + * EXPERIMENTAL + */ get storage() { return this.currentBlock.storage; }, }; return { + memoize: (func: (...args: any[]) => any) => { + const wrapped = async function (this: any, ...args: any[]) { + const ret = await func.apply(this, args); + // if (typeof ret === "object") { + // return observable(ret); + // } + return ret; + }; + return memoize(wrapped, (args) => { + return JSON.stringify(args); + }); + }, // routing, // // DocumentView, Input, @@ -79,6 +156,7 @@ export default function getExposeGlobalVariables( computed: computed as (func: () => any) => any, onDispose: runContext.onDispose, editor, + AutoForm: observer( < T extends { diff --git a/packages/frame/tsconfig.json b/packages/frame/tsconfig.json index 0af6d565f..22deff6ac 100644 --- a/packages/frame/tsconfig.json +++ b/packages/frame/tsconfig.json @@ -20,7 +20,8 @@ "downlevelIteration": true, "outDir": "dist", "composite": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "stripInternal": true }, "include": ["src"], "references": [