From 26d5df5b505e2fa7ce344bce02b837c424c49b8c Mon Sep 17 00:00:00 2001 From: jkcs <1778768609@qq.com> Date: Thu, 22 Aug 2024 09:52:22 +0800 Subject: [PATCH 1/3] inline equation --- .../05-equation/.bnexample.json | 11 + examples/06-custom-schema/05-equation/App.tsx | 90 ++++++ .../06-custom-schema/05-equation/Equation.tsx | 292 ++++++++++++++++++ .../06-custom-schema/05-equation/README.md | 11 + .../06-custom-schema/05-equation/index.html | 14 + .../06-custom-schema/05-equation/main.tsx | 11 + .../06-custom-schema/05-equation/package.json | 40 +++ .../06-custom-schema/05-equation/styles.css | 62 ++++ .../05-equation/tsconfig.json | 36 +++ .../05-equation/vite.config.ts | 32 ++ package-lock.json | 9 +- .../core/src/schema/inlineContent/types.ts | 6 + .../src/schema/ReactInlineContentSpec.tsx | 13 +- playground/package.json | 2 + playground/src/examples.gen.tsx | 27 ++ 15 files changed, 650 insertions(+), 6 deletions(-) create mode 100644 examples/06-custom-schema/05-equation/.bnexample.json create mode 100644 examples/06-custom-schema/05-equation/App.tsx create mode 100644 examples/06-custom-schema/05-equation/Equation.tsx create mode 100644 examples/06-custom-schema/05-equation/README.md create mode 100644 examples/06-custom-schema/05-equation/index.html create mode 100644 examples/06-custom-schema/05-equation/main.tsx create mode 100644 examples/06-custom-schema/05-equation/package.json create mode 100644 examples/06-custom-schema/05-equation/styles.css create mode 100644 examples/06-custom-schema/05-equation/tsconfig.json create mode 100644 examples/06-custom-schema/05-equation/vite.config.ts diff --git a/examples/06-custom-schema/05-equation/.bnexample.json b/examples/06-custom-schema/05-equation/.bnexample.json new file mode 100644 index 0000000000..308c3fc66f --- /dev/null +++ b/examples/06-custom-schema/05-equation/.bnexample.json @@ -0,0 +1,11 @@ +{ + "playground": true, + "docs": false, + "author": "jkcs", + "tags": ["Equation", "Inline Equation", "Custom Schemas", "Latex", "Slash Menu"], + "dependencies": { + "katex": "^0.16.11", + "@types/katex": "^0.16.7", + "react-icons": "^5.2.1" + } +} diff --git a/examples/06-custom-schema/05-equation/App.tsx b/examples/06-custom-schema/05-equation/App.tsx new file mode 100644 index 0000000000..9bdb684d98 --- /dev/null +++ b/examples/06-custom-schema/05-equation/App.tsx @@ -0,0 +1,90 @@ +import { + BlockNoteSchema, + defaultInlineContentSpecs, + filterSuggestionItems, +} from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import { RiFormula } from "react-icons/ri"; +import "@blocknote/mantine/style.css"; + +import { InlineEquation } from "./Equation"; + +// Our schema with block specs, which contain the configs and implementations for blocks +// that we want our editor to use. +const schema = BlockNoteSchema.create({ + inlineContentSpecs: { + ...defaultInlineContentSpecs, + inlineEquation: InlineEquation, + }, +}); + +const insertInlineEquation = (editor: typeof schema.BlockNoteEditor) => ({ + icon: <RiFormula size={18} />, + title: "Inline Equation", + key: "inlineEquation", + subtext: "Insert mathematical symbols in text.", + aliases: ["equation", "latex", "katex"], + group: "Other", + onItemClick: () => { + editor.insertInlineContent([ + { + type: "inlineEquation", + }, + " ", // add a space after the mention + ]); + }, +}); + +export default function App() { + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: [ + "This is an example inline equation ", + { + type: "inlineEquation", + props: { + content: "c = \\pm\\sqrt{a^2 + b^2}", + }, + }, + ], + }, + { + type: "paragraph", + content: "Press the '/' key to open the Slash Menu and add another", + }, + { + type: "paragraph", + }, + { + type: "paragraph", + }, + ], + }); + + // Renders the editor instance. + return ( + <BlockNoteView editor={editor} slashMenu={false}> + <SuggestionMenuController + triggerCharacter={"/"} + getItems={async (query: any) => + filterSuggestionItems( + [ + ...getDefaultReactSlashMenuItems(editor), + insertInlineEquation(editor), + ], + query + ) + } + /> + </BlockNoteView> + ); +} diff --git a/examples/06-custom-schema/05-equation/Equation.tsx b/examples/06-custom-schema/05-equation/Equation.tsx new file mode 100644 index 0000000000..7efe7668a3 --- /dev/null +++ b/examples/06-custom-schema/05-equation/Equation.tsx @@ -0,0 +1,292 @@ +import { + createReactInlineContentSpec, + useBlockNoteEditor, + useComponentsContext, + useEditorContentOrSelectionChange, +} from "@blocknote/react"; +import { NodeViewWrapper } from "@tiptap/react"; +import { + ChangeEvent, + forwardRef, + MouseEvent as ReactMouseEvent, + TextareaHTMLAttributes, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import katex from "katex"; +import { AiOutlineEnter } from "react-icons/ai"; +import "katex/dist/katex.min.css"; +import "./styles.css"; +import { Node as TipTapNode } from "@tiptap/pm/model"; + +const TextareaView = forwardRef< + HTMLTextAreaElement, + { + autofocus?: boolean; + } & TextareaHTMLAttributes<HTMLTextAreaElement> +>((props, ref) => { + const { autofocus, ...rest } = props; + useEffect(() => { + if (autofocus && ref && typeof ref !== "function" && ref.current) { + ref.current.setSelectionRange(0, ref.current.value.length); + ref.current.focus(); + } + }, [autofocus, ref]); + + return ( + <textarea + ref={ref} + className={"equation-textarea"} + value={props.value} + onChange={props.onChange} + {...rest} + /> + ); +}); + +export const InlineEquationView = (props: { node: TipTapNode }) => { + const content = props.node.attrs.content; + const nodeSize = props.node.nodeSize; + const textareaRef = useRef<HTMLTextAreaElement | null>(null); + const contentRef = useRef<HTMLElement | null>(null); + const containerRef = useRef<HTMLElement | null>(null); + const [focus, setFocus] = useState(!content); + const [curEdge, setCurEdge] = useState(!content); + const Components = useComponentsContext()!; + const editor = useBlockNoteEditor(); + const html = useMemo( + () => + katex.renderToString(content, { + throwOnError: false, + }), + [content] + ); + + const getTextareaEdge = () => { + const $textarea = textareaRef.current; + if (!$textarea) { + return {}; + } + + return { + isLeftEdge: + $textarea.selectionStart === 0 && $textarea.selectionEnd === 0, + isRightEdge: + $textarea.selectionStart === $textarea.value.length && + $textarea.selectionEnd === $textarea.value.length, + }; + }; + + const getPos = useCallback((): number => { + let position = 0; + + editor._tiptapEditor.state.doc.descendants( + (node: TipTapNode, pos: number) => { + if (node === props.node) { + position = pos; + return false; + } + } + ); + + return position; + }, [editor, props.node]); + + useEditorContentOrSelectionChange(() => { + const pos = getPos(); + const courPos = editor._tiptapEditor.state.selection.from; + const selection = editor.getSelection(); + + setCurEdge(!selection && (courPos === pos + nodeSize || courPos === pos)); + }); + + useEffect(() => { + if (focus) { + contentRef.current?.click(); + } + }, [focus]); + + const handleEnter = useCallback( + (event: ReactMouseEvent | KeyboardEvent) => { + event.preventDefault(); + const pos = getPos(); + if (!content) { + const node = props.node; + const view = editor._tiptapEditor.view; + + const tr = view.state.tr.delete(pos, pos + node.nodeSize); + + view.dispatch(tr); + editor._tiptapEditor.commands.setTextSelection(pos); + } else { + editor._tiptapEditor.commands.setTextSelection(pos + nodeSize); + } + editor.focus(); + setFocus(false); + setCurEdge(true); + }, + [content, editor, getPos, nodeSize, props.node] + ); + + const handleMenuNavigationKeys = useCallback( + (event: KeyboardEvent) => { + const textareaEdge = getTextareaEdge(); + const pos = getPos(); + const courPos = editor._tiptapEditor.state.selection.from; + + if (event.key === "ArrowLeft") { + if (courPos === pos + nodeSize && !focus) { + setFocus(true); + } + if (textareaEdge.isLeftEdge) { + event.preventDefault(); + editor.focus(); + editor._tiptapEditor.commands.setTextSelection(pos); + setFocus(false); + } + return true; + } + + if (event.key === "ArrowRight") { + if (courPos === pos && !focus) { + setFocus(true); + } + if (textareaEdge.isRightEdge) { + event.preventDefault(); + editor.focus(); + editor._tiptapEditor.commands.setTextSelection(pos + nodeSize); + setFocus(false); + } + return true; + } + + if (event.key === "Enter" && focus) { + handleEnter(event); + return true; + } + + return false; + }, + [editor, focus, getPos, handleEnter, nodeSize] + ); + + useEffect(() => { + const domEle = editor._tiptapEditor?.view?.dom; + if (focus || curEdge) { + domEle?.addEventListener("keydown", handleMenuNavigationKeys, true); + } + + return () => { + domEle?.removeEventListener("keydown", handleMenuNavigationKeys, true); + }; + }, [editor, focus, handleMenuNavigationKeys, curEdge]); + + const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => { + const val = e.target.value; + const pos = getPos(); + const node = props.node; + const view = editor._tiptapEditor.view; + + const tr = view.state.tr.replaceWith( + pos, + pos + node.nodeSize, + view.state.schema.nodes.inlineEquation.create( + { + ...node.attrs, + content: val || "", + }, + null + ) + ); + + view.dispatch(tr); + setFocus(true); + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setFocus(false); + } + }; + + document.addEventListener("pointerup", handleClickOutside, true); + return () => { + document.removeEventListener("pointerup", handleClickOutside, true); + }; + }, []); + + return ( + <NodeViewWrapper as={"span"} ref={containerRef}> + <Components.Generic.Popover.Root opened={focus}> + <Components.Generic.Popover.Trigger> + <span + className={"equation " + (focus ? "focus" : "")} + ref={contentRef}> + {!content ? ( + <span onClick={() => setFocus(true)} className={"equation-empty"}> + New Equation + </span> + ) : ( + <span + onClick={() => setFocus(true)} + className={"equation-content"} + dangerouslySetInnerHTML={{ __html: html }}></span> + )} + </span> + </Components.Generic.Popover.Trigger> + <Components.Generic.Popover.Content + className={"bn-popover-content bn-form-popover"} + variant={"form-popover"}> + <label className={"equation-label"}> + <TextareaView + placeholder={"c^2 = a^2 + b^2"} + ref={textareaRef} + autofocus + value={content} + onChange={handleChange} + /> + <span onClick={handleEnter} className={"equation-enter"}> + <AiOutlineEnter /> + </span> + </label> + </Components.Generic.Popover.Content> + </Components.Generic.Popover.Root> + </NodeViewWrapper> + ); +}; + +export const InlineEquation = createReactInlineContentSpec( + { + type: "inlineEquation", + propSchema: { + content: { + default: "", + }, + }, + content: "none", + // copy content + renderHTML: (props) => { + const { HTMLAttributes, node } = props; + const dom = document.createElement("span"); + dom.setAttribute("data-inline-content-type", "inlineEquation"); + Object.keys(HTMLAttributes).forEach((key) => { + dom.setAttribute(key, HTMLAttributes[key]); + }); + dom.innerText = node.attrs.content; + + return { dom }; + }, + }, + { + render: (props) => { + return <InlineEquationView node={props.node} />; + }, + } +); diff --git a/examples/06-custom-schema/05-equation/README.md b/examples/06-custom-schema/05-equation/README.md new file mode 100644 index 0000000000..2e140b5666 --- /dev/null +++ b/examples/06-custom-schema/05-equation/README.md @@ -0,0 +1,11 @@ +# Inline Equation + +In this example, we create a custom `Inline Equation` + +**Try it out:** Press the "/" key to open the Slash Menu and insert an `Equation` block! + +**Relevant Docs:** + +- [Custom Blocks](/docs/custom-schemas/custom-blocks) +- [Changing Slash Menu Items](/docs/ui-components/suggestion-menus#changing-slash-menu-items) +- [Editor Setup](/docs/editor-basics/setup) \ No newline at end of file diff --git a/examples/06-custom-schema/05-equation/index.html b/examples/06-custom-schema/05-equation/index.html new file mode 100644 index 0000000000..772555bb29 --- /dev/null +++ b/examples/06-custom-schema/05-equation/index.html @@ -0,0 +1,14 @@ +<html lang="en"> + <head> + <script> + <!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY --> + </script> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Inline Equation</title> + </head> + <body> + <div id="root"></div> + <script type="module" src="./main.tsx"></script> + </body> +</html> diff --git a/examples/06-custom-schema/05-equation/main.tsx b/examples/06-custom-schema/05-equation/main.tsx new file mode 100644 index 0000000000..f88b490fbd --- /dev/null +++ b/examples/06-custom-schema/05-equation/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +const root = createRoot(document.getElementById("root")!); +root.render( + <React.StrictMode> + <App /> + </React.StrictMode> +); diff --git a/examples/06-custom-schema/05-equation/package.json b/examples/06-custom-schema/05-equation/package.json new file mode 100644 index 0000000000..4751728ce7 --- /dev/null +++ b/examples/06-custom-schema/05-equation/package.json @@ -0,0 +1,40 @@ +{ + "name": "@blocknote/example-equation", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --max-warnings 0" + }, + "dependencies": { + "@blocknote/core": "latest", + "@blocknote/react": "latest", + "@blocknote/ariakit": "latest", + "@blocknote/mantine": "latest", + "@blocknote/shadcn": "latest", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "katex": "^0.16.11", + "@types/katex": "^0.16.7", + "react-icons": "^5.2.1" + }, + "devDependencies": { + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^8.10.0", + "vite": "^5.3.4" + }, + "eslintConfig": { + "extends": [ + "../../../.eslintrc.js" + ] + }, + "eslintIgnore": [ + "dist" + ] +} \ No newline at end of file diff --git a/examples/06-custom-schema/05-equation/styles.css b/examples/06-custom-schema/05-equation/styles.css new file mode 100644 index 0000000000..b1793b3e9c --- /dev/null +++ b/examples/06-custom-schema/05-equation/styles.css @@ -0,0 +1,62 @@ +.equation-content { + caret-color: rgb(55, 53, 47); + padding: 2px 2px; + border-radius: 4px; + transform: translate3d(-4px, 0, 0); + margin-right: -4px; + white-space: pre; +} + + +.equation.focus .equation-content { + background: rgba(37, 135, 231, 0.12); +} + +.equation .equation-empty { + white-space: nowrap; + font-size: 12px; + background: rgb(207, 207, 207); + caret-color: rgb(55, 53, 47); + vertical-align: top; + padding: 2px 4px; + border-radius: 4px; + transform: translate3d(-4px, 0, 0); + margin-right: -8px; +} + +.equation-label { + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding: 4px; +} + +.equation-textarea { + min-width: 200px; + margin-right: 10px; + outline: none; + border: none; + font-size: 14px; +} + +.equation-enter { + user-select: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + white-space: nowrap; + height: 28px; + border-radius: 4px; + box-shadow: rgba(15, 15, 15, 0.1) 0 0 0 1px inset, rgba(15, 15, 15, 0.1) 0 1px 2px; + background: rgb(35, 131, 226); + color: white; + fill: white; + line-height: 1.2; + padding-left: 12px; + padding-right: 12px; + font-size: 14px; + font-weight: 500; + align-self: flex-start; +} \ No newline at end of file diff --git a/examples/06-custom-schema/05-equation/tsconfig.json b/examples/06-custom-schema/05-equation/tsconfig.json new file mode 100644 index 0000000000..1bd8ab3c57 --- /dev/null +++ b/examples/06-custom-schema/05-equation/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": [ + "." + ], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} \ No newline at end of file diff --git a/examples/06-custom-schema/05-equation/vite.config.ts b/examples/06-custom-schema/05-equation/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/06-custom-schema/05-equation/vite.config.ts @@ -0,0 +1,32 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/" + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/" + ), + } as any), + }, +})); diff --git a/package-lock.json b/package-lock.json index e3d7c46a74..9848e8f358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17763,13 +17763,14 @@ "dev": true }, "node_modules/katex": { - "version": "0.16.10", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz", - "integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==", + "version": "0.16.11", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.11.tgz", + "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], + "license": "MIT", "dependencies": { "commander": "^8.3.0" }, @@ -28840,6 +28841,7 @@ "@mantine/core": "^7.10.1", "@mui/icons-material": "^5.16.1", "@mui/material": "^5.16.1", + "@types/katex": "^0.16.7", "@uppy/core": "^3.13.1", "@uppy/dashboard": "^3.9.1", "@uppy/drag-drop": "^3.1.1", @@ -28851,6 +28853,7 @@ "@uppy/status-bar": "^3.1.1", "@uppy/webcam": "^3.4.2", "@uppy/xhr-upload": "^3.4.0", + "katex": "^0.16.11", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.2.1", diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index 5ef62f593e..c8d5036e3c 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -1,11 +1,17 @@ import { Node } from "@tiptap/core"; import { PropSchema, Props } from "../propTypes"; import { StyleSchema, Styles } from "../styles/types"; +import { Node as ProseMirrorNode } from "prosemirror-model"; +import { DOMOutputSpec } from "@tiptap/pm/model"; export type CustomInlineContentConfig = { type: string; content: "styled" | "none"; // | "plain" readonly propSchema: PropSchema; + renderHTML?: (props: { + node: ProseMirrorNode; + HTMLAttributes: Record<string, any>; + }) => DOMOutputSpec; // content: "inline" | "none" | "table"; }; // InlineContentConfig contains the "schema" info about an InlineContent type diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index 27999dcfea..5027b7371a 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -23,6 +23,7 @@ import { // import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView"; import { FC } from "react"; import { renderToDOMSpec } from "./@util/ReactRenderUtil"; +import { Node } from "@tiptap/pm/model"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks @@ -35,6 +36,7 @@ export type ReactInlineContentImplementation< render: FC<{ inlineContent: InlineContentFromConfig<T, S>; contentRef: (node: HTMLElement | null) => void; + node: Node; }>; // TODO? // toExternalHTML?: FC<{ @@ -109,7 +111,10 @@ export function createReactInlineContentSpec< return getInlineContentParseRules(inlineContentConfig); }, - renderHTML({ node }) { + renderHTML({ node, ...args }) { + if (inlineContentConfig.renderHTML) { + return inlineContentConfig.renderHTML({ node, ...args }); + } const editor = this.options.editor; const ic = nodeToCustomInlineContent( @@ -119,7 +124,9 @@ export function createReactInlineContentSpec< ) as any as InlineContentFromConfig<T, S>; // TODO: fix cast const Content = inlineContentImplementation.render; const output = renderToDOMSpec( - (refCB) => <Content inlineContent={ic} contentRef={refCB} />, + (refCB) => ( + <Content inlineContent={ic} contentRef={refCB} node={node} /> + ), editor ); @@ -131,7 +138,6 @@ export function createReactInlineContentSpec< ); }, - // TODO: needed? addNodeView() { const editor = this.options.editor; return (props) => @@ -148,6 +154,7 @@ export function createReactInlineContentSpec< propSchema={inlineContentConfig.propSchema}> <Content contentRef={ref} + node={props.node} inlineContent={ nodeToCustomInlineContent( props.node, diff --git a/playground/package.json b/playground/package.json index 0992b1d34f..14ec4b6db9 100644 --- a/playground/package.json +++ b/playground/package.json @@ -25,6 +25,7 @@ "@mantine/core": "^7.10.1", "@mui/icons-material": "^5.16.1", "@mui/material": "^5.16.1", + "@types/katex": "^0.16.7", "@uppy/core": "^3.13.1", "@uppy/dashboard": "^3.9.1", "@uppy/drag-drop": "^3.1.1", @@ -36,6 +37,7 @@ "@uppy/status-bar": "^3.1.1", "@uppy/webcam": "^3.4.2", "@uppy/xhr-upload": "^3.4.0", + "katex": "^0.16.11", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.2.1", diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 1941512e3f..89c3852ae9 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -886,6 +886,33 @@ "slug": "custom-schema" } }, + { + "projectSlug": "equation", + "fullSlug": "custom-schema/equation", + "pathFromRoot": "examples/06-custom-schema/05-equation", + "config": { + "playground": true, + "docs": false, + "author": "jkcs", + "tags": [ + "Equation", + "Inline Equation", + "Custom Schemas", + "Latex", + "Slash Menu" + ], + "dependencies": { + "katex": "^0.16.11", + "@types/katex": "^0.16.7", + "react-icons": "^5.2.1" + } as any + }, + "title": "Inline Equation", + "group": { + "pathFromRoot": "examples/06-custom-schema", + "slug": "custom-schema" + } + }, { "projectSlug": "react-custom-blocks", "fullSlug": "custom-schema/react-custom-blocks", From 2871cc67c565bdcbab5d6303ab3796f4206f21bc Mon Sep 17 00:00:00 2001 From: jkcs <1778768609@qq.com> Date: Thu, 22 Aug 2024 10:12:48 +0800 Subject: [PATCH 2/3] fix: npm lint --- packages/react/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/package.json b/packages/react/package.json index b7b27abcec..f229ba6908 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -55,6 +55,7 @@ "@floating-ui/react": "^0.26.4", "@tiptap/core": "^2.5.0", "@tiptap/react": "^2.5.0", + "@tiptap/pm": "^2.5.0", "lodash.merge": "^4.6.2", "react": "^18", "react-dom": "^18", From 519f34a00aaa767f3686a59ababbd2bcb53fec9d Mon Sep 17 00:00:00 2001 From: yousefed <yousefdardiry@gmail.com> Date: Mon, 26 Aug 2024 14:01:00 +0200 Subject: [PATCH 3/3] implement isSelected --- .../05-equation/.bnexample.json | 2 +- examples/06-custom-schema/05-equation/App.tsx | 4 +- .../06-custom-schema/05-equation/Equation.tsx | 106 ++++++------------ .../06-custom-schema/05-equation/README.md | 2 +- .../06-custom-schema/05-equation/styles.css | 92 +++++++-------- .../core/src/schema/inlineContent/types.ts | 4 +- packages/mantine/src/popover/Popover.tsx | 5 +- .../react/src/editor/ComponentsContext.tsx | 3 +- .../src/schema/ReactInlineContentSpec.tsx | 20 +++- playground/src/examples.gen.tsx | 2 +- 10 files changed, 107 insertions(+), 133 deletions(-) diff --git a/examples/06-custom-schema/05-equation/.bnexample.json b/examples/06-custom-schema/05-equation/.bnexample.json index 308c3fc66f..896230e294 100644 --- a/examples/06-custom-schema/05-equation/.bnexample.json +++ b/examples/06-custom-schema/05-equation/.bnexample.json @@ -2,7 +2,7 @@ "playground": true, "docs": false, "author": "jkcs", - "tags": ["Equation", "Inline Equation", "Custom Schemas", "Latex", "Slash Menu"], + "tags": ["Equation", "Inline Equation", "Custom Schemas", "Latex", "Katex"], "dependencies": { "katex": "^0.16.11", "@types/katex": "^0.16.7", diff --git a/examples/06-custom-schema/05-equation/App.tsx b/examples/06-custom-schema/05-equation/App.tsx index 9bdb684d98..f5c7c4215a 100644 --- a/examples/06-custom-schema/05-equation/App.tsx +++ b/examples/06-custom-schema/05-equation/App.tsx @@ -4,14 +4,14 @@ import { filterSuggestionItems, } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; import { SuggestionMenuController, getDefaultReactSlashMenuItems, useCreateBlockNote, } from "@blocknote/react"; -import { BlockNoteView } from "@blocknote/mantine"; import { RiFormula } from "react-icons/ri"; -import "@blocknote/mantine/style.css"; import { InlineEquation } from "./Equation"; diff --git a/examples/06-custom-schema/05-equation/Equation.tsx b/examples/06-custom-schema/05-equation/Equation.tsx index 7efe7668a3..512efa369c 100644 --- a/examples/06-custom-schema/05-equation/Equation.tsx +++ b/examples/06-custom-schema/05-equation/Equation.tsx @@ -1,26 +1,25 @@ +import { InlineContentFromConfig } from "@blocknote/core"; import { createReactInlineContentSpec, useBlockNoteEditor, useComponentsContext, - useEditorContentOrSelectionChange, } from "@blocknote/react"; +import { Node as TipTapNode } from "@tiptap/pm/model"; import { NodeViewWrapper } from "@tiptap/react"; +import katex from "katex"; +import "katex/dist/katex.min.css"; import { ChangeEvent, - forwardRef, MouseEvent as ReactMouseEvent, TextareaHTMLAttributes, + forwardRef, useCallback, useEffect, useMemo, useRef, - useState, } from "react"; -import katex from "katex"; import { AiOutlineEnter } from "react-icons/ai"; -import "katex/dist/katex.min.css"; import "./styles.css"; -import { Node as TipTapNode } from "@tiptap/pm/model"; const TextareaView = forwardRef< HTMLTextAreaElement, @@ -47,14 +46,17 @@ const TextareaView = forwardRef< ); }); -export const InlineEquationView = (props: { node: TipTapNode }) => { - const content = props.node.attrs.content; +export const InlineEquationView = (props: { + inlineContent: InlineContentFromConfig<typeof InlineEquation.config, any>; + node: TipTapNode; + isSelected: boolean; +}) => { + const content = props.inlineContent.props.content; const nodeSize = props.node.nodeSize; const textareaRef = useRef<HTMLTextAreaElement | null>(null); const contentRef = useRef<HTMLElement | null>(null); const containerRef = useRef<HTMLElement | null>(null); - const [focus, setFocus] = useState(!content); - const [curEdge, setCurEdge] = useState(!content); + const Components = useComponentsContext()!; const editor = useBlockNoteEditor(); const html = useMemo( @@ -95,25 +97,12 @@ export const InlineEquationView = (props: { node: TipTapNode }) => { return position; }, [editor, props.node]); - useEditorContentOrSelectionChange(() => { - const pos = getPos(); - const courPos = editor._tiptapEditor.state.selection.from; - const selection = editor.getSelection(); - - setCurEdge(!selection && (courPos === pos + nodeSize || courPos === pos)); - }); - - useEffect(() => { - if (focus) { - contentRef.current?.click(); - } - }, [focus]); - const handleEnter = useCallback( - (event: ReactMouseEvent | KeyboardEvent) => { + (event: ReactMouseEvent | React.KeyboardEvent) => { event.preventDefault(); const pos = getPos(); if (!content) { + // TODO: implement BlockNote API to easily delete inline content const node = props.node; const view = editor._tiptapEditor.view; @@ -122,68 +111,50 @@ export const InlineEquationView = (props: { node: TipTapNode }) => { view.dispatch(tr); editor._tiptapEditor.commands.setTextSelection(pos); } else { + // TODO: implement BlockNote API to easily update cursor position editor._tiptapEditor.commands.setTextSelection(pos + nodeSize); } editor.focus(); - setFocus(false); - setCurEdge(true); }, [content, editor, getPos, nodeSize, props.node] ); const handleMenuNavigationKeys = useCallback( - (event: KeyboardEvent) => { + (event: React.KeyboardEvent) => { const textareaEdge = getTextareaEdge(); const pos = getPos(); - const courPos = editor._tiptapEditor.state.selection.from; if (event.key === "ArrowLeft") { - if (courPos === pos + nodeSize && !focus) { - setFocus(true); - } if (textareaEdge.isLeftEdge) { + // TODO: implement BlockNote API to set cursor position event.preventDefault(); editor.focus(); editor._tiptapEditor.commands.setTextSelection(pos); - setFocus(false); } return true; } if (event.key === "ArrowRight") { - if (courPos === pos && !focus) { - setFocus(true); - } if (textareaEdge.isRightEdge) { + // TODO: implement BlockNote API to set cursor position event.preventDefault(); editor.focus(); editor._tiptapEditor.commands.setTextSelection(pos + nodeSize); - setFocus(false); } return true; } - if (event.key === "Enter" && focus) { + if (event.key === "Enter" && props.isSelected) { handleEnter(event); return true; } return false; }, - [editor, focus, getPos, handleEnter, nodeSize] + [editor, getPos, handleEnter, nodeSize, props.isSelected] ); - useEffect(() => { - const domEle = editor._tiptapEditor?.view?.dom; - if (focus || curEdge) { - domEle?.addEventListener("keydown", handleMenuNavigationKeys, true); - } - - return () => { - domEle?.removeEventListener("keydown", handleMenuNavigationKeys, true); - }; - }, [editor, focus, handleMenuNavigationKeys, curEdge]); - + // TODO: implement BlockNote API to easily update inline content const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const val = e.target.value; const pos = getPos(); @@ -203,39 +174,19 @@ export const InlineEquationView = (props: { node: TipTapNode }) => { ); view.dispatch(tr); - setFocus(true); }; - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setFocus(false); - } - }; - - document.addEventListener("pointerup", handleClickOutside, true); - return () => { - document.removeEventListener("pointerup", handleClickOutside, true); - }; - }, []); - return ( <NodeViewWrapper as={"span"} ref={containerRef}> - <Components.Generic.Popover.Root opened={focus}> + <Components.Generic.Popover.Root opened={props.isSelected}> <Components.Generic.Popover.Trigger> <span - className={"equation " + (focus ? "focus" : "")} + className={"equation " + (props.isSelected ? "focus" : "")} ref={contentRef}> {!content ? ( - <span onClick={() => setFocus(true)} className={"equation-empty"}> - New Equation - </span> + <span className={"equation-empty"}>New Equation</span> ) : ( <span - onClick={() => setFocus(true)} className={"equation-content"} dangerouslySetInnerHTML={{ __html: html }}></span> )} @@ -251,6 +202,7 @@ export const InlineEquationView = (props: { node: TipTapNode }) => { autofocus value={content} onChange={handleChange} + onKeyDown={handleMenuNavigationKeys} /> <span onClick={handleEnter} className={"equation-enter"}> <AiOutlineEnter /> @@ -286,7 +238,13 @@ export const InlineEquation = createReactInlineContentSpec( }, { render: (props) => { - return <InlineEquationView node={props.node} />; + return ( + <InlineEquationView + node={props.node} + inlineContent={props.inlineContent} + isSelected={props.isSelected} + /> + ); }, } ); diff --git a/examples/06-custom-schema/05-equation/README.md b/examples/06-custom-schema/05-equation/README.md index 2e140b5666..1d40f6f5a1 100644 --- a/examples/06-custom-schema/05-equation/README.md +++ b/examples/06-custom-schema/05-equation/README.md @@ -8,4 +8,4 @@ In this example, we create a custom `Inline Equation` - [Custom Blocks](/docs/custom-schemas/custom-blocks) - [Changing Slash Menu Items](/docs/ui-components/suggestion-menus#changing-slash-menu-items) -- [Editor Setup](/docs/editor-basics/setup) \ No newline at end of file +- [Editor Setup](/docs/editor-basics/setup) diff --git a/examples/06-custom-schema/05-equation/styles.css b/examples/06-custom-schema/05-equation/styles.css index b1793b3e9c..3507e3df3b 100644 --- a/examples/06-custom-schema/05-equation/styles.css +++ b/examples/06-custom-schema/05-equation/styles.css @@ -1,62 +1,62 @@ .equation-content { - caret-color: rgb(55, 53, 47); - padding: 2px 2px; - border-radius: 4px; - transform: translate3d(-4px, 0, 0); - margin-right: -4px; - white-space: pre; + caret-color: rgb(55, 53, 47); + padding: 2px 2px; + border-radius: 4px; + transform: translate3d(-4px, 0, 0); + margin-right: -4px; + white-space: pre; } - .equation.focus .equation-content { - background: rgba(37, 135, 231, 0.12); + background: rgba(37, 135, 231, 0.12); } .equation .equation-empty { - white-space: nowrap; - font-size: 12px; - background: rgb(207, 207, 207); - caret-color: rgb(55, 53, 47); - vertical-align: top; - padding: 2px 4px; - border-radius: 4px; - transform: translate3d(-4px, 0, 0); - margin-right: -8px; + white-space: nowrap; + font-size: 12px; + background: rgb(207, 207, 207); + caret-color: rgb(55, 53, 47); + vertical-align: top; + padding: 2px 4px; + border-radius: 4px; + transform: translate3d(-4px, 0, 0); + margin-right: -8px; } .equation-label { - display: flex; - align-items: flex-start; - justify-content: flex-start; - padding: 4px; + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding: 4px; } .equation-textarea { - min-width: 200px; - margin-right: 10px; - outline: none; - border: none; - font-size: 14px; + min-width: 200px; + margin-right: 10px; + outline: none; + border: none; + font-size: 14px; } .equation-enter { - user-select: none; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - white-space: nowrap; - height: 28px; - border-radius: 4px; - box-shadow: rgba(15, 15, 15, 0.1) 0 0 0 1px inset, rgba(15, 15, 15, 0.1) 0 1px 2px; - background: rgb(35, 131, 226); - color: white; - fill: white; - line-height: 1.2; - padding-left: 12px; - padding-right: 12px; - font-size: 14px; - font-weight: 500; - align-self: flex-start; -} \ No newline at end of file + user-select: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + white-space: nowrap; + height: 28px; + border-radius: 4px; + box-shadow: rgba(15, 15, 15, 0.1) 0 0 0 1px inset, + rgba(15, 15, 15, 0.1) 0 1px 2px; + background: rgb(35, 131, 226); + color: white; + fill: white; + line-height: 1.2; + padding-left: 12px; + padding-right: 12px; + font-size: 14px; + font-weight: 500; + align-self: flex-start; +} diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index c8d5036e3c..708bab857d 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -1,8 +1,8 @@ import { Node } from "@tiptap/core"; +import { DOMOutputSpec } from "@tiptap/pm/model"; +import { Node as ProseMirrorNode } from "prosemirror-model"; import { PropSchema, Props } from "../propTypes"; import { StyleSchema, Styles } from "../styles/types"; -import { Node as ProseMirrorNode } from "prosemirror-model"; -import { DOMOutputSpec } from "@tiptap/pm/model"; export type CustomInlineContentConfig = { type: string; diff --git a/packages/mantine/src/popover/Popover.tsx b/packages/mantine/src/popover/Popover.tsx index b81f842e65..28af93afa9 100644 --- a/packages/mantine/src/popover/Popover.tsx +++ b/packages/mantine/src/popover/Popover.tsx @@ -11,7 +11,7 @@ import { forwardRef } from "react"; export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"] ) => { - const { children, opened, position, ...rest } = props; + const { children, opened, onChange, position, ...rest } = props; assertEmpty(rest); @@ -20,7 +20,8 @@ export const Popover = ( withinPortal={false} zIndex={10000} opened={opened} - position={position}> + position={position} + onChange={onChange}> {children} </MantinePopover> ); diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 7a46df4549..24d6861ab4 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -9,8 +9,8 @@ import { useContext, } from "react"; -import { DefaultReactSuggestionItem } from "../components/SuggestionMenu/types"; import { DefaultReactGridSuggestionItem } from "../components/SuggestionMenu/GridSuggestionMenu/types"; +import { DefaultReactSuggestionItem } from "../components/SuggestionMenu/types"; export type ComponentProps = { FormattingToolbar: { @@ -241,6 +241,7 @@ export type ComponentProps = { children?: ReactNode; opened?: boolean; position?: "top" | "right" | "bottom" | "left"; + onChange?: (open: boolean) => void; }; Content: { className?: string; diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index 5027b7371a..68243236ae 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -21,9 +21,9 @@ import { ReactNodeViewRenderer, } from "@tiptap/react"; // import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView"; +import { Node } from "@tiptap/pm/model"; import { FC } from "react"; import { renderToDOMSpec } from "./@util/ReactRenderUtil"; -import { Node } from "@tiptap/pm/model"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks @@ -37,6 +37,7 @@ export type ReactInlineContentImplementation< inlineContent: InlineContentFromConfig<T, S>; contentRef: (node: HTMLElement | null) => void; node: Node; + isSelected: boolean; }>; // TODO? // toExternalHTML?: FC<{ @@ -93,7 +94,7 @@ export function createReactInlineContentSpec< name: inlineContentConfig.type as T["type"], inline: true, group: "inline", - selectable: inlineContentConfig.content === "styled", + selectable: true, //inlineContentConfig.content === "styled", atom: inlineContentConfig.content === "none", content: (inlineContentConfig.content === "styled" ? "inline*" @@ -125,7 +126,12 @@ export function createReactInlineContentSpec< const Content = inlineContentImplementation.render; const output = renderToDOMSpec( (refCB) => ( - <Content inlineContent={ic} contentRef={refCB} node={node} /> + <Content + inlineContent={ic} + contentRef={refCB} + node={node} + isSelected={false} + /> ), editor ); @@ -147,6 +153,13 @@ export function createReactInlineContentSpec< const ref = (NodeViewContent({}) as any).ref; const Content = inlineContentImplementation.render; + + const isSelected = + props.selected && + props.editor.state.selection.from === props.getPos() && + props.editor.state.selection.to === + props.getPos() + props.node.nodeSize; + return ( <InlineContentWrapper inlineContentProps={props.node.attrs as Props<T["propSchema"]>} @@ -162,6 +175,7 @@ export function createReactInlineContentSpec< editor.schema.styleSchema ) as any as InlineContentFromConfig<T, S> // TODO: fix cast } + isSelected={isSelected} /> </InlineContentWrapper> ); diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 89c3852ae9..c8eb80b955 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -899,7 +899,7 @@ "Inline Equation", "Custom Schemas", "Latex", - "Slash Menu" + "Katex" ], "dependencies": { "katex": "^0.16.11",