From f0a6eb264f546597f05ca86513b29f17a06d3cbf Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 28 Nov 2023 22:41:16 +0100 Subject: [PATCH 1/3] use refs for blocks --- packages/react/src/ReactBlockSpec.tsx | 105 +++++++----------- packages/react/src/ReactInlineContentSpec.tsx | 42 +------ packages/react/src/ReactRenderUtil.ts | 37 ++++++ packages/react/src/ReactStyleSpec.tsx | 40 +------ .../reactCustomParagraph/basic/internal.html | 2 +- .../reactCustomParagraph/nested/internal.html | 2 +- .../reactCustomParagraph/styled/internal.html | 2 +- .../basic/external.html | 2 +- .../basic/internal.html | 2 +- .../nested/external.html | 2 +- .../nested/internal.html | 2 +- .../styled/external.html | 2 +- .../styled/internal.html | 2 +- .../src/test/testCases/customReactBlocks.tsx | 10 +- 14 files changed, 98 insertions(+), 154 deletions(-) create mode 100644 packages/react/src/ReactRenderUtil.ts diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 3d7100ab2c..b5f48dc2df 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -1,6 +1,5 @@ import { BlockFromConfig, - BlockNoteDOMAttributes, BlockNoteEditor, BlockSchemaWithBlock, camelToDataKebab, @@ -24,8 +23,8 @@ import { NodeViewWrapper, ReactNodeViewRenderer, } from "@tiptap/react"; -import { createContext, ElementType, FC, HTMLProps, useContext } from "react"; -import { renderToString } from "react-dom/server"; +import { FC } from "react"; +import { renderToDOMSpec } from "./ReactRenderUtil"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks @@ -38,41 +37,16 @@ export type ReactCustomBlockImplementation< render: FC<{ block: BlockFromConfig; editor: BlockNoteEditor, I, S>; + contentRef: (node: HTMLElement | null) => void; }>; toExternalHTML?: FC<{ block: BlockFromConfig; editor: BlockNoteEditor, I, S>; + contentRef: (node: HTMLElement | null) => void; }>; parse?: (el: HTMLElement) => PartialBlockFromConfig | undefined; }; -const BlockNoteDOMAttributesContext = createContext({}); - -export const InlineContent = ( - props: { as?: Tag } & HTMLProps -) => { - const inlineContentDOMAttributes = - useContext(BlockNoteDOMAttributesContext).inlineContent || {}; - - const classNames = mergeCSSClasses( - props.className || "", - "bn-inline-content", - inlineContentDOMAttributes.class - ); - - return ( - key !== "class" - ) - )} - {...props} - className={classNames} - /> - ); -}; - // Function that wraps the React component returned from 'blockConfig.render' in // a `NodeViewWrapper` which also acts as a `blockContent` div. It contains the // block type and props as HTML attributes. @@ -163,9 +137,12 @@ export function createReactBlockSpec< const blockContentDOMAttributes = this.options.domAttributes?.blockContent || {}; + // hacky, should export `useReactNodeView` from tiptap to get access to ref + const ref = (NodeViewContent({}) as any).ref; + const Content = blockImplementation.render; const BlockContent = reactWrapInBlockStructure( - , + , block.type, block.props, blockConfig.propSchema, @@ -188,47 +165,43 @@ export function createReactBlockSpec< node.options.domAttributes?.blockContent || {}; const Content = blockImplementation.render; - const BlockContent = reactWrapInBlockStructure( - , - block.type, - block.props, - blockConfig.propSchema, - blockContentDOMAttributes - ); - const parent = document.createElement("div"); - parent.innerHTML = renderToString(); - - return { - dom: parent.firstElementChild! as HTMLElement, - contentDOM: (parent.querySelector(".bn-inline-content") || - undefined) as HTMLElement | undefined, - }; + return renderToDOMSpec((refCB) => { + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + return ; + }); }, toExternalHTML: (block, editor) => { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - let Content = blockImplementation.toExternalHTML; - if (Content === undefined) { - Content = blockImplementation.render; - } - const BlockContent = reactWrapInBlockStructure( - , - block.type, - block.props, - blockConfig.propSchema, - blockContentDOMAttributes - ); - - const parent = document.createElement("div"); - parent.innerHTML = renderToString(); - - return { - dom: parent.firstElementChild! as HTMLElement, - contentDOM: (parent.querySelector(".bn-inline-content") || - undefined) as HTMLElement | undefined, - }; + const Content = + blockImplementation.toExternalHTML || blockImplementation.render; + + return renderToDOMSpec((refCB) => { + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + return ; + }); }, }); } diff --git a/packages/react/src/ReactInlineContentSpec.tsx b/packages/react/src/ReactInlineContentSpec.tsx index 8da9be42bc..70f2db7c7d 100644 --- a/packages/react/src/ReactInlineContentSpec.tsx +++ b/packages/react/src/ReactInlineContentSpec.tsx @@ -15,8 +15,7 @@ import { } from "@tiptap/react"; // import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView"; import { FC } from "react"; -import { flushSync } from "react-dom"; -import { createRoot } from "react-dom/client"; +import { renderToDOMSpec } from "./ReactRenderUtil"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks @@ -71,44 +70,11 @@ export function createReactInlineContentSpec< editor.inlineContentSchema, editor.styleSchema ) as any as InlineContentFromConfig; // TODO: fix cast - const Content = inlineContentImplementation.render; - let contentDOM: HTMLElement | undefined; - const div = document.createElement("div"); - const root = createRoot(div); - flushSync(() => { - root.render( - (contentDOM = el || undefined)} - /> - ); - }); - - if (!div.childElementCount) { - // TODO - console.warn("ReactInlineContentSpec: renderHTML() failed"); - return { - dom: document.createElement("span"), - }; - } - - // clone so we can unmount the react root - contentDOM?.setAttribute("data-tmp-find", "true"); - const cloneRoot = div.cloneNode(true) as HTMLElement; - const dom = cloneRoot.firstElementChild! as HTMLElement; - const contentDOMClone = cloneRoot.querySelector( - "[data-tmp-find]" - ) as HTMLElement | null; - contentDOMClone?.removeAttribute("data-tmp-find"); - - root.unmount(); - - return { - dom, - contentDOM: contentDOMClone || undefined, - }; + return renderToDOMSpec((refCB) => ( + + )); }, // TODO: needed? diff --git a/packages/react/src/ReactRenderUtil.ts b/packages/react/src/ReactRenderUtil.ts new file mode 100644 index 0000000000..36262e9392 --- /dev/null +++ b/packages/react/src/ReactRenderUtil.ts @@ -0,0 +1,37 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; + +export function renderToDOMSpec( + fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode +) { + let contentDOM: HTMLElement | undefined; + const div = document.createElement("div"); + const root = createRoot(div); + flushSync(() => { + root.render(fc((el) => (contentDOM = el || undefined))); + }); + + if (!div.childElementCount) { + // TODO + console.warn("ReactInlineContentSpec: renderHTML() failed"); + return { + dom: document.createElement("span"), + }; + } + + // clone so we can unmount the react root + contentDOM?.setAttribute("data-tmp-find", "true"); + const cloneRoot = div.cloneNode(true) as HTMLElement; + const dom = cloneRoot.firstElementChild! as HTMLElement; + const contentDOMClone = cloneRoot.querySelector( + "[data-tmp-find]" + ) as HTMLElement | null; + contentDOMClone?.removeAttribute("data-tmp-find"); + + root.unmount(); + + return { + dom, + contentDOM: contentDOMClone || undefined, + }; +} diff --git a/packages/react/src/ReactStyleSpec.tsx b/packages/react/src/ReactStyleSpec.tsx index e9baef7503..645a519e48 100644 --- a/packages/react/src/ReactStyleSpec.tsx +++ b/packages/react/src/ReactStyleSpec.tsx @@ -1,8 +1,7 @@ import { createInternalStyleSpec, StyleConfig } from "@blocknote/core"; import { Mark } from "@tiptap/react"; import { FC } from "react"; -import { flushSync } from "react-dom"; -import { createRoot } from "react-dom/client"; +import { renderToDOMSpec } from "./ReactRenderUtil"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks @@ -49,40 +48,9 @@ export function createReactStyleSpec( const Content = styleImplementation.render; - let contentDOM: HTMLElement | undefined; - const div = document.createElement("div"); - const root = createRoot(div); - flushSync(() => { - root.render( - (contentDOM = el || undefined)} - /> - ); - }); - - if (!div.childElementCount) { - // TODO - console.warn("ReactSdtyleSpec: renderHTML() failed"); - return { - dom: document.createElement("span"), - }; - } - - // clone so we can unmount the react root - contentDOM?.setAttribute("data-tmp-find", "true"); - const cloneRoot = div.cloneNode(true) as HTMLElement; - const dom = cloneRoot.firstElementChild! as HTMLElement; - const contentDOMClone = - (cloneRoot.querySelector("[data-tmp-find]") as HTMLElement) || null; - contentDOMClone?.removeAttribute("data-tmp-find"); - - root.unmount(); - - return { - dom, - contentDOM: contentDOMClone || undefined, - }; + return renderToDOMSpec((refCB) => ( + + )); }, }); diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html index 91eec85769..edde3826ef 100644 --- a/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html @@ -1 +1 @@ -

React Custom Paragraph

\ No newline at end of file +

React Custom Paragraph

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html index 22dd233fa1..faec73f053 100644 --- a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html @@ -1 +1 @@ -

React Custom Paragraph

Nested React Custom Paragraph 1

Nested React Custom Paragraph 2

\ No newline at end of file +

React Custom Paragraph

Nested React Custom Paragraph 1

Nested React Custom Paragraph 2

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html index ec4f7f99a2..dd2e249332 100644 --- a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html @@ -1 +1 @@ -

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html index 1a5c3daa4a..a12e18e1e3 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html @@ -1 +1 @@ -

React Custom Paragraph

\ No newline at end of file +

React Custom Paragraph

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html index 08534f9e77..ef4a1496c0 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html @@ -1 +1 @@ -

React Custom Paragraph

\ No newline at end of file +

React Custom Paragraph

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html index a61e824d02..f34364cb2a 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html @@ -1 +1 @@ -

Custom React Paragraph

Nested React Custom Paragraph 1

Nested React Custom Paragraph 2

\ No newline at end of file +

Custom React Paragraph

Nested React Custom Paragraph 1

Nested React Custom Paragraph 2

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html index 5ce1aa3e93..b036c67a6d 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html @@ -1 +1 @@ -

Custom React Paragraph

Nested React Custom Paragraph 1

Nested React Custom Paragraph 2

\ No newline at end of file +

Custom React Paragraph

Nested React Custom Paragraph 1

Nested React Custom Paragraph 2

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html index 816f2ca547..df6c3a0e11 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html @@ -1 +1 @@ -

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html index fefa7e8680..fdc04d2f52 100644 --- a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html @@ -1 +1 @@ -

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/react/src/test/testCases/customReactBlocks.tsx b/packages/react/src/test/testCases/customReactBlocks.tsx index fc709cb2a6..8dd528f74d 100644 --- a/packages/react/src/test/testCases/customReactBlocks.tsx +++ b/packages/react/src/test/testCases/customReactBlocks.tsx @@ -9,7 +9,7 @@ import { defaultProps, uploadToTmpFilesDotOrg_DEV_ONLY, } from "@blocknote/core"; -import { InlineContent, createReactBlockSpec } from "../../ReactBlockSpec"; +import { createReactBlockSpec } from "../../ReactBlockSpec"; const ReactCustomParagraph = createReactBlockSpec( { @@ -18,8 +18,8 @@ const ReactCustomParagraph = createReactBlockSpec( content: "inline", }, { - render: () => ( - + render: (props) => ( +

), toExternalHTML: () => (

Hello World

@@ -34,8 +34,8 @@ const SimpleReactCustomParagraph = createReactBlockSpec( content: "inline", }, { - render: () => ( - + render: (props) => ( +

), } ); From 8487e5eda1708a18f3085041390faa3e446aca8a Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 28 Nov 2023 22:52:07 +0100 Subject: [PATCH 2/3] update react htmlConversion test --- .../react/src/test/htmlConversion.test.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/react/src/test/htmlConversion.test.tsx b/packages/react/src/test/htmlConversion.test.tsx index 5a2f466e3f..08c01088db 100644 --- a/packages/react/src/test/htmlConversion.test.tsx +++ b/packages/react/src/test/htmlConversion.test.tsx @@ -6,15 +6,18 @@ import { InlineContentSchema, PartialBlock, StyleSchema, + addIdsToBlocks, createExternalHTMLExporter, createInternalHTMLSerializer, + partialBlocksToBlocksForTesting, } from "@blocknote/core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks"; import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent"; import { customReactStylesTestCases } from "./testCases/customReactStyles"; -function convertToHTMLAndCompareSnapshots< +// TODO: code same from @blocknote/core, maybe create separate test util package +async function convertToHTMLAndCompareSnapshots< B extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -24,6 +27,7 @@ function convertToHTMLAndCompareSnapshots< snapshotDirectory: string, snapshotName: string ) { + addIdsToBlocks(blocks); const serializer = createInternalHTMLSerializer( editor._tiptapEditor.schema, editor @@ -37,6 +41,16 @@ function convertToHTMLAndCompareSnapshots< "/internal.html"; expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath); + // turn the internalHTML back into blocks, and make sure no data was lost + const fullBlocks = partialBlocksToBlocksForTesting( + editor.blockSchema, + blocks + ); + const parsed = await editor.tryParseHTMLToBlocks(internalHTML); + + expect(parsed).toStrictEqual(fullBlocks); + + // Create the "external" HTML, which is a cleaned up HTML representation, but lossy const exporter = createExternalHTMLExporter( editor._tiptapEditor.schema, editor @@ -75,9 +89,9 @@ describe("Test React HTML conversion", () => { for (const document of testCase.documents) { // eslint-disable-next-line no-loop-func - it("Convert " + document.name + " to HTML", () => { + it("Convert " + document.name + " to HTML", async () => { const nameSplit = document.name.split("/"); - convertToHTMLAndCompareSnapshots( + await convertToHTMLAndCompareSnapshots( editor, document.blocks, nameSplit[0], From 9272626c500c14a7caded2b40139a407e15f34c2 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:58:50 +0100 Subject: [PATCH 3/3] Custom inline content and styles commands/copy & paste fixes (#425) * Fixed commands and internal copy/paste for inline content * Fixed internal copy/paste for styles * Small cleanup * fix some tests --------- Co-authored-by: yousefed --- .../fontSize/basic/external.html | 2 +- .../fontSize/basic/internal.html | 2 +- .../__snapshots__/mention/basic/external.html | 2 +- .../__snapshots__/mention/basic/internal.html | 2 +- .../__snapshots__/small/basic/external.html | 2 +- .../__snapshots__/small/basic/internal.html | 2 +- .../__snapshots__/tag/basic/external.html | 2 +- .../__snapshots__/tag/basic/internal.html | 2 +- .../api/exporters/html/htmlConversion.test.ts | 3 - .../testCases/cases/customInlineContent.ts | 4 +- .../src/api/testCases/cases/defaultSchema.ts | 26 +++--- .../Blocks/api/inlineContent/createSpec.ts | 31 ++++++- .../Blocks/api/inlineContent/internal.ts | 35 +++++++- .../Blocks/api/styles/createSpec.ts | 43 +++++---- .../extensions/Blocks/api/styles/internal.ts | 49 ++++++++++- .../extensions/Blocks/nodes/BlockContainer.ts | 28 +++--- .../ParagraphBlockContent.ts | 1 + packages/react/src/ReactInlineContentSpec.tsx | 88 +++++++++++++++---- packages/react/src/ReactStyleSpec.tsx | 41 +++++---- .../fontSize/basic/external.html | 2 +- .../fontSize/basic/internal.html | 2 +- .../__snapshots__/mention/basic/external.html | 2 +- .../__snapshots__/mention/basic/internal.html | 2 +- .../__snapshots__/small/basic/external.html | 2 +- .../__snapshots__/small/basic/internal.html | 2 +- .../__snapshots__/tag/basic/external.html | 2 +- .../__snapshots__/tag/basic/internal.html | 2 +- 27 files changed, 276 insertions(+), 105 deletions(-) diff --git a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html index 4c7e8f174d..49b9ce6858 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html +++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html @@ -1 +1 @@ -

This is text with a custom fontSize

\ No newline at end of file +

This is text with a custom fontSize

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html index 3e2beaedd6..3fe864246c 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html @@ -1 +1 @@ -

This is text with a custom fontSize

\ No newline at end of file +

This is text with a custom fontSize

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html index e1513fed2d..2e6f533ca1 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html +++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html @@ -1 +1 @@ -

I enjoy working with@Matthew

\ No newline at end of file +

I enjoy working with@Matthew

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html index 7af6dad9c7..6ca7d81c2c 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html @@ -1 +1 @@ -

I enjoy working with@Matthew

\ No newline at end of file +

I enjoy working with@Matthew

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html index 4206d07a95..35c3d5c232 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html +++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html @@ -1 +1 @@ -

This is a small text

\ No newline at end of file +

This is a small text

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html index 805c78112e..73836f647d 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html @@ -1 +1 @@ -

This is a small text

\ No newline at end of file +

This is a small text

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html index 4229ae0a83..b8387e9a55 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html +++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html @@ -1 +1 @@ -

I love #BlockNote

\ No newline at end of file +

I love #BlockNote

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html index dac5db0ca8..bac28633b0 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html @@ -1 +1 @@ -

I love #BlockNote

\ No newline at end of file +

I love #BlockNote

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts index 9f52f2d558..f6592f1bb7 100644 --- a/packages/core/src/api/exporters/html/htmlConversion.test.ts +++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts @@ -369,9 +369,6 @@ describe("Test HTML conversion", () => { for (const document of testCase.documents) { // eslint-disable-next-line no-loop-func it("Convert " + document.name + " to HTML", async () => { - if (document.name !== "complex/misc") { - return; - } const nameSplit = document.name.split("/"); await convertToHTMLAndCompareSnapshots( editor, diff --git a/packages/core/src/api/testCases/cases/customInlineContent.ts b/packages/core/src/api/testCases/cases/customInlineContent.ts index 8ad1828152..304df912cb 100644 --- a/packages/core/src/api/testCases/cases/customInlineContent.ts +++ b/packages/core/src/api/testCases/cases/customInlineContent.ts @@ -87,7 +87,7 @@ export const customInlineContentTestCases: EditorTestCases< user: "Matthew", }, content: undefined, - } as any, + } as any, // TODO ], }, ], @@ -103,7 +103,7 @@ export const customInlineContentTestCases: EditorTestCases< type: "tag", // props: {}, content: "BlockNote", - } as any, + } as any, // TODO ], }, ], diff --git a/packages/core/src/api/testCases/cases/defaultSchema.ts b/packages/core/src/api/testCases/cases/defaultSchema.ts index bb7ccf4526..87aa6b01b1 100644 --- a/packages/core/src/api/testCases/cases/defaultSchema.ts +++ b/packages/core/src/api/testCases/cases/defaultSchema.ts @@ -24,7 +24,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "paragraph/empty", blocks: [ { - type: "paragraph" as const, + type: "paragraph", }, ], }, @@ -32,7 +32,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "paragraph/basic", blocks: [ { - type: "paragraph" as const, + type: "paragraph", content: "Paragraph", }, ], @@ -41,12 +41,12 @@ export const defaultSchemaTestCases: EditorTestCases< name: "paragraph/styled", blocks: [ { - type: "paragraph" as const, + type: "paragraph", props: { textAlignment: "center", textColor: "orange", backgroundColor: "pink", - } as const, + }, content: [ { type: "text", @@ -83,15 +83,15 @@ export const defaultSchemaTestCases: EditorTestCases< name: "paragraph/nested", blocks: [ { - type: "paragraph" as const, + type: "paragraph", content: "Paragraph", children: [ { - type: "paragraph" as const, + type: "paragraph", content: "Nested Paragraph 1", }, { - type: "paragraph" as const, + type: "paragraph", content: "Nested Paragraph 2", }, ], @@ -102,7 +102,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/button", blocks: [ { - type: "image" as const, + type: "image", }, ], }, @@ -110,7 +110,7 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/basic", blocks: [ { - type: "image" as const, + type: "image", props: { url: "exampleURL", caption: "Caption", @@ -123,20 +123,20 @@ export const defaultSchemaTestCases: EditorTestCases< name: "image/nested", blocks: [ { - type: "image" as const, + type: "image", props: { url: "exampleURL", caption: "Caption", width: 256, - } as const, + }, children: [ { - type: "image" as const, + type: "image", props: { url: "exampleURL", caption: "Caption", width: 256, - } as const, + }, }, ], }, diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts b/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts index 124923268a..534ce36bf2 100644 --- a/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts +++ b/packages/core/src/extensions/Blocks/api/inlineContent/createSpec.ts @@ -1,8 +1,13 @@ import { Node } from "@tiptap/core"; +import { ParseRule } from "@tiptap/pm/model"; import { nodeToCustomInlineContent } from "../../../../api/nodeConversions/nodeConversions"; import { propsToAttributes } from "../blocks/internal"; +import { Props } from "../blocks/types"; import { StyleSchema } from "../styles/types"; -import { createInlineContentSpecFromTipTapNode } from "./internal"; +import { + addInlineContentAttributes, + createInlineContentSpecFromTipTapNode, +} from "./internal"; import { InlineContentConfig, InlineContentFromConfig, @@ -37,6 +42,16 @@ export type CustomInlineContentImplementation< }; }; +export function getInlineContentParseRules( + config: InlineContentConfig +): ParseRule[] { + return [ + { + tag: `.bn-inline-content-section[data-inline-content-type="${config.type}"]`, + }, + ]; +} + export function createInlineContentSpec< T extends InlineContentConfig, S extends StyleSchema @@ -57,6 +72,10 @@ export function createInlineContentSpec< return propsToAttributes(inlineContentConfig.propSchema); }, + parseHTML() { + return getInlineContentParseRules(inlineContentConfig); + }, + renderHTML({ node }) { const editor = this.options.editor; @@ -68,7 +87,15 @@ export function createInlineContentSpec< ) as any as InlineContentFromConfig // TODO: fix cast ); - return output; + return { + dom: addInlineContentAttributes( + output.dom, + inlineContentConfig.type, + node.attrs as Props, + inlineContentConfig.propSchema + ), + contentDOM: output.contentDOM, + }; }, }); diff --git a/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts b/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts index 9c623c44cf..d081338be8 100644 --- a/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts +++ b/packages/core/src/extensions/Blocks/api/inlineContent/internal.ts @@ -1,5 +1,6 @@ import { Node } from "@tiptap/core"; -import { PropSchema } from "../blocks/types"; +import { camelToDataKebab } from "../blocks/internal"; +import { Props, PropSchema } from "../blocks/types"; import { InlineContentConfig, InlineContentImplementation, @@ -7,6 +8,38 @@ import { InlineContentSpec, InlineContentSpecs, } from "./types"; +import { mergeCSSClasses } from "../../../../shared/utils"; + +// Function that adds necessary classes and attributes to the `dom` element +// returned from a custom inline content's 'render' function, to ensure no data +// is lost on internal copy & paste. +export function addInlineContentAttributes< + IType extends string, + PSchema extends PropSchema +>( + element: HTMLElement, + inlineContentType: IType, + inlineContentProps: Props, + propSchema: PSchema +): HTMLElement { + // Sets inline content section class + element.className = mergeCSSClasses( + "bn-inline-content-section", + element.className + ); + // Sets content type attribute + element.setAttribute("data-inline-content-type", inlineContentType); + // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props + // set to their default values. + Object.entries(inlineContentProps) + .filter(([prop, value]) => value !== propSchema[prop].default) + .map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + .forEach(([prop, value]) => element.setAttribute(prop, value)); + + return element; +} // This helper function helps to instantiate a InlineContentSpec with a // config and implementation that conform to the type of Config diff --git a/packages/core/src/extensions/Blocks/api/styles/createSpec.ts b/packages/core/src/extensions/Blocks/api/styles/createSpec.ts index 9f0d742f75..14c1c2274f 100644 --- a/packages/core/src/extensions/Blocks/api/styles/createSpec.ts +++ b/packages/core/src/extensions/Blocks/api/styles/createSpec.ts @@ -1,6 +1,11 @@ import { Mark } from "@tiptap/core"; +import { ParseRule } from "@tiptap/pm/model"; import { UnreachableCaseError } from "../../../../shared/utils"; -import { createInternalStyleSpec } from "./internal"; +import { + addStyleAttributes, + createInternalStyleSpec, + stylePropsToAttributes, +} from "./internal"; import { StyleConfig, StyleSpec } from "./types"; export type CustomStyleImplementation = { @@ -17,6 +22,14 @@ export type CustomStyleImplementation = { // TODO: support serialization +export function getStyleParseRules(config: StyleConfig): ParseRule[] { + return [ + { + tag: `.bn-style[data-style-type="${config.type}"]`, + }, + ]; +} + export function createStyleSpec( styleConfig: T, styleImplementation: CustomStyleImplementation @@ -25,21 +38,11 @@ export function createStyleSpec( name: styleConfig.type, addAttributes() { - if (styleConfig.propSchema === "boolean") { - return {}; - } - return { - stringValue: { - default: undefined, - // TODO: parsing + return stylePropsToAttributes(styleConfig.propSchema); + }, - // parseHTML: (element) => - // element.getAttribute(`data-${styleConfig.type}`), - // renderHTML: (attributes) => ({ - // [`data-${styleConfig.type}`]: attributes.stringValue, - // }), - }, - }; + parseHTML() { + return getStyleParseRules(styleConfig); }, renderHTML({ mark }) { @@ -58,7 +61,15 @@ export function createStyleSpec( } // const renderResult = styleImplementation.render(); - return renderResult; + return { + dom: addStyleAttributes( + renderResult.dom, + styleConfig.type, + mark.attrs.stringValue, + styleConfig.propSchema + ), + contentDOM: renderResult.contentDOM, + }; }, }); diff --git a/packages/core/src/extensions/Blocks/api/styles/internal.ts b/packages/core/src/extensions/Blocks/api/styles/internal.ts index 648bb133d5..27b32a3f7a 100644 --- a/packages/core/src/extensions/Blocks/api/styles/internal.ts +++ b/packages/core/src/extensions/Blocks/api/styles/internal.ts @@ -1,4 +1,4 @@ -import { Mark } from "@tiptap/core"; +import { Attributes, Mark } from "@tiptap/core"; import { StyleConfig, StyleImplementation, @@ -7,6 +7,53 @@ import { StyleSpec, StyleSpecs, } from "./types"; +import { mergeCSSClasses } from "../../../../shared/utils"; + +export function stylePropsToAttributes( + propSchema: StylePropSchema +): Attributes { + if (propSchema === "boolean") { + return {}; + } + return { + stringValue: { + default: undefined, + keepOnSplit: true, + parseHTML: (element) => element.getAttribute("data-value"), + renderHTML: (attributes) => + attributes.stringValue !== undefined + ? { + "data-value": attributes.stringValue, + } + : {}, + }, + }; +} + +// Function that adds necessary classes and attributes to the `dom` element +// returned from a custom style's 'render' function, to ensure no data is lost +// on internal copy & paste. +export function addStyleAttributes< + SType extends string, + PSchema extends StylePropSchema +>( + element: HTMLElement, + styleType: SType, + styleValue: PSchema extends "boolean" ? undefined : string, + propSchema: PSchema +): HTMLElement { + // Sets inline content section class + element.className = mergeCSSClasses("bn-style", element.className); + // Sets content type attribute + element.setAttribute("data-style-type", styleType); + // Adds style value as an HTML attribute in kebab-case with "data-" prefix, if + // the style takes a string value. + if (propSchema === "string") { + element.setAttribute("data-value", styleValue as string); + } + + return element; +} // This helper function helps to instantiate a stylespec with a // config and implementation that conform to the type of Config diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 443cc7d7fd..bba83b4308 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -483,13 +483,12 @@ export const BlockContainer = Node.create<{ // Reverts block content type to a paragraph if the selection is at the start of the block. () => commands.command(({ state }) => { - const { contentType } = getBlockInfoFromPos( + const { contentType, startPos } = getBlockInfoFromPos( state.doc, state.selection.from )!; - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; + const selectionAtBlockStart = state.selection.from === startPos + 1; const isParagraph = contentType.name === "paragraph"; if (selectionAtBlockStart && !isParagraph) { @@ -504,8 +503,12 @@ export const BlockContainer = Node.create<{ // Removes a level of nesting if the block is indented if the selection is at the start of the block. () => commands.command(({ state }) => { - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; + const { startPos } = getBlockInfoFromPos( + state.doc, + state.selection.from + )!; + + const selectionAtBlockStart = state.selection.from === startPos + 1; if (selectionAtBlockStart) { return commands.liftListItem("blockContainer"); @@ -522,10 +525,8 @@ export const BlockContainer = Node.create<{ state.selection.from )!; - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; - const selectionEmpty = - state.selection.anchor === state.selection.head; + const selectionAtBlockStart = state.selection.from === startPos + 1; + const selectionEmpty = state.selection.empty; const blockAtDocStart = startPos === 2; const posBetweenBlocks = startPos - 1; @@ -552,17 +553,14 @@ export const BlockContainer = Node.create<{ // end of the block. () => commands.command(({ state }) => { - const { node, contentNode, depth, endPos } = getBlockInfoFromPos( + const { node, depth, endPos } = getBlockInfoFromPos( state.doc, state.selection.from )!; const blockAtDocEnd = false; - const selectionAtBlockEnd = - state.selection.$anchor.parentOffset === - contentNode.firstChild!.nodeSize; - const selectionEmpty = - state.selection.anchor === state.selection.head; + const selectionAtBlockEnd = state.selection.from === endPos - 1; + const selectionEmpty = state.selection.empty; const hasChildBlocks = node.childCount === 2; if ( diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts index a645ba347c..8c826f413e 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts @@ -15,6 +15,7 @@ export const ParagraphBlockContent = createStronglyTypedTiptapNode({ group: "blockContent", parseHTML() { return [ + { tag: "div[data-content-type=" + this.name + "]" }, { tag: "p", priority: 200, diff --git a/packages/react/src/ReactInlineContentSpec.tsx b/packages/react/src/ReactInlineContentSpec.tsx index 70f2db7c7d..adf51e09a2 100644 --- a/packages/react/src/ReactInlineContentSpec.tsx +++ b/packages/react/src/ReactInlineContentSpec.tsx @@ -1,9 +1,14 @@ import { + addInlineContentAttributes, + camelToDataKebab, createInternalInlineContentSpec, createStronglyTypedTiptapNode, + getInlineContentParseRules, InlineContentConfig, InlineContentFromConfig, nodeToCustomInlineContent, + Props, + PropSchema, propsToAttributes, StyleSchema, } from "@blocknote/core"; @@ -36,6 +41,40 @@ export type ReactInlineContentImplementation< // }>; }; +// Function that adds a wrapper with necessary classes and attributes to the +// component returned from a custom inline content's 'render' function, to +// ensure no data is lost on internal copy & paste. +export function reactWrapInInlineContentStructure< + IType extends string, + PSchema extends PropSchema +>( + element: JSX.Element, + inlineContentType: IType, + inlineContentProps: Props, + propSchema: PSchema +) { + return () => ( + // Creates inline content section element + value !== propSchema[prop].default) + .map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + )}> + {element} + + ); +} + // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createReactInlineContentSpec< @@ -50,6 +89,8 @@ export function createReactInlineContentSpec< name: inlineContentConfig.type as T["type"], inline: true, group: "inline", + selectable: inlineContentConfig.content === "styled", + atom: inlineContentConfig.content === "none", content: (inlineContentConfig.content === "styled" ? "inline*" : "") as T["content"] extends "styled" ? "inline*" : "", @@ -58,9 +99,9 @@ export function createReactInlineContentSpec< return propsToAttributes(inlineContentConfig.propSchema); }, - // parseHTML() { - // return parse(blockConfig); - // }, + parseHTML() { + return getInlineContentParseRules(inlineContentConfig); + }, renderHTML({ node }) { const editor = this.options.editor; @@ -71,10 +112,19 @@ export function createReactInlineContentSpec< editor.styleSchema ) as any as InlineContentFromConfig; // TODO: fix cast const Content = inlineContentImplementation.render; - - return renderToDOMSpec((refCB) => ( + const output = renderToDOMSpec((refCB) => ( )); + + return { + dom: addInlineContentAttributes( + output.dom, + inlineContentConfig.type, + node.attrs as Props, + inlineContentConfig.propSchema + ), + contentDOM: output.contentDOM, + }; }, // TODO: needed? @@ -88,20 +138,22 @@ export function createReactInlineContentSpec< const ref = (NodeViewContent({}) as any).ref; const Content = inlineContentImplementation.render; - return ( - - // TODO: fix cast - } - /> - + const FullContent = reactWrapInInlineContentStructure( + // TODO: fix cast + } + />, + inlineContentConfig.type, + props.node.attrs as Props, + inlineContentConfig.propSchema ); + return ; }, { className: "bn-ic-react-node-view-renderer", diff --git a/packages/react/src/ReactStyleSpec.tsx b/packages/react/src/ReactStyleSpec.tsx index 645a519e48..cb401850b7 100644 --- a/packages/react/src/ReactStyleSpec.tsx +++ b/packages/react/src/ReactStyleSpec.tsx @@ -1,4 +1,10 @@ -import { createInternalStyleSpec, StyleConfig } from "@blocknote/core"; +import { + addStyleAttributes, + createInternalStyleSpec, + getStyleParseRules, + StyleConfig, + stylePropsToAttributes, +} from "@blocknote/core"; import { Mark } from "@tiptap/react"; import { FC } from "react"; import { renderToDOMSpec } from "./ReactRenderUtil"; @@ -22,21 +28,11 @@ export function createReactStyleSpec( name: styleConfig.type, addAttributes() { - if (styleConfig.propSchema === "boolean") { - return {}; - } - return { - stringValue: { - default: undefined, - // TODO: parsing - - // parseHTML: (element) => - // element.getAttribute(`data-${styleConfig.type}`), - // renderHTML: (attributes) => ({ - // [`data-${styleConfig.type}`]: attributes.stringValue, - // }), - }, - }; + return stylePropsToAttributes(styleConfig.propSchema); + }, + + parseHTML() { + return getStyleParseRules(styleConfig); }, renderHTML({ mark }) { @@ -47,10 +43,19 @@ export function createReactStyleSpec( } const Content = styleImplementation.render; - - return renderToDOMSpec((refCB) => ( + const renderResult = renderToDOMSpec((refCB) => ( )); + + return { + dom: addStyleAttributes( + renderResult.dom, + styleConfig.type, + mark.attrs.stringValue, + styleConfig.propSchema + ), + contentDOM: renderResult.contentDOM, + }; }, }); diff --git a/packages/react/src/test/__snapshots__/fontSize/basic/external.html b/packages/react/src/test/__snapshots__/fontSize/basic/external.html index 00a5bc6b6e..6c8910692f 100644 --- a/packages/react/src/test/__snapshots__/fontSize/basic/external.html +++ b/packages/react/src/test/__snapshots__/fontSize/basic/external.html @@ -1 +1 @@ -

This is text with a custom fontSize

\ No newline at end of file +

This is text with a custom fontSize

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/fontSize/basic/internal.html b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html index a41d39869a..998d9bcf8b 100644 --- a/packages/react/src/test/__snapshots__/fontSize/basic/internal.html +++ b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html @@ -1 +1 @@ -

This is text with a custom fontSize

\ No newline at end of file +

This is text with a custom fontSize

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/mention/basic/external.html b/packages/react/src/test/__snapshots__/mention/basic/external.html index e1513fed2d..2e6f533ca1 100644 --- a/packages/react/src/test/__snapshots__/mention/basic/external.html +++ b/packages/react/src/test/__snapshots__/mention/basic/external.html @@ -1 +1 @@ -

I enjoy working with@Matthew

\ No newline at end of file +

I enjoy working with@Matthew

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/mention/basic/internal.html b/packages/react/src/test/__snapshots__/mention/basic/internal.html index 7af6dad9c7..6ca7d81c2c 100644 --- a/packages/react/src/test/__snapshots__/mention/basic/internal.html +++ b/packages/react/src/test/__snapshots__/mention/basic/internal.html @@ -1 +1 @@ -

I enjoy working with@Matthew

\ No newline at end of file +

I enjoy working with@Matthew

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/small/basic/external.html b/packages/react/src/test/__snapshots__/small/basic/external.html index 4206d07a95..35c3d5c232 100644 --- a/packages/react/src/test/__snapshots__/small/basic/external.html +++ b/packages/react/src/test/__snapshots__/small/basic/external.html @@ -1 +1 @@ -

This is a small text

\ No newline at end of file +

This is a small text

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/small/basic/internal.html b/packages/react/src/test/__snapshots__/small/basic/internal.html index 805c78112e..73836f647d 100644 --- a/packages/react/src/test/__snapshots__/small/basic/internal.html +++ b/packages/react/src/test/__snapshots__/small/basic/internal.html @@ -1 +1 @@ -

This is a small text

\ No newline at end of file +

This is a small text

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/tag/basic/external.html b/packages/react/src/test/__snapshots__/tag/basic/external.html index 4229ae0a83..b8387e9a55 100644 --- a/packages/react/src/test/__snapshots__/tag/basic/external.html +++ b/packages/react/src/test/__snapshots__/tag/basic/external.html @@ -1 +1 @@ -

I love #BlockNote

\ No newline at end of file +

I love #BlockNote

\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/tag/basic/internal.html b/packages/react/src/test/__snapshots__/tag/basic/internal.html index dac5db0ca8..bac28633b0 100644 --- a/packages/react/src/test/__snapshots__/tag/basic/internal.html +++ b/packages/react/src/test/__snapshots__/tag/basic/internal.html @@ -1 +1 @@ -

I love #BlockNote

\ No newline at end of file +

I love #BlockNote

\ No newline at end of file