From d218e3dfb2af3d38719ccbc18de91c8ed2b90b26 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 02:18:08 +0200 Subject: [PATCH 01/61] Made formatting toolbar buttons not show if they are not applicable --- packages/core/src/index.ts | 1 + .../DefaultButtons/ColorStyleButton.tsx | 47 +++++- .../DefaultButtons/CreateLinkButton.tsx | 28 +++- .../DefaultButtons/TextAlignButton.tsx | 83 ++++------ .../DefaultButtons/ToggledStyleButton.tsx | 38 ++++- .../ColorPicker/components/ColorIcon.tsx | 1 + .../ColorPicker/components/ColorPicker.tsx | 154 ++++++++++-------- .../DefaultButtons/BlockColorsButton.tsx | 30 ++-- 8 files changed, 245 insertions(+), 137 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8c9244f81..59243e2d5a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export * from "./extensions/Blocks/api/block"; export * from "./extensions/Blocks/api/blockTypes"; export * from "./extensions/Blocks/api/defaultBlocks"; export * from "./extensions/Blocks/api/inlineContentTypes"; +export * from "./extensions/Blocks/api/selectionTypes"; export * from "./extensions/Blocks/api/serialization"; export * as blockStyles from "./extensions/Blocks/nodes/Block.module.css"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx index 2eca1eb91a..d7e3ae14bc 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Menu } from "@mantine/core"; -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { ColorIcon } from "../../../SharedComponents/ColorPicker/components/ColorIcon"; import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker"; @@ -10,6 +10,11 @@ import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChang export const ColorStyleButton = (props: { editor: BlockNoteEditor; }) => { + const [selectedBlocks, setSelectedBlocks] = useState[]>( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); const [currentTextColor, setCurrentTextColor] = useState( props.editor.getActiveStyles().textColor || "default" ); @@ -18,6 +23,11 @@ export const ColorStyleButton = (props: { ); useEditorContentChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); setCurrentTextColor(props.editor.getActiveStyles().textColor || "default"); setCurrentBackgroundColor( props.editor.getActiveStyles().backgroundColor || "default" @@ -25,6 +35,11 @@ export const ColorStyleButton = (props: { }); useEditorSelectionChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); setCurrentTextColor(props.editor.getActiveStyles().textColor || "default"); setCurrentBackgroundColor( props.editor.getActiveStyles().backgroundColor || "default" @@ -51,6 +66,20 @@ export const ColorStyleButton = (props: { [props.editor] ); + const show = useMemo(() => { + for (const block of selectedBlocks) { + if (block.content !== undefined) { + return true; + } + } + + return false; + }, [selectedBlocks]); + + if (!show) { + return null; + } + return ( @@ -65,12 +94,16 @@ export const ColorStyleButton = (props: { )} /> - + diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx index 465c89ab7d..ca69de016e 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState } from "react"; -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { useCallback, useMemo, useState } from "react"; +import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { RiLink } from "react-icons/ri"; import LinkToolbarButton from "../LinkToolbarButton"; import { formatKeyboardShortcut } from "../../../utils"; @@ -8,6 +8,11 @@ import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChang export const CreateLinkButton = (props: { editor: BlockNoteEditor; }) => { + const [selectedBlocks, setSelectedBlocks] = useState[]>( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); const [url, setUrl] = useState( props.editor.getSelectedLinkUrl() || "" ); @@ -16,6 +21,11 @@ export const CreateLinkButton = (props: { ); useEditorSelectionChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); setText(props.editor.getSelectedText() || ""); setUrl(props.editor.getSelectedLinkUrl() || ""); }); @@ -28,6 +38,20 @@ export const CreateLinkButton = (props: { [props.editor] ); + const show = useMemo(() => { + for (const block of selectedBlocks) { + if (block.content === undefined) { + return false; + } + } + + return true; + }, [selectedBlocks]); + + if (!show) { + return null; + } + return ( (props: { editor: BlockNoteEditor; textAlignment: TextAlignment; }) => { - const [activeTextAlignment, setActiveTextAlignment] = useState(() => { - const block = props.editor.getTextCursorPosition().block; - - if ("textAlignment" in block.props) { - return block.props.textAlignment as TextAlignment; - } - - return; - }); + const [selectedBlocks, setSelectedBlocks] = useState[]>( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); useEditorContentChange(props.editor, () => { - const block = props.editor.getTextCursorPosition().block; - - if ("textAlignment" in block.props) { - setActiveTextAlignment(block.props.textAlignment as TextAlignment); - } + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); }); useEditorSelectionChange(props.editor, () => { - const block = props.editor.getTextCursorPosition().block; - - if ("textAlignment" in block.props) { - setActiveTextAlignment(block.props.textAlignment as TextAlignment); - } + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); }); - const show = useMemo(() => { - const selection = props.editor.getSelection(); - - if (selection) { - for (const block of selection.blocks) { - if (!("textAlignment" in block.props)) { - return false; - } - } - } else { - const block = props.editor.getTextCursorPosition().block; + const textAlignment = useMemo(() => { + const block = selectedBlocks[0]; - if (!("textAlignment" in block.props)) { - return false; - } + if ("textAlignment" in block.props) { + return block.props.textAlignment as TextAlignment; } - return true; - }, [props.editor]); + return; + }, [selectedBlocks]); const setTextAlignment = useCallback( (textAlignment: TextAlignment) => { props.editor.focus(); - const selection = props.editor.getSelection(); - - if (selection) { - for (const block of selection.blocks) { - props.editor.updateBlock(block, { - props: { textAlignment: textAlignment }, - } as PartialBlock); - } - } else { - const block = props.editor.getTextCursorPosition().block; - + for (const block of selectedBlocks) { props.editor.updateBlock(block, { props: { textAlignment: textAlignment }, } as PartialBlock); } }, - [props.editor] + [props.editor, selectedBlocks] ); + const show = useMemo(() => { + for (const block of selectedBlocks) { + if ("textAlignment" in block.props) { + return true; + } + } + + return false; + }, [selectedBlocks]); + if (!show) { return null; } @@ -105,7 +92,7 @@ export const TextAlignButton = (props: { return ( setTextAlignment(props.textAlignment)} - isSelected={activeTextAlignment === props.textAlignment} + isSelected={textAlignment === props.textAlignment} mainTooltip={ props.textAlignment === "justify" ? "Justify Text" diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx index 5328b5e5ba..ff9b30a7c3 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx @@ -7,9 +7,14 @@ import { RiStrikethrough, RiUnderline, } from "react-icons/ri"; -import { BlockNoteEditor, BlockSchema, ToggledStyle } from "@blocknote/core"; +import { + Block, + BlockNoteEditor, + BlockSchema, + ToggledStyle, +} from "@blocknote/core"; import { IconType } from "react-icons"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; @@ -33,15 +38,30 @@ export const ToggledStyleButton = (props: { editor: BlockNoteEditor; toggledStyle: ToggledStyle; }) => { + const [selectedBlocks, setSelectedBlocks] = useState[]>( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); const [active, setActive] = useState( props.toggledStyle in props.editor.getActiveStyles() ); useEditorContentChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); setActive(props.toggledStyle in props.editor.getActiveStyles()); }); useEditorSelectionChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); setActive(props.toggledStyle in props.editor.getActiveStyles()); }); @@ -50,6 +70,20 @@ export const ToggledStyleButton = (props: { props.editor.toggleStyles({ [style]: true }); }; + const show = useMemo(() => { + for (const block of selectedBlocks) { + if (block.content !== undefined) { + return true; + } + } + + return false; + }, [selectedBlocks]); + + if (!show) { + return null; + } + return ( toggleStyle(props.toggledStyle)} diff --git a/packages/react/src/SharedComponents/ColorPicker/components/ColorIcon.tsx b/packages/react/src/SharedComponents/ColorPicker/components/ColorIcon.tsx index 32229fcf2a..f4be23e79a 100644 --- a/packages/react/src/SharedComponents/ColorPicker/components/ColorIcon.tsx +++ b/packages/react/src/SharedComponents/ColorPicker/components/ColorIcon.tsx @@ -20,6 +20,7 @@ export const ColorIcon = ( className={classes.root} sx={(theme) => { return { + pointerEvents: "none", backgroundColor: theme.other.backgroundColors[backgroundColor], color: theme.other.textColors[textColor], fontSize: (size * 0.75).toString() + "px", diff --git a/packages/react/src/SharedComponents/ColorPicker/components/ColorPicker.tsx b/packages/react/src/SharedComponents/ColorPicker/components/ColorPicker.tsx index 2d8532b95c..729f73e42b 100644 --- a/packages/react/src/SharedComponents/ColorPicker/components/ColorPicker.tsx +++ b/packages/react/src/SharedComponents/ColorPicker/components/ColorPicker.tsx @@ -5,77 +5,95 @@ import { TiTick } from "react-icons/ti"; export const ColorPicker = (props: { onClick?: () => void; iconSize?: number; - textColor: string; - setTextColor: (color: string) => void; - backgroundColor: string; - setBackgroundColor: (color: string) => void; + text?: { + color: string; + setColor: (color: string) => void; + }; + background?: { + color: string; + setColor: (color: string) => void; + }; }) => { + const TextColorSection = () => + props.text ? ( + <> + Text + {[ + "default", + "gray", + "brown", + "red", + "orange", + "yellow", + "green", + "blue", + "purple", + "pink", + ].map((color) => ( + { + props.onClick && props.onClick(); + props.text!.setColor(color); + }} + component={"div"} + data-test={"text-color-" + color} + icon={} + key={"text-color-" + color} + rightSection={ + props.text!.color === color ? ( + + ) : ( +
+ ) + }> + {color.charAt(0).toUpperCase() + color.slice(1)} + + ))} + + ) : null; + + const BackgroundColorSection = () => + props.background ? ( + <> + Background + {[ + "default", + "gray", + "brown", + "red", + "orange", + "yellow", + "green", + "blue", + "purple", + "pink", + ].map((color) => ( + { + props.onClick && props.onClick(); + props.background!.setColor(color); + }} + component={"div"} + data-test={"background-color-" + color} + icon={} + key={"background-color-" + color} + rightSection={ + props.background!.color === color ? ( + + ) : ( +
+ ) + }> + {color.charAt(0).toUpperCase() + color.slice(1)} + + ))} + + ) : null; + return ( <> - Text - {[ - "default", - "gray", - "brown", - "red", - "orange", - "yellow", - "green", - "blue", - "purple", - "pink", - ].map((color) => ( - { - props.onClick && props.onClick(); - props.setTextColor(color); - }} - component={"div"} - data-test={"text-color-" + color} - icon={} - key={"text-color-" + color} - rightSection={ - props.textColor === color ? ( - - ) : ( -
- ) - }> - {color.charAt(0).toUpperCase() + color.slice(1)} - - ))} - Background - {[ - "default", - "gray", - "brown", - "red", - "orange", - "yellow", - "green", - "blue", - "purple", - "pink", - ].map((color) => ( - { - props.onClick && props.onClick(); - props.setBackgroundColor(color); - }} - component={"div"} - data-test={"background-color-" + color} - icon={} - key={"background-color-" + color} - rightSection={ - props.backgroundColor === color ? ( - - ) : ( -
- ) - }> - {color.charAt(0).toUpperCase() + color.slice(1)} - - ))} + + ); }; diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx index f506655b85..2671626fba 100644 --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx @@ -56,17 +56,27 @@ export const BlockColorsButton = ( style={{ marginLeft: "5px" }}> - props.editor.updateBlock(props.block, { - props: { textColor: color }, - } as PartialBlock) + text={ + "textColor" in props.block.props + ? { + color: props.block.props.textColor, + setColor: (color) => + props.editor.updateBlock(props.block, { + props: { textColor: color }, + } as PartialBlock), + } + : undefined } - setBackgroundColor={(color) => - props.editor.updateBlock(props.block, { - props: { backgroundColor: color }, - } as PartialBlock) + background={ + "backgroundColor" in props.block.props + ? { + color: props.block.props.backgroundColor, + setColor: (color) => + props.editor.updateBlock(props.block, { + props: { backgroundColor: color }, + } as PartialBlock), + } + : undefined } /> From 3f6210ffdd0210ff34e0e0821530b13244669756 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 02:19:08 +0200 Subject: [PATCH 02/61] Made formatting toolbar placement change based on text alignment --- .../FormattingToolbarPositioner.tsx | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx index 2baaca73cb..e8f7c2cf64 100644 --- a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx @@ -3,11 +3,13 @@ import { BlockSchema, DefaultBlockSchema, } from "@blocknote/core"; -import Tippy from "@tippyjs/react"; +import Tippy, { tippy } from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; import { sticky } from "tippy.js"; import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar"; +import { useEditorContentChange } from "../../hooks/useEditorContentChange"; +import { useEditorSelectionChange } from "../../hooks/useEditorSelectionChange"; export type FormattingToolbarProps< BSchema extends BlockSchema = DefaultBlockSchema @@ -21,11 +23,39 @@ export const FormattingToolbarPositioner = < editor: BlockNoteEditor; formattingToolbar?: FC>; }) => { + // Placement is dynamic based on the text alignment of the current block. + const getPlacement = useMemo( + () => () => { + const block = props.editor.getTextCursorPosition().block; + + if (!("textAlignment" in block.props)) { + return "top-start"; + } + + switch (block.props.textAlignment) { + case "left": + return "top-start"; + case "center": + return "top"; + case "right": + return "top-end"; + default: + return "top-start"; + } + }, + [props.editor] + ); + const [show, setShow] = useState(false); + const [placement, setPlacement] = useState<"top-start" | "top" | "top-end">( + getPlacement + ); const referencePos = useRef(); useEffect(() => { + tippy.setDefaultProps({ maxWidth: "" }); + return props.editor.formattingToolbar.onUpdate((state) => { setShow(state.show); @@ -33,8 +63,12 @@ export const FormattingToolbarPositioner = < }); }, [props.editor]); + useEditorContentChange(props.editor, () => setPlacement(getPlacement())); + useEditorSelectionChange(props.editor, () => setPlacement(getPlacement())); + const getReferenceClientRect = useMemo( () => { + console.log("getReferenceClientRect"); if (!referencePos) { return undefined; } @@ -58,7 +92,7 @@ export const FormattingToolbarPositioner = < interactive={true} visible={show} animation={"fade"} - placement={"top-start"} + placement={placement} sticky={true} plugins={tippyPlugins} /> From 132e7ce7c1fa066fbc266b6c393fe710445720fa Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 02:31:37 +0200 Subject: [PATCH 03/61] Improved typing for blocks without inline content and made them selectable --- packages/core/src/BlockNoteEditor.ts | 9 +++- .../nodeConversions/nodeConversions.test.ts | 10 +++- .../api/nodeConversions/nodeConversions.ts | 10 ++-- .../core/src/api/nodeConversions/testUtil.ts | 6 +-- .../Blocks/NonEditableBlockPlugin.ts | 17 +++++++ .../core/src/extensions/Blocks/api/block.ts | 9 ++-- .../src/extensions/Blocks/api/blockTypes.ts | 31 +++++++---- .../extensions/Blocks/nodes/BlockContainer.ts | 51 ++++++++++++++----- packages/react/src/ReactBlockSpec.tsx | 9 ++-- 9 files changed, 114 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/extensions/Blocks/NonEditableBlockPlugin.ts diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index ee148c006b..7a669a963d 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -460,6 +460,12 @@ export class BlockNoteEditor { posBeforeNode + 2 )!; + // For blocks without inline content + if (contentNode.type.spec.content === "") { + this._tiptapEditor.commands.setNodeSelection(startPos); + return; + } + if (placement === "start") { this._tiptapEditor.commands.setTextSelection(startPos + 1); } else { @@ -475,7 +481,8 @@ export class BlockNoteEditor { public getSelection(): Selection | undefined { if ( this._tiptapEditor.state.selection.from === - this._tiptapEditor.state.selection.to + this._tiptapEditor.state.selection.to || + "node" in this._tiptapEditor.state.selection ) { return undefined; } diff --git a/packages/core/src/api/nodeConversions/nodeConversions.test.ts b/packages/core/src/api/nodeConversions/nodeConversions.test.ts index c08719efe7..8b4b3ac19b 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts @@ -49,7 +49,10 @@ describe("Simple ProseMirror Node Conversions", () => { expect(firstBlockConversion).toMatchSnapshot(); - const firstNodeConversion = blockToNode(firstBlockConversion, tt.schema); + const firstNodeConversion = blockToNode( + firstBlockConversion as any, + tt.schema + ); expect(firstNodeConversion).toStrictEqual(node); }); @@ -147,7 +150,10 @@ describe("Complex ProseMirror Node Conversions", () => { expect(firstBlockConversion).toMatchSnapshot(); - const firstNodeConversion = blockToNode(firstBlockConversion, tt.schema); + const firstNodeConversion = blockToNode( + firstBlockConversion as any, + tt.schema + ); expect(firstNodeConversion).toStrictEqual(node); }); diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index d5f47b7759..f0d0387ded 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -409,7 +409,7 @@ export function nodeToBlock( } } - const content = contentNodeToInlineContent(blockInfo.contentNode); + const blockSpec = blockSchema[blockInfo.contentType.name]; const children: Block[] = []; for (let i = 0; i < blockInfo.numChildBlocks; i++) { @@ -420,11 +420,13 @@ export function nodeToBlock( const block: Block = { id, - type: blockInfo.contentType.name, + type: blockSpec.node.name, props, - content, + content: blockSpec.containsInlineContent + ? contentNodeToInlineContent(blockInfo.contentNode) + : undefined, children, - }; + } as Block; blockCache?.set(node, block); diff --git a/packages/core/src/api/nodeConversions/testUtil.ts b/packages/core/src/api/nodeConversions/testUtil.ts index 272121ba01..c1740b0120 100644 --- a/packages/core/src/api/nodeConversions/testUtil.ts +++ b/packages/core/src/api/nodeConversions/testUtil.ts @@ -48,14 +48,14 @@ export function partialBlockToBlockForTesting( ): Block { const withDefaults = { id: "", - type: "paragraph" as any, + type: "paragraph", // because at this point we don't have an easy way to access default props at runtime, // partialBlockToBlockForTesting will not set them. props: {} as any, - content: [], + content: [] as any, children: [], ...partialBlock, - }; + } satisfies PartialBlock; return { ...withDefaults, diff --git a/packages/core/src/extensions/Blocks/NonEditableBlockPlugin.ts b/packages/core/src/extensions/Blocks/NonEditableBlockPlugin.ts new file mode 100644 index 0000000000..b650dc3990 --- /dev/null +++ b/packages/core/src/extensions/Blocks/NonEditableBlockPlugin.ts @@ -0,0 +1,17 @@ +import { Plugin, PluginKey } from "prosemirror-state"; + +const PLUGIN_KEY = new PluginKey("non-editable-block"); +// Prevent typing for blocks without inline content, as this would otherwise +// convert them into paragraph blocks. +export const NonEditableBlockPlugin = () => { + return new Plugin({ + key: PLUGIN_KEY, + props: { + handleKeyDown: (view, event) => { + if ("node" in view.state.selection) { + event.preventDefault(); + } + }, + }, + }); +}; diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index e961e05a3b..9c7b844080 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -124,7 +124,7 @@ export function createBlockSpec< BSchema extends BlockSchema >( blockConfig: BlockConfig -): BlockSpec { +): BlockSpec { const node = createTipTapBlock< BType, { @@ -134,7 +134,7 @@ export function createBlockSpec< >({ name: blockConfig.type, content: blockConfig.containsInlineContent ? "inline*" : "", - selectable: blockConfig.containsInlineContent, + selectable: true, addAttributes() { return propsToAttributes(blockConfig); @@ -176,7 +176,9 @@ export function createBlockSpec< // Gets BlockNote editor instance const editor = this.options.editor! as BlockNoteEditor< - BSchema & { [k in BType]: BlockSpec } + BSchema & { + [k in BType]: BlockSpec; + } >; // Gets position of the node if (typeof getPos === "boolean") { @@ -237,6 +239,7 @@ export function createBlockSpec< return { node: node as TipTapNode, propSchema: blockConfig.propSchema, + containsInlineContent: blockConfig.containsInlineContent, }; } diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index 884f3d83ac..515e312312 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -96,7 +96,9 @@ export type BlockConfig< * The custom block to render */ block: SpecificBlock< - BSchema & { [k in Type]: BlockSpec }, + BSchema & { + [k in Type]: BlockSpec; + }, Type >, /** @@ -104,7 +106,9 @@ export type BlockConfig< * This is typed generically. If you want an editor with your custom schema, you need to * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` */ - editor: BlockNoteEditor }> + editor: BlockNoteEditor< + BSchema & { [k in Type]: BlockSpec } + > // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics ) => ContainsInlineContent extends true @@ -121,18 +125,23 @@ export type BlockConfig< // the TipTap node used to implement it. Usually created using `createBlockSpec` // though it can also be defined from scratch by providing your own TipTap node, // allowing for more advanced custom blocks. -export type BlockSpec = { - readonly propSchema: PSchema; +export type BlockSpec< + Type extends string, + PSchema extends PropSchema, + ContainsInlineContent extends boolean +> = { node: TipTapNode; + readonly propSchema: PSchema; + containsInlineContent: ContainsInlineContent; }; // Utility type. For a given object block schema, ensures that the key of each // block spec matches the name of the TipTap node in it. export type TypesMatch< - Blocks extends Record> + Blocks extends Record> > = Blocks extends { [Type in keyof Blocks]: Type extends string - ? Blocks[Type] extends BlockSpec + ? Blocks[Type] extends BlockSpec ? Blocks[Type] : never : never; @@ -146,7 +155,7 @@ export type TypesMatch< // both the blocks' internal implementation (as TipTap nodes) and the type // information for the external API. export type BlockSchema = TypesMatch< - Record> + Record> >; // Converts each block spec into a Block object without children. We later merge @@ -157,7 +166,9 @@ type BlocksWithoutChildren = { id: string; type: BType; props: Props; - content: InlineContent[]; + content: BSchema[BType]["containsInlineContent"] extends true + ? InlineContent[] + : undefined; }; }; @@ -182,7 +193,9 @@ type PartialBlocksWithoutChildren = { id: string; type: BType; props: Partial>; - content: PartialInlineContent[] | string; + content: BSchema[BType]["containsInlineContent"] extends true + ? PartialInlineContent[] | string + : undefined; }>; }; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index 651ced426f..f6c173ea60 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -16,6 +16,7 @@ import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; import styles from "./Block.module.css"; import BlockAttributes from "./BlockAttributes"; import { mergeCSSClasses } from "../../../shared/utils"; +import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin"; declare module "@tiptap/core" { interface Commands { @@ -192,18 +193,42 @@ export const BlockContainer = Node.create<{ ); } - // Changes the blockContent node type and adds the provided props as attributes. Also preserves all existing - // attributes that are compatible with the new type. - state.tr.setNodeMarkup( - startPos, - block.type === undefined - ? undefined - : state.schema.nodes[block.type], - { - ...contentNode.attrs, - ...block.props, - } - ); + // Since some block types contain inline content and others don't, + // we either need to call setNodeMarkup to just update type & + // attributes, or replaceWith to replace the whole blockContent. + const oldType = contentNode.type.name; + const newType = block.type || oldType; + + const oldContentType = state.schema.nodes[oldType].spec.content; + const newContentType = state.schema.nodes[newType].spec.content; + + if (oldContentType === "inline*" && newContentType === "") { + // Replaces the blockContent node with one of the new type and + // adds the provided props as attributes. Also preserves all + // existing attributes that are compatible with the new type. + state.tr.replaceWith( + startPos, + endPos, + state.schema.nodes[newType].create({ + ...contentNode.attrs, + ...block.props, + }) + ); + } else { + // Changes the blockContent node type and adds the provided props + // as attributes. Also preserves all existing attributes that are + // compatible with the new type. + state.tr.setNodeMarkup( + startPos, + block.type === undefined + ? undefined + : state.schema.nodes[block.type], + { + ...contentNode.attrs, + ...block.props, + } + ); + } // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing // attributes. @@ -380,7 +405,7 @@ export const BlockContainer = Node.create<{ }, addProseMirrorPlugins() { - return [PreviousBlockTypePlugin()]; + return [PreviousBlockTypePlugin(), NonEditableBlockPlugin()]; }, addKeyboardShortcuts() { diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 3cba870887..7e87d711b9 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -77,7 +77,7 @@ export function createReactBlockSpec< BSchema extends BlockSchema >( blockConfig: ReactBlockConfig -): BlockSpec { +): BlockSpec { const node = createTipTapBlock< BType, { @@ -87,7 +87,7 @@ export function createReactBlockSpec< >({ name: blockConfig.type, content: blockConfig.containsInlineContent ? "inline*" : "", - selectable: blockConfig.containsInlineContent, + selectable: true, addAttributes() { return propsToAttributes(blockConfig); @@ -122,7 +122,9 @@ export function createReactBlockSpec< // Gets BlockNote editor instance const editor = this.options.editor! as BlockNoteEditor< - BSchema & { [k in BType]: BlockSpec } + BSchema & { + [k in BType]: BlockSpec; + } >; // Gets position of the node const pos = @@ -169,5 +171,6 @@ export function createReactBlockSpec< return { node: node, propSchema: blockConfig.propSchema, + containsInlineContent: blockConfig.containsInlineContent, }; } From df5c7901f86fb8bd7b130de987c7e4aa0576bb53 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 02:32:34 +0200 Subject: [PATCH 04/61] Updated default blocks with new typing --- .../src/extensions/Blocks/api/defaultBlocks.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index d60d716cce..f7615b9fe9 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -2,7 +2,7 @@ import { HeadingBlockContent } from "../nodes/BlockContent/HeadingBlockContent/H import { BulletListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; import { NumberedListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { ParagraphBlockContent } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; -import { PropSchema, TypesMatch } from "./blockTypes"; +import { BlockSchema, PropSchema, TypesMatch } from "./blockTypes"; export const defaultProps = { backgroundColor: { @@ -21,24 +21,28 @@ export type DefaultProps = typeof defaultProps; export const defaultBlockSchema = { paragraph: { - propSchema: defaultProps, node: ParagraphBlockContent, + propSchema: defaultProps, + containsInlineContent: true, }, heading: { + node: HeadingBlockContent, propSchema: { ...defaultProps, level: { default: "1", values: ["1", "2", "3"] as const }, }, - node: HeadingBlockContent, + containsInlineContent: true, }, bulletListItem: { - propSchema: defaultProps, node: BulletListItemBlockContent, + propSchema: defaultProps, + containsInlineContent: true, }, numberedListItem: { - propSchema: defaultProps, node: NumberedListItemBlockContent, + propSchema: defaultProps, + containsInlineContent: true, }, -} as const; +} as const satisfies BlockSchema; export type DefaultBlockSchema = TypesMatch; From b137f43f82b1b97b83f86e128d9fb9fa8457aad1 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 02:33:57 +0200 Subject: [PATCH 05/61] Added image block (React implementation) --- .../extensions/Blocks/nodes/Block.module.css | 4 + packages/react/package.json | 9 + packages/react/src/Image.tsx | 402 ++++++++++++++++++ packages/react/src/index.ts | 2 + 4 files changed, 417 insertions(+) create mode 100644 packages/react/src/Image.tsx diff --git a/packages/core/src/extensions/Blocks/nodes/Block.module.css b/packages/core/src/extensions/Blocks/nodes/Block.module.css index 2326e580a6..eed28cab0b 100644 --- a/packages/core/src/extensions/Blocks/nodes/Block.module.css +++ b/packages/core/src/extensions/Blocks/nodes/Block.module.css @@ -278,6 +278,10 @@ NESTED BLOCKS content: "List"; } +.isEmpty .blockContent[data-content-type="captionedImage"] .inlineContent:before { + content: "Caption"; +} + /* TEXT COLORS */ [data-text-color="gray"] { color: #9b9a97; diff --git a/packages/react/package.json b/packages/react/package.json index bba3aad89e..75aad1c6f4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -55,6 +55,15 @@ "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", + "@uppy/core": "^3.4.0", + "@uppy/dashboard": "^3.5.1", + "@uppy/drag-drop": "^3.0.3", + "@uppy/file-input": "^3.0.3", + "@uppy/google-drive": "^3.2.1", + "@uppy/progress-bar": "^3.0.3", + "@uppy/react": "^3.1.3", + "@uppy/remote-sources": "^1.0.3", + "@uppy/tus": "^3.1.3", "lodash": "^4.17.21", "react": "^18.2.0", "react-icons": "^4.3.1", diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx new file mode 100644 index 0000000000..63a8de2a56 --- /dev/null +++ b/packages/react/src/Image.tsx @@ -0,0 +1,402 @@ +// TODO: Vanilla version and move to core +import { + BlockNoteEditor, + BlockSchema, + BlockSpec, + defaultProps, + PropSchema, + SpecificBlock, +} from "@blocknote/core"; + +import Uppy, { UploadResult } from "@uppy/core"; +import Tus from "@uppy/tus"; +import GoogleDrive from "@uppy/google-drive"; +import Url from "@uppy/url"; +import { Dashboard } from "@uppy/react"; + +import { CSSProperties, useEffect, useMemo, useState } from "react"; + +import "@uppy/core/dist/style.css"; +import "@uppy/dashboard/dist/style.css"; +import "@uppy/drag-drop/dist/style.css"; +import "@uppy/file-input/dist/style.css"; +import "@uppy/progress-bar/dist/style.css"; + +import { createReactBlockSpec, InlineContent } from "./ReactBlockSpec"; + +// Converts text alignment prop values to the flexbox `align-items` values. +const textAlignmentToAlignItems = ( + textAlignment: "left" | "center" | "right" | "justify" +): "flex-start" | "center" | "flex-end" => { + switch (textAlignment) { + case "left": + return "flex-start"; + case "center": + return "center"; + case "right": + return "flex-end"; + default: + return "flex-start"; + } +}; + +// Max & min image widths as a fraction of the editor's width +const maxWidth = 1.0; +const minWidth = 0.1; + +const imagePropSchema = { + textAlignment: defaultProps.textAlignment, + backgroundColor: defaultProps.backgroundColor, + // Image src + src: { + // TODO: Better default + default: "https://via.placeholder.com/150" as const, + }, + // Image width as a fraction of the editor's width + width: { + default: "0.5" as const, + }, + // Whether to show the image upload dashboard or not + replacing: { + default: "false" as const, + values: ["true", "false"] as const, + }, +} satisfies PropSchema; + +const ImageComponent = (props: { + block: Caption extends true + ? SpecificBlock< + BlockSchema & { + captionedImage: BlockSpec< + "captionedImage", + typeof imagePropSchema, + Caption + >; + }, + "captionedImage" + > + : SpecificBlock< + BlockSchema & { + image: BlockSpec<"image", typeof imagePropSchema, Caption>; + }, + "image" + >; + editor: Caption extends true + ? BlockNoteEditor< + BlockSchema & { + captionedImage: BlockSpec< + "captionedImage", + typeof imagePropSchema, + Caption + >; + } + > + : BlockNoteEditor< + BlockSchema & { + image: BlockSpec<"image", typeof imagePropSchema, Caption>; + } + >; + caption: Caption; +}) => { + // Used to check if the resizing handles should be shown. + const [hovered, setHovered] = useState(false); + + // Used to check if the image is being resized, and which resize handle is + // being used (left or right). + const [resizeHandle, setResizeHandle] = useState<"left" | "right" | null>( + null + ); + + // Used to calculate the new width while resizing the image, as the cursor X + // offset is just added to the initial width. Both values are represented in + // px. + const [initialWidth, setInitialWidth] = useState(0); + const [initialClientX, setInitialClientX] = useState(0); + + // The editor's width in px. + const [editorWidth, setEditorWidth] = useState( + props.editor.domElement.firstElementChild!.clientWidth + ); + + // The image width, represented as a fraction of the editor's width. + const [width, setWidth] = useState(() => parseFloat(props.block.props.width)); + + // Creates an Uppy instance for file uploading. + // TODO: Server endpoints/URLs + const [uppy] = useState(() => + new Uppy({ autoProceed: true, debug: true }) + .use(Tus, { + endpoint: "https://master.tus.io/files/", + }) + .use(GoogleDrive, { + companionUrl: "https://companion.uppy.io", + }) + .use(Url, { + companionUrl: "https://companion.uppy.io", + }) + ); + + // Takes a width value in px, converts it to a fraction of the editor's + // width, and updates the `width` state. Allows the image to be re-rendered + // without having to update the block. + const updateWidth = useMemo( + () => (newWidth: number) => { + newWidth = newWidth / editorWidth; + + if (newWidth < minWidth) { + setWidth(minWidth); + } else if (newWidth > maxWidth) { + setWidth(maxWidth); + } else { + setWidth(newWidth); + } + }, + [editorWidth] + ); + + // Sets up listeners for when the image is being resized. + useEffect(() => { + // Stops mouse movements from resizing the image and updates the block's + // `width` prop to the new value. + const mouseUpHandler = () => { + setResizeHandle(null); + props.editor.updateBlock(props.block, { + type: props.caption ? "captionedImage" : "image", + props: { + width: width.toString(), + }, + }); + }; + // Re-renders the image with an updated width depending on the cursor + // offset from when the resize began, and which resize handle is being used. + const mouseMoveHandler = (e: MouseEvent) => { + if (!resizeHandle) { + return; + } + + if ( + textAlignmentToAlignItems(props.block.props.textAlignment) === "center" + ) { + if (resizeHandle === "left") { + updateWidth(initialWidth + (initialClientX - e.clientX) * 2); + } else { + updateWidth(initialWidth + (e.clientX - initialClientX) * 2); + } + } else { + if (resizeHandle === "left") { + updateWidth(initialWidth + initialClientX - e.clientX); + } else { + updateWidth(initialWidth + e.clientX - initialClientX); + } + } + }; + // Re-renders the image when the viewport is resized. By storing the image + // width as a fraction of the editor's width, this allows the image to + // maintain its size relative to the editor. + const resizeHandler = () => { + setEditorWidth(props.editor.domElement.firstElementChild!.clientWidth); + }; + + window.addEventListener("mouseup", mouseUpHandler); + window.addEventListener("mousemove", mouseMoveHandler); + window.addEventListener("resize", resizeHandler); + + return () => { + window.removeEventListener("mouseup", mouseUpHandler); + window.removeEventListener("mousemove", mouseMoveHandler); + window.removeEventListener("resize", resizeHandler); + }; + }, [ + initialClientX, + initialWidth, + props.block, + props.block.props.textAlignment, + props.caption, + props.editor, + props.editor.domElement.firstElementChild, + resizeHandle, + updateWidth, + width, + ]); + + // Sets up handlers for when the image is replaced with a new one, uploaded + // using Uppy. + useEffect(() => { + // Throws an error if the user tries to replace the image with more than one + // file. + const onUpload = (data: { id: string; fileIDs: string[] }) => { + if (data.fileIDs.length > 1) { + throw new Error("Only one file can be uploaded at a time"); + } + }; + // Throws an error if the user tries to replace the image with more than one + // file or if the upload fails. Otherwise, updates the block's `src` prop + // with the uploaded image's URL. + const onComplete = (result: UploadResult) => { + if (result.successful.length + result.failed.length > 1) { + throw new Error("Only one file can be uploaded at a time"); + } + + if (result.failed.length > 0) { + throw new Error("Upload failed"); + } + + // Updating both `src` and `replacing` props at the same time causes some + // kind of delay in rendering. While both the TipTap state and the DOM are + // updated instantly, the image itself takes a while to display the new + // source. + props.editor.updateBlock(props.block, { + type: props.caption ? "captionedImage" : "image", + props: { + src: result.successful[0].response!.uploadURL, + }, + }); + setTimeout(() => { + props.editor.updateBlock(props.block, { + type: props.caption ? "captionedImage" : "image", + props: { + replacing: "false", + }, + }); + uppy.cancelAll(); + }, 2000); + }; + + uppy.on("upload", onUpload); + uppy.on("complete", onComplete); + + return () => { + uppy.off("upload", onUpload); + uppy.off("complete", onComplete); + }; + }, [props.block, props.caption, props.editor, uppy]); + + return ( + // Wrapper element to set the image alignment +
+ {/*Wrapper element for the image and resize handles*/} +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + position: "relative", + width: "fit-content", + }}> + {/*Image element*/} + {/*TODO: Alt text?*/} + + {/*Image upload dashboard*/} + + {/*Left resize handle*/} +
{ + e.preventDefault(); + setInitialWidth(width * editorWidth); + setInitialClientX(e.clientX); + setResizeHandle("left"); + }} + style={{ + ...resizeHandleStyles, + display: + props.block.props.replacing === "false" && + (hovered || resizeHandle) + ? "block" + : "none", + left: "4px", + }} + /> + {/*Right resize handle*/} +
{ + e.preventDefault(); + setInitialWidth(width * editorWidth); + setInitialClientX(e.clientX); + setResizeHandle("right"); + }} + style={{ + ...resizeHandleStyles, + display: + props.block.props.replacing === "false" && + (hovered || resizeHandle) + ? "block" + : "none", + right: "4px", + }} + /> +
+ {props.caption && ( + + )} +
+ ); +}; + +const resizeHandleStyles: CSSProperties = { + position: "absolute", + + width: "8px", + height: "30px", + + backgroundColor: "black", + border: "1px solid white", + borderRadius: "4px", + + cursor: "ew-resize", +}; + +export const Image = createReactBlockSpec< + "image", + typeof imagePropSchema, + false, + BlockSchema & { + image: BlockSpec<"image", typeof imagePropSchema, false>; + } +>({ + type: "image", + propSchema: imagePropSchema, + containsInlineContent: false, + render: (props) => , +}); + +export const CaptionedImage = createReactBlockSpec< + "captionedImage", + typeof imagePropSchema, + true, + BlockSchema & { + captionedImage: BlockSpec<"captionedImage", typeof imagePropSchema, true>; + } +>({ + type: "captionedImage", + propSchema: imagePropSchema, + containsInlineContent: true, + render: (props) => , +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8357ece0a4..731c05eee4 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -42,4 +42,6 @@ export * from "./hooks/useEditorForceUpdate"; export * from "./hooks/useEditorContentChange"; export * from "./hooks/useEditorSelectionChange"; +export * from "./Image"; + export * from "./ReactBlockSpec"; From 251c888103577566aafbedce7c700072aaf7264f Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 02:34:26 +0200 Subject: [PATCH 06/61] Added image upload and caption formatting toolbar buttons --- .../DefaultButtons/ReplaceImageButton.tsx | 125 ++++++++++++++++++ .../ToggleImageCaptionButton.tsx | 89 +++++++++++++ .../components/DefaultFormattingToolbar.tsx | 5 + 3 files changed, 219 insertions(+) create mode 100644 packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx create mode 100644 packages/react/src/FormattingToolbar/components/DefaultButtons/ToggleImageCaptionButton.tsx diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx new file mode 100644 index 0000000000..c56359cf68 --- /dev/null +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx @@ -0,0 +1,125 @@ +import { + Block, + BlockNoteEditor, + BlockSchema, + PartialBlock, +} from "@blocknote/core"; +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; +import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; +import { useMemo, useState } from "react"; +import { RiImageEditFill } from "react-icons/ri"; + +export const ReplaceImageButton = (props: { + editor: BlockNoteEditor; +}) => { + const [selectedBlocks, setSelectedBlocks] = useState[]>( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); + + useEditorContentChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); + }); + + useEditorSelectionChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); + }); + + const show = useMemo(() => { + if (selectedBlocks.length > 1) { + return false; + } + + // Checks if the selected block is an image. + if (selectedBlocks[0].type === "image") { + // Checks if the image has a src prop which can take any string value. + if ( + !("src" in props.editor.schema["image"].propSchema) || + props.editor.schema["image"].propSchema.src.values !== undefined + ) { + return false; + } + + // Checks if the image has a replacing prop which can take either "true" + // or "false". + if ( + !("replacing" in props.editor.schema["image"].propSchema) || + !props.editor.schema["image"].propSchema.replacing.values?.includes( + "true" + ) || + !props.editor.schema["image"].propSchema.replacing.values?.includes( + "false" + ) || + props.editor.schema["image"].propSchema.replacing.values?.length !== 2 + ) { + return false; + } + + return true; + } + + // Checks if the selected block is a captionedImage. + if (selectedBlocks[0].type === "captionedImage") { + // Checks if the image has a src prop which can take any string value. + if ( + !("src" in props.editor.schema["captionedImage"].propSchema) || + props.editor.schema["captionedImage"].propSchema.src.values !== + undefined + ) { + return false; + } + + // Checks if the image has a replacing prop which can take either "true" + // or "false". + if ( + !("replacing" in props.editor.schema["captionedImage"].propSchema) || + !props.editor.schema[ + "captionedImage" + ].propSchema.replacing.values?.includes("true") || + !props.editor.schema[ + "captionedImage" + ].propSchema.replacing.values?.includes("false") || + props.editor.schema["captionedImage"].propSchema.replacing.values + ?.length !== 2 + ) { + return false; + } + + return true; + } + + return false; + }, [props.editor.schema, selectedBlocks]); + + if (!show) { + return null; + } + + return ( + { + props.editor.updateBlock(selectedBlocks[0], { + type: selectedBlocks[0].type, + props: { + replacing: + selectedBlocks[0].props.replacing === "true" ? "false" : "true", + }, + } as PartialBlock); + props.editor.focus(); + }} + isSelected={selectedBlocks[0].props.replacing === "true"} + mainTooltip={"Replace Image"} + icon={RiImageEditFill} + /> + ); +}; diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggleImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggleImageCaptionButton.tsx new file mode 100644 index 0000000000..0aaf98ae93 --- /dev/null +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggleImageCaptionButton.tsx @@ -0,0 +1,89 @@ +import { + Block, + BlockNoteEditor, + BlockSchema, + PartialBlock, +} from "@blocknote/core"; +import { useMemo, useState } from "react"; +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { RiText } from "react-icons/ri"; +import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; +import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; + +export const ToggleImageCaptionButton = (props: { + editor: BlockNoteEditor; +}) => { + const [selectedBlocks, setSelectedBlocks] = useState[]>( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); + + useEditorContentChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); + }); + + useEditorSelectionChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); + }); + + const show = useMemo(() => { + if (selectedBlocks.length > 1) { + return false; + } + + return ( + // Checks if the schema contains an image and captioned image block. + "image" in props.editor.schema && + "captionedImage" in props.editor.schema && + // Checks if the selected block is an image or captioned image. + (selectedBlocks[0].type === "image" || + selectedBlocks[0].type === "captionedImage") && + // Checks if the block has a src prop which can take any string value. + "src" in props.editor.schema["image"].propSchema && + props.editor.schema["image"].propSchema.src.values === undefined && + "src" in props.editor.schema["captionedImage"].propSchema && + props.editor.schema["captionedImage"].propSchema.src.values === + undefined && + // Checks if the image block doesn't contain inline content and the + // captioned image block does. + !props.editor.schema["image"].containsInlineContent && + props.editor.schema["captionedImage"].containsInlineContent + ); + }, [props.editor.schema, selectedBlocks]); + + if (!show) { + return null; + } + + return ( + { + if (selectedBlocks[0].type === "image") { + props.editor.updateBlock(selectedBlocks[0], { + type: "captionedImage", + props: selectedBlocks[0].props, + } as PartialBlock); + } else { + props.editor.updateBlock(selectedBlocks[0], { + type: "image", + props: selectedBlocks[0].props, + } as PartialBlock); + } + props.editor.setTextCursorPosition(selectedBlocks[0]); + props.editor.focus(); + }} + isSelected={selectedBlocks[0].type === "captionedImage"} + mainTooltip={"Add/Remove Caption"} + icon={RiText} + /> + ); +}; diff --git a/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx b/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx index 5008aab1b7..ff2ebae1fe 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx @@ -14,6 +14,8 @@ import { UnnestBlockButton, } from "./DefaultButtons/NestBlockButtons"; import { CreateLinkButton } from "./DefaultButtons/CreateLinkButton"; +import { ReplaceImageButton } from "./DefaultButtons/ReplaceImageButton"; +import { ToggleImageCaptionButton } from "./DefaultButtons/ToggleImageCaptionButton"; export const DefaultFormattingToolbar = ( props: FormattingToolbarProps & { @@ -24,6 +26,9 @@ export const DefaultFormattingToolbar = ( + + + From 70c7cb9156290cb0fbbeee147ab49e219ede87d9 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 02:34:39 +0200 Subject: [PATCH 07/61] Added image slash menu item --- .../SlashMenu/defaultSlashMenuItems.ts | 34 +++++++++++++++++++ .../SlashMenu/defaultReactSlashMenuItems.tsx | 6 ++++ 2 files changed, 40 insertions(+) diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index e6bc7830dd..6848994b8f 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -9,6 +9,12 @@ function insertOrUpdateBlock( ) { const currentBlock = editor.getTextCursorPosition().block; + if (currentBlock.content === undefined) { + throw new Error( + "Slash Menu open in a block that doesn't contain inline content." + ); + } + if ( (currentBlock.content.length === 1 && currentBlock.content[0].type === "text" && @@ -105,5 +111,33 @@ export const getDefaultSlashMenuItems = ( }); } + if ( + "image" in schema && + "replacing" in schema["image"].propSchema && + schema["image"].propSchema.replacing.values?.includes("true") && + schema["image"].propSchema.replacing.values?.includes("false") && + schema["image"].propSchema.replacing.values?.length === 2 + ) { + slashMenuItems.push({ + name: "Image", + aliases: [ + "image", + "imageUpload", + "upload", + "img", + "picture", + "media", + "url", + "drive", + "dropbox", + ], + execute: (editor) => + insertOrUpdateBlock(editor, { + type: "image", + props: { replacing: "true" }, + } as PartialBlock), + }); + } + return slashMenuItems; }; diff --git a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx index 3c13b21d61..b8b1fe6a18 100644 --- a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx +++ b/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx @@ -9,6 +9,7 @@ import { RiH1, RiH2, RiH3, + RiImage2Fill, RiListOrdered, RiListUnordered, RiText, @@ -59,6 +60,11 @@ const extraFields: Record< hint: "Used for the body of your document", shortcut: formatKeyboardShortcut("Mod-Alt-0"), }, + Image: { + group: "Media", + icon: , + hint: "Insert an image", + }, }; export function getDefaultReactSlashMenuItems( From 5136c1ff359563fd9c168d5c22aa421940a480fb Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 02:35:05 +0200 Subject: [PATCH 08/61] Temporarily updated `App.tsx` to show image block --- examples/editor/src/App.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/examples/editor/src/App.tsx b/examples/editor/src/App.tsx index b3a9068243..64d7044e03 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/src/App.tsx @@ -1,14 +1,28 @@ // import logo from './logo.svg' +import { BlockSchema, defaultBlockSchema } from "@blocknote/core"; import "@blocknote/core/style.css"; -import { BlockNoteView, useBlockNote } from "@blocknote/react"; +import { + BlockNoteView, + useBlockNote, + Image, + CaptionedImage, +} from "@blocknote/react"; import styles from "./App.module.css"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; +const customSchema = { + ...defaultBlockSchema, + image: Image, + captionedImage: CaptionedImage, +} satisfies BlockSchema; + function App() { const editor = useBlockNote({ + blockSchema: customSchema, onEditorContentChange: (editor) => { console.log(editor.topLevelBlocks); + console.log(editor.topLevelBlocks[0]); }, domAttributes: { editor: { From 8cbbc50ade4eb587b1eb7687b3cc3ef58dbc61ec Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Sun, 27 Aug 2023 03:10:23 +0200 Subject: [PATCH 09/61] Added placeholder `alt` attribute to image --- packages/react/src/Image.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx index 63a8de2a56..0013f64a40 100644 --- a/packages/react/src/Image.tsx +++ b/packages/react/src/Image.tsx @@ -295,6 +295,7 @@ const ImageComponent = (props: { {/*TODO: Alt text?*/} {"placeholder"} Date: Fri, 1 Sep 2023 00:05:36 +0200 Subject: [PATCH 10/61] Added `destroy` function so vanilla custom blocks can clean ip between renders --- packages/core/src/extensions/Blocks/api/block.ts | 9 ++++++--- packages/core/src/extensions/Blocks/api/blockTypes.ts | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index 9c7b844080..b23c35c8ca 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -1,4 +1,4 @@ -import { Attribute, Node } from "@tiptap/core"; +import { Attribute, Attributes, Node } from "@tiptap/core"; import { BlockNoteDOMAttributes, BlockNoteEditor } from "../../.."; import styles from "../nodes/Block.module.css"; import { @@ -10,6 +10,7 @@ import { TipTapNodeConfig, } from "./blockTypes"; import { mergeCSSClasses } from "../../../shared/utils"; +import { ParseRule } from "prosemirror-model"; export function camelToDataKebab(str: string): string { return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); @@ -27,7 +28,7 @@ export function propsToAttributes< BlockConfig, "render" > -) { +): Attributes { const tiptapAttributes: Record = {}; Object.entries(blockConfig.propSchema).forEach(([name, spec]) => { @@ -63,7 +64,7 @@ export function parse< BlockConfig, "render" > -) { +): ParseRule[] { return [ { tag: "div[data-content-type=" + blockConfig.type + "]", @@ -228,9 +229,11 @@ export function createBlockSpec< ? { dom: blockContent, contentDOM: rendered.contentDOM, + destroy: rendered.destroy, } : { dom: blockContent, + destroy: rendered.destroy, }; }; }, diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index 515e312312..831125a4c7 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -115,9 +115,11 @@ export type BlockConfig< ? { dom: HTMLElement; contentDOM: HTMLElement; + destroy?: () => void; } : { dom: HTMLElement; + destroy?: () => void; }; }; From dc3b00ce4a6c35340f46c642140567789461692c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 00:10:41 +0200 Subject: [PATCH 11/61] Added vanilla image block --- .../extensions/Blocks/api/defaultBlocks.ts | 2 + .../ImageBlockContent/ImageBlockContent.ts | 472 ++++++++++++++++++ .../SlashMenu/defaultSlashMenuItems.ts | 9 +- 3 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index f7615b9fe9..bf7465ad1e 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -3,6 +3,7 @@ import { BulletListItemBlockContent } from "../nodes/BlockContent/ListItemBlockC import { NumberedListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { ParagraphBlockContent } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; import { BlockSchema, PropSchema, TypesMatch } from "./blockTypes"; +import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent"; export const defaultProps = { backgroundColor: { @@ -43,6 +44,7 @@ export const defaultBlockSchema = { propSchema: defaultProps, containsInlineContent: true, }, + image: Image, } as const satisfies BlockSchema; export type DefaultBlockSchema = TypesMatch; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts new file mode 100644 index 0000000000..d8cebcc7c0 --- /dev/null +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -0,0 +1,472 @@ +import Uppy from "@uppy/core"; +import Dashboard from "@uppy/dashboard"; +import Tus from "@uppy/tus"; +import GoogleDrive from "@uppy/google-drive"; +import Url from "@uppy/url"; + +import { + BlockNoteEditor, + BlockSchema, + BlockSpec, + createBlockSpec, + defaultProps, + PropSchema, + SpecificBlock, +} from "../../../../../index"; + +// Converts text alignment prop values to the flexbox `align-items` values. +const textAlignmentToAlignItems = ( + textAlignment: "left" | "center" | "right" | "justify" +): "flex-start" | "center" | "flex-end" => { + switch (textAlignment) { + case "left": + return "flex-start"; + case "center": + return "center"; + case "right": + return "flex-end"; + default: + return "flex-start"; + } +}; + +// Sets generic styles for a resize handle, regardless of whether it's the +// left or right one. +const setResizeHandleStyles = (resizeHandle: HTMLDivElement) => { + resizeHandle.style.position = "absolute"; + resizeHandle.style.width = "8px"; + resizeHandle.style.height = "30px"; + resizeHandle.style.backgroundColor = "black"; + resizeHandle.style.border = "1px solid white"; + resizeHandle.style.borderRadius = "4px"; + resizeHandle.style.cursor = "ew-resize"; +}; + +// Max & min image widths as a percentage of the editor's width. +const maxPercentWidth = 1.0; +const minPercentWidth = 0.1; + +const imagePropSchema = { + textAlignment: defaultProps.textAlignment, + backgroundColor: defaultProps.backgroundColor, + // Image src. + src: { + // TODO: Better default + default: "" as const, + }, + // Image caption. + caption: { + default: "" as const, + }, + // Image width as a percentage of the editor's width. + width: { + default: "0.5" as const, + }, + // Whether to show the image upload dashboard or the image itself. + replacing: { + default: "false" as const, + values: ["true", "false"] as const, + }, +} satisfies PropSchema; + +const renderImage = ( + block: SpecificBlock< + BlockSchema & { image: BlockSpec<"image", typeof imagePropSchema, false> }, + "image" + >, + editor: BlockNoteEditor< + BlockSchema & { image: BlockSpec<"image", typeof imagePropSchema, false> } + > +) => { + // Wrapper element to set the image alignment, contains both image/image + // upload dashboard and caption. + const wrapper = document.createElement("div"); + wrapper.id = "wrapper"; + wrapper.style.display = "flex"; + wrapper.style.flexDirection = "column"; + wrapper.style.alignItems = textAlignmentToAlignItems( + block.props.textAlignment + ); + wrapper.style.userSelect = "none"; + wrapper.style.width = "100%"; + + // Image upload dashboard. + const imageUploadDashboard = document.createElement("div"); + imageUploadDashboard.id = "uppy-dashboard"; + imageUploadDashboard.style.display = + block.props.replacing === "true" ? "block" : "none"; + imageUploadDashboard.style.borderRadius = "4px"; + imageUploadDashboard.style.width = `${Math.min( + editor.domElement.firstElementChild!.clientWidth, + 750 + )}px`; + + const addImageButton = document.createElement("div"); + addImageButton.style.display = + block.props.replacing === "false" && block.props.src === "" + ? "flex" + : "none"; + addImageButton.style.flexDirection = "row"; + addImageButton.style.alignItems = "center"; + addImageButton.style.gap = "8px"; + addImageButton.style.backgroundColor = "whitesmoke"; + addImageButton.style.borderRadius = "4px"; + addImageButton.style.cursor = "pointer"; + addImageButton.style.padding = "12px"; + + const addImageButtonIcon = document.createElement("div"); + addImageButtonIcon.style.backgroundImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z'%3E%3C/path%3E%3C/svg%3E")`; + addImageButtonIcon.style.width = "24px"; + addImageButtonIcon.style.height = "24px"; + + const addImageButtonText = document.createElement("p"); + addImageButtonText.innerText = "Add Image"; + + // Wrapper element for the image and resize handles. + const imageWrapper = document.createElement("div"); + imageWrapper.style.display = + block.props.replacing === "false" && block.props.src !== "" + ? "flex" + : "none"; + imageWrapper.style.flexDirection = "row"; + imageWrapper.style.alignItems = "center"; + imageWrapper.style.position = "relative"; + imageWrapper.style.borderRadius = "4px"; + imageWrapper.style.width = "fit-content"; + + // Image element. + const image = document.createElement("img"); + image.src = block.props.src; + image.alt = "placeholder"; + image.contentEditable = "false"; + image.draggable = false; + image.style.width = `${ + parseFloat(block.props.width) * + editor.domElement.firstElementChild!.clientWidth + }px`; + + // Resize handle elements. + const leftResizeHandle = document.createElement("div"); + leftResizeHandle.style.left = "4px"; + setResizeHandleStyles(leftResizeHandle); + const rightResizeHandle = document.createElement("div"); + rightResizeHandle.style.right = "4px"; + setResizeHandleStyles(rightResizeHandle); + + const caption = document.createElement("p"); + caption.innerText = block.props.caption; + caption.style.fontSize = "0.8em !important"; + + const handleEditorUpdate = () => { + const selection = editor.getSelection()?.blocks || []; + const currentBlock = editor.getTextCursorPosition().block; + + const isSelected = + [currentBlock, ...selection].find( + (selectedBlock) => selectedBlock.id === block.id + ) !== undefined; + + if (isSelected) { + imageUploadDashboard.style.outline = "4px solid rgb(100, 160, 255)"; + addImageButton.style.outline = "4px solid rgb(100, 160, 255)"; + imageWrapper.style.outline = "4px solid rgb(100, 160, 255)"; + } else { + imageUploadDashboard.style.outline = "none"; + addImageButton.style.outline = "none"; + imageWrapper.style.outline = "none"; + } + }; + editor.onEditorContentChange(handleEditorUpdate); + editor.onEditorSelectionChange(handleEditorUpdate); + + // Creates an Uppy instance for file uploading and replaces the + // imageUploadDashboard element with an Uppy Dashboard. + // TODO: Server endpoints/URLs + const uppy = new Uppy({ autoProceed: true }) + .use(Tus, { + endpoint: "https://master.tus.io/files/", + }) + .use(GoogleDrive, { + companionUrl: "https://companion.uppy.io", + }) + .use(Url, { + companionUrl: "https://companion.uppy.io", + }) + .use(Dashboard, { + id: block.id, + plugins: ["GoogleDrive", "Url"], + target: imageUploadDashboard, + inline: true, + hideProgressAfterFinish: true, + }); + // Throws an error if the user tries to replace the image with more than one + // file. + uppy.on("upload", (data) => { + if (data.fileIDs.length > 1) { + throw new Error("Only one file can be uploaded at a time"); + } + }); + // Throws an error if the user tries to replace the image with more than one + // file or if the upload fails. Otherwise, updates the block's `src` prop + // with the uploaded image's URL. + uppy.on("complete", (result) => { + if (result.successful.length + result.failed.length > 1) { + throw new Error("Only one file can be uploaded at a time"); + } + + if (result.failed.length > 0) { + throw new Error("Upload failed"); + } + + // TODO: Timeout is only there to give you time to read the "upload + // complete" message, it's not actually needed. Should it stay? + setTimeout(() => { + editor.updateBlock(block, { + type: "image", + props: { + src: result.successful[0].response!.uploadURL, + replacing: "false", + }, + }); + }, 2000); + }); + + // Temporary parameters set when the user begins resizing the image, used to + // calculate the new width of the image. + let resizeParams: + | { + handleUsed: "left" | "right"; + initialWidth: number; + initialClientX: number; + } + | undefined; + + // Updates the image width with an updated width depending on the cursor X + // offset from when the resize began, and which resize handle is being used. + const windowMouseMoveHandler = (event: MouseEvent) => { + if (!resizeParams) { + return; + } + + let newWidth: number; + + if (textAlignmentToAlignItems(block.props.textAlignment) === "center") { + if (resizeParams.handleUsed === "left") { + newWidth = + resizeParams.initialWidth + + (resizeParams.initialClientX - event.clientX) * 2; + } else { + newWidth = + resizeParams.initialWidth + + (event.clientX - resizeParams.initialClientX) * 2; + } + } else { + if (resizeParams.handleUsed === "left") { + newWidth = + resizeParams.initialWidth + + resizeParams.initialClientX - + event.clientX; + } else { + newWidth = + resizeParams.initialWidth + + event.clientX - + resizeParams.initialClientX; + } + } + + if ( + newWidth < + minPercentWidth * editor.domElement.firstElementChild!.clientWidth + ) { + image.style.width = `${ + minPercentWidth * editor.domElement.firstElementChild!.clientWidth + }px`; + } else if ( + newWidth > + maxPercentWidth * editor.domElement.firstElementChild!.clientWidth + ) { + image.style.width = `${ + maxPercentWidth * editor.domElement.firstElementChild!.clientWidth + }px`; + } else { + image.style.width = `${newWidth}px`; + } + }; + // Stops mouse movements from resizing the image and updates the block's + // `width` prop to the new value. + const windowMouseUpHandler = (event: MouseEvent) => { + if (!resizeParams) { + return; + } + + // Hides the drag handles if the cursor is no longer over the image. + if ( + (!event.target || !imageWrapper.contains(event.target as Node)) && + imageWrapper.contains(leftResizeHandle) && + imageWrapper.contains(rightResizeHandle) + ) { + leftResizeHandle.style.display = "none"; + rightResizeHandle.style.display = "none"; + } + + resizeParams = undefined; + + const percentWidth = + parseInt(image.style.width.slice(0, -2)) / + editor.domElement.firstElementChild!.clientWidth; + + editor.updateBlock(block, { + type: "image", + props: { + width: percentWidth.toString(), + }, + }); + }; + // Updates the image width when the viewport is resized. By storing the image + // width as a fraction of the editor's width, this allows the image to + // maintain its size relative to the editor. + const windowResizeHandler = () => { + image.style.width = `${ + parseFloat(block.props.width) * + editor.domElement.firstElementChild!.clientWidth + }px`; + imageUploadDashboard.style.width = `${Math.min( + editor.domElement.firstElementChild!.clientWidth, + 750 + )}px`; + }; + + // Displays the image upload dashboard. + const addImageButtonClickHandler = () => { + editor.updateBlock(block, { + type: "image", + props: { + replacing: "true", + }, + }); + }; + // Changes the add image button background color on hover. + const addImageButtonMouseEnterHandler = () => { + addImageButton.style.backgroundColor = "gainsboro"; + }; + const addImageButtonMouseLeaveHandler = () => { + addImageButton.style.backgroundColor = "whitesmoke"; + }; + + // Shows the resize handles when hovering over the image with the cursor. + const imageMouseEnterHandler = () => { + leftResizeHandle.style.display = "block"; + rightResizeHandle.style.display = "block"; + }; + // Hides the resize handles when the cursor leaves the image, unless the + // cursor moves to one of the resize handles. + const imageMouseLeaveHandler = (event: MouseEvent) => { + if ( + event.relatedTarget === leftResizeHandle || + event.relatedTarget === rightResizeHandle + ) { + return; + } + + if (resizeParams) { + return; + } + + leftResizeHandle.style.display = "none"; + rightResizeHandle.style.display = "none"; + }; + + // Sets the resize params, allowing the user to begin resizing the image by + // moving the cursor left or right. + const leftResizeHandleMouseDownHandler = (event: MouseEvent) => { + event.preventDefault(); + + resizeParams = { + handleUsed: "left", + initialWidth: + parseFloat(block.props.width) * + editor.domElement.firstElementChild!.clientWidth, + initialClientX: event.clientX, + }; + }; + const rightResizeHandleMouseDownHandler = (event: MouseEvent) => { + event.preventDefault(); + + resizeParams = { + handleUsed: "right", + initialWidth: + parseFloat(block.props.width) * + editor.domElement.firstElementChild!.clientWidth, + initialClientX: event.clientX, + }; + }; + + wrapper.appendChild(imageUploadDashboard); + wrapper.appendChild(addImageButton); + addImageButton.appendChild(addImageButtonIcon); + addImageButton.appendChild(addImageButtonText); + wrapper.appendChild(imageWrapper); + imageWrapper.appendChild(image); + imageWrapper.appendChild(leftResizeHandle); + imageWrapper.appendChild(rightResizeHandle); + wrapper.appendChild(caption); + + window.addEventListener("mousemove", windowMouseMoveHandler); + window.addEventListener("mouseup", windowMouseUpHandler); + window.addEventListener("resize", windowResizeHandler); + addImageButton.addEventListener("click", addImageButtonClickHandler); + addImageButton.addEventListener( + "mouseenter", + addImageButtonMouseEnterHandler + ); + addImageButton.addEventListener( + "mouseleave", + addImageButtonMouseLeaveHandler + ); + image.addEventListener("mouseenter", imageMouseEnterHandler); + image.addEventListener("mouseleave", imageMouseLeaveHandler); + leftResizeHandle.addEventListener( + "mousedown", + leftResizeHandleMouseDownHandler + ); + rightResizeHandle.addEventListener( + "mousedown", + rightResizeHandleMouseDownHandler + ); + + return { + dom: wrapper, + destroy: () => { + uppy.close(); + window.removeEventListener("mousemove", windowMouseMoveHandler); + window.removeEventListener("mouseup", windowMouseUpHandler); + window.removeEventListener("resize", windowResizeHandler); + addImageButton.removeEventListener("click", addImageButtonClickHandler); + addImageButton.removeEventListener( + "mouseenter", + addImageButtonMouseEnterHandler + ); + addImageButton.removeEventListener( + "mouseleave", + addImageButtonMouseLeaveHandler + ); + image.removeEventListener("mouseenter", imageMouseEnterHandler); + image.removeEventListener("mouseleave", imageMouseLeaveHandler); + leftResizeHandle.removeEventListener( + "mousedown", + leftResizeHandleMouseDownHandler + ); + rightResizeHandle.removeEventListener( + "mousedown", + rightResizeHandleMouseDownHandler + ); + }, + }; +}; + +export const Image = createBlockSpec({ + type: "image", + propSchema: imagePropSchema, + containsInlineContent: false, + render: renderImage, +}); diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 6848994b8f..1be155a918 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -111,13 +111,7 @@ export const getDefaultSlashMenuItems = ( }); } - if ( - "image" in schema && - "replacing" in schema["image"].propSchema && - schema["image"].propSchema.replacing.values?.includes("true") && - schema["image"].propSchema.replacing.values?.includes("false") && - schema["image"].propSchema.replacing.values?.length === 2 - ) { + if ("image" in schema && "replacing" in schema["image"].propSchema) { slashMenuItems.push({ name: "Image", aliases: [ @@ -134,7 +128,6 @@ export const getDefaultSlashMenuItems = ( execute: (editor) => insertOrUpdateBlock(editor, { type: "image", - props: { replacing: "true" }, } as PartialBlock), }); } From 6d0e8b08ebcd4e2e84d359c87b93a39b3bf5a04d Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 00:12:12 +0200 Subject: [PATCH 12/61] Added Uppy deps --- packages/core/package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/package.json b/packages/core/package.json index 3fd1977742..71b69a2044 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -69,6 +69,15 @@ "@tiptap/extension-text": "^2.0.3", "@tiptap/extension-underline": "^2.0.3", "@tiptap/pm": "^2.0.3", + "@uppy/core": "^3.4.0", + "@uppy/dashboard": "^3.5.1", + "@uppy/drag-drop": "^3.0.3", + "@uppy/file-input": "^3.0.3", + "@uppy/google-drive": "^3.2.1", + "@uppy/progress-bar": "^3.0.3", + "@uppy/react": "^3.1.3", + "@uppy/remote-sources": "^1.0.3", + "@uppy/tus": "^3.1.3", "hast-util-from-dom": "^4.2.0", "lodash": "^4.17.21", "prosemirror-model": "^1.18.3", From 9766343237f121d98be774d44371fe6694ee2b00 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 00:13:05 +0200 Subject: [PATCH 13/61] Updated `ReplaceImageButton.tsx` --- .../DefaultButtons/ReplaceImageButton.tsx | 81 ++++--------------- 1 file changed, 17 insertions(+), 64 deletions(-) diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx index c56359cf68..ac708bd5b7 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx @@ -35,71 +35,24 @@ export const ReplaceImageButton = (props: { ); }); - const show = useMemo(() => { - if (selectedBlocks.length > 1) { - return false; - } - - // Checks if the selected block is an image. - if (selectedBlocks[0].type === "image") { - // Checks if the image has a src prop which can take any string value. - if ( - !("src" in props.editor.schema["image"].propSchema) || - props.editor.schema["image"].propSchema.src.values !== undefined - ) { - return false; - } - - // Checks if the image has a replacing prop which can take either "true" + const show = useMemo( + () => + // Checks if only one block is selected. + selectedBlocks.length === 1 && + // Checks if the selected block is an image. + selectedBlocks[0].type === "image" && + // Checks if the image has a `replacing` prop which can take either "true" // or "false". - if ( - !("replacing" in props.editor.schema["image"].propSchema) || - !props.editor.schema["image"].propSchema.replacing.values?.includes( - "true" - ) || - !props.editor.schema["image"].propSchema.replacing.values?.includes( - "false" - ) || - props.editor.schema["image"].propSchema.replacing.values?.length !== 2 - ) { - return false; - } - - return true; - } - - // Checks if the selected block is a captionedImage. - if (selectedBlocks[0].type === "captionedImage") { - // Checks if the image has a src prop which can take any string value. - if ( - !("src" in props.editor.schema["captionedImage"].propSchema) || - props.editor.schema["captionedImage"].propSchema.src.values !== - undefined - ) { - return false; - } - - // Checks if the image has a replacing prop which can take either "true" - // or "false". - if ( - !("replacing" in props.editor.schema["captionedImage"].propSchema) || - !props.editor.schema[ - "captionedImage" - ].propSchema.replacing.values?.includes("true") || - !props.editor.schema[ - "captionedImage" - ].propSchema.replacing.values?.includes("false") || - props.editor.schema["captionedImage"].propSchema.replacing.values - ?.length !== 2 - ) { - return false; - } - - return true; - } - - return false; - }, [props.editor.schema, selectedBlocks]); + "replacing" in props.editor.schema["image"].propSchema && + props.editor.schema["image"].propSchema.replacing.values?.includes( + "true" + ) && + props.editor.schema["image"].propSchema.replacing.values?.includes( + "false" + ) && + props.editor.schema["image"].propSchema.replacing.values?.length === 2, + [props.editor.schema, selectedBlocks] + ); if (!show) { return null; From 00fac4e4e0ff71a553f00ccae11a8d487b0a94d2 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 00:13:31 +0200 Subject: [PATCH 14/61] Small fix in `BlockColorsButton.tsx` --- .../DragHandleMenu/DefaultButtons/BlockColorsButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx index 2671626fba..42cd53fc77 100644 --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx +++ b/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx @@ -31,7 +31,7 @@ export const BlockColorsButton = ( }, []); if ( - !("textColor" in props.block.props) || + !("textColor" in props.block.props) && !("backgroundColor" in props.block.props) ) { return null; From e514d619470f3c873725cc245de84112b3bd4e86 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 00:14:19 +0200 Subject: [PATCH 15/61] Typing fix for hyperlink toolbar components --- .../components/DefaultHyperlinkToolbar.tsx | 10 ++++++---- .../components/HyperlinkToolbarPositioner.tsx | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx b/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx index ec0850ec34..dc4a1a5f5f 100644 --- a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx +++ b/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx @@ -1,13 +1,15 @@ import { useRef, useState } from "react"; import { RiExternalLinkFill, RiLinkUnlink } from "react-icons/ri"; +import { BlockSchema } from "@blocknote/core"; import { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner"; - import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; import { ToolbarButton } from "../../SharedComponents/Toolbar/components/ToolbarButton"; -import { EditHyperlinkMenu } from "../EditHyperlinkMenu/components/EditHyperlinkMenu"; +import { EditHyperlinkMenu } from "./EditHyperlinkMenu/components/EditHyperlinkMenu"; -export const DefaultHyperlinkToolbar = (props: HyperlinkToolbarProps) => { +export const DefaultHyperlinkToolbar = ( + props: HyperlinkToolbarProps +) => { const [isEditing, setIsEditing] = useState(false); const editMenuRef = useRef(null); @@ -16,7 +18,7 @@ export const DefaultHyperlinkToolbar = (props: HyperlinkToolbarProps) => { props.editHyperlink(url, text)} + update={props.editHyperlink} // TODO: Better way of waiting for fade out onBlur={(event) => setTimeout(() => { diff --git a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx index 9f93103e43..3e8c967dfd 100644 --- a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx +++ b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx @@ -11,8 +11,8 @@ import { FC, useEffect, useMemo, useRef, useState } from "react"; import { DefaultHyperlinkToolbar } from "./DefaultHyperlinkToolbar"; -export type HyperlinkToolbarProps = Pick< - HyperlinkToolbarProsemirrorPlugin, +export type HyperlinkToolbarProps = Pick< + HyperlinkToolbarProsemirrorPlugin, "editHyperlink" | "deleteHyperlink" | "startHideTimer" | "stopHideTimer" > & Omit; @@ -21,7 +21,7 @@ export const HyperlinkToolbarPositioner = < BSchema extends BlockSchema = DefaultBlockSchema >(props: { editor: BlockNoteEditor; - hyperlinkToolbar?: FC; + hyperlinkToolbar?: FC>; }) => { const [show, setShow] = useState(false); const [url, setUrl] = useState(); From 3812d3a4774f32408f449633c652e41c996098a4 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 00:16:04 +0200 Subject: [PATCH 16/61] Created reusable components for link & caption components --- packages/react/src/BlockNoteTheme.ts | 86 +++++------ .../DefaultButtons/CreateLinkButton.tsx | 45 +++--- .../DefaultButtons/ImageCaptionButton.tsx | 133 ++++++++++++++++++ .../ToggleImageCaptionButton.tsx | 89 ------------ .../components/DefaultFormattingToolbar.tsx | 4 +- .../components/LinkToolbarButton.tsx | 81 ----------- .../components/EditHyperlinkMenu.tsx | 51 ------- .../components/EditHyperlinkMenuItem.tsx | 34 ----- .../components/EditHyperlinkMenuItemIcon.tsx | 31 ---- .../components/EditHyperlinkMenuItemInput.tsx | 32 ----- .../components/EditHyperlinkMenu.tsx | 90 ++++++++++++ .../components/ToolbarInputDropdown.tsx | 27 ++++ .../components/ToolbarInputDropdownButton.tsx | 38 +++++ .../components/ToolbarInputDropdownItem.tsx | 36 +++++ 14 files changed, 396 insertions(+), 381 deletions(-) create mode 100644 packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx delete mode 100644 packages/react/src/FormattingToolbar/components/DefaultButtons/ToggleImageCaptionButton.tsx delete mode 100644 packages/react/src/FormattingToolbar/components/LinkToolbarButton.tsx delete mode 100644 packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx delete mode 100644 packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItem.tsx delete mode 100644 packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemIcon.tsx delete mode 100644 packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemInput.tsx create mode 100644 packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx create mode 100644 packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdown.tsx create mode 100644 packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx create mode 100644 packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts index 9d6d6ede56..ee31d37068 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -149,49 +149,6 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { ), }), }, - EditHyperlinkMenu: { - styles: () => ({ - root: _.merge( - { - backgroundColor: theme.colors.menu.background, - border: border, - borderRadius: outerBorderRadius, - boxShadow: shadow, - color: theme.colors.menu.text, - gap: "4px", - minWidth: "145px", - padding: "2px", - // Row - ".mantine-Group-root": { - flexWrap: "nowrap", - gap: "8px", - paddingInline: "6px", - // Row icon - ".mantine-Container-root": { - color: theme.colors.menu.text, - display: "flex", - justifyContent: "center", - padding: 0, - width: "fit-content", - }, - // Row input field - ".mantine-TextInput-root": { - width: "300px", - ".mantine-TextInput-wrapper": { - ".mantine-TextInput-input": { - border: "none", - color: theme.colors.menu.text, - fontSize: "12px", - padding: 0, - }, - }, - }, - }, - }, - theme.componentStyles?.(theme).EditHyperlinkMenu || {} - ), - }), - }, Editor: { styles: () => ({ root: _.merge( @@ -282,6 +239,49 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { ), }), }, + ToolbarInputDropdown: { + styles: () => ({ + root: _.merge( + { + backgroundColor: theme.colors.menu.background, + border: border, + borderRadius: outerBorderRadius, + boxShadow: shadow, + color: theme.colors.menu.text, + gap: "4px", + minWidth: "145px", + padding: "2px", + // Row + ".mantine-Group-root": { + flexWrap: "nowrap", + gap: "8px", + paddingInline: "6px", + // Row icon + ".mantine-Container-root": { + color: theme.colors.menu.text, + display: "flex", + justifyContent: "center", + padding: 0, + width: "fit-content", + }, + // Row input field + ".mantine-TextInput-root": { + width: "300px", + ".mantine-TextInput-wrapper": { + ".mantine-TextInput-input": { + border: "none", + color: theme.colors.menu.text, + fontSize: "12px", + padding: 0, + }, + }, + }, + }, + }, + theme.componentStyles?.(theme).EditHyperlinkMenu || {} + ), + }), + }, Tooltip: { styles: () => ({ root: _.merge( diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx index ca69de016e..6f49041f6b 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx @@ -1,9 +1,12 @@ import { useCallback, useMemo, useState } from "react"; import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { RiLink } from "react-icons/ri"; -import LinkToolbarButton from "../LinkToolbarButton"; -import { formatKeyboardShortcut } from "../../../utils"; import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; +import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { EditHyperlinkMenu } from "../../../HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu"; +import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; +import { formatKeyboardShortcut } from "../../../utils"; export const CreateLinkButton = (props: { editor: BlockNoteEditor; @@ -16,9 +19,17 @@ export const CreateLinkButton = (props: { const [url, setUrl] = useState( props.editor.getSelectedLinkUrl() || "" ); - const [text, setText] = useState( - props.editor.getSelectedText() || "" - ); + const [text, setText] = useState(props.editor.getSelectedText()); + + useEditorContentChange(props.editor, () => { + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); + setText(props.editor.getSelectedText() || ""); + setUrl(props.editor.getSelectedLinkUrl() || ""); + }); useEditorSelectionChange(props.editor, () => { setSelectedBlocks( @@ -30,10 +41,10 @@ export const CreateLinkButton = (props: { setUrl(props.editor.getSelectedLinkUrl() || ""); }); - const setLink = useCallback( - (url: string, text?: string) => { - props.editor.focus(); + const update = useCallback( + (url: string, text: string) => { props.editor.createLink(url, text); + props.editor.focus(); }, [props.editor] ); @@ -53,15 +64,13 @@ export const CreateLinkButton = (props: { } return ( - + + + + ); }; diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx new file mode 100644 index 0000000000..db9b5c43ee --- /dev/null +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx @@ -0,0 +1,133 @@ +import { + Block, + BlockNoteEditor, + BlockSchema, + PartialBlock, +} from "@blocknote/core"; +import { + ChangeEvent, + KeyboardEvent, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { RiText } from "react-icons/ri"; +import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; +import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; +import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; +import { ToolbarInputDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdown"; +import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; + +export const ImageCaptionButton = (props: { + editor: BlockNoteEditor; +}) => { + const [selectedBlocks, setSelectedBlocks] = useState[]>( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ); + + useEditorContentChange(props.editor, () => + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ) + ); + useEditorSelectionChange(props.editor, () => + setSelectedBlocks( + props.editor.getSelection()?.blocks || [ + props.editor.getTextCursorPosition().block, + ] + ) + ); + + const show = useMemo( + () => + // Checks if only one block is selected. + selectedBlocks.length === 1 && + // Checks if the selected block is an image. + selectedBlocks[0].type === "image" && + // Checks if the block has a `caption` prop which can take any string + // value. + "caption" in props.editor.schema["image"].propSchema && + props.editor.schema["image"].propSchema.caption.values === undefined && + // Checks if the block has a `src` prop which can take any string value. + "src" in props.editor.schema["image"].propSchema && + props.editor.schema["image"].propSchema.src.values === undefined && + // Checks if the `src` prop is not set to an empty string. + selectedBlocks[0].props.src !== "" && + // Checks if the image has a `replacing` prop which can take either "true" + // or "false". + "replacing" in props.editor.schema["image"].propSchema && + props.editor.schema["image"].propSchema.replacing.values?.includes( + "true" + ) && + props.editor.schema["image"].propSchema.replacing.values?.includes( + "false" + ) && + props.editor.schema["image"].propSchema.replacing.values?.length === 2 && + // Checks if the `replacing` prop is set to "false". + selectedBlocks[0].props.replacing === "false", + [props.editor.schema, selectedBlocks] + ); + + const [currentCaption, setCurrentCaption] = useState( + show ? selectedBlocks[0].props.caption : "" + ); + + useEffect( + () => setCurrentCaption(show ? selectedBlocks[0].props.caption : ""), + [selectedBlocks, show] + ); + + const handleEnter = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + props.editor.updateBlock(selectedBlocks[0], { + type: "image", + props: { + caption: currentCaption, + }, + } as PartialBlock); + } + }, + [currentCaption, props.editor, selectedBlocks] + ); + + const handleChange = useCallback( + (event: ChangeEvent) => + setCurrentCaption(event.currentTarget.value), + [] + ); + + if (!show) { + return null; + } + + return ( + + + + + + + ); +}; diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggleImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggleImageCaptionButton.tsx deleted file mode 100644 index 0aaf98ae93..0000000000 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggleImageCaptionButton.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { - Block, - BlockNoteEditor, - BlockSchema, - PartialBlock, -} from "@blocknote/core"; -import { useMemo, useState } from "react"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { RiText } from "react-icons/ri"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; - -export const ToggleImageCaptionButton = (props: { - editor: BlockNoteEditor; -}) => { - const [selectedBlocks, setSelectedBlocks] = useState[]>( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - - useEditorContentChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - }); - - useEditorSelectionChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - }); - - const show = useMemo(() => { - if (selectedBlocks.length > 1) { - return false; - } - - return ( - // Checks if the schema contains an image and captioned image block. - "image" in props.editor.schema && - "captionedImage" in props.editor.schema && - // Checks if the selected block is an image or captioned image. - (selectedBlocks[0].type === "image" || - selectedBlocks[0].type === "captionedImage") && - // Checks if the block has a src prop which can take any string value. - "src" in props.editor.schema["image"].propSchema && - props.editor.schema["image"].propSchema.src.values === undefined && - "src" in props.editor.schema["captionedImage"].propSchema && - props.editor.schema["captionedImage"].propSchema.src.values === - undefined && - // Checks if the image block doesn't contain inline content and the - // captioned image block does. - !props.editor.schema["image"].containsInlineContent && - props.editor.schema["captionedImage"].containsInlineContent - ); - }, [props.editor.schema, selectedBlocks]); - - if (!show) { - return null; - } - - return ( - { - if (selectedBlocks[0].type === "image") { - props.editor.updateBlock(selectedBlocks[0], { - type: "captionedImage", - props: selectedBlocks[0].props, - } as PartialBlock); - } else { - props.editor.updateBlock(selectedBlocks[0], { - type: "image", - props: selectedBlocks[0].props, - } as PartialBlock); - } - props.editor.setTextCursorPosition(selectedBlocks[0]); - props.editor.focus(); - }} - isSelected={selectedBlocks[0].type === "captionedImage"} - mainTooltip={"Add/Remove Caption"} - icon={RiText} - /> - ); -}; diff --git a/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx b/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx index ff2ebae1fe..7843311293 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx @@ -15,7 +15,7 @@ import { } from "./DefaultButtons/NestBlockButtons"; import { CreateLinkButton } from "./DefaultButtons/CreateLinkButton"; import { ReplaceImageButton } from "./DefaultButtons/ReplaceImageButton"; -import { ToggleImageCaptionButton } from "./DefaultButtons/ToggleImageCaptionButton"; +import { ImageCaptionButton } from "./DefaultButtons/ImageCaptionButton"; export const DefaultFormattingToolbar = ( props: FormattingToolbarProps & { @@ -26,7 +26,7 @@ export const DefaultFormattingToolbar = ( - + diff --git a/packages/react/src/FormattingToolbar/components/LinkToolbarButton.tsx b/packages/react/src/FormattingToolbar/components/LinkToolbarButton.tsx deleted file mode 100644 index b32534e576..0000000000 --- a/packages/react/src/FormattingToolbar/components/LinkToolbarButton.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import Tippy from "@tippyjs/react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - ToolbarButton, - ToolbarButtonProps, -} from "../../SharedComponents/Toolbar/components/ToolbarButton"; -import { EditHyperlinkMenu } from "../../HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu"; - -type HyperlinkButtonProps = ToolbarButtonProps & { - hyperlinkIsActive: boolean; - activeHyperlinkUrl: string; - activeHyperlinkText: string; - setHyperlink: (url: string, text?: string) => void; -}; - -/** - * The link menu button opens a tooltip on click - */ -export const LinkToolbarButton = (props: HyperlinkButtonProps) => { - const [creationMenu, setCreationMenu] = useState(); - const [creationMenuOpen, setCreationMenuOpen] = useState(false); - - const buttonRef = useRef(null); - const menuRef = useRef(null); - - // TODO: review code; does this pattern still make sense? - const updateCreationMenu = useCallback(() => { - setCreationMenu( - { - props.setHyperlink(url, text); - setCreationMenuOpen(false); - }} - ref={menuRef} - /> - ); - }, [props]); - - const handleClick = useCallback( - (event: MouseEvent) => { - if (buttonRef.current?.contains(event.target as HTMLElement)) { - setCreationMenuOpen(!creationMenuOpen); - return; - } - - if (menuRef.current?.contains(event.target as HTMLElement)) { - return; - } - - setCreationMenuOpen(false); - }, - [creationMenuOpen] - ); - - useEffect(() => { - document.body.addEventListener("click", handleClick); - return () => document.body.removeEventListener("click", handleClick); - }, [handleClick]); - - return ( - - - - ); -}; - -export default LinkToolbarButton; diff --git a/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx b/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx deleted file mode 100644 index dfc9135e4a..0000000000 --- a/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { createStyles, Stack } from "@mantine/core"; -import { forwardRef, HTMLAttributes, useState } from "react"; -import { RiLink, RiText } from "react-icons/ri"; -import { EditHyperlinkMenuItem } from "./EditHyperlinkMenuItem"; - -export type EditHyperlinkMenuProps = { - url: string; - text: string; - update: (url: string, text: string) => void; -}; - -/** - * Menu which opens when editing an existing hyperlink or creating a new one. - * Provides input fields for setting the hyperlink URL and title. - */ -export const EditHyperlinkMenu = forwardRef< - HTMLDivElement, - EditHyperlinkMenuProps & HTMLAttributes ->(({ url, text, update, className, ...props }, ref) => { - const { classes } = createStyles({ root: {} })(undefined, { - name: "EditHyperlinkMenu", - }); - - const [currentUrl, setCurrentUrl] = useState(url); - const [currentText, setCurrentText] = useState(text); - - return ( - - setCurrentUrl(value)} - onSubmit={() => update(currentUrl, currentText)} - /> - setCurrentText(value)} - onSubmit={() => update(url, currentText)} - /> - - ); -}); diff --git a/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItem.tsx b/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItem.tsx deleted file mode 100644 index ab6e3c16d0..0000000000 --- a/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItem.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { IconType } from "react-icons"; -import { EditHyperlinkMenuItemIcon } from "./EditHyperlinkMenuItemIcon"; -import { EditHyperlinkMenuItemInput } from "./EditHyperlinkMenuItemInput"; -import { Group } from "@mantine/core"; - -export type EditHyperlinkMenuItemProps = { - icon: IconType; - mainIconTooltip: string; - secondaryIconTooltip?: string; - autofocus?: boolean; - placeholder?: string; - value?: string; - onChange: (value: string) => void; - onSubmit: () => void; -}; - -export function EditHyperlinkMenuItem(props: EditHyperlinkMenuItemProps) { - return ( - - - - - ); -} diff --git a/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemIcon.tsx b/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemIcon.tsx deleted file mode 100644 index a026d787e2..0000000000 --- a/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemIcon.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { IconType } from "react-icons"; -import Tippy from "@tippyjs/react"; -import { TooltipContent } from "../../../SharedComponents/Tooltip/components/TooltipContent"; -import { Container } from "@mantine/core"; - -export type EditHyperlinkMenuItemIconProps = { - icon: IconType; - mainTooltip: string; - secondaryTooltip?: string; -}; - -export function EditHyperlinkMenuItemIcon( - props: EditHyperlinkMenuItemIconProps -) { - const Icon = props.icon; - - return ( - - } - placement="left"> - - - - - ); -} diff --git a/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemInput.tsx b/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemInput.tsx deleted file mode 100644 index e9d488e618..0000000000 --- a/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemInput.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { KeyboardEvent } from "react"; -import { TextInput } from "@mantine/core"; - -export type EditHyperlinkMenuItemInputProps = { - autofocus?: boolean; - placeholder?: string; - value?: string; - onChange: (value: string) => void; - onSubmit: () => void; -}; - -export function EditHyperlinkMenuItemInput( - props: EditHyperlinkMenuItemInputProps -) { - function handleEnter(event: KeyboardEvent) { - if (event.key === "Enter") { - event.preventDefault(); - props.onSubmit(); - } - } - - return ( - props.onChange(event.currentTarget.value)} - onKeyDown={handleEnter} - placeholder={props.placeholder} - /> - ); -} diff --git a/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx b/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx new file mode 100644 index 0000000000..f447946b40 --- /dev/null +++ b/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx @@ -0,0 +1,90 @@ +import { + ChangeEvent, + forwardRef, + HTMLAttributes, + KeyboardEvent, + useCallback, + useEffect, + useState, +} from "react"; +import { RiLink, RiText } from "react-icons/ri"; +import { ToolbarInputDropdown } from "../../../../SharedComponents/Toolbar/components/ToolbarInputDropdown"; +import { ToolbarInputDropdownItem } from "../../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; + +export type EditHyperlinkMenuProps = { + url: string; + text: string; + update: (url: string, text: string) => void; +}; + +/** + * Menu which opens when editing an existing hyperlink or creating a new one. + * Provides input fields for setting the hyperlink URL and title. + */ +export const EditHyperlinkMenu = forwardRef< + HTMLDivElement, + EditHyperlinkMenuProps & HTMLAttributes +>(({ url, text, update, ...props }, ref) => { + const [currentUrl, setCurrentUrl] = useState(url); + const [currentText, setCurrentText] = useState(text); + + useEffect(() => { + setCurrentUrl(url); + setCurrentText(text); + }, [text, url]); + + const handleEnter = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + update(currentUrl, currentText); + } + }, + [update, currentUrl, currentText] + ); + + const handleUrlChange = useCallback( + (event: ChangeEvent) => + setCurrentUrl(event.currentTarget.value), + [] + ); + + const handleTextChange = useCallback( + (event: ChangeEvent) => + setCurrentText(event.currentTarget.value), + [] + ); + + const handleSubmit = useCallback( + () => update(currentUrl, currentText), + [update, currentUrl, currentText] + ); + + return ( + + + + + ); +}); diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdown.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdown.tsx new file mode 100644 index 0000000000..6b23f81cd2 --- /dev/null +++ b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdown.tsx @@ -0,0 +1,27 @@ +import { forwardRef, HTMLAttributes, ReactElement } from "react"; +import { createStyles, Stack } from "@mantine/core"; +import { ToolbarInputDropdownItem } from "./ToolbarInputDropdownItem"; + +export type ToolbarInputDropdownProps = { + children: + | ReactElement + | Array>; +}; + +export const ToolbarInputDropdown = forwardRef< + HTMLDivElement, + ToolbarInputDropdownProps & HTMLAttributes +>(({ className, ...props }, ref) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "ToolbarInputDropdown", + }); + + return ( + + {props.children} + + ); +}); diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx new file mode 100644 index 0000000000..2665dc531d --- /dev/null +++ b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx @@ -0,0 +1,38 @@ +import { ToolbarButton } from "./ToolbarButton"; +import Tippy from "@tippyjs/react"; +import { ReactElement, useCallback, useState } from "react"; +import { ToolbarInputDropdown } from "./ToolbarInputDropdown"; + +export type ToolbarInputDropdownButtonProps = { + children: [ + ReactElement, + ReactElement + ]; +}; + +export const ToolbarInputDropdownButton = ( + props: ToolbarInputDropdownButtonProps +) => { + const [renderDropdown, setRenderDropdown] = useState(false); + + // TODO: review code; does this pattern still make sense? + // This is to make autofocus work on the input fields in the dropdown. + const destroyDropdown = useCallback(() => { + setRenderDropdown(false); + }, []); + const createDropdown = useCallback(() => { + setRenderDropdown(true); + }, []); + + return ( + + {props.children[0]} + + ); +}; diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx new file mode 100644 index 0000000000..d5262382c0 --- /dev/null +++ b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx @@ -0,0 +1,36 @@ +import { Container, Group, TextInput, TextInputProps } from "@mantine/core"; +import { IconType } from "react-icons"; +import { TooltipContent } from "../../Tooltip/components/TooltipContent"; +import Tippy from "@tippyjs/react"; + +export type ToolbarInputDropdownItemProps = { + icon: IconType; + mainTooltip: string; + secondaryTooltip?: string; + + inputProps?: TextInputProps; +}; + +export const ToolbarInputDropdownItem = ( + props: ToolbarInputDropdownItemProps +) => { + const Icon = props.icon; + + return ( + + + } + placement="left"> + + + + + + + ); +}; From a6019892011416bd24b51e719683119df96c548f Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 00:28:39 +0200 Subject: [PATCH 17/61] Fixed import issue --- .../src/api/nodeConversions/nodeConversions.ts | 3 +-- .../src/extensions/Blocks/api/defaultBlocks.ts | 18 ++---------------- .../src/extensions/Blocks/api/defaultProps.ts | 16 ++++++++++++++++ packages/core/src/index.ts | 1 + 4 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/extensions/Blocks/api/defaultProps.ts diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index f0d0387ded..f6cb53a072 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -5,8 +5,7 @@ import { BlockSchema, PartialBlock, } from "../../extensions/Blocks/api/blockTypes"; - -import { defaultProps } from "../../extensions/Blocks/api/defaultBlocks"; +import { defaultProps } from "../../extensions/Blocks/api/defaultProps"; import { ColorStyle, InlineContent, diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index bf7465ad1e..546d6ecf33 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -2,23 +2,9 @@ import { HeadingBlockContent } from "../nodes/BlockContent/HeadingBlockContent/H import { BulletListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; import { NumberedListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { ParagraphBlockContent } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; -import { BlockSchema, PropSchema, TypesMatch } from "./blockTypes"; +import { BlockSchema, TypesMatch } from "./blockTypes"; import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent"; - -export const defaultProps = { - backgroundColor: { - default: "transparent" as const, - }, - textColor: { - default: "black" as const, // TODO - }, - textAlignment: { - default: "left" as const, - values: ["left", "center", "right", "justify"] as const, - }, -} satisfies PropSchema; - -export type DefaultProps = typeof defaultProps; +import { defaultProps } from "./defaultProps"; export const defaultBlockSchema = { paragraph: { diff --git a/packages/core/src/extensions/Blocks/api/defaultProps.ts b/packages/core/src/extensions/Blocks/api/defaultProps.ts new file mode 100644 index 0000000000..d535c8c6b8 --- /dev/null +++ b/packages/core/src/extensions/Blocks/api/defaultProps.ts @@ -0,0 +1,16 @@ +import { PropSchema } from "./blockTypes"; + +export const defaultProps = { + backgroundColor: { + default: "transparent" as const, + }, + textColor: { + default: "black" as const, // TODO + }, + textAlignment: { + default: "left" as const, + values: ["left", "center", "right", "justify"] as const, + }, +} satisfies PropSchema; + +export type DefaultProps = typeof defaultProps; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59243e2d5a..c0794c53f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export * from "./BlockNoteEditor"; export * from "./BlockNoteExtensions"; export * from "./extensions/Blocks/api/block"; export * from "./extensions/Blocks/api/blockTypes"; +export * from "./extensions/Blocks/api/defaultProps"; export * from "./extensions/Blocks/api/defaultBlocks"; export * from "./extensions/Blocks/api/inlineContentTypes"; export * from "./extensions/Blocks/api/selectionTypes"; From 9d729abe0f7e15e433c8836d633c96b6046998de Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 00:29:11 +0200 Subject: [PATCH 18/61] Made image selection border also include caption --- .../ImageBlockContent/ImageBlockContent.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index d8cebcc7c0..8b6bfc7984 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -122,6 +122,14 @@ const renderImage = ( const addImageButtonText = document.createElement("p"); addImageButtonText.innerText = "Add Image"; + const imageAndCaptionWrapper = document.createElement("div"); + imageAndCaptionWrapper.style.display = + block.props.replacing === "false" && block.props.src !== "" + ? "flex" + : "none"; + imageAndCaptionWrapper.style.flexDirection = "column"; + imageAndCaptionWrapper.style.borderRadius = "4px"; + // Wrapper element for the image and resize handles. const imageWrapper = document.createElement("div"); imageWrapper.style.display = @@ -131,7 +139,6 @@ const renderImage = ( imageWrapper.style.flexDirection = "row"; imageWrapper.style.alignItems = "center"; imageWrapper.style.position = "relative"; - imageWrapper.style.borderRadius = "4px"; imageWrapper.style.width = "fit-content"; // Image element. @@ -169,11 +176,11 @@ const renderImage = ( if (isSelected) { imageUploadDashboard.style.outline = "4px solid rgb(100, 160, 255)"; addImageButton.style.outline = "4px solid rgb(100, 160, 255)"; - imageWrapper.style.outline = "4px solid rgb(100, 160, 255)"; + imageAndCaptionWrapper.style.outline = "4px solid rgb(100, 160, 255)"; } else { imageUploadDashboard.style.outline = "none"; addImageButton.style.outline = "none"; - imageWrapper.style.outline = "none"; + imageAndCaptionWrapper.style.outline = "none"; } }; editor.onEditorContentChange(handleEditorUpdate); @@ -405,11 +412,12 @@ const renderImage = ( wrapper.appendChild(addImageButton); addImageButton.appendChild(addImageButtonIcon); addImageButton.appendChild(addImageButtonText); - wrapper.appendChild(imageWrapper); + wrapper.appendChild(imageAndCaptionWrapper); + imageAndCaptionWrapper.appendChild(imageWrapper); imageWrapper.appendChild(image); imageWrapper.appendChild(leftResizeHandle); imageWrapper.appendChild(rightResizeHandle); - wrapper.appendChild(caption); + imageAndCaptionWrapper.appendChild(caption); window.addEventListener("mousemove", windowMouseMoveHandler); window.addEventListener("mouseup", windowMouseUpHandler); From 69a3e491f7ceb8ab7432bc2425ee3e6228afbdf0 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 21:24:07 +0200 Subject: [PATCH 19/61] Added comment --- packages/core/src/BlockNoteEditor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index 7a669a963d..d958518546 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -479,6 +479,8 @@ export class BlockNoteEditor { * Gets a snapshot of the current selection. */ public getSelection(): Selection | undefined { + // Either the TipTap selection is empty, or it's a node selection. In either + // case, it only spans one block, so we return undefined. if ( this._tiptapEditor.state.selection.from === this._tiptapEditor.state.selection.to || From f89c036e5473d5b5596d454cc4047c3ae76d13bf Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 21:25:11 +0200 Subject: [PATCH 20/61] Added new hooks to clean up code --- .../DefaultButtons/ColorStyleButton.tsx | 33 +++----------- .../DefaultButtons/CreateLinkButton.tsx | 31 +++---------- .../DefaultButtons/ImageCaptionButton.tsx | 34 +++------------ .../DefaultButtons/NestBlockButtons.tsx | 22 +++------- .../DefaultButtons/ReplaceImageButton.tsx | 37 +++------------- .../DefaultButtons/TextAlignButton.tsx | 29 ++----------- .../DefaultButtons/ToggledStyleButton.tsx | 43 +++++-------------- .../DefaultDropdowns/BlockTypeDropdown.tsx | 9 +--- .../FormattingToolbarPositioner.tsx | 6 +-- packages/react/src/hooks/useEditorChange.ts | 11 +++++ packages/react/src/hooks/useSelectedBlocks.ts | 19 ++++++++ 11 files changed, 81 insertions(+), 193 deletions(-) create mode 100644 packages/react/src/hooks/useEditorChange.ts create mode 100644 packages/react/src/hooks/useSelectedBlocks.ts diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx index d7e3ae14bc..aa7402bdba 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx @@ -1,20 +1,18 @@ import { useCallback, useMemo, useState } from "react"; import { Menu } from "@mantine/core"; -import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; + import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { ColorIcon } from "../../../SharedComponents/ColorPicker/components/ColorIcon"; import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; +import { useEditorChange } from "../../../hooks/useEditorChange"; export const ColorStyleButton = (props: { editor: BlockNoteEditor; }) => { - const [selectedBlocks, setSelectedBlocks] = useState[]>( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); + const selectedBlocks = useSelectedBlocks(props.editor); + const [currentTextColor, setCurrentTextColor] = useState( props.editor.getActiveStyles().textColor || "default" ); @@ -22,24 +20,7 @@ export const ColorStyleButton = (props: { props.editor.getActiveStyles().backgroundColor || "default" ); - useEditorContentChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - setCurrentTextColor(props.editor.getActiveStyles().textColor || "default"); - setCurrentBackgroundColor( - props.editor.getActiveStyles().backgroundColor || "default" - ); - }); - - useEditorSelectionChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); + useEditorChange(props.editor, () => { setCurrentTextColor(props.editor.getActiveStyles().textColor || "default"); setCurrentBackgroundColor( props.editor.getActiveStyles().backgroundColor || "default" diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx index 6f49041f6b..188a3bf32c 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx @@ -1,42 +1,25 @@ import { useCallback, useMemo, useState } from "react"; -import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { RiLink } from "react-icons/ri"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; + import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { EditHyperlinkMenu } from "../../../HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; +import { useEditorChange } from "../../../hooks/useEditorChange"; import { formatKeyboardShortcut } from "../../../utils"; export const CreateLinkButton = (props: { editor: BlockNoteEditor; }) => { - const [selectedBlocks, setSelectedBlocks] = useState[]>( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); + const selectedBlocks = useSelectedBlocks(props.editor); + const [url, setUrl] = useState( props.editor.getSelectedLinkUrl() || "" ); const [text, setText] = useState(props.editor.getSelectedText()); - useEditorContentChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - setText(props.editor.getSelectedText() || ""); - setUrl(props.editor.getSelectedLinkUrl() || ""); - }); - - useEditorSelectionChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); + useEditorChange(props.editor, () => { setText(props.editor.getSelectedText() || ""); setUrl(props.editor.getSelectedLinkUrl() || ""); }); diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx index db9b5c43ee..5b9abc948c 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx @@ -1,9 +1,3 @@ -import { - Block, - BlockNoteEditor, - BlockSchema, - PartialBlock, -} from "@blocknote/core"; import { ChangeEvent, KeyboardEvent, @@ -12,37 +6,19 @@ import { useMemo, useState, } from "react"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; import { RiText } from "react-icons/ri"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; + +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; import { ToolbarInputDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdown"; import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; export const ImageCaptionButton = (props: { editor: BlockNoteEditor; }) => { - const [selectedBlocks, setSelectedBlocks] = useState[]>( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - - useEditorContentChange(props.editor, () => - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ) - ); - useEditorSelectionChange(props.editor, () => - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ) - ); + const selectedBlocks = useSelectedBlocks(props.editor); const show = useMemo( () => diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx index 50b5bbe9eb..68a12d2ddd 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx @@ -1,21 +1,17 @@ -import { formatKeyboardShortcut } from "../../../utils"; +import { useCallback, useState } from "react"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { RiIndentDecrease, RiIndentIncrease } from "react-icons/ri"; + import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; -import { useCallback, useState } from "react"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; +import { useEditorChange } from "../../../hooks/useEditorChange"; +import { formatKeyboardShortcut } from "../../../utils"; export const NestBlockButton = (props: { editor: BlockNoteEditor; }) => { const [canNestBlock, setCanNestBlock] = useState(); - useEditorContentChange(props.editor, () => { - setCanNestBlock(props.editor.canNestBlock()); - }); - - useEditorSelectionChange(props.editor, () => { + useEditorChange(props.editor, () => { setCanNestBlock(props.editor.canNestBlock()); }); @@ -40,11 +36,7 @@ export const UnnestBlockButton = (props: { }) => { const [canUnnestBlock, setCanUnnestBlock] = useState(); - useEditorContentChange(props.editor, () => { - setCanUnnestBlock(props.editor.canUnnestBlock()); - }); - - useEditorSelectionChange(props.editor, () => { + useEditorChange(props.editor, () => { setCanUnnestBlock(props.editor.canUnnestBlock()); }); diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx index ac708bd5b7..dbaf22d0de 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx @@ -1,39 +1,14 @@ -import { - Block, - BlockNoteEditor, - BlockSchema, - PartialBlock, -} from "@blocknote/core"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; +import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; import { RiImageEditFill } from "react-icons/ri"; +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; + export const ReplaceImageButton = (props: { editor: BlockNoteEditor; }) => { - const [selectedBlocks, setSelectedBlocks] = useState[]>( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - - useEditorContentChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - }); - - useEditorSelectionChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - }); + const selectedBlocks = useSelectedBlocks(props.editor); const show = useMemo( () => diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx index 93b46398c7..e80b37e6eb 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx @@ -1,11 +1,10 @@ +import { useCallback, useMemo } from "react"; import { - Block, BlockNoteEditor, BlockSchema, DefaultProps, PartialBlock, } from "@blocknote/core"; -import { useCallback, useMemo, useState } from "react"; import { IconType } from "react-icons"; import { RiAlignCenter, @@ -13,9 +12,9 @@ import { RiAlignLeft, RiAlignRight, } from "react-icons/ri"; + import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; type TextAlignment = DefaultProps["textAlignment"]["values"][number]; @@ -30,27 +29,7 @@ export const TextAlignButton = (props: { editor: BlockNoteEditor; textAlignment: TextAlignment; }) => { - const [selectedBlocks, setSelectedBlocks] = useState[]>( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - - useEditorContentChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - }); - - useEditorSelectionChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - }); + const selectedBlocks = useSelectedBlocks(props.editor); const textAlignment = useMemo(() => { const block = selectedBlocks[0]; diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx index ff9b30a7c3..e8a9647436 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx @@ -1,5 +1,6 @@ -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { formatKeyboardShortcut } from "../../../utils"; +import { useMemo, useState } from "react"; +import { BlockNoteEditor, BlockSchema, ToggledStyle } from "@blocknote/core"; +import { IconType } from "react-icons"; import { RiBold, RiCodeFill, @@ -7,16 +8,11 @@ import { RiStrikethrough, RiUnderline, } from "react-icons/ri"; -import { - Block, - BlockNoteEditor, - BlockSchema, - ToggledStyle, -} from "@blocknote/core"; -import { IconType } from "react-icons"; -import { useMemo, useState } from "react"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; + +import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; +import { useEditorChange } from "../../../hooks/useEditorChange"; +import { formatKeyboardShortcut } from "../../../utils"; const shortcuts: Record = { bold: "Mod+B", @@ -38,30 +34,13 @@ export const ToggledStyleButton = (props: { editor: BlockNoteEditor; toggledStyle: ToggledStyle; }) => { - const [selectedBlocks, setSelectedBlocks] = useState[]>( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); + const selectedBlocks = useSelectedBlocks(props.editor); + const [active, setActive] = useState( props.toggledStyle in props.editor.getActiveStyles() ); - useEditorContentChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); - setActive(props.toggledStyle in props.editor.getActiveStyles()); - }); - - useEditorSelectionChange(props.editor, () => { - setSelectedBlocks( - props.editor.getSelection()?.blocks || [ - props.editor.getTextCursorPosition().block, - ] - ); + useEditorChange(props.editor, () => { setActive(props.toggledStyle in props.editor.getActiveStyles()); }); diff --git a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx index 13d80a6ede..692c1e1b2f 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx @@ -11,9 +11,8 @@ import { } from "react-icons/ri"; import { ToolbarDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarDropdown"; -import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange"; -import { useEditorContentChange } from "../../../hooks/useEditorContentChange"; import { ToolbarDropdownItemProps } from "../../../SharedComponents/Toolbar/components/ToolbarDropdownItem"; +import { useEditorChange } from "../../../hooks/useEditorChange"; export type BlockTypeDropdownItem = { name: string; @@ -117,11 +116,7 @@ export const BlockTypeDropdown = (props: { [block, filteredItems, props.editor] ); - useEditorContentChange(props.editor, () => { - setBlock(props.editor.getTextCursorPosition().block); - }); - - useEditorSelectionChange(props.editor, () => { + useEditorChange(props.editor, () => { setBlock(props.editor.getTextCursorPosition().block); }); diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx index e8f7c2cf64..86abaa875d 100644 --- a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx @@ -8,8 +8,7 @@ import { FC, useEffect, useMemo, useRef, useState } from "react"; import { sticky } from "tippy.js"; import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar"; -import { useEditorContentChange } from "../../hooks/useEditorContentChange"; -import { useEditorSelectionChange } from "../../hooks/useEditorSelectionChange"; +import { useEditorChange } from "../../hooks/useEditorChange"; export type FormattingToolbarProps< BSchema extends BlockSchema = DefaultBlockSchema @@ -63,8 +62,7 @@ export const FormattingToolbarPositioner = < }); }, [props.editor]); - useEditorContentChange(props.editor, () => setPlacement(getPlacement())); - useEditorSelectionChange(props.editor, () => setPlacement(getPlacement())); + useEditorChange(props.editor, () => setPlacement(getPlacement())); const getReferenceClientRect = useMemo( () => { diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts new file mode 100644 index 0000000000..f9408af9ba --- /dev/null +++ b/packages/react/src/hooks/useEditorChange.ts @@ -0,0 +1,11 @@ +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { useEditorContentChange } from "./useEditorContentChange"; +import { useEditorSelectionChange } from "./useEditorSelectionChange"; + +export function useEditorChange( + editor: BlockNoteEditor, + callback: () => void +) { + useEditorContentChange(editor, callback); + useEditorSelectionChange(editor, callback); +} diff --git a/packages/react/src/hooks/useSelectedBlocks.ts b/packages/react/src/hooks/useSelectedBlocks.ts new file mode 100644 index 0000000000..d8c2b71712 --- /dev/null +++ b/packages/react/src/hooks/useSelectedBlocks.ts @@ -0,0 +1,19 @@ +import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { useState } from "react"; +import { useEditorChange } from "./useEditorChange"; + +export function useSelectedBlocks( + editor: BlockNoteEditor +) { + const [selectedBlocks, setSelectedBlocks] = useState[]>( + editor.getSelection()?.blocks || [editor.getTextCursorPosition().block] + ); + + useEditorChange(editor, () => + setSelectedBlocks( + editor.getSelection()?.blocks || [editor.getTextCursorPosition().block] + ) + ); + + return selectedBlocks; +} From af8fc7c0f2009508969d4215f51f1dd87a1c3af6 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 1 Sep 2023 21:26:30 +0200 Subject: [PATCH 21/61] Updated exports --- packages/react/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 731c05eee4..6cad7bafd3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -41,6 +41,8 @@ export * from "./hooks/useBlockNote"; export * from "./hooks/useEditorForceUpdate"; export * from "./hooks/useEditorContentChange"; export * from "./hooks/useEditorSelectionChange"; +export * from "./hooks/useEditorChange"; +export * from "./hooks/useSelectedBlocks"; export * from "./Image"; From b4904cbc7deab923feec574434b36fd14dfdce2c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 4 Sep 2023 22:28:55 +0200 Subject: [PATCH 22/61] Changed image replacing from Uppy to custom UI and removed `replacing` prop --- packages/core/src/editor.module.css | 2 +- .../ImageBlockContent/ImageBlockContent.ts | 112 +----------- .../SlashMenu/defaultSlashMenuItems.ts | 2 +- packages/react/src/BlockNoteTheme.ts | 32 ++-- .../DefaultButtons/ImageCaptionButton.tsx | 18 +- .../DefaultButtons/ReplaceImageButton.tsx | 160 ++++++++++++++---- .../components/EditHyperlinkMenu.tsx | 4 +- .../components/ToolbarInputDropdown.tsx | 7 +- .../components/ToolbarInputDropdownButton.tsx | 21 ++- .../components/ToolbarInputDropdownItem.tsx | 49 +++--- 10 files changed, 207 insertions(+), 200 deletions(-) diff --git a/packages/core/src/editor.module.css b/packages/core/src/editor.module.css index 77e29b673f..76c65d9d13 100644 --- a/packages/core/src/editor.module.css +++ b/packages/core/src/editor.module.css @@ -36,7 +36,7 @@ Tippy popups that are appended to document.body directly .defaultStyles h2, .defaultStyles h3, .defaultStyles li { - all: unset !important; + all: unset; margin: 0; padding: 0; font-size: inherit; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index 8b6bfc7984..f24939d378 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -1,9 +1,3 @@ -import Uppy from "@uppy/core"; -import Dashboard from "@uppy/dashboard"; -import Tus from "@uppy/tus"; -import GoogleDrive from "@uppy/google-drive"; -import Url from "@uppy/url"; - import { BlockNoteEditor, BlockSchema, @@ -62,11 +56,6 @@ const imagePropSchema = { width: { default: "0.5" as const, }, - // Whether to show the image upload dashboard or the image itself. - replacing: { - default: "false" as const, - values: ["true", "false"] as const, - }, } satisfies PropSchema; const renderImage = ( @@ -90,22 +79,9 @@ const renderImage = ( wrapper.style.userSelect = "none"; wrapper.style.width = "100%"; - // Image upload dashboard. - const imageUploadDashboard = document.createElement("div"); - imageUploadDashboard.id = "uppy-dashboard"; - imageUploadDashboard.style.display = - block.props.replacing === "true" ? "block" : "none"; - imageUploadDashboard.style.borderRadius = "4px"; - imageUploadDashboard.style.width = `${Math.min( - editor.domElement.firstElementChild!.clientWidth, - 750 - )}px`; - + // Button element that opens the image upload dashboard. const addImageButton = document.createElement("div"); - addImageButton.style.display = - block.props.replacing === "false" && block.props.src === "" - ? "flex" - : "none"; + addImageButton.style.display = block.props.src === "" ? "flex" : "none"; addImageButton.style.flexDirection = "row"; addImageButton.style.alignItems = "center"; addImageButton.style.gap = "8px"; @@ -124,18 +100,13 @@ const renderImage = ( const imageAndCaptionWrapper = document.createElement("div"); imageAndCaptionWrapper.style.display = - block.props.replacing === "false" && block.props.src !== "" - ? "flex" - : "none"; + block.props.src !== "" ? "flex" : "none"; imageAndCaptionWrapper.style.flexDirection = "column"; imageAndCaptionWrapper.style.borderRadius = "4px"; // Wrapper element for the image and resize handles. const imageWrapper = document.createElement("div"); - imageWrapper.style.display = - block.props.replacing === "false" && block.props.src !== "" - ? "flex" - : "none"; + imageWrapper.style.display = block.props.src !== "" ? "flex" : "none"; imageWrapper.style.flexDirection = "row"; imageWrapper.style.alignItems = "center"; imageWrapper.style.position = "relative"; @@ -147,6 +118,7 @@ const renderImage = ( image.alt = "placeholder"; image.contentEditable = "false"; image.draggable = false; + image.style.borderRadius = "4px"; image.style.width = `${ parseFloat(block.props.width) * editor.domElement.firstElementChild!.clientWidth @@ -162,7 +134,8 @@ const renderImage = ( const caption = document.createElement("p"); caption.innerText = block.props.caption; - caption.style.fontSize = "0.8em !important"; + caption.style.fontSize = "0.8em"; + caption.style.padding = block.props.caption ? "4px" : "0"; const handleEditorUpdate = () => { const selection = editor.getSelection()?.blocks || []; @@ -174,11 +147,9 @@ const renderImage = ( ) !== undefined; if (isSelected) { - imageUploadDashboard.style.outline = "4px solid rgb(100, 160, 255)"; addImageButton.style.outline = "4px solid rgb(100, 160, 255)"; imageAndCaptionWrapper.style.outline = "4px solid rgb(100, 160, 255)"; } else { - imageUploadDashboard.style.outline = "none"; addImageButton.style.outline = "none"; imageAndCaptionWrapper.style.outline = "none"; } @@ -186,58 +157,6 @@ const renderImage = ( editor.onEditorContentChange(handleEditorUpdate); editor.onEditorSelectionChange(handleEditorUpdate); - // Creates an Uppy instance for file uploading and replaces the - // imageUploadDashboard element with an Uppy Dashboard. - // TODO: Server endpoints/URLs - const uppy = new Uppy({ autoProceed: true }) - .use(Tus, { - endpoint: "https://master.tus.io/files/", - }) - .use(GoogleDrive, { - companionUrl: "https://companion.uppy.io", - }) - .use(Url, { - companionUrl: "https://companion.uppy.io", - }) - .use(Dashboard, { - id: block.id, - plugins: ["GoogleDrive", "Url"], - target: imageUploadDashboard, - inline: true, - hideProgressAfterFinish: true, - }); - // Throws an error if the user tries to replace the image with more than one - // file. - uppy.on("upload", (data) => { - if (data.fileIDs.length > 1) { - throw new Error("Only one file can be uploaded at a time"); - } - }); - // Throws an error if the user tries to replace the image with more than one - // file or if the upload fails. Otherwise, updates the block's `src` prop - // with the uploaded image's URL. - uppy.on("complete", (result) => { - if (result.successful.length + result.failed.length > 1) { - throw new Error("Only one file can be uploaded at a time"); - } - - if (result.failed.length > 0) { - throw new Error("Upload failed"); - } - - // TODO: Timeout is only there to give you time to read the "upload - // complete" message, it's not actually needed. Should it stay? - setTimeout(() => { - editor.updateBlock(block, { - type: "image", - props: { - src: result.successful[0].response!.uploadURL, - replacing: "false", - }, - }); - }, 2000); - }); - // Temporary parameters set when the user begins resizing the image, used to // calculate the new width of the image. let resizeParams: @@ -337,21 +256,8 @@ const renderImage = ( parseFloat(block.props.width) * editor.domElement.firstElementChild!.clientWidth }px`; - imageUploadDashboard.style.width = `${Math.min( - editor.domElement.firstElementChild!.clientWidth, - 750 - )}px`; }; - // Displays the image upload dashboard. - const addImageButtonClickHandler = () => { - editor.updateBlock(block, { - type: "image", - props: { - replacing: "true", - }, - }); - }; // Changes the add image button background color on hover. const addImageButtonMouseEnterHandler = () => { addImageButton.style.backgroundColor = "gainsboro"; @@ -408,7 +314,6 @@ const renderImage = ( }; }; - wrapper.appendChild(imageUploadDashboard); wrapper.appendChild(addImageButton); addImageButton.appendChild(addImageButtonIcon); addImageButton.appendChild(addImageButtonText); @@ -422,7 +327,6 @@ const renderImage = ( window.addEventListener("mousemove", windowMouseMoveHandler); window.addEventListener("mouseup", windowMouseUpHandler); window.addEventListener("resize", windowResizeHandler); - addImageButton.addEventListener("click", addImageButtonClickHandler); addImageButton.addEventListener( "mouseenter", addImageButtonMouseEnterHandler @@ -445,11 +349,9 @@ const renderImage = ( return { dom: wrapper, destroy: () => { - uppy.close(); window.removeEventListener("mousemove", windowMouseMoveHandler); window.removeEventListener("mouseup", windowMouseUpHandler); window.removeEventListener("resize", windowResizeHandler); - addImageButton.removeEventListener("click", addImageButtonClickHandler); addImageButton.removeEventListener( "mouseenter", addImageButtonMouseEnterHandler diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 1be155a918..96d4ad09d3 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -111,7 +111,7 @@ export const getDefaultSlashMenuItems = ( }); } - if ("image" in schema && "replacing" in schema["image"].propSchema) { + if ("image" in schema) { slashMenuItems.push({ name: "Image", aliases: [ diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts index ee31d37068..bda9c21d7b 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -254,25 +254,27 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { // Row ".mantine-Group-root": { flexWrap: "nowrap", - gap: "8px", - paddingInline: "6px", - // Row icon - ".mantine-Container-root": { - color: theme.colors.menu.text, - display: "flex", - justifyContent: "center", - padding: 0, - width: "fit-content", - }, // Row input field - ".mantine-TextInput-root": { + ".mantine-TextInput-root, .mantine-FileInput-root": { width: "300px", - ".mantine-TextInput-wrapper": { - ".mantine-TextInput-input": { - border: "none", + ".mantine-TextInput-wrapper:hover": { + backgroundColor: theme.colors.hovered.background, + }, + ".mantine-TextInput-wrapper, .mantine-FileInput-wrapper": { + padding: 0, + borderRadius: "4px", + ".mantine-FileInput-icon": { color: theme.colors.menu.text, + }, + ".mantine-TextInput-input, .mantine-FileInput-input": { + border: "none", fontSize: "12px", - padding: 0, + ".mantine-FileInput-placeholder": { + color: theme.colors.menu.text, + }, + }, + ".mantine-FileInput-input:hover": { + backgroundColor: theme.colors.hovered.background, }, }, }, diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx index 5b9abc948c..fa88fdea76 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx @@ -12,8 +12,8 @@ import { RiText } from "react-icons/ri"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; import { ToolbarInputDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdown"; -import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; +import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; export const ImageCaptionButton = (props: { editor: BlockNoteEditor; @@ -34,19 +34,7 @@ export const ImageCaptionButton = (props: { "src" in props.editor.schema["image"].propSchema && props.editor.schema["image"].propSchema.src.values === undefined && // Checks if the `src` prop is not set to an empty string. - selectedBlocks[0].props.src !== "" && - // Checks if the image has a `replacing` prop which can take either "true" - // or "false". - "replacing" in props.editor.schema["image"].propSchema && - props.editor.schema["image"].propSchema.replacing.values?.includes( - "true" - ) && - props.editor.schema["image"].propSchema.replacing.values?.includes( - "false" - ) && - props.editor.schema["image"].propSchema.replacing.values?.length === 2 && - // Checks if the `replacing` prop is set to "false". - selectedBlocks[0].props.replacing === "false", + selectedBlocks[0].props.src !== "", [props.editor.schema, selectedBlocks] ); @@ -93,8 +81,8 @@ export const ImageCaptionButton = (props: { /> (props: { editor: BlockNoteEditor; @@ -15,39 +28,128 @@ export const ReplaceImageButton = (props: { // Checks if only one block is selected. selectedBlocks.length === 1 && // Checks if the selected block is an image. - selectedBlocks[0].type === "image" && - // Checks if the image has a `replacing` prop which can take either "true" - // or "false". - "replacing" in props.editor.schema["image"].propSchema && - props.editor.schema["image"].propSchema.replacing.values?.includes( - "true" - ) && - props.editor.schema["image"].propSchema.replacing.values?.includes( - "false" - ) && - props.editor.schema["image"].propSchema.replacing.values?.length === 2, - [props.editor.schema, selectedBlocks] + selectedBlocks[0].type === "image", + [selectedBlocks] ); - if (!show) { - return null; - } + const [isOpen, setIsOpen] = useState( + show && selectedBlocks[0].props.src === "" + ); + const [currentURL, setCurrentURL] = useState( + show ? selectedBlocks[0].props.src : "" + ); - return ( - { + useEffect(() => { + setIsOpen(show && selectedBlocks[0].props.src === ""); + setCurrentURL(show ? selectedBlocks[0].props.src : ""); + }, [selectedBlocks, show]); + + const handleURLChange = useCallback( + (event: ChangeEvent) => { + setCurrentURL(event.currentTarget.value); + }, + [] + ); + + const handleURLEnter = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); props.editor.updateBlock(selectedBlocks[0], { - type: selectedBlocks[0].type, + type: "image", props: { - replacing: - selectedBlocks[0].props.replacing === "true" ? "false" : "true", + src: currentURL, }, } as PartialBlock); - props.editor.focus(); - }} - isSelected={selectedBlocks[0].props.replacing === "true"} - mainTooltip={"Replace Image"} - icon={RiImageEditFill} - /> + } + }, + [currentURL, props.editor, selectedBlocks] + ); + + const handleURLPaste = useCallback( + (event: ClipboardEvent) => { + event.preventDefault(); + props.editor.updateBlock(selectedBlocks[0], { + type: "image", + props: { + src: event.clipboardData!.getData("text/plain"), + }, + } as PartialBlock); + }, + [props.editor, selectedBlocks] + ); + + const handleFileChange = useCallback( + (payload: File) => { + const blockID = selectedBlocks[0].id; + + // TODO: Proper backend - right now using imgbb.com since you can get an + // API key for free by just making an account. + // https://imgbb.com/ + const body = new FormData(); + body.append("key", API_KEY); + body.append("image", payload); + + fetch("https://api.imgbb.com/1/upload?expiration=600", { + method: "POST", + body: body, + }) + .then((response) => { + console.log(response); + return response.json(); + }) + .then((data) => { + console.log(data); + const block = props.editor.getBlock(blockID); + + if (block === undefined || block.type !== "image") { + return; + } + + props.editor.updateBlock(blockID, { + type: "image", + props: { + src: data.data.url, + }, + } as PartialBlock); + }); + }, + [props.editor, selectedBlocks] + ); + + if (!show) { + return null; + } + + return ( + + setIsOpen(!isOpen)} + isSelected={isOpen} + mainTooltip={"Replace Image"} + icon={RiImageEditFill} + /> + + + + + ); }; diff --git a/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx b/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx index f447946b40..96a936206b 100644 --- a/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx +++ b/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx @@ -63,8 +63,8 @@ export const EditHyperlinkMenu = forwardRef< return ( - | Array>; + | ReactElement + | Array>; }; export const ToolbarInputDropdown = forwardRef< diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx index 2665dc531d..2a9a92d347 100644 --- a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx +++ b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx @@ -1,5 +1,5 @@ import { ToolbarButton } from "./ToolbarButton"; -import Tippy from "@tippyjs/react"; +import Tippy, { TippyProps } from "@tippyjs/react"; import { ReactElement, useCallback, useState } from "react"; import { ToolbarInputDropdown } from "./ToolbarInputDropdown"; @@ -11,7 +11,8 @@ export type ToolbarInputDropdownButtonProps = { }; export const ToolbarInputDropdownButton = ( - props: ToolbarInputDropdownButtonProps + props: ToolbarInputDropdownButtonProps & + Omit, "content" | "children"> ) => { const [renderDropdown, setRenderDropdown] = useState(false); @@ -26,12 +27,20 @@ export const ToolbarInputDropdownButton = ( return ( { + createDropdown(); + props.onShow?.(instance); + }} + onHidden={(instance) => { + destroyDropdown(); + props.onShow?.(instance); + }} content={renderDropdown ? props.children[1] : null} - trigger={"click"} + trigger={props.visible === undefined ? "click" : undefined} interactive={true} - maxWidth={500}> + maxWidth={500} + zIndex={9000} + {...props}> {props.children[0]} ); diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx index d5262382c0..615e9122f9 100644 --- a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx +++ b/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx @@ -1,36 +1,39 @@ -import { Container, Group, TextInput, TextInputProps } from "@mantine/core"; +import { + FileInput, + FileInputProps, + Group, + TextInput, + TextInputProps, +} from "@mantine/core"; import { IconType } from "react-icons"; -import { TooltipContent } from "../../Tooltip/components/TooltipContent"; -import Tippy from "@tippyjs/react"; -export type ToolbarInputDropdownItemProps = { - icon: IconType; - mainTooltip: string; - secondaryTooltip?: string; +export type InputType = "text" | "file"; + +export type InputProps = { + text: TextInputProps; + file: FileInputProps; +}; - inputProps?: TextInputProps; +export const inputComponents: Record = { + text: TextInput, + file: FileInput, +}; + +export type ToolbarInputDropdownItemProps = { + type: Type; + inputProps: Omit; + icon: IconType; }; -export const ToolbarInputDropdownItem = ( - props: ToolbarInputDropdownItemProps +export const ToolbarInputDropdownItem = ( + props: ToolbarInputDropdownItemProps ) => { const Icon = props.icon; + const Input = inputComponents[props.type]; return ( - - } - placement="left"> - - - - - + } {...props.inputProps} /> ); }; From de2fb4557906c981f1f38a4d3369be629f0dfae5 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 4 Sep 2023 22:29:26 +0200 Subject: [PATCH 23/61] Fixed z-index for UI elements --- .../components/FormattingToolbarPositioner.tsx | 2 +- .../HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx | 1 + .../src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx | 2 +- packages/react/src/SideMenu/components/SideMenuPositioner.tsx | 1 + packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx | 1 + 5 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx index 86abaa875d..532e16e785 100644 --- a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx @@ -66,7 +66,6 @@ export const FormattingToolbarPositioner = < const getReferenceClientRect = useMemo( () => { - console.log("getReferenceClientRect"); if (!referencePos) { return undefined; } @@ -93,6 +92,7 @@ export const FormattingToolbarPositioner = < placement={placement} sticky={true} plugins={tippyPlugins} + zIndex={3000} /> ); }; diff --git a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx index 3e8c967dfd..66b76706ce 100644 --- a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx +++ b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx @@ -82,6 +82,7 @@ export const HyperlinkToolbarPositioner = < visible={show} animation={"fade"} placement={"top-start"} + zIndex={4000} /> ); }; diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx index 14acb8b9bb..b8857051e7 100644 --- a/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx +++ b/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx @@ -18,7 +18,7 @@ export function ToolbarDropdown(props: ToolbarDropdownProps) { } return ( - + ); }; diff --git a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx b/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx index 35b79eb423..9452659f4c 100644 --- a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx +++ b/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx @@ -83,6 +83,7 @@ export const SlashMenuPositioner = < visible={show} animation={"fade"} placement={"bottom-start"} + zIndex={2000} /> ); }; From 6bb91225054a6e432a424ef8981fff7a1aa3da03 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 5 Sep 2023 04:39:52 +0200 Subject: [PATCH 24/61] Changed image `width` prop to be in px --- .../ImageBlockContent/ImageBlockContent.ts | 74 +++++++++---------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index f24939d378..de396fd01f 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -27,6 +27,7 @@ const textAlignmentToAlignItems = ( // Sets generic styles for a resize handle, regardless of whether it's the // left or right one. const setResizeHandleStyles = (resizeHandle: HTMLDivElement) => { + resizeHandle.style.display = "none"; resizeHandle.style.position = "absolute"; resizeHandle.style.width = "8px"; resizeHandle.style.height = "30px"; @@ -36,9 +37,8 @@ const setResizeHandleStyles = (resizeHandle: HTMLDivElement) => { resizeHandle.style.cursor = "ew-resize"; }; -// Max & min image widths as a percentage of the editor's width. -const maxPercentWidth = 1.0; -const minPercentWidth = 0.1; +// Min image width in px. +const minWidth = 64; const imagePropSchema = { textAlignment: defaultProps.textAlignment, @@ -52,9 +52,9 @@ const imagePropSchema = { caption: { default: "" as const, }, - // Image width as a percentage of the editor's width. + // Image width in px. width: { - default: "0.5" as const, + default: "512" as const, }, } satisfies PropSchema; @@ -79,7 +79,7 @@ const renderImage = ( wrapper.style.userSelect = "none"; wrapper.style.width = "100%"; - // Button element that opens the image upload dashboard. + // Button element that acts as a placeholder for images with no src. const addImageButton = document.createElement("div"); addImageButton.style.display = block.props.src === "" ? "flex" : "none"; addImageButton.style.flexDirection = "row"; @@ -90,14 +90,17 @@ const renderImage = ( addImageButton.style.cursor = "pointer"; addImageButton.style.padding = "12px"; + // Icon for the add image button. const addImageButtonIcon = document.createElement("div"); addImageButtonIcon.style.backgroundImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z'%3E%3C/path%3E%3C/svg%3E")`; addImageButtonIcon.style.width = "24px"; addImageButtonIcon.style.height = "24px"; + // Text for the add image button. const addImageButtonText = document.createElement("p"); addImageButtonText.innerText = "Add Image"; + // Wrapper element for the image, resize handles and caption. const imageAndCaptionWrapper = document.createElement("div"); imageAndCaptionWrapper.style.display = block.props.src !== "" ? "flex" : "none"; @@ -119,10 +122,10 @@ const renderImage = ( image.contentEditable = "false"; image.draggable = false; image.style.borderRadius = "4px"; - image.style.width = `${ - parseFloat(block.props.width) * + image.style.width = `${Math.min( + parseFloat(block.props.width), editor.domElement.firstElementChild!.clientWidth - }px`; + )}px`; // Resize handle elements. const leftResizeHandle = document.createElement("div"); @@ -132,11 +135,13 @@ const renderImage = ( rightResizeHandle.style.right = "4px"; setResizeHandleStyles(rightResizeHandle); + // Caption element. const caption = document.createElement("p"); caption.innerText = block.props.caption; caption.style.fontSize = "0.8em"; caption.style.padding = block.props.caption ? "4px" : "0"; + // Adds a light blue outline to selected image blocks. const handleEditorUpdate = () => { const selection = editor.getSelection()?.blocks || []; const currentBlock = editor.getTextCursorPosition().block; @@ -200,19 +205,13 @@ const renderImage = ( } } - if ( - newWidth < - minPercentWidth * editor.domElement.firstElementChild!.clientWidth - ) { + // Ensures the image is not wider than the editor and not smaller than a + // predetermined minimum width. + if (newWidth < minWidth) { + image.style.width = `${minWidth}px`; + } else if (newWidth > editor.domElement.firstElementChild!.clientWidth) { image.style.width = `${ - minPercentWidth * editor.domElement.firstElementChild!.clientWidth - }px`; - } else if ( - newWidth > - maxPercentWidth * editor.domElement.firstElementChild!.clientWidth - ) { - image.style.width = `${ - maxPercentWidth * editor.domElement.firstElementChild!.clientWidth + editor.domElement.firstElementChild!.clientWidth }px`; } else { image.style.width = `${newWidth}px`; @@ -237,25 +236,28 @@ const renderImage = ( resizeParams = undefined; - const percentWidth = - parseInt(image.style.width.slice(0, -2)) / - editor.domElement.firstElementChild!.clientWidth; - editor.updateBlock(block, { type: "image", props: { - width: percentWidth.toString(), + width: image.style.width.slice(0, -2), }, }); }; - // Updates the image width when the viewport is resized. By storing the image - // width as a fraction of the editor's width, this allows the image to - // maintain its size relative to the editor. + // Updates the image width when the viewport is resized. const windowResizeHandler = () => { - image.style.width = `${ - parseFloat(block.props.width) * + const width = Math.min( + parseFloat(block.props.width), editor.domElement.firstElementChild!.clientWidth - }px`; + ); + + image.style.width = `${width}px`; + + editor.updateBlock(block, { + type: "image", + props: { + width: `${width}`, + }, + }); }; // Changes the add image button background color on hover. @@ -296,9 +298,7 @@ const renderImage = ( resizeParams = { handleUsed: "left", - initialWidth: - parseFloat(block.props.width) * - editor.domElement.firstElementChild!.clientWidth, + initialWidth: parseFloat(block.props.width), initialClientX: event.clientX, }; }; @@ -307,9 +307,7 @@ const renderImage = ( resizeParams = { handleUsed: "right", - initialWidth: - parseFloat(block.props.width) * - editor.domElement.firstElementChild!.clientWidth, + initialWidth: parseFloat(block.props.width), initialClientX: event.clientX, }; }; From 68c12758ba17c36932de71611746ebfa3aa3e051 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 5 Sep 2023 04:40:54 +0200 Subject: [PATCH 25/61] Removed Uppy dependency --- packages/core/package.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 71b69a2044..3fd1977742 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -69,15 +69,6 @@ "@tiptap/extension-text": "^2.0.3", "@tiptap/extension-underline": "^2.0.3", "@tiptap/pm": "^2.0.3", - "@uppy/core": "^3.4.0", - "@uppy/dashboard": "^3.5.1", - "@uppy/drag-drop": "^3.0.3", - "@uppy/file-input": "^3.0.3", - "@uppy/google-drive": "^3.2.1", - "@uppy/progress-bar": "^3.0.3", - "@uppy/react": "^3.1.3", - "@uppy/remote-sources": "^1.0.3", - "@uppy/tus": "^3.1.3", "hast-util-from-dom": "^4.2.0", "lodash": "^4.17.21", "prosemirror-model": "^1.18.3", From 229c27fd8a8bfc5141cfac4cb71b248dd29d2f7e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 5 Sep 2023 04:41:05 +0200 Subject: [PATCH 26/61] Reverted `App.tsx` --- examples/editor/src/App.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/examples/editor/src/App.tsx b/examples/editor/src/App.tsx index 64d7044e03..b3a9068243 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/src/App.tsx @@ -1,28 +1,14 @@ // import logo from './logo.svg' -import { BlockSchema, defaultBlockSchema } from "@blocknote/core"; import "@blocknote/core/style.css"; -import { - BlockNoteView, - useBlockNote, - Image, - CaptionedImage, -} from "@blocknote/react"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; import styles from "./App.module.css"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; -const customSchema = { - ...defaultBlockSchema, - image: Image, - captionedImage: CaptionedImage, -} satisfies BlockSchema; - function App() { const editor = useBlockNote({ - blockSchema: customSchema, onEditorContentChange: (editor) => { console.log(editor.topLevelBlocks); - console.log(editor.topLevelBlocks[0]); }, domAttributes: { editor: { From d15673b6fc647a8cdce0d0102d3e81cd6456300f Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 5 Sep 2023 15:39:47 +0200 Subject: [PATCH 27/61] Removed React image block implementation --- packages/react/src/Image.tsx | 403 ----------------------------------- 1 file changed, 403 deletions(-) delete mode 100644 packages/react/src/Image.tsx diff --git a/packages/react/src/Image.tsx b/packages/react/src/Image.tsx deleted file mode 100644 index 0013f64a40..0000000000 --- a/packages/react/src/Image.tsx +++ /dev/null @@ -1,403 +0,0 @@ -// TODO: Vanilla version and move to core -import { - BlockNoteEditor, - BlockSchema, - BlockSpec, - defaultProps, - PropSchema, - SpecificBlock, -} from "@blocknote/core"; - -import Uppy, { UploadResult } from "@uppy/core"; -import Tus from "@uppy/tus"; -import GoogleDrive from "@uppy/google-drive"; -import Url from "@uppy/url"; -import { Dashboard } from "@uppy/react"; - -import { CSSProperties, useEffect, useMemo, useState } from "react"; - -import "@uppy/core/dist/style.css"; -import "@uppy/dashboard/dist/style.css"; -import "@uppy/drag-drop/dist/style.css"; -import "@uppy/file-input/dist/style.css"; -import "@uppy/progress-bar/dist/style.css"; - -import { createReactBlockSpec, InlineContent } from "./ReactBlockSpec"; - -// Converts text alignment prop values to the flexbox `align-items` values. -const textAlignmentToAlignItems = ( - textAlignment: "left" | "center" | "right" | "justify" -): "flex-start" | "center" | "flex-end" => { - switch (textAlignment) { - case "left": - return "flex-start"; - case "center": - return "center"; - case "right": - return "flex-end"; - default: - return "flex-start"; - } -}; - -// Max & min image widths as a fraction of the editor's width -const maxWidth = 1.0; -const minWidth = 0.1; - -const imagePropSchema = { - textAlignment: defaultProps.textAlignment, - backgroundColor: defaultProps.backgroundColor, - // Image src - src: { - // TODO: Better default - default: "https://via.placeholder.com/150" as const, - }, - // Image width as a fraction of the editor's width - width: { - default: "0.5" as const, - }, - // Whether to show the image upload dashboard or not - replacing: { - default: "false" as const, - values: ["true", "false"] as const, - }, -} satisfies PropSchema; - -const ImageComponent = (props: { - block: Caption extends true - ? SpecificBlock< - BlockSchema & { - captionedImage: BlockSpec< - "captionedImage", - typeof imagePropSchema, - Caption - >; - }, - "captionedImage" - > - : SpecificBlock< - BlockSchema & { - image: BlockSpec<"image", typeof imagePropSchema, Caption>; - }, - "image" - >; - editor: Caption extends true - ? BlockNoteEditor< - BlockSchema & { - captionedImage: BlockSpec< - "captionedImage", - typeof imagePropSchema, - Caption - >; - } - > - : BlockNoteEditor< - BlockSchema & { - image: BlockSpec<"image", typeof imagePropSchema, Caption>; - } - >; - caption: Caption; -}) => { - // Used to check if the resizing handles should be shown. - const [hovered, setHovered] = useState(false); - - // Used to check if the image is being resized, and which resize handle is - // being used (left or right). - const [resizeHandle, setResizeHandle] = useState<"left" | "right" | null>( - null - ); - - // Used to calculate the new width while resizing the image, as the cursor X - // offset is just added to the initial width. Both values are represented in - // px. - const [initialWidth, setInitialWidth] = useState(0); - const [initialClientX, setInitialClientX] = useState(0); - - // The editor's width in px. - const [editorWidth, setEditorWidth] = useState( - props.editor.domElement.firstElementChild!.clientWidth - ); - - // The image width, represented as a fraction of the editor's width. - const [width, setWidth] = useState(() => parseFloat(props.block.props.width)); - - // Creates an Uppy instance for file uploading. - // TODO: Server endpoints/URLs - const [uppy] = useState(() => - new Uppy({ autoProceed: true, debug: true }) - .use(Tus, { - endpoint: "https://master.tus.io/files/", - }) - .use(GoogleDrive, { - companionUrl: "https://companion.uppy.io", - }) - .use(Url, { - companionUrl: "https://companion.uppy.io", - }) - ); - - // Takes a width value in px, converts it to a fraction of the editor's - // width, and updates the `width` state. Allows the image to be re-rendered - // without having to update the block. - const updateWidth = useMemo( - () => (newWidth: number) => { - newWidth = newWidth / editorWidth; - - if (newWidth < minWidth) { - setWidth(minWidth); - } else if (newWidth > maxWidth) { - setWidth(maxWidth); - } else { - setWidth(newWidth); - } - }, - [editorWidth] - ); - - // Sets up listeners for when the image is being resized. - useEffect(() => { - // Stops mouse movements from resizing the image and updates the block's - // `width` prop to the new value. - const mouseUpHandler = () => { - setResizeHandle(null); - props.editor.updateBlock(props.block, { - type: props.caption ? "captionedImage" : "image", - props: { - width: width.toString(), - }, - }); - }; - // Re-renders the image with an updated width depending on the cursor - // offset from when the resize began, and which resize handle is being used. - const mouseMoveHandler = (e: MouseEvent) => { - if (!resizeHandle) { - return; - } - - if ( - textAlignmentToAlignItems(props.block.props.textAlignment) === "center" - ) { - if (resizeHandle === "left") { - updateWidth(initialWidth + (initialClientX - e.clientX) * 2); - } else { - updateWidth(initialWidth + (e.clientX - initialClientX) * 2); - } - } else { - if (resizeHandle === "left") { - updateWidth(initialWidth + initialClientX - e.clientX); - } else { - updateWidth(initialWidth + e.clientX - initialClientX); - } - } - }; - // Re-renders the image when the viewport is resized. By storing the image - // width as a fraction of the editor's width, this allows the image to - // maintain its size relative to the editor. - const resizeHandler = () => { - setEditorWidth(props.editor.domElement.firstElementChild!.clientWidth); - }; - - window.addEventListener("mouseup", mouseUpHandler); - window.addEventListener("mousemove", mouseMoveHandler); - window.addEventListener("resize", resizeHandler); - - return () => { - window.removeEventListener("mouseup", mouseUpHandler); - window.removeEventListener("mousemove", mouseMoveHandler); - window.removeEventListener("resize", resizeHandler); - }; - }, [ - initialClientX, - initialWidth, - props.block, - props.block.props.textAlignment, - props.caption, - props.editor, - props.editor.domElement.firstElementChild, - resizeHandle, - updateWidth, - width, - ]); - - // Sets up handlers for when the image is replaced with a new one, uploaded - // using Uppy. - useEffect(() => { - // Throws an error if the user tries to replace the image with more than one - // file. - const onUpload = (data: { id: string; fileIDs: string[] }) => { - if (data.fileIDs.length > 1) { - throw new Error("Only one file can be uploaded at a time"); - } - }; - // Throws an error if the user tries to replace the image with more than one - // file or if the upload fails. Otherwise, updates the block's `src` prop - // with the uploaded image's URL. - const onComplete = (result: UploadResult) => { - if (result.successful.length + result.failed.length > 1) { - throw new Error("Only one file can be uploaded at a time"); - } - - if (result.failed.length > 0) { - throw new Error("Upload failed"); - } - - // Updating both `src` and `replacing` props at the same time causes some - // kind of delay in rendering. While both the TipTap state and the DOM are - // updated instantly, the image itself takes a while to display the new - // source. - props.editor.updateBlock(props.block, { - type: props.caption ? "captionedImage" : "image", - props: { - src: result.successful[0].response!.uploadURL, - }, - }); - setTimeout(() => { - props.editor.updateBlock(props.block, { - type: props.caption ? "captionedImage" : "image", - props: { - replacing: "false", - }, - }); - uppy.cancelAll(); - }, 2000); - }; - - uppy.on("upload", onUpload); - uppy.on("complete", onComplete); - - return () => { - uppy.off("upload", onUpload); - uppy.off("complete", onComplete); - }; - }, [props.block, props.caption, props.editor, uppy]); - - return ( - // Wrapper element to set the image alignment -
- {/*Wrapper element for the image and resize handles*/} -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: "flex", - flexDirection: "row", - alignItems: "center", - position: "relative", - width: "fit-content", - }}> - {/*Image element*/} - {/*TODO: Alt text?*/} - {"placeholder"} - {/*Image upload dashboard*/} - - {/*Left resize handle*/} -
{ - e.preventDefault(); - setInitialWidth(width * editorWidth); - setInitialClientX(e.clientX); - setResizeHandle("left"); - }} - style={{ - ...resizeHandleStyles, - display: - props.block.props.replacing === "false" && - (hovered || resizeHandle) - ? "block" - : "none", - left: "4px", - }} - /> - {/*Right resize handle*/} -
{ - e.preventDefault(); - setInitialWidth(width * editorWidth); - setInitialClientX(e.clientX); - setResizeHandle("right"); - }} - style={{ - ...resizeHandleStyles, - display: - props.block.props.replacing === "false" && - (hovered || resizeHandle) - ? "block" - : "none", - right: "4px", - }} - /> -
- {props.caption && ( - - )} -
- ); -}; - -const resizeHandleStyles: CSSProperties = { - position: "absolute", - - width: "8px", - height: "30px", - - backgroundColor: "black", - border: "1px solid white", - borderRadius: "4px", - - cursor: "ew-resize", -}; - -export const Image = createReactBlockSpec< - "image", - typeof imagePropSchema, - false, - BlockSchema & { - image: BlockSpec<"image", typeof imagePropSchema, false>; - } ->({ - type: "image", - propSchema: imagePropSchema, - containsInlineContent: false, - render: (props) => , -}); - -export const CaptionedImage = createReactBlockSpec< - "captionedImage", - typeof imagePropSchema, - true, - BlockSchema & { - captionedImage: BlockSpec<"captionedImage", typeof imagePropSchema, true>; - } ->({ - type: "captionedImage", - propSchema: imagePropSchema, - containsInlineContent: true, - render: (props) => , -}); From 3ea172aa237be46d1db2ead58242f616a7a947d8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 5 Sep 2023 15:49:41 +0200 Subject: [PATCH 28/61] Fixed exports --- packages/react/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6cad7bafd3..fe4caafd2f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -44,6 +44,4 @@ export * from "./hooks/useEditorSelectionChange"; export * from "./hooks/useEditorChange"; export * from "./hooks/useSelectedBlocks"; -export * from "./Image"; - export * from "./ReactBlockSpec"; From 4341c9f6e368bd19b8b8151a950d68993ad84215 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 5 Sep 2023 15:56:16 +0200 Subject: [PATCH 29/61] Fixed image block imports --- .../BlockContent/ImageBlockContent/ImageBlockContent.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index de396fd01f..955174c46f 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -1,12 +1,12 @@ +import { defaultProps } from "../../../api/defaultProps"; import { - BlockNoteEditor, BlockSchema, BlockSpec, - createBlockSpec, - defaultProps, PropSchema, SpecificBlock, -} from "../../../../../index"; +} from "../../../api/blockTypes"; +import { BlockNoteEditor } from "../../../../../BlockNoteEditor"; +import { createBlockSpec } from "../../../api/block"; // Converts text alignment prop values to the flexbox `align-items` values. const textAlignmentToAlignItems = ( From f637a9eb7d2ec577ffe6eca7d1e4c66b968d80fb Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 5 Sep 2023 16:19:24 +0200 Subject: [PATCH 30/61] Small fixes to tests --- .../dark-slash-menu-chromium-linux.png | Bin 41340 -> 45080 bytes .../dark-slash-menu-firefox-linux.png | Bin 56489 -> 60104 bytes .../dark-slash-menu-webkit-linux.png | Bin 79830 -> 86532 bytes tests/utils/const.ts | 2 +- 4 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png b/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png index 5e60173c6cbaea833e8e7f60062060410c6aedf3..b7f35913d596190ba5d75f9797da195ebb0159b3 100644 GIT binary patch literal 45080 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

_C#h6JCh$;&Wa=X))y5 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 E4^5%o2LJ#7 literal 41340 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�ZOSIX5dY6G1ZbhatqN4xCjA7 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 V5wY$sZnz87t{vLyiQA0*{sUUaN~!<= diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png b/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png index 4591f75fecd01c39e31811b02e9cbf394d9a1262..a92a1866680774c661396d6a10a7e965e8de0e83 100644 GIT binary patch delta 30433 zcmce;cU)A>wk^840fj~p6+~2$et>`k#RvjTP*iegax#H{1j%_dC}KcCk)%YG)I=2| z2T>6P0m&JZoKd30I~T$}-`;1R`_8%VzW1N}T)kG+tg0Et7_*XcsmdFuGIGtYOdfAo zT>T_<<-&eV%{}M#{}rsCTg!PqNS{A%>yua(fgb3 z>EA1#7m->Cn46n3PVju_S=Bi(npLgqp4#Mgx~iu=BCB|0(bFq*%EhItdT#MdQ2rU# zeHM6Xa?fR>X?Or)VjWF-o>FyN?Z5!cO(M8tcNo)Ni?K4j&>9)PD_ z@(_`*ryS8|5pOd6OHpLHRY)sEr-XTs9Ucg5!Qb`j+JTtPF4ekI!_RTljOU6G9BQ)g zz!i_DWr`){;fYxaliIg-R=j}0JjUlPk8>mby(1+t+t(f^a&o``f0z8wqGl{V?d?EA z4(F8)fdBKJB)~|Vtyx*uM>2l^1|M)B6~;}3aZ`yMcA$svO{$TR8hOR1GT6uAXR7cs z$=h~%tW0=X7kG66JvP&M_{DpvPyUQ+58#) znlct-JEufOM2<;GK0xp|7EWZ#JoPsT_&~{K9G=J#q-8F&?-&kWQol5R6i-WS0MB;s zl$*m$zyY@O3=+H~gx15qG^Tp$XgL{ey<@1!?z5w>4D+4Bi`+dgoG^TK>0^q^&mUUP z<>=-jv6;{&!+PqNh1|f_69!E-hT(giuVsJ_sB_ZdVq}WP+Ok=aREBEf=y3NPHo2UH z_0fP&N9JY1FxM9BA|s6T{h=e9s2S0668x(BA)EO1x$%xOVhW3+Zvuk(NtAPoCGRl{ zJ7Bv+A;f88B@V&&E{2~k^G5JDXnYuvVUGASp5~`)!;f6Om$&yc2aNIDeHf#jjyco1 z9cBI^BDEt#y|o(qRUw?+n5v(@XjUC%r4g_ADqPfzXXdxuiAc}c7N-iqY@MbwL#qY~$-N`Z zEZVu#wXx9MSz|Sqk;qv9!~7npfA;qJlvl8=-*Zj?%&ZDa;O!rv@S}D?K}&wX(bjV{6+n z`}_AmwDb4g>7lxvofTW*#fRX@8|A_6aHtLv5uHiIw&nHJ<5$W9 zQ#Oe?k6#~%a!*6E%vO!s*?7)<^%22^Orsphf_8 z-+9=5#mHpdNZOlSf_lxt+D7Jfu;l6V_=)1xl@ve4Fp+ot)zLPW%lu!#uwQ**w}k)B z9&Si(Ge(!M+7$hc*XK#m2#R(a<`eHl17%;gvWb|GJ%4@XZRxxx$4ovIZT#Zw*8G)) zkyNczz4(mo7XgxX3QxD4>_*%dh7#Unc{Y%=xeM!C610O&!PGP`ZKlcjgX>37P6jsH zj=D#@Gv|16*Kv2MMzeXMnY?*dEIF4K$QKMcB)NRx^Ss?QBgsS<>BP?+9Tn=C1~cGjL|H!XwTyUhw5H8 z@8G_Wqv4os-SJeNrn~#yO~Z!B5JvY<{;i~>1c^=AHeKVENTON0*W#EHtV=%U)JS@< zQ^Fq0XxWMVh+>7M{0my9d$0ttB2$d(jAZ&s{w~cCJ_M~da(l4%#9Or& zXLI9{Yzls!EyY>TrH1tL*!P`4DqU&s%#Lc%bWeVNZ`E1gdV{Lu?oQrTbY&{~mOCQ3 zF!(|%&%s=SF>I>V&iL!IXl8P!=x|fI4R55wV>VbLq3e;SU0)YYmeP-Zq9%VXIMd$a zM-B-USZWNN6aNz6bh8t^E#oCP(iB?iB_)g}N*y!DoE+x#OX0-qF4MiL2oZ!k@I20B zs{iZQV+EnZzL%wzr-KabzaFDAvzr-im~GUz;Vw`)@p2Dh5nZ(Xz}XxsF(&2Np}J2D zlJh1bjj7Mvi=4x5dxZ=#G72t}Xq&Pun^L$-7Hmkny=Gfbvor}Ba@>XB*G7eIAK8q( z7;xWqbhl<%<`*u%z25SpwT}IEUqxuPzIE=$<+&8yHwKepSHC=Yc{-QQ+lThr=fyO` ze8R79s-9=jCWVkgLv3N;*k8QlTDLcC64?J7Z^%V1a>**)6?G(R{8WVvl32LXUllo$ z7Z-z$7ED#y`-i=B8|fBu0N)QCwW$lk@MO%21EHkoMZ{ z=Z}xa&8qgDV2HFx3G(G|A3faE3$EAd(?hmdv{pg5rOl=|a(pOenU-*^`13*168F8Qnsa73FK1%hYf+j zp&?bj$-3ybDa+YTm!1diG!ND07fW~7@y5)FIaOCoOYptXYqT?27CJuzr-2k$ePBJI zX~x35GI=l8d~`1fP7xuoU38$wxbOaI6B1B z^Q!rljmU`|g3|L>&(U&DW)pd6$P1g_uEeGmPGt6yHedNmPbjevUns))tzaj@v*X7A z?Mr!1wHff<589)iXe1~fBW1j`>FSKkOywM_k=^EaF@j=Z>^VcqK5eMbnPqk@-)Rgr zU`{JNE*Rca6k*wvrX+@3Z_bF_y61v@b=*AcvblDjW4+pVrUl!D7sJwqqNbI$xX2a7 zP@j`Dz7U`7R+k|o$md?5XAx2qL+WvoVjYXmY>HmF%?yV#415cIgRkkypZ{^+aBz3} zuj+iW=8TKuj0Z?7=3AB*=bRNrX7c&7D*_4;I^_O5_rOmq7eXOwF}i?LEK%9cCWm-y zQiD^hKbhrGB_4M?_{OKjL(F8a`?c)&&vY(}iv3R63`O7GiBZZb#=Zz3DH;h6lNt9N zy%s2FTy)wRIcZ+|m$Q1d5UJOyJ?9%~-!LOP4Vh}w7F+KhHpR;!>4=C~{)u+mn{C-s zRpJv4n->or1^*k><+W%PrIWPc1^aZ|uvci^cdUg^hk>X^2N>zR73q<>Q+!D)RN|4I zFRZ9b4+S1XDs^!>sd~qpuRSQuE#OBk&-K-DNl-HD=-PhHBK4n}Iwjo+dztr8^i-Ov zBE?g$e13e~X;kOgF-xr?d`ZL@|CQ0EbTpa-aZ7S(Qr6im2QM!nnl8}Lr2;A5%H9vn zYmOMw*=>-{N->#J;i&NfL}Y%h{^i29(Guk&Y_d=}+zmn8-TS{roT_6(o?m?{cJ!ru z0K4vi``WlfMAgsvT5+M{NMo)9RiP|#+KjTPK8{ip5vn}ec;u3&Q86#-X!mw@NFysM z1PK+?ZzM4f*Lrh2J8osy-~lflVHXte>UnTtbZM&ES=t0L)Ts!r5M=WbO}zVBCJT#4 zfW=c)Ja7|o)%LZ;lPXIuTCkW4aP>tfPtQBhmP7twZBsa-r7jH4SY5IG7I^}H7yLYx zaKR)hV_6sfK>X+2RJD|I00lP#i+y3W-l4b>IgDTmRJjxN* z?z0HL#iEQp7}8Y2eb%AE$lboP>u$42N$xX$^G3mM)*GViV_r7cvZ=KJFM_u zSg_4&dAt?Lx}~y6Hdhguu&8}`my~YQ5#`z+j)w%hrYV*xyWLLV?eT|V!v{6GG96xV zhAIn>brl!Wt5l1Pq@>*3`q*j&4y*HKrh?U##m`=UT^_Iadb$DU(%;Hbm4EsZq}q8` zqs0Qz{he`zeS3~sFkP6COS?OZSDgNxvD_X|xO#dgz9gfN&42Uy9G7-+BGdX$W+CHL z1)qYd$g5J7h*q6^&##?!9ad7tyOT}WUmeMS@3k$=j7&8!;7p2g+P}KekO}I{`v;tc zll$`&LWO3*751&Z=qCas z;@4$IpKG5Vb6c$}{XTBhdK9)-awu#j-X-_jWlhnsM>r|v9^l7@e z{PsO|Gib78rCL&wvo|Wn_qU!}d8!YMCMrEY+M{qH#xEKb3Z{{x`UvQFc zuuLyB71Ybj028;osg@j}{Lb{yxma5`*J)`#P?dG&{;pgNg-DIlvq?d%AOD@}M@d zsXz2`xtI*H`!(Iwlk1+ZJ0A=+@$%>$1TPKFWLgTj-u5=NAxYztuRi5RYo22=eI1hw zA5(qY1@W<&6PG^F=0j1_9-TCtW?bwAUXDOVVn&ZaW;hoPRdyRxSZ^Zlq|_)&_k0N! z=#U_kk3(T|9cNM?Nl{n!4(1X1v9&Q#?LAv>j4FD8WJg$ZAeK`|$*;dIC#t<@uQf5WnEUR>QKb!DSwirxCj_9^ ztt2E-WIWW^>joM>d7;4#D!`u$iE*hG74$3(=_-=HKUg%T4B<=QbhMDP4X+9p%|Bm; zsMVOD!l9iM-}jYp(yS_Ch(RV?%+ccQVgB~}*w!KNqN__&D%o3d;Lsg6tqi*!P3p>b z9O++CeD{npRWEPD_h#jEHd6CO4cYB}=e%`4eCbO2%~O{alVWTJsv>o{lA2oDD|gb6 zfA&i^glfV@(Y|sG+bBT&CY*Y7B%Hm^30Y3>@OyQryazz@M|aW8$aY&I)~WDqFJC) z80q0lh`7WX)Fv)%bFQ&v9g<+cnzNl zkeH6!bhZoLuQLC$UkG{=`4f`a5l`uH#jr(|AHQV#6FJepBWmQGv|zbg8d6j4KjImJ zRR`mU)`0b-{hEk$?z|BXbymFiN!i$7{zlCQ2};Y%A?5)RG&(`NDo&gAG$D<$E?#z; zJH>{7Uf7$NZ8#eDMTSPXB1F*MPbX1*0;}`v=e`J}e~_Sl`TlVCTLj@m&ZBbDn(lMs zPJ^+2dHh70%X<6wVrF{bOnXP>yqayb!O&h;C)T~NefA^u zMOI?0*$2}hb^aDUFzr@zP0fKjhq5r!5&mcCV zEsUmscKvLiWCX`K0Wh1K4h*_9sJaFa=p*pNqi~B&_txPz8m1V02HDmF%CaM191+p} zyGHw=noNSP#XtXKwo&U4;CE_aSIh1YZ$XWD;Pme;Y&h!u2Ou{KPcn^J|Dq}pP9whb z=V8FIx8P8GNo5$X+g_!2$sr%#X;iNekny&N^j4gV2{0YCh19=kR&L{1omudb>8zf2VU*O@}* z!?}+`7V)lRJSUGN9>32l5W!LjCt2PNJbd8Eqs|?~@LibVtnU*1XCZKynZ0nT$;1^f$ofE&> zF{a@5^-@;Av)5K}UI_9palWnl$;3dI-p!+Xr)gj)2g6_}@7m`9zyGt?7}okrXldZ+ z^;a@V!a_}cokS*{jOxwcCV-Mu%V3^&LbkDImh`bF3B^jiAQRx4G`GMmzH`;O;uz(|;a|=(b z^8m@w1;{8>?(U9paX8MLj9OrbjH*LD>mbnITqx~JA|syY>(!QRBdi_`K4B+**U*+p zTOa`vV5nk_=6lySU@0ez_qOhpEwr3yU^4uw1b~v}@s9la4k5EGW*h<}isqwPZ@&Yu zuZJL=Q4Bpawz1E2VCCN*3G#;m^ap{O3#peLDv>gaIeaYDC_f@?Gz*0FUQ804>-TL+*6Fad# zxxhXaV4vay=Pu7VCAT!@>!c+0k5TU9r=X18gTnw<@W-vzIo~(!J!bJ_dAbf&p&R4y zn3UFt>xUHf*`$#*)UKo0%eHU4`)^JuqZ`-#ZZpRyWz}tZ@S=crn!{E#T-dU(h7mLQ z_p+t@AIhNH26kvC5ITRnHW>+YhRSaLp+B?(U~_AUIY8&B+Ucm#`1FqmoXOuP7Bh*# z**2SOm~U<7lWeFO8nbPT_ey%?7`hF`X=PcE^fRwL0-U%?%xU!6*1g9wrB;{fm|nV8 zTq)!(fdKdx0ETA_BDxtS-Vazs%idad3`e~PdXSzs%r8DwDcaF_8w#&13_BJsUP6C( zNxIKohh#aVA8wA{RnkgPn)i7n*ssUOzs=DHJwD3D*A81UcieGdExFaq_RPJo5qmROp6 zzlNEJnkjoz5ozoRZ446Asw^2+%^B~ET)xw=l=4N?t=wW0bFhshAuhv739YLub5e7a zkU{Ii`j6fhF0#G&+*t+i0VS?j{1<>3y3mIwf6%ft4iz&akY0Q>LYE!0<-1CK>BnGZ z&Q>F4gx-~(0cdlJS+o^alUU}PetmlUS`8-)516?LtGO{OE(H#15j7bgh)jSL^Efqv zgP|&X0~x4pw4|qEg)j4hs{)yHBiD7R|J<_S)r^e@gZ>vGMo|!{|Go)N+cw)t4j$@JnCJFZBdQ>4}9pPx9!MYWAw zcFyFx z&Rf|}H%rKN9h0O9aJ$V?1;V#l$Iyu^njwE+%`6 z7Fwu=BwS6WxYRk8v6|)7-7YLq!FAK|Mwmb$?5Q=7<*z-Lj90a%P)-@&OCnz?!QdTq?VpwX1jD#bm4aXByhkfSHD6*?lN+-E#AkNrg`C)mkLdG_R6#(>dag}qVPRGngtgV z{WKG@vo)V}5o0KOQjS2dFW&*&mTF|9x+dwxQ03fbQv<9gvxOKUW2%fmk zMqQg}uECOMZd?c(BTxPvg|g>zD9wmytBs1nO$cBX36V}u^&h5qQag4yHW`jrt8Yb$ z7sth&ci#;;Y1t)f*!jBOGA$P^V^e*OsP?Gpd||fshGa9(lk++73EGPB4Ke5io7 z5a7a44<$#`J#NhI@@jJs@tZBuU^e2%#rTv#ebuUf3K#Y&k2b8Jgt>*eiHX}SFXXpr zpHJ{y1)kwDRw0WUYAf7j9{fm;0H|T$e6Bn>y=XY3i$YN0#Y_F}4Vne-X9{ITplYzs zF^hWJilCkFm&B5t7PF91K{JE+sEBsQ07*)6w)t|J+s|;bs2g35_g;nY2-s>K3oAf- z6QWlKq*ia-Hu%1?=Ezi{CNMq{%Mz-NX&kVs9QQJT-2bzxka%vH8a;9xRu#}(@1W8w zs1iFoe|yI5K`2s2clt@laJ42);?;MQEtXP+|7a z&$f0vM0uh?jt}xg9YyZ)(|mfccR+crfms--kG51PzAuzlKBE@wQ!TYxK)+g`pb*Ji zszuTK?E92{*P#r3o5Ew&1Y%b3RIQ{BVdfGQv7_nLWqF9;JqEtLVCE49!EffD@*G-U z;VUmcJXLmk8;AAO00?53V+!~0s@XBs>?v?RN8H<`U_}TWoUo^`;7lk!9_$<036@RG zvYL9(${BD5@gsE-q~CN%T?b36Jf{Voa0|%RsF94UzuwCFF{C{u%?&eIMmnT=9sm+O zsZcWEiyDb8*+Mgvp;0Yn384L)FIs{XHXuxp>4%t>*EhXTAmcdU9AEm=ye>W$d!*Ju%PbvA&k72 z=N!d{Ga(jq8I%v1OWMX>#-RULBPi(q0ypHCNYTjBG$_u_a~S%;uj}Zd1f%G9Z0BpW zPhGj=y7X7xY?3YEndy(Oc$PhvOytr`+k#v=k7J-Z5*PJ=z z>#CQR95+QNC)w_HPr9dDG3$8Dva#~Pc~aK16E;=M6#@K2d`WNDnelaG+xr@5YtGtt zvL3@f+FqWCsPevFf#_h(agvSp@wIdjQo~=UyM+i8F-Gp2_7J`0+*iMb9ezJHkVIwe z5b5#jv$X;l0#@#7<7*cZb(vOO62Hp#@Q{d+KvqGviO#}&NEyV35ZlVI!`62TXKU&^ zgBK=Ta53@}jn^}k5y9xG&&)e0`_-&IC7bvccCE2JRP(iU-0QKP_6dUGOk(11Rzcp} zg_+XLJ6ZTiR{w@F@$S`paja}ZJ zqu09m5g6s=xvpFqjHSqvPq?HKeuR6RolI$+3h5tj&#hzgdC^y@$Dh*oxl?){s1~w- z|L_7Vrt?-`3V@wy<7k7rsUS(Yf3>`38Opy!fp_Hf3N|JXt0q|^K5>zW-w7C9Z5^(; z>K!@0@=X$dao4#_>Kp)EyrZ!|05XVi5W23xU6Ys$ezb*7<|cM)N;Wd zdO|4*IOD3qh3NqbZGAOU?GWR`pFN-QCD6^hfL!BC0HE}Vz}QE9S&Yu&L>qd_L z81lO(rq5pOXO#9;c`{r-?J(A59ne$Jdnc1H`SAuga0Y5_He zy{!rxEB7|||8?wD}nV8A}OR_>S9tk+B>+E79npTfv@A0CY6^q*%&8lgZD za~!@RmPxD#cKnsNayXs4av^;7l*ZNOf5-Y9bbx}RSwlslPb{fUe#UX-_rNpCCx^o6 zT7_BIs%Gw~^&u#Kjpo15K5Q|QA9kwbc=e9H@F^lN^M@%bkyI1kN&zo>e=8#HG&&e^ zaRMlx$Xk@diO*H5>+{$|li;4L0#Ts}QrBby3h5lklHd?`6irx=j# z7E+LfzlFkCF&&xP!*BX0F?A8i%dPV+Z`3tVlsVfc_Vu}$*(D?OVNAdHY#Z7+Kz~)G zl<*B|-(07mxKOcT>6@EAN_Zc?RxCismulUqYP2xE9L0~0N;1t(Y5q(oT-o4*7L{pC zE>`nY%dUGxML_&2Gg)60!*&(M()rva0^<030F$kN=Ev#hN0zpAR>)SmeuER5e<(g~ z%X`~`b@2I_`=h1w!Vd3Lt0)=Ggc7Wv_lCC4W(*p=EhZ|EZ|A`H%2aMWixMH;BO)6yAY1>+j)o}4;F~|hQ<9oMn zlUfRF;ON*pVgE{)QhFpZlQOAnO6Z2NE!YxqdB62U^H_G5wfw#tw?s8d;?WF@fJu`g zAi}x5JDdZAB74Og231);@Hf}(X3oSGV-~b;d5c+@`ri}BzeV#MU52``<8C?z(bHd( zf4I+luH8N$RX@85s5~K-ac%+v!zG%X^q9A;JKspqE}s8E&%&UN3rjcFZjbPRqeF1< z*wAC5`!Q#5*W5IaR!$o*xm6^?s_m^-f-)P+H9?(>60CYJJxZfUpm9v9RsLRmXZwNC z_-EF3XuQceoR$%2Ol3%NNzawEL0n}8s+JZ8IkjsEF*o|Y1dp@RC=pUGQI)uOYBINj zdSn!sH|J;#-x#0szIPyC0dbatir>|he#wtx{(!}Mtzs|FXCNM>|BJb#ui%HL&@6~1 zD6{%+OdnV(xQ`@VIFd~urax9QCG0O;-reU4v_l?Ir2)0ORWsh~OB7p|BX782%R^vFG2MF$OHS3!nlth+7|*IH|li+~G0xRhOfv+lT%-i1*&G zikjv~X~b!R;>Muw8KswLc!(SbH15@BFEByf@G(OR?alHaFVbv?3i(hLbtgqc@nQt( zJb#PQ$n^N*rZo=f)f$H+Orr&fl>;t@(wPRRi977@v4Lpxt)%CIPN7r~$0j$0e>`)IKN8Z>SmTbYNw#w!W5}26--Wov>Qy1C?;=Z;NV_06 z5Eba1USLG?D|IicW>EM+)?mM9F9K@5RR`uEFwTSX6OHG2BYXL!r4~m;%|OaCVXW=( zzgKv6ciBf@M-o-8ol2T5ult;%SDVZoM{vm0%!J%SXp2T)H2;F;#dyjo$ZTF4coxsL zqLzli;UVe#1^PL*vHAK-)uw>nbo>paK;vT!|3c<1l7fC#fadhAN&*>|3_r$6Ticg@N0~RdjxZ`I%_e1<8Gn9Q3FIf8rJH8f_&)L*kBr;(i zGkI-e4MIs+lL4{OS`h;q8@V|37*ZrwG?Nn1Wn}O1>tT*WKfLsqXxN*HJ$G6$GZDc| zj)~_0wle6BU!KL}oiC|~f`*Mt2e**QDO6GppH&o+j@jtN3%-xwWlJWgDa2%XIRF0D zC-hgnHD*tZv!iods+?dh z{Hvc_8__)w%yoKy-hH|wx#6YRuF6-V!VZ5JXIoh$in!{=YiPqhKP`5{^`13?TLQq z(A#)VU?KG21u+aF&7t+8*U`8x*^b06g-q)D%@+x7xuNgqcip ztLHLANmh6){IJRzQFHDLSmVFQBjI!C_s?d7Fnv}SZMotxstv*k50%?4#)kor{F?&z zWLkFmn*w}^lNp}VoSp~zc+J*w((rb|o66nW?_^-B#v2_ATIwr+V;vA}*s>912$VoCds%xZXFSxFfJ_J)`4%4Muqs>9*-1@hCOWLT+3!MH*XoqYs?# zssW+&jn1XG!hX?H0nu@=e;>471O1&wV^Sp}>%<=;Ew)9;JU7$UMgreE3+6KI&HQE# zWQP`~A&ygl92AKg$5NQ3ohEb{Xh`O*^?>qi2P|Bpho~O)e%`<|6cMot+_D=s18oKX z&s3*RIc#08aPU}UzGEJ<6IN6R4CC;*RtU__xDWo_T}dOc(OO^v-`-?zG{_7)6@t~^ z&cD85!@^;B&17%QvGol?dke zYpgacXLk_LO)7y$fvlya#z@hUAlar3gEE3ay~8$%0YLP3lI9(3%q>+0H9I%H8xP+V z8SpjYB9m3%Cz8^&Td-+hWC)^yG8k^;c|_P}n=b7cqz3vEJHm#qGqK`_S|0v=(q39+ zPxVu(+ZEB;D7|;rD+l7g}K^`c59?QaQaq2 zX`J(fvQCLs=cOptO2Zima~P_Rv!X`-%!(FxJ(Jz0j}F7j)37%Cn3R}Wk7(hI;_ z!4JUp`@!~%N<6aC2k&P;kiU zNM9LLgt}cto?~hGKYxF2{TzHeaRMKG)>r84L-;|zk) z)3YN&`fn?5Z()7hKm@V+m=C>B1_bP8pgWEX9Xr>ojW4++*G?wJVB0`K!eK)WiRXmf zOg^k%jz7+nIYCH=cQxJLop$>fdps09(l z6{DEr7WD@)C9%@xSO;Ju*yL*CmG)YK43f>Z1c%WUdrw$LEQ2CXm#uL0b%jAqB6o#- zf8{Gcke^1Sb|21mni{^pCrZNoty1`rj~F1^6hhf+_Vwvi5Tc?X?rBYFcom8Kvk&%Q zJXhY&gS34R)l2{j+T`v9k^9f0F%^P=zE$HSz!e^a6TiTMtc)11zMdUx(}&I{s42YO zfP7pJ*xE55iyRlJ0Zy3_s;M(_Gm447Anj`dbYUiJmY!I)0bYxVnW>RNs+b*Z2@*v7t+eO*8d!vp_5#;8`n4&G%y^Iw)`G5XdvZgA<*$i~IN!VTlt)0_ND`ev;xaKZu0}n$v$`9&Ecu7=ZENsOUNB=O7%$8lV7; zJdS$IPo_v9j>IwmM-8~6(?EOG3VjsUVLir+NwG}@uJ)EdPHM%>LQ#B$)G`zWC9w|V z8HSvRBTqDxZ!r79)bp3;djisaj066b30ZPZ^aRv_@%%D04?vuCs_kpCu!THVFPtRP zZNS;>x>FXfItfDaufu3MWjiK=cdmvs*#;Av?6oQQrj>5Q)=@0yzt4LMjJlG1{`vj& zqoeu~PVSj6)z~5KfPbR&EYnOT=`_AhlrRmEL`&#q$OaK-$3qNJxs<^_!_=HasZLIu z3ZZwmx|TGVln`a{iOt!d(X9UAc-@h16!Q31`)Ua@5N%cr=Y+69OUA1fOp@J?8C|nY zZ_}tD&~P_!y;)60&l2A_Jl<|!{q?vz#*i%9_?o_I0`33M>^FtkFE-FJ z!qnY7aMYq66D_i9+?j>*oWn7MM&Br7&E52mZmqDxm)pOi?0Fc|3}dVi)9D>bh;|y2 znD4%+8&LPU8!(-LJqQmRUiEoU0Ccrr1Y9eL`*Fy2OW}7=Hb5Iwky5)hBW_HPHSS)q z+}Qyl$yVo);Icd&1e2I(w4-z%yhj4HOT8(6YS53pK za`i$h6-P3(5nvvYG~N4_TBl;AEP!J|xryZ4<-M8xnzq3W-^Fnn8vtZ-4ILXuv!JRr zV`~I{c{6QuzD?y4psX9K2;5 zxy9o%cW7@ebq*jKnEo$sdDUxSh*!HIG03|Rm_45)B{rVt6sctcoVQF#vn^sQ%mQuTZFKJ0s_l1v;aAA-7%a``$B|H4rUxJ#8MpkRLT1MFuH^;rikG z7ny|*4f)J(^=m3pkyRsxVqEiyFCN+{_|m(X)7e%P($Kf=ik9Up#^0r^W^zQ~t!Y{a z^iuQPRC^`dGTCl?*ggwV)Ug>f2Xr8x({lUwu$Ye2es0l|T*WvW#v;$f*|hy9zQqxZ z6u!^6k~-arg_$3Ya7KaJF_&i?>#j6&wW9nw;gnP~H?Vo1EqUmJE!*@|@#=Cl1gRgh zbCn~%UJ!t|`F4HEemyka!3x3pE~G4gSdK;AsO}|H6WP^Zc>@Z!RvH$A?$XU{hn|yV zn7u(Wdg<*>kEu2(e96+i8^zK1yR$1J&97bXCBtIfrS;8 zw?n`ug72~td!@H%A-OW&lQyjNJ$7Yf(ET;e1W3^o(4A`mn(o}1<&1Z#LNe^ozAxjt z1okYO=-Iw@OeV}=Z$znT=xPJFt_7e(E5Chqcg#Ip4ZTL8n`dxfuY&&iVlbYCBtoQ$ z|1tzCtlPDcfMhXcKv%3aDmk__QC1At(E;Vn%2!v9;)z~-OmXDWqynMBE(m%d57@*T zJL3BWD7Vmt{B#B83<|9#k`OnQrx~2^B^Nq^tH<7*0{m*^9~_VB=3j>la%z-M|CG!2 zH(pH@Fhd4s3^(@=hX3SWEpWfInwHdoquP|Sa_v63AWd_18v&{@kN2*}*Td%(4EN%x z+~Eo37#>3Fic;vXR0jQNl+J?@dcS&^5q{OWq~-%>{maGJ#Cf%$X!RRN{G!b44C*>cf1nngN>B<&ZG!kJV#Wf`o3$+BW){qmNNS=J|h z4-px01+fb>u+Mi<-QGA{XR!E}xpEo=fwd@j27p%jwf+0IpF0?MjV^;9aeV$3f68#{ zo};$RWU*L1ZEH{}Y>pWZhxSQ*9u5#(`}$K78?x*Nl(lt}`${R5pgQT~D-a7DSez(U z$-|2n<{B20oCxgwB}jKhr0We*%9(4W~@nOKM2}DD@6Dd~L41 zS@v8X=${oEdD)j&yQ$wM~Bdl)V}E%Ye=|9T)sZp z{cfDPbB)&d!|D)8zY3vsIBhG^t_{oF>*Fj>#!~^WgO{vV) z=#W;5+ho~Zn@YEOh((tr+@{NC2LTFihHe#BNH}BMLxegs(v2jrj%Hv^2y*CzdSZeE zK8Ow})F1j#;Yt zKjdek#S{^p#JaNg8#N0R9CuOM+QB1u+P{PuZzDCy{oj6-XbLf)xbv6*BruN%gog$y zW17|dsT1wILx+f3jt>cLB!5M)d%r)Mk^`JU&RYan-W%L|5ewIDtLzR_NMpaaWh(E& zXd3w zCV!T(^t({9`eHWuSM{@#_!1d+UsuMB)0PP1y{){j18#xnh3tLRiC}@aw3;WhU@ho7 zd6gBd)$Q9Gpr0CbUmVM}f(r^*Igoe@HO`WK9A9oBlm4PrwtfmsM^k3r^93Kz!_-%HU<`az*n2M>2qYs5-;!Cw7q({Ak!F z#aH?AOd57mLHMvLji;91;k9n?_8Xscp!=F=DTuWzpv&Bq1Lg*{{UgJe)!IRn2-L zpllhkq1^X;4xD9}G7|ctovZ8OG%RO6`XYZ+)PHKtK)B_B(+1kyZ|On1!Nkx^GJbMp zaUvU(nr)!ZM8|&I7k&#(1z+_tuYH$!11WkVjjo<#iixsx15^}N-=3dwg00yK?I-z7 zJGz@KgnS(cx0#%V@|8In9)GV8%CF2kdaARw6J$Wyoo>nWO-#G7{sd1WE53tuJ1&zr zC4fU2cTgAHJ1Yd;U*$|GL$!2Cgx-h1o#%L>wav`k;Nm}H3Hx5?-pqm~S;3w{=#EiT z#z-2h3j$;JucOuD1yjn{Z3UwcMU#le^1ei;Fl}{d(e!^(*v;Mok zx2^ySs>DE0AZ-oTM-sivjhL>K8DaLFisd9pC*kFNb6|&{&J%Lh)rHVE6Onv+`jBq{ zL6EuCB5gXtWg-Aij8sn!RPEe@^_tr`5@v)&H-&UMrPD+b$%@)=YlWf73@|xn#}W|L z*bo^-L*SmAn0-MB>fZ);BNR4vV&OA~77oI7a;&0<2=3Vz%ZFqlkKs7RbS?HiIyb0c>wV0T+|v+EZtNi0Fr z{+&cAZWj1v-{1t=k2kZS^^373$Ft|;gM%zI?14q|6bGHB;mL?01@fHR-QPF=vnr68 z%oS~N+mBd}WnW%Yk5ds;(e->h!m^~ zceXr^vLrn+SCJ&8nV(M&sp7x$8xCs6V=cimq&4E^d{c}xRrW27ii@=-e>T%(u0?CmW#6v#a@=X<`rHkiqRlEV6P#%!Aga!^!a5nWx3*0+e5 zs@-8iQvGsf@jFHpy29@UfSwqp?8th#fH8}U{hxKXFoIvH;yThbd|YGsDQy46p}!(=iZgW2pBKt z1a9M`{mqYISL@8`*LgXdapgNyyJG&?h|r~}>)iWDK5zIKDXI_{q4A#9H_Co?2MyG( zhVy@Lpd{cEFOD-p@T!^r1jyo!82DZ&Ntk6a7|sZ6U-+vUD*ybe z?9U%^9ouW5)F8L^2dQg^J|*t-Pd4V)|ncLQ(075ETQ zUEWVUec9<6bXG8yM-#1!-RY32f%1+&(?B^HCEx{vUr!x4%c+hZA|Qo~s$EH&{Obii znrtV=&{7w4_2n)Xfl|lk{P#y2b5{eyvK}*ZfZn5D0)bVCNU`U4FRUE)o(8x-{!{S3 zzZ?SiEGfT>W9th5JvP`)Ml-HB&}ia!0PMg z+FgC*I%NXyh&c#(`hA&5vp)bl@IpLrdQ5Atf76ETkPWX~!|@@%G}+0cSia{|i8mG2 z=N=#J%1p+z)`p%l4O&U++=~E8Sph>0$`P@-aPWoIX*oX#bH&k~v%2{|%k~=5#NbO3 zqPhB4~Mq`nu zyMS>~0j3JabS`^EOvv<%m`G-Eu6Whz!JcEUq2&i1V@5hX=Q`kauj4GV&~Q;g;|onn znV=LB80$ZcjqKoc7+LAA0oRSaZjPIrSBkNYA`+6(}{b2?HZ ztM1|gJ0Y)d^Gq%{)BcKpXFyVVD`NQcE#2n<>w{2z0)se{RQ(1D=rO!B0bwq|qNUIW zjfNwCDxDqDWDykOQ8B53ZZt_hK3+R7iUa$ET!HMlLihWoI*fJ%whI)8KtEP zLb)HD#@o${mww%Mw@x=IybdL}`H2A>e2{n%kd$3-PyTzUmUj%KC_anO4&GWBE~>Pd z>@eQmB2+M0ngS`2H?*c?`LVklY&(hJ%&>6w6=dce!@nq0_F9P73i^9M$iM>>m{ve& zSkwLN{R}1g)J(svD1-`3{>Srmm*)mq{Tz(2uG#VBVFaDAf-Q5Uxp5H)tXnW_oyE{W zO$*U^W2#j5juWa+hwp|#JW8X$9=sBtD5xul=Bun@Cu1!u$F4WqrW1%XWK@i?7sPG0 zi?oy-s_xs=aXQ!1*|q`HB?GDUp)0oR*!755P+{c-hjc^ZtREO$3NO6{$@+jH;?4~y zbgX}c#Ns-3Q5j(uYPiNU zvcmb#+35{4p6a(1{(qeDEhM96Q^ifpVcr3!e%9d!>jv)i7o>V&N10Cm#mxi%W%?uK zn;D$;f7A7ZGnwv>*nWVK4|erACG^D2GynJA_CJ3a`cEBM|2($;`P=^IFGK&wX7hH~ zL^f)J!*u@%AJ`5@P0;83HlYtLB5Hfo#%>A6!k4+|Srm4QF&0D{gx(n!#rv$Cs?w2hw!t2MLQ4Us(vx?ve z@|7-d|Cp!xb=R0trSL0%lG^Z#G%U1?ZT*Sg*b z0RkvQL9Kd#;Di%mwH61E;Lth%;sA(@Rx2tBC^8vgCo0lAB}V=huDiulfrgHhZtV*80Bn4e$HzPn9q2={Ol37A(eQ z8*4(40O!RxJaj5k_ccH1*Jc3Jz8h7jY?Gx&40c+FBJATzB2ggwUpYQB=Wfc4_3TTT zb%nu&nMf82GJyif&jdz0FB5eOq6W4L)q)%7HIc$l!}0zrZqSBntxWYs&*u88Taho5V?A~v zsvu%8FQBH8w&l+8=T|o`m7yPIm)MW^r0%CHHKbMX{FjybL7<|L#53JQBpFh8|8KD(K=~;b|sp~8D=c}fIjc_d(ozaM{S8r7Z=!M zUcDZE!luH1{|yDP_vv_ZY7n)%R#AHTQ_k(66jjD*$=T zj>e%uD6-&dbd(j}(p^-pzJC)ltjaz31wffw!3B%c(K9b-nvl9K`KZol9O%4fibD&F zwnBt*)@&mF;*%QOS$}W%!9tAXpV+65=C~3^!4$3Eb|a8H153U4z!iEds9gpOKlZl; zPt*my^)x?I)=4x)?JVjV4vG-=+@HSTvKJ}yjS4H+&|H=3Y+i`+@^1M+_m|;=A!FK5 z+(>r#15nx=@Gr#b0)%)4h0Zpwk!{ynX%CZiHAlBs{5XDiFd${m$j<%p%`rqus_A+U zKcG@OrG&=-rLpxbpkJd_Cyklu@_Jkd(6q1<*OePdRcS#xp!yL0Z;#v8j_obO#q zE+VpRPLUf%^d%pnJ{F-xQ6{vj6_e8DQySEmYB^v}Q!^&npA_@Xzr?9UP z-|HP&f_eF!Fj-opI+>+(=y135E_0kyz$s|6I zLbn3H<&x_gpW3si^Bo; zru}^E6^0j}Q|U`0w0*yq67oHwQ_{$KiPESagld-`7CR7NXE^fWf%Ei=@t@AbZSa7z zD`sthf<@ZWc{!KjN>Kl7FF{b=T!OgrT?x@9E!mg!8(gy6it8U8@>_57;*f@WabB(M zjLVJSqCPYV>w3J)Bq=;&a@Lt~l+nINHbL#zt7`V#G4$S~TeP!s2xopv&$_{EIe2lb ze=)3v99jcB#i#P=oOI^wppqhv6fBFYga+&2pj>3g2YbzG^B^qh1+G0~MR$GTR6ZdK z8@?q0$MW*2QTG^0)j5_oNPf$L{-QME^<#MY6?jjcy-vK!(9-~KoapDtL z@)(dL<<(a%yul!ee9D|vwFG3c^#*?4R$*LBf-9JVknTcib5VTS6!#dRy>%5ebwYgC zx$@+vg6RBpR>kwrc>C+YVQ@%x$}MMx4OtK+rDN-fWmIJng^bmaq0Cy2o(D9QPHB1YEFq%nWikr?5SaXujq(!+BT z5abi^=C+oMX5CMOCzYn|_lu$%3|l@R(>&QwSBr3O^FI+BL)N{*5fRf6!b7OKgH;wo zf&o)?Jf zL>*ru)GRRX?uBl3R=~prchrxAQX440?2>(v4n(rpEhnr8pxSCs-b}}d9t1#74JzR~ zpapk>VA-3a$~y!7I7}tY3kHxrgyy9ja2ZyB%6(*T5UAhMYK&kq#mf7HF=!aW8v8)#=o% zt8U;v5CV_f&ng?z1Yg2s(!__WTy^LkxDEH&A@c63mpEDd5T$JXW<>|~=mdSFqI32e z9s78ykDY)`Vxqs?0_8X_7~uDHdo={eLa!6XW0=FuYIGiVB8m%qbyneK@&Y<VmAu>N%~`y>FTu02e1yYt|R<5BX3*FO13rYvRV1 zJGy+#zkBqi`n1Fu<-5R@PGu7AK{@iN^0@q;FMV`F4quS*zj?SGOAy6^B>wd6PZ9zm zLU+)M9ZMz$K${E5;)kRwJY9(@dt*#(%HB2i{Y>r7HkA0}UrlMmifO-tInr-C{$8AO zc%#W%=n*!gC=C=SvxRF2TB4b&9|&XSrfzaLpFF_FCUk)nLV{#-2EEBBO(}<<<(cU^ znMLnc$g!LG#6_f zTWRX}il`#`HeZbq?-VcY^wP6dI{8)0S zb(xEq%(3Z`*Q^&(bGtU7pHt<@lvU5JdActYE#s^0=PT92seSmG!!;ZT@0SckdN_^# zqzVR(#*uLg)HE+mezH)V-l%z~$U!_fAb0iIfa>s9mM+{MU@cNoBiLa z2t1}P_6FloY|rZM9^1;rDFm*rau0vTtjF=f(Oce{Dm_6(#S%@`yEBC$=pgdqm)KLM zT>@76jllU!j-Edy_3ZE9HF;AG`jQ*&Kt69fah4rXXd+`G#p9))|1gZnVC~gkF@>rv z2`juZu*NfMwZLLmwP>W5hZ(t>8mox9iU{Zh#QK;Ho&-X!<*!jb(fw0Hr3azcG}W$5)Ji?G z_|$aj0P>Yy)QgTQ9^X|7t#F26++hPLD_YD0Ho$p-l)Y@ONbGDoNiU0B#3VHzjnf;4{M<*<;s0d%d+ z=sxjvCUf=;ZE1$YewA2ap!f}{&hN$5>jhUR+mUBq4h2X& z?gcEv-2DShmH=td6Ql6$8eQgEDi6-zdBM--&{$Y0s$VAoL%jyTpY8Zgz^Zp^Rna5Y z;VyGPUWc5_2Omaw{=_qa1|m(m#<6zbN#`Hca%C6Yc9oSEW%_rBDGV9RNfMWiwbBDX zs%)f`$?N2B{ET_#R96q9D~7>0p_81o=6i`KXS2)n{JpBbVtvh1njSAJF6=0{?>HFh zS+AhcP=8zM0VIX8F`j9w?&be3I^mPO55Od!dau3@FUk>Cqb4rcGJ^{I>^lWxY@uEf zlh>}icA*9UK>ioL>CAbFbWZE-F7aa2(}JD|8g!sPW7P zpLTWvY2(p%$0Orp*s0I(MH%wIf3eYio`H!g&(}+WJ`{GBB9!=*FrtxVrmjE-s?K^L zzR3dTa@3x;*#l(RG*rZW#n$TK^|87Nsjku?P@?qdq&M)3Mr2f-t&9jr%g*1XYuh3Kd7?=6&Bq` zS?r&*+8C0<0WuqItIXv=t`nnU2E*G`YBZQ$+L@}Hvcj!O>of0{d&WitVQw7Eu4Ynk zj)Lr+exB-iPTlb~64t@&_FyMPIwf_J2PY^XRixOs{Wy6YHYbEW2(*^gcOC~RVYo20 zk+Ns!Wyp4xXi=FN{Jz{JBCEl3_-09Ld#OBexZz0dBa^GfcR$DTf2%(%$Q~Zz?kuvf zkyHkH&dW<2Z0)BFKRgGwkGz6(6N4e~DX#m05}so{bnq2xTUIbFmx|;s1Rvy1-_NPv?z8zfC1VwKf{;-P7rC?Sk4KPVRNZqK?y3KI zu>pfw%yfP!FlEJ2{_a&(+P%*Mon-A5Y&UU5)w2hy%0i2iB~(;>#leScPLn~k9@E9p zeq(NC{W&!oCsD4SR=wKIZtEHBIDMM-mUR{-w3o(zr~>6t^^jo9JY7|ls?T+p;IAS0 zFjFL#9`BZGclQYV%1Y(Et-<2D0iR0Gdz!rQEcP>C$w{ zKVjm0Q|-=I;<59etL*^9Wa!@3*tbfxHR!!`PWHd9?JuW&6Y6rU_5GQpp^GJHx^b|-+qvscSaU7%mxZd1<^W|#Rnsb`22FUjqt*k)R{%1UML+CEu@ z6_Y9A*Vdh)gyUHmL;GZ&B#mY1+;3I$o}Vzt4`g2DN{_NW@QHWg+Ec;@mYR|JRZNLm*Ei<_s84kg<)@|+f zWRhXR#bmH@x-Om)j?syW3}~~Fq&2+E%wFQJH~6r$Gjq=odP(URz3O)CQdeQBSI3%E zhPRKj#4*IPySH+#@-XcuTl;wa(8O}s2p7JX%wiYiikwX(C6oIG6^|<#pTB-QLQ&k? zxU3hy@sJE-^m=!~RlKxSj&%?>#d*-F=i^oU!%TaFKniWiBKXNztDmnVr}HabZH zTLV^02aAl`vS|8wd4;Sy!tY)>+1+cI-VV8uq52K+k^9|tcZYQqS*I!&x4e1%fV>=* zTg>55;cE9vacwB`#1c_vnJ6{A#5q;Vdd>Ys6Y&16>P8U)K23#Wyf4(&Zd5iJvXvgz ztAi&ExtfkK9ID=0&5*^N+|Rt%gNO)**FQ>EJj>o0Jn!@7Y> zdQ*{7YOGsUsy{SvV^cuI@=oqYLN>P$6pl3Xn|MZf&D7@sCnOqDtcVioNtEV}hn24yVFbm~Z^Z`ls*Cr;lFSbFjN zz|J$2@GE1jjgNaL&Qju+!dRW^*Rs8PEj~^`y)8E0Hnpc9#nv&)$~yX+j#I+k5uBFd zinM(||B`2S>98r8_b<_mX9gNssC-;pA1%CPpy|n@s;ws)M(=r|ukju1`SbYbn`H)? zay(cuwmCZMo`J?p(A1g@aD{;;rn;wMMP&4d+xi;czzy48B3EdZVlU8NEsS3Ko4y7I z&)Kpt`j5K?ngh6>eD0IzR}}^t8xb`=orWt6G|jnGTjELGXq$3rLV~y&7p4Z(PypnI4Pf^;7R)R{R-_NEoSt4tnFOti87X?hC@NeaE`(+tRybk^YA|?(9 delta 26156 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 W9J_7GX%~PdWbNvRRpp_=BmV%l`mtaD diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png b/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png index b9ecf3ac9c7d8b8178d3e0552c01e695ea479d1b..9cbdd307dbd3e05e655622ccc29d258af23ed3c8 100644 GIT binary patch delta 21729 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 delta 16204 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

)K22Xn&)?Gk}^(u=%1yiJ;1$ zO&0iQNFuo6fuPjD>@v(K5%3`W)5C4>ZGj$rH~1HlJuOX|*|)e!M*UfaGz) zHj6n2Q&sj)47RML_N>BPTxV^ z0GIgq_&y2irSqy3y$~8M2jmG>og3VsQ#j3%m3&Kx>^US$tX3PwN%i60%Y9n>xS~XoY%L$MmO;*|P4)wr=be(@QMtmSbjC}CVH}V6d@4Ym z?wf8&%VQ-kb#P3V9$52xPEOvN8TTszy-+}(P;ieKtFi4^_S0P7%zH%5ONEP>hVRIy zW@g-bZ-;BeXVD1zp5BFS@iogNS~(0l_Z{RzO2zhVedE!$%UbFe{IO|E$;BfYX!{3< zw1<@-e5CxqD>k_nnXPy%+7s6EM z;za@ygMS3dz4zq5t1XPVc(s2!Z5CZ zMqfNQ8Q}Q&dpyGX=|t;Lb=A2W1zH_E^K_-eN8Y}o5-VchHcrfLD;;qf>(%AtMcG@2 z9{4$+BcBGD{Y_U{Xvv<1#J;z);SZA_pARV=H%~Ig^W8)VJ^-MSO z;->ueoYXp|UqhpxyS9KmaSe9?&VYs=efLRLM@rsEm6s=PM3e$RMwuLLpT3fWVF z2r2Q@v7N=EPi5yp}-dHfvZCo{IV6iiHSEc@5U7 zgT_o#jL8VX#gBcLpq;M~O6_xjWfY122x(fGY)yi4{WVTTl8k!NyzXn}F4<-Mawa`^ zt6CnZmWQdXe?r+5Ec|^VA+!{6kCr{25D!0eDMsYFCiU3Yb}|&GYIS^vqs;6B58b`q zZ6h#kBKJO!K`m?*OIEOYg0sajIh4b{$+njElyyK9+(sMY()DvmkhOtm$F@lcv)6>)2Q|bD4Ymw z!9>In;^w+~dtZwb?+S6Ox-S!|YoPkvyeHA6B?ar6XUhT|oz%~#>$q!ro zZjots_&1i07-6}kTgJRfyOin&jv~o92U$HL`j?P>k&0gG7QmoA_F!M%o0GFdqtf@E zaTHZ*Na>zOj_VI!A=jO?cF}_c!v}#yAJRMAF|K@# zAh{Am(q9!sk_qOvBi%E>Pf#fTa5W5?HFZ}jvh$3(EtMx7lo0l2Q`dIGD81EOhy)Vu z!-3Eih(>)vCns3xi=j2dtcGWV1jD{V_aN(%M~6F-aHKgJV~3_JUR|s?Cs&-+&4xRR zI|}F#g-<24&`>5$!YMYc<0ppx$HegGAHnYL@BgDY0fl9-Yu`T_6u!T`F@W?Q|F^V##a^&s zDS~ip`m;M4pZJ@gdY;kv`df+ z(2u+AAFTG{i=oJ09nl{%4gF6h+yBEf(4RN1pB01e`SYo?)pKIZyaG5DT;KQ9K@{{O!2^3P*{B0s6|{b?Hd*R;`pe$an#&cDsS zKPv{`^Y7=y0Nekkkq_7#_Wyb01Ka;+HNK6EJ export const ALIGN_TEXT_RIGHT_BUTTON_SELECTOR = `[data-test="alignTextRight"]`; export const NEST_BLOCK_BUTTON_SELECTOR = `[data-test="nestBlock"]`; export const UNNEST_BLOCK_BUTTON_SELECTOR = `[data-test="unnestBlock"]`; -export const LINK_BUTTON_SELECTOR = `[data-test="link"]`; +export const LINK_BUTTON_SELECTOR = `[data-test="createLink"]`; export const TYPE_DELAY = 10; From f95f5753f5ebf397d27a97e090f5cc71a36fb860 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 7 Sep 2023 22:26:29 +0200 Subject: [PATCH 31/61] Changed image replacement to use image specific toolbar --- packages/core/src/BlockNoteEditor.ts | 4 + .../extensions/Blocks/nodes/BlockContainer.ts | 24 +- .../ImageBlockContent/ImageBlockContent.ts | 22 +- .../ImageToolbar/ImageToolbarPlugin.ts | 247 ++++++++++++++++++ .../SlashMenu/defaultSlashMenuItems.ts | 7 +- packages/core/src/index.ts | 1 + packages/react/src/BlockNoteTheme.ts | 41 +++ packages/react/src/BlockNoteView.tsx | 2 + .../DefaultButtons/ReplaceImageButton.tsx | 141 ++-------- .../components/DefaultImageToolbar.tsx | 51 ++++ .../components/ImageToolbarPositioner.tsx | 122 +++++++++ .../components/Inputs/ImageURLInput.tsx | 79 ++++++ .../components/Inputs/UploadImageInput.tsx | 70 +++++ 13 files changed, 675 insertions(+), 136 deletions(-) create mode 100644 packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts create mode 100644 packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx create mode 100644 packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx create mode 100644 packages/react/src/ImageToolbar/components/Inputs/ImageURLInput.tsx create mode 100644 packages/react/src/ImageToolbar/components/Inputs/UploadImageInput.tsx diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts index d958518546..dafd0e537e 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/BlockNoteEditor.ts @@ -50,6 +50,7 @@ import { SlashMenuProsemirrorPlugin } from "./extensions/SlashMenu/SlashMenuPlug import { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; import { UniqueID } from "./extensions/UniqueID/UniqueID"; import { mergeCSSClasses } from "./shared/utils"; +import { ImageToolbarProsemirrorPlugin } from "./extensions/ImageToolbar/ImageToolbarPlugin"; export type BlockNoteEditorOptions = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. @@ -151,6 +152,7 @@ export class BlockNoteEditor { public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; public readonly slashMenu: SlashMenuProsemirrorPlugin; public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin; + public readonly imageToolbar: ImageToolbarProsemirrorPlugin; constructor( private readonly options: Partial> = {} @@ -178,6 +180,7 @@ export class BlockNoteEditor { getDefaultSlashMenuItems(newOptions.blockSchema) ); this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this); + this.imageToolbar = new ImageToolbarProsemirrorPlugin(this); const extensions = getBlockNoteExtensions({ editor: this, @@ -195,6 +198,7 @@ export class BlockNoteEditor { this.formattingToolbar.plugin, this.slashMenu.plugin, this.hyperlinkToolbar.plugin, + this.imageToolbar.plugin, ]; }, }); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts index f6c173ea60..9a487ee1e2 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts @@ -1,6 +1,6 @@ import { mergeAttributes, Node } from "@tiptap/core"; import { Fragment, Node as PMNode, Slice } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { NodeSelection, TextSelection } from "prosemirror-state"; import { blockToNode, inlineContentToNodes, @@ -206,14 +206,20 @@ export const BlockContainer = Node.create<{ // Replaces the blockContent node with one of the new type and // adds the provided props as attributes. Also preserves all // existing attributes that are compatible with the new type. - state.tr.replaceWith( - startPos, - endPos, - state.schema.nodes[newType].create({ - ...contentNode.attrs, - ...block.props, - }) - ); + // Need to reset the selection since replacing the block content + // sets it to the next block. + state.tr + .replaceWith( + startPos, + endPos, + state.schema.nodes[newType].create({ + ...contentNode.attrs, + ...block.props, + }) + ) + .setSelection( + new NodeSelection(state.tr.doc.resolve(startPos)) + ); } else { // Changes the blockContent node type and adds the provided props // as attributes. Also preserves all existing attributes that are diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts index 955174c46f..59b18aee6f 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts @@ -7,6 +7,7 @@ import { } from "../../../api/blockTypes"; import { BlockNoteEditor } from "../../../../../BlockNoteEditor"; import { createBlockSpec } from "../../../api/block"; +import { imageToolbarPluginKey } from "../../../../ImageToolbar/ImageToolbarPlugin"; // Converts text alignment prop values to the flexbox `align-items` values. const textAlignmentToAlignItems = ( @@ -45,7 +46,6 @@ const imagePropSchema = { backgroundColor: defaultProps.backgroundColor, // Image src. src: { - // TODO: Better default default: "" as const, }, // Image caption. @@ -89,6 +89,7 @@ const renderImage = ( addImageButton.style.borderRadius = "4px"; addImageButton.style.cursor = "pointer"; addImageButton.style.padding = "12px"; + addImageButton.style.width = "100%"; // Icon for the add image button. const addImageButtonIcon = document.createElement("div"); @@ -260,6 +261,18 @@ const renderImage = ( }); }; + // Prevents focus from moving to the button. + const addImageButtonMouseDownHandler = (event: MouseEvent) => { + event.preventDefault(); + }; + // Opens the image toolbar. + const addImageButtonClickHandler = () => { + editor._tiptapEditor.view.dispatch( + editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { + block: block, + }) + ); + }; // Changes the add image button background color on hover. const addImageButtonMouseEnterHandler = () => { addImageButton.style.backgroundColor = "gainsboro"; @@ -325,6 +338,8 @@ const renderImage = ( window.addEventListener("mousemove", windowMouseMoveHandler); window.addEventListener("mouseup", windowMouseUpHandler); window.addEventListener("resize", windowResizeHandler); + addImageButton.addEventListener("mousedown", addImageButtonMouseDownHandler); + addImageButton.addEventListener("click", addImageButtonClickHandler); addImageButton.addEventListener( "mouseenter", addImageButtonMouseEnterHandler @@ -350,6 +365,11 @@ const renderImage = ( window.removeEventListener("mousemove", windowMouseMoveHandler); window.removeEventListener("mouseup", windowMouseUpHandler); window.removeEventListener("resize", windowResizeHandler); + addImageButton.removeEventListener( + "mousedown", + addImageButtonMouseDownHandler + ); + addImageButton.removeEventListener("click", addImageButtonClickHandler); addImageButton.removeEventListener( "mouseenter", addImageButtonMouseEnterHandler diff --git a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts new file mode 100644 index 0000000000..5577f5675b --- /dev/null +++ b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts @@ -0,0 +1,247 @@ +import { Node as PMNode } from "prosemirror-model"; +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { + BaseUiElementCallbacks, + BaseUiElementState, + BlockNoteEditor, + BlockSchema, + BlockSpec, + SpecificBlock, +} from "../.."; +import { EventEmitter } from "../../shared/EventEmitter"; + +export type ImageToolbarCallbacks = BaseUiElementCallbacks; + +export type ImageToolbarState = BaseUiElementState & { + block: SpecificBlock< + BlockSchema & { + image: BlockSpec< + "image", + { + src: { default: string }; + }, + false + >; + }, + "image" + >; +}; + +export class ImageToolbarView { + private imageToolbarState?: ImageToolbarState; + public updateImageToolbar: () => void; + + public prevWasEditable: boolean | null = null; + + public shouldShow: (state: EditorState) => boolean = (state) => + "node" in state.selection && + (state.selection.node as PMNode).type.name === "image" && + (state.selection.node as PMNode).attrs.src === ""; + + constructor( + // private readonly editor: BlockNoteEditor, + private readonly pluginKey: PluginKey, + private readonly pmView: EditorView, + updateImageToolbar: (imageToolbarState: ImageToolbarState) => void + ) { + this.updateImageToolbar = () => { + if (!this.imageToolbarState) { + throw new Error("Attempting to update uninitialized image toolbar"); + } + + updateImageToolbar(this.imageToolbarState); + }; + + pmView.dom.addEventListener("mousedown", this.mouseDownHandler); + + pmView.dom.addEventListener("dragstart", this.dragstartHandler); + + pmView.dom.addEventListener("blur", this.blurHandler); + + document.addEventListener("scroll", this.scrollHandler); + } + + mouseDownHandler = () => { + if (this.imageToolbarState?.show) { + this.imageToolbarState.show = false; + this.updateImageToolbar(); + } + }; + + // For dragging the whole editor. + dragstartHandler = () => { + if (this.imageToolbarState?.show) { + this.imageToolbarState.show = false; + this.updateImageToolbar(); + } + }; + + blurHandler = (event: FocusEvent) => { + const editorWrapper = this.pmView.dom.parentElement!; + + // Checks if the focus is moving to an element outside the editor. If it is, + // the toolbar is hidden. + if ( + // An element is clicked. + event && + event.relatedTarget && + // Element is inside the editor. + (editorWrapper === (event.relatedTarget as Node) || + editorWrapper.contains(event.relatedTarget as Node)) + ) { + return; + } + + if (this.imageToolbarState?.show) { + this.imageToolbarState.show = false; + this.updateImageToolbar(); + } + }; + + scrollHandler = () => { + if (this.imageToolbarState?.show) { + const blockElement = document.querySelector( + `[data-node-type="blockContainer"][data-id="${this.imageToolbarState.block.id}"]` + )!; + + this.imageToolbarState.referencePos = + blockElement.getBoundingClientRect(); + this.updateImageToolbar(); + } + }; + + update(view: EditorView, prevState: EditorState) { + // if ( + // this.prevWasEditable === null || + // this.prevWasEditable === this.editor.isEditable + // ) { + // return; + // } + + const pluginState: { + block: SpecificBlock< + BlockSchema & { + image: BlockSpec< + "image", + { + src: { default: string }; + }, + false + >; + }, + "image" + >; + } = this.pluginKey.getState(view.state); + + if (!this.imageToolbarState?.show && pluginState.block) { + const blockElement = document.querySelector( + `[data-node-type="blockContainer"][data-id="${pluginState.block.id}"]` + )!; + + this.imageToolbarState = { + show: true, + referencePos: blockElement.getBoundingClientRect(), + block: pluginState.block, + }; + + this.updateImageToolbar(); + + return; + } + + if ( + !view.state.selection.eq(prevState.selection) || + !view.state.doc.eq(prevState.doc) + ) { + if (this.imageToolbarState?.show) { + this.imageToolbarState.show = false; + + this.updateImageToolbar(); + } + } + } + + destroy() { + this.pmView.dom.removeEventListener("mousedown", this.mouseDownHandler); + + this.pmView.dom.removeEventListener("dragstart", this.dragstartHandler); + + this.pmView.dom.removeEventListener("blur", this.blurHandler); + + document.removeEventListener("scroll", this.scrollHandler); + } +} + +export const imageToolbarPluginKey = new PluginKey("ImageToolbarPlugin"); + +export class ImageToolbarProsemirrorPlugin< + BSchema extends BlockSchema +> extends EventEmitter { + private view: ImageToolbarView | undefined; + public readonly plugin: Plugin; + + constructor(_editor: BlockNoteEditor) { + super(); + this.plugin = new Plugin<{ + block: + | SpecificBlock< + BlockSchema & { + image: BlockSpec< + "image", + { + src: { default: string }; + }, + false + >; + }, + "image" + > + | undefined; + }>({ + key: imageToolbarPluginKey, + view: (editorView) => { + this.view = new ImageToolbarView( + // editor, + imageToolbarPluginKey, + editorView, + (state) => { + this.emit("update", state); + } + ); + return this.view; + }, + state: { + init: () => { + return { + block: undefined, + }; + }, + apply: (transaction) => { + const block: + | SpecificBlock< + BlockSchema & { + image: BlockSpec< + "image", + { + src: { default: string }; + }, + false + >; + }, + "image" + > + | undefined = transaction.getMeta(imageToolbarPluginKey)?.block; + + return { + block, + }; + }, + }, + }); + } + + public onUpdate(callback: (state: ImageToolbarState) => void) { + return this.on("update", callback); + } +} diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 96d4ad09d3..341d70d847 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -125,10 +125,13 @@ export const getDefaultSlashMenuItems = ( "drive", "dropbox", ], - execute: (editor) => + execute: (editor) => { insertOrUpdateBlock(editor, { type: "image", - } as PartialBlock), + } as PartialBlock); + // Don't want to select the add image button + editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!); + }, }); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c0794c53f0..26bd8eff8e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from "./extensions/Blocks/api/serialization"; export * as blockStyles from "./extensions/Blocks/nodes/Block.module.css"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; +export * from "./extensions/ImageToolbar/ImageToolbarPlugin"; export * from "./extensions/SideMenu/SideMenuPlugin"; export * from "./extensions/SlashMenu/BaseSlashMenuItem"; export * from "./extensions/SlashMenu/SlashMenuPlugin"; diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts index bda9c21d7b..79ec27c71b 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -125,6 +125,47 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { ), }), }, + FileInput: { + styles: () => ({ + root: {}, + input: { + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + border: "none", + borderRadius: "4px", + "&:hover": { + backgroundColor: theme.colors.hovered.background, + }, + }, + wrapper: { + border: `solid ${theme.colors.border} 1px`, + borderRadius: "4px", + // boxShadow: theme.colors.shadow, + }, + placeholder: { + color: `${theme.colors.menu.text} !important`, + fontWeight: 600, + }, + }), + }, + TextInput: { + styles: () => ({ + root: {}, + input: { + border: `solid ${theme.colors.border} 1px`, + borderRadius: "4px", + paddingInline: "8%", + height: "32px", + }, + wrapper: { + // border: `solid ${theme.colors.border} 1px`, + // borderRadius: "4px", + minWidth: "0", + }, + }), + }, ColorIcon: { styles: () => ({ root: _.merge( diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx index 619c37c25d..65a8b4ff54 100644 --- a/packages/react/src/BlockNoteView.tsx +++ b/packages/react/src/BlockNoteView.tsx @@ -9,6 +9,7 @@ import { HyperlinkToolbarPositioner } from "./HyperlinkToolbar/components/Hyperl import { SideMenuPositioner } from "./SideMenu/components/SideMenuPositioner"; import { SlashMenuPositioner } from "./SlashMenu/components/SlashMenuPositioner"; import { darkDefaultTheme, lightDefaultTheme } from "./defaultThemes"; +import { ImageToolbarPositioner } from "./ImageToolbar/components/ImageToolbarPositioner"; // Renders the editor as well as all menus & toolbars using default styles. function BaseBlockNoteView( @@ -32,6 +33,7 @@ function BaseBlockNoteView( <> + diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx index acabdbb4dd..141f7f8802 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx @@ -1,28 +1,19 @@ -import { - ChangeEvent, - ClipboardEvent, - KeyboardEvent, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; -import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; -import { RiImageEditFill, RiLink } from "react-icons/ri"; +import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { useMemo, useState } from "react"; +import { RiImageEditFill } from "react-icons/ri"; +import Tippy from "@tippyjs/react"; import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { ToolbarInputDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdown"; -import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; -import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; - -const API_KEY = ""; +import { DefaultImageToolbar } from "../../../ImageToolbar/components/DefaultImageToolbar"; export const ReplaceImageButton = (props: { editor: BlockNoteEditor; }) => { const selectedBlocks = useSelectedBlocks(props.editor); + const [isOpen, setIsOpen] = useState(false); + const show = useMemo( () => // Checks if only one block is selected. @@ -32,124 +23,26 @@ export const ReplaceImageButton = (props: { [selectedBlocks] ); - const [isOpen, setIsOpen] = useState( - show && selectedBlocks[0].props.src === "" - ); - const [currentURL, setCurrentURL] = useState( - show ? selectedBlocks[0].props.src : "" - ); - - useEffect(() => { - setIsOpen(show && selectedBlocks[0].props.src === ""); - setCurrentURL(show ? selectedBlocks[0].props.src : ""); - }, [selectedBlocks, show]); - - const handleURLChange = useCallback( - (event: ChangeEvent) => { - setCurrentURL(event.currentTarget.value); - }, - [] - ); - - const handleURLEnter = useCallback( - (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - props.editor.updateBlock(selectedBlocks[0], { - type: "image", - props: { - src: currentURL, - }, - } as PartialBlock); - } - }, - [currentURL, props.editor, selectedBlocks] - ); - - const handleURLPaste = useCallback( - (event: ClipboardEvent) => { - event.preventDefault(); - props.editor.updateBlock(selectedBlocks[0], { - type: "image", - props: { - src: event.clipboardData!.getData("text/plain"), - }, - } as PartialBlock); - }, - [props.editor, selectedBlocks] - ); - - const handleFileChange = useCallback( - (payload: File) => { - const blockID = selectedBlocks[0].id; - - // TODO: Proper backend - right now using imgbb.com since you can get an - // API key for free by just making an account. - // https://imgbb.com/ - const body = new FormData(); - body.append("key", API_KEY); - body.append("image", payload); - - fetch("https://api.imgbb.com/1/upload?expiration=600", { - method: "POST", - body: body, - }) - .then((response) => { - console.log(response); - return response.json(); - }) - .then((data) => { - console.log(data); - const block = props.editor.getBlock(blockID); - - if (block === undefined || block.type !== "image") { - return; - } - - props.editor.updateBlock(blockID, { - type: "image", - props: { - src: data.data.url, - }, - } as PartialBlock); - }); - }, - [props.editor, selectedBlocks] - ); - if (!show) { return null; } return ( - + + }> setIsOpen(!isOpen)} isSelected={isOpen} mainTooltip={"Replace Image"} icon={RiImageEditFill} /> - - - - - + ); }; diff --git a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx new file mode 100644 index 0000000000..15b3cac742 --- /dev/null +++ b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx @@ -0,0 +1,51 @@ +import { BlockSchema } from "@blocknote/core"; + +import { ImageToolbarProps } from "./ImageToolbarPositioner"; +import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; +import { ToolbarButton } from "../../SharedComponents/Toolbar/components/ToolbarButton"; +import { useMemo, useState } from "react"; +import { UploadImageInput } from "./Inputs/UploadImageInput"; +import { Stack } from "@mantine/core"; +import { ImageURLInput } from "./Inputs/ImageURLInput"; + +export const DefaultImageToolbar = ( + props: ImageToolbarProps +) => { + const [openTab, setOpenTab] = useState<"upload" | "embed">("upload"); + + const Input = useMemo( + () => () => { + switch (openTab) { + case "upload": + return ; + case "embed": + return ; + default: + return null; + } + }, + [openTab, props] + ); + + return ( + + + + setOpenTab("upload")}> + Upload + + setOpenTab("embed")}> + Embed + + + + + + ); +}; diff --git a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx new file mode 100644 index 0000000000..a923965cc7 --- /dev/null +++ b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx @@ -0,0 +1,122 @@ +import { + BaseUiElementState, + BlockNoteEditor, + BlockSchema, + BlockSpec, + DefaultBlockSchema, + ImageToolbarState, + SpecificBlock, +} from "@blocknote/core"; +import Tippy, { tippy } from "@tippyjs/react"; +import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { sticky } from "tippy.js"; + +import { DefaultImageToolbar } from "./DefaultImageToolbar"; +import { useEditorChange } from "../../hooks/useEditorChange"; + +export type ImageToolbarProps< + BSchema extends BlockSchema = DefaultBlockSchema +> = Omit & { + editor: BlockNoteEditor; +}; + +export const ImageToolbarPositioner = < + BSchema extends BlockSchema = DefaultBlockSchema +>(props: { + editor: BlockNoteEditor; + imageToolbar?: FC>; +}) => { + // Placement is dynamic based on the text alignment of the current block. + const getPlacement = useMemo( + () => () => { + const block = props.editor.getTextCursorPosition().block; + + if (!("textAlignment" in block.props)) { + return "top-start"; + } + + switch (block.props.textAlignment) { + case "left": + return "top-start"; + case "center": + return "top"; + case "right": + return "top-end"; + default: + return "top-start"; + } + }, + [props.editor] + ); + + const [show, setShow] = useState(false); + const [block, setBlock] = useState< + SpecificBlock< + BlockSchema & { + image: BlockSpec< + "image", + { + src: { default: string }; + }, + false + >; + }, + "image" + > + >(); + const [placement, setPlacement] = useState<"top-start" | "top" | "top-end">( + getPlacement + ); + + const referencePos = useRef(); + + useEffect(() => { + tippy.setDefaultProps({ maxWidth: "" }); + + return props.editor.imageToolbar.onUpdate((imageToolbarState) => { + setShow(imageToolbarState.show); + setBlock(imageToolbarState.block); + + referencePos.current = imageToolbarState.referencePos; + }); + }, [props.editor]); + + useEditorChange(props.editor, () => setPlacement(getPlacement())); + + const getReferenceClientRect = useMemo( + () => { + if (!referencePos) { + return undefined; + } + return () => referencePos.current!; + }, + [referencePos.current] // eslint-disable-line + ); + + const imageToolbarElement = useMemo(() => { + const ImageToolbar = props.imageToolbar || DefaultImageToolbar; + + return ; + }, [block, props.editor, props.imageToolbar]); + + return ( + + ); +}; + +// We want Tippy to call `getReferenceClientRect` whenever the reference +// DOMRect's position changes. This happens automatically on scroll, but we need +// the `sticky` plugin to make it happen in all cases. This is most evident +// when changing the text alignment using the image toolbar. +const tippyPlugins = [sticky]; diff --git a/packages/react/src/ImageToolbar/components/Inputs/ImageURLInput.tsx b/packages/react/src/ImageToolbar/components/Inputs/ImageURLInput.tsx new file mode 100644 index 0000000000..b63944c46e --- /dev/null +++ b/packages/react/src/ImageToolbar/components/Inputs/ImageURLInput.tsx @@ -0,0 +1,79 @@ +import { + BlockNoteEditor, + BlockSchema, + BlockSpec, + PartialBlock, + SpecificBlock, +} from "@blocknote/core"; +import { + ChangeEvent, + ClipboardEvent, + KeyboardEvent, + useCallback, + useState, +} from "react"; +import { TextInput } from "@mantine/core"; + +export const ImageURLInput = (props: { + editor: BlockNoteEditor; + block: SpecificBlock< + BlockSchema & { + image: BlockSpec< + "image", + { + src: { default: string }; + }, + false + >; + }, + "image" + >; +}) => { + const [currentURL, setCurrentURL] = useState(""); + + const handleURLChange = useCallback( + (event: ChangeEvent) => { + setCurrentURL(event.currentTarget.value); + }, + [] + ); + + const handleURLEnter = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + props.editor.updateBlock(props.block, { + type: "image", + props: { + src: currentURL, + }, + } as PartialBlock); + } + }, + [currentURL, props.block, props.editor] + ); + + const handleURLPaste = useCallback( + (event: ClipboardEvent) => { + event.preventDefault(); + props.editor.updateBlock(props.block, { + type: "image", + props: { + src: event.clipboardData!.getData("text/plain"), + }, + } as PartialBlock); + }, + [props.block, props.editor] + ); + + return ( + + ); +}; diff --git a/packages/react/src/ImageToolbar/components/Inputs/UploadImageInput.tsx b/packages/react/src/ImageToolbar/components/Inputs/UploadImageInput.tsx new file mode 100644 index 0000000000..4deade56a2 --- /dev/null +++ b/packages/react/src/ImageToolbar/components/Inputs/UploadImageInput.tsx @@ -0,0 +1,70 @@ +import { + BlockNoteEditor, + BlockSchema, + BlockSpec, + PartialBlock, + SpecificBlock, +} from "@blocknote/core"; +import { FileInput } from "@mantine/core"; +import { useCallback } from "react"; + +const API_KEY = "ddada50bc0e11e4c060af6ce9ceacfc4"; + +export const UploadImageInput = (props: { + editor: BlockNoteEditor; + block: SpecificBlock< + BlockSchema & { + image: BlockSpec< + "image", + { + src: { default: string }; + }, + false + >; + }, + "image" + >; +}) => { + const handleFileChange = useCallback( + (payload: File) => { + // TODO: Proper backend - right now using imgbb.com since you can get an + // API key for free by just making an account. + // https://imgbb.com/ + const body = new FormData(); + body.append("key", API_KEY); + body.append("image", payload); + + fetch("https://api.imgbb.com/1/upload?expiration=600", { + method: "POST", + body: body, + }) + .then((response) => { + return response.json(); + }) + .then((data) => { + const block = props.editor.getBlock(props.block); + + if (block === undefined || block.type !== "image") { + return; + } + + props.editor.updateBlock(props.block, { + type: "image", + props: { + src: data.data.url, + }, + } as PartialBlock); + }); + }, + [props.block, props.editor] + ); + + return ( + + ); +}; From 59122aca53cab6fb05929443b80e1bf1b3a32412 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 11 Sep 2023 16:39:03 +0200 Subject: [PATCH 32/61] Made image toolbar open immediately after inserting an image using the slash menu --- .../extensions/SlashMenu/defaultSlashMenuItems.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 341d70d847..a7788b4dfc 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -2,6 +2,7 @@ import { BlockNoteEditor } from "../../BlockNoteEditor"; import { BlockSchema, PartialBlock } from "../Blocks/api/blockTypes"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; import { defaultBlockSchema } from "../Blocks/api/defaultBlocks"; +import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; function insertOrUpdateBlock( editor: BlockNoteEditor, @@ -130,7 +131,16 @@ export const getDefaultSlashMenuItems = ( type: "image", } as PartialBlock); // Don't want to select the add image button - editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!); + editor.setTextCursorPosition( + editor.getTextCursorPosition().prevBlock!, + "end" + ); + // Immediately open the image toolbar + editor._tiptapEditor.view.dispatch( + editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { + block: editor.getTextCursorPosition().nextBlock, + }) + ); }, }); } From 806946081e78f64b8aa723ca0fa3990c878f3fea Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 11 Sep 2023 16:39:35 +0200 Subject: [PATCH 33/61] Fixed replace image button having persistent `open` state --- .../components/DefaultButtons/ReplaceImageButton.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx index 141f7f8802..7813e750a9 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx +++ b/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx @@ -1,5 +1,5 @@ import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { RiImageEditFill } from "react-icons/ri"; import Tippy from "@tippyjs/react"; @@ -14,6 +14,10 @@ export const ReplaceImageButton = (props: { const [isOpen, setIsOpen] = useState(false); + useEffect(() => { + setIsOpen(false); + }, [selectedBlocks]); + const show = useMemo( () => // Checks if only one block is selected. From acf7172287619b564a3d45788b425fcabdb5fc83 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 11 Sep 2023 16:39:54 +0200 Subject: [PATCH 34/61] `ImageToolbarPositioner.tsx` cleanup --- .../components/ImageToolbarPositioner.tsx | 40 +------------------ 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx index a923965cc7..9400cf75d9 100644 --- a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx +++ b/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx @@ -9,10 +9,8 @@ import { } from "@blocknote/core"; import Tippy, { tippy } from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; -import { sticky } from "tippy.js"; import { DefaultImageToolbar } from "./DefaultImageToolbar"; -import { useEditorChange } from "../../hooks/useEditorChange"; export type ImageToolbarProps< BSchema extends BlockSchema = DefaultBlockSchema @@ -26,29 +24,6 @@ export const ImageToolbarPositioner = < editor: BlockNoteEditor; imageToolbar?: FC>; }) => { - // Placement is dynamic based on the text alignment of the current block. - const getPlacement = useMemo( - () => () => { - const block = props.editor.getTextCursorPosition().block; - - if (!("textAlignment" in block.props)) { - return "top-start"; - } - - switch (block.props.textAlignment) { - case "left": - return "top-start"; - case "center": - return "top"; - case "right": - return "top-end"; - default: - return "top-start"; - } - }, - [props.editor] - ); - const [show, setShow] = useState(false); const [block, setBlock] = useState< SpecificBlock< @@ -64,9 +39,6 @@ export const ImageToolbarPositioner = < "image" > >(); - const [placement, setPlacement] = useState<"top-start" | "top" | "top-end">( - getPlacement - ); const referencePos = useRef(); @@ -81,8 +53,6 @@ export const ImageToolbarPositioner = < }); }, [props.editor]); - useEditorChange(props.editor, () => setPlacement(getPlacement())); - const getReferenceClientRect = useMemo( () => { if (!referencePos) { @@ -107,16 +77,8 @@ export const ImageToolbarPositioner = < interactive={true} visible={show} animation={"fade"} - placement={placement} - sticky={true} - plugins={tippyPlugins} + placement={"bottom"} zIndex={5000} /> ); }; - -// We want Tippy to call `getReferenceClientRect` whenever the reference -// DOMRect's position changes. This happens automatically on scroll, but we need -// the `sticky` plugin to make it happen in all cases. This is most evident -// when changing the text alignment using the image toolbar. -const tippyPlugins = [sticky]; From f768c8b66bfd4c55204d90797168da680b21c65c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 13 Sep 2023 16:27:08 +0200 Subject: [PATCH 35/61] Made image toolbar design more similar to Notion --- packages/react/src/BlockNoteTheme.ts | 7 ------- .../src/ImageToolbar/components/DefaultImageToolbar.tsx | 7 +++++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts index 79ec27c71b..f0b4f736bc 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -142,7 +142,6 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { wrapper: { border: `solid ${theme.colors.border} 1px`, borderRadius: "4px", - // boxShadow: theme.colors.shadow, }, placeholder: { color: `${theme.colors.menu.text} !important`, @@ -156,14 +155,8 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { input: { border: `solid ${theme.colors.border} 1px`, borderRadius: "4px", - paddingInline: "8%", height: "32px", }, - wrapper: { - // border: `solid ${theme.colors.border} 1px`, - // borderRadius: "4px", - minWidth: "0", - }, }), }, ColorIcon: { diff --git a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx index 15b3cac742..344a020f48 100644 --- a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx +++ b/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx @@ -28,8 +28,11 @@ export const DefaultImageToolbar = ( ); return ( - - + + Date: Tue, 19 Sep 2023 00:30:16 +0200 Subject: [PATCH 36/61] Cleaned up block typing --- .../api/nodeConversions/nodeConversions.ts | 7 +- .../core/src/extensions/Blocks/api/block.ts | 29 ++- .../src/extensions/Blocks/api/blockTypes.ts | 52 ++-- .../extensions/Blocks/api/defaultBlocks.ts | 8 +- .../HeadingBlockContent.ts | 2 +- .../BulletListItemBlockContent.ts | 5 +- .../NumberedListItemBlockContent.ts | 236 +++++++++--------- .../ParagraphBlockContent.ts | 2 +- packages/react/src/ReactBlockSpec.tsx | 6 +- 9 files changed, 189 insertions(+), 158 deletions(-) diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts index f6cb53a072..6be5ec9cf3 100644 --- a/packages/core/src/api/nodeConversions/nodeConversions.ts +++ b/packages/core/src/api/nodeConversions/nodeConversions.ts @@ -421,9 +421,10 @@ export function nodeToBlock( id, type: blockSpec.node.name, props, - content: blockSpec.containsInlineContent - ? contentNodeToInlineContent(blockInfo.contentNode) - : undefined, + content: + blockSpec.node.config.content === "inline*" + ? contentNodeToInlineContent(blockInfo.contentNode) + : undefined, children, } as Block; diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts index b23c35c8ca..acbad03076 100644 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ b/packages/core/src/extensions/Blocks/api/block.ts @@ -121,20 +121,23 @@ export function render< export function createBlockSpec< BType extends string, PSchema extends PropSchema, - ContainsInlineContent extends boolean, + ContainsInlineContent extends false, BSchema extends BlockSchema >( blockConfig: BlockConfig ): BlockSpec { const node = createTipTapBlock< BType, + ContainsInlineContent, { editor: BlockNoteEditor; domAttributes?: BlockNoteDOMAttributes; } >({ name: blockConfig.type, - content: blockConfig.containsInlineContent ? "inline*" : "", + content: (blockConfig.containsInlineContent + ? "inline*" + : "") as ContainsInlineContent extends true ? "inline*" : "", selectable: true, addAttributes() { @@ -204,7 +207,10 @@ export function createBlockSpec< // Render elements const rendered = blockConfig.render(block as any, editor); // Add HTML attributes to contentDOM - if ("contentDOM" in rendered) { + if (blockConfig.containsInlineContent) { + const contentDOM = (rendered as { contentDOM: HTMLElement }) + .contentDOM; + const inlineContentDOMAttributes = this.options.domAttributes?.inlineContent || {}; // Add custom HTML attributes @@ -212,12 +218,12 @@ export function createBlockSpec< inlineContentDOMAttributes )) { if (attribute !== "class") { - rendered.contentDOM.setAttribute(attribute, value); + contentDOM.setAttribute(attribute, value); } } // Merge existing classes with inlineContent & custom classes - rendered.contentDOM.className = mergeCSSClasses( - rendered.contentDOM.className, + contentDOM.className = mergeCSSClasses( + contentDOM.className, styles.inlineContent, inlineContentDOMAttributes.class ); @@ -240,14 +246,14 @@ export function createBlockSpec< }); return { - node: node as TipTapNode, + node: node as TipTapNode, propSchema: blockConfig.propSchema, - containsInlineContent: blockConfig.containsInlineContent, }; } export function createTipTapBlock< Type extends string, + ContainsInlineContent extends boolean, Options extends { domAttributes?: BlockNoteDOMAttributes; } = { @@ -255,8 +261,8 @@ export function createTipTapBlock< }, Storage = any >( - config: TipTapNodeConfig -): TipTapNode { + config: TipTapNodeConfig +): TipTapNode { // Type cast is needed as Node.name is mutable, though there is basically no // reason to change it after creation. Alternative is to wrap Node in a new // class, which I don't think is worth it since we'd only be changing 1 @@ -264,5 +270,6 @@ export function createTipTapBlock< return Node.create({ ...config, group: "blockContent", - }) as TipTapNode; + content: config.content, + }) as TipTapNode; } diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts index 831125a4c7..776a7d2bab 100644 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ b/packages/core/src/extensions/Blocks/api/blockTypes.ts @@ -16,11 +16,13 @@ export type BlockNoteDOMAttributes = Partial<{ }>; // A configuration for a TipTap node, but with stricter type constraints on the -// "name" and "group" properties. The "name" property is now always a string -// literal type, and the "blockGroup" property cannot be configured as it should -// always be "blockContent". Used as the parameter in `createTipTapNode`. +// "name" and "content" properties. The "name" property is now always a string +// literal type, and the "content" property can only be "inline*" or "". Used as +// the parameter in `createTipTapNode`. The "group" is also removed as +// `createTipTapNode` always sets it to "blockContent" export type TipTapNodeConfig< Name extends string, + ContainsInlineContent extends boolean, Options extends { domAttributes?: BlockNoteDOMAttributes; } = { @@ -33,22 +35,43 @@ export type TipTapNodeConfig< : K extends "group" ? never : NodeConfig[K]; +} & { + content: ContainsInlineContent extends true ? "inline*" : ""; }; -// A TipTap node with stricter type constraints on the "name" and "group" -// properties. The "name" property is now a string literal type, and the -// "blockGroup" property is now "blockContent". Returned by `createTipTapNode`. +// A TipTap node with stricter type constraints on the "name", "group", and +// "content properties. The "name" property is now a string literal type, and +// the "blockGroup" property is now "blockContent", and the "content" property +// can only be "inline*" or "". Returned by `createTipTapNode`. export type TipTapNode< Name extends string, + ContainsInlineContent extends boolean, Options extends { domAttributes?: BlockNoteDOMAttributes; } = { domAttributes?: BlockNoteDOMAttributes; }, Storage = any -> = Node & { - name: Name; - group: "blockContent"; +> = { + [Key in keyof Node]: Key extends "name" + ? Name + : Key extends "config" + ? { + [ConfigKey in keyof Node< + Options, + Storage + >["config"]]: ConfigKey extends "group" + ? "blockContent" + : ConfigKey extends "content" + ? ContainsInlineContent extends true + ? "inline*" + : "" + : NodeConfig["config"][ConfigKey]; + } & { + group: "blockContent"; + content: ContainsInlineContent extends true ? "inline*" : ""; + } + : Node["config"][Key]; }; // Defines a single prop spec, which includes the default value the prop should @@ -132,14 +155,13 @@ export type BlockSpec< PSchema extends PropSchema, ContainsInlineContent extends boolean > = { - node: TipTapNode; + node: TipTapNode; readonly propSchema: PSchema; - containsInlineContent: ContainsInlineContent; }; // Utility type. For a given object block schema, ensures that the key of each // block spec matches the name of the TipTap node in it. -export type TypesMatch< +type NamesMatch< Blocks extends Record> > = Blocks extends { [Type in keyof Blocks]: Type extends string @@ -156,7 +178,7 @@ export type TypesMatch< // `blocks` option of the BlockNoteEditor. From a block schema, we can derive // both the blocks' internal implementation (as TipTap nodes) and the type // information for the external API. -export type BlockSchema = TypesMatch< +export type BlockSchema = NamesMatch< Record> >; @@ -168,7 +190,7 @@ type BlocksWithoutChildren = { id: string; type: BType; props: Props; - content: BSchema[BType]["containsInlineContent"] extends true + content: BSchema[BType]["node"]["config"]["content"] extends "inline*" ? InlineContent[] : undefined; }; @@ -195,7 +217,7 @@ type PartialBlocksWithoutChildren = { id: string; type: BType; props: Partial>; - content: BSchema[BType]["containsInlineContent"] extends true + content: BSchema[BType]["node"]["config"]["content"] extends "inline*" ? PartialInlineContent[] | string : undefined; }>; diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts index 546d6ecf33..f266c80bab 100644 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts @@ -2,7 +2,7 @@ import { HeadingBlockContent } from "../nodes/BlockContent/HeadingBlockContent/H import { BulletListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; import { NumberedListItemBlockContent } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; import { ParagraphBlockContent } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; -import { BlockSchema, TypesMatch } from "./blockTypes"; +import { BlockSchema } from "./blockTypes"; import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent"; import { defaultProps } from "./defaultProps"; @@ -10,7 +10,6 @@ export const defaultBlockSchema = { paragraph: { node: ParagraphBlockContent, propSchema: defaultProps, - containsInlineContent: true, }, heading: { node: HeadingBlockContent, @@ -18,19 +17,16 @@ export const defaultBlockSchema = { ...defaultProps, level: { default: "1", values: ["1", "2", "3"] as const }, }, - containsInlineContent: true, }, bulletListItem: { node: BulletListItemBlockContent, propSchema: defaultProps, - containsInlineContent: true, }, numberedListItem: { node: NumberedListItemBlockContent, propSchema: defaultProps, - containsInlineContent: true, }, image: Image, } as const satisfies BlockSchema; -export type DefaultBlockSchema = TypesMatch; +export type DefaultBlockSchema = typeof defaultBlockSchema; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts index 08c9c70ebb..cec5feb537 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts @@ -3,7 +3,7 @@ import { createTipTapBlock } from "../../../api/block"; import styles from "../../Block.module.css"; import { mergeCSSClasses } from "../../../../../shared/utils"; -export const HeadingBlockContent = createTipTapBlock<"heading">({ +export const HeadingBlockContent = createTipTapBlock<"heading", true>({ name: "heading", content: "inline*", diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index 566698135d..87b7f67c4f 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -4,7 +4,10 @@ import { handleEnter } from "../ListItemKeyboardShortcuts"; import styles from "../../../Block.module.css"; import { mergeCSSClasses } from "../../../../../../shared/utils"; -export const BulletListItemBlockContent = createTipTapBlock<"bulletListItem">({ +export const BulletListItemBlockContent = createTipTapBlock< + "bulletListItem", + true +>({ name: "bulletListItem", content: "inline*", diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index 7745a372c7..e2afbc7269 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -5,134 +5,134 @@ import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin"; import styles from "../../../Block.module.css"; import { mergeCSSClasses } from "../../../../../../shared/utils"; -export const NumberedListItemBlockContent = - createTipTapBlock<"numberedListItem">({ - name: "numberedListItem", - content: "inline*", - - addAttributes() { - return { - index: { - default: null, - parseHTML: (element) => element.getAttribute("data-index"), - renderHTML: (attributes) => { - return { - "data-index": attributes.index, - }; - }, +export const NumberedListItemBlockContent = createTipTapBlock< + "numberedListItem", + true +>({ + name: "numberedListItem", + content: "inline*", + + addAttributes() { + return { + index: { + default: null, + parseHTML: (element) => element.getAttribute("data-index"), + renderHTML: (attributes) => { + return { + "data-index": attributes.index, + }; }, - }; - }, - - addInputRules() { - return [ - // Creates an ordered list when starting with "1.". - new InputRule({ - find: new RegExp(`^1\\.\\s$`), - handler: ({ state, chain, range }) => { - chain() - .BNUpdateBlock(state.selection.from, { - type: "numberedListItem", - props: {}, - }) - // Removes the "1." characters used to set the list. - .deleteRange({ from: range.from, to: range.to }); - }, - }), - ]; - }, - - addKeyboardShortcuts() { - return { - Enter: () => handleEnter(this.editor), - }; - }, - - addProseMirrorPlugins() { - return [NumberedListIndexingPlugin()]; - }, - - parseHTML() { - return [ - // Case for regular HTML list structure. - // (e.g.: when pasting from other apps) - { - tag: "li", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } + }, + }; + }, + + addInputRules() { + return [ + // Creates an ordered list when starting with "1.". + new InputRule({ + find: new RegExp(`^1\\.\\s$`), + handler: ({ state, chain, range }) => { + chain() + .BNUpdateBlock(state.selection.from, { + type: "numberedListItem", + props: {}, + }) + // Removes the "1." characters used to set the list. + .deleteRange({ from: range.from, to: range.to }); + }, + }), + ]; + }, + + addKeyboardShortcuts() { + return { + Enter: () => handleEnter(this.editor), + }; + }, + + addProseMirrorPlugins() { + return [NumberedListIndexingPlugin()]; + }, + + parseHTML() { + return [ + // Case for regular HTML list structure. + // (e.g.: when pasting from other apps) + { + tag: "li", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } - const parent = element.parentElement; + const parent = element.parentElement; - if (parent === null) { - return false; - } + if (parent === null) { + return false; + } - if (parent.tagName === "OL") { - return {}; - } + if (parent.tagName === "OL") { + return {}; + } - return false; - }, - node: "numberedListItem", + return false; }, - // Case for BlockNote list structure. - // (e.g.: when pasting from blocknote) - { - tag: "p", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } + node: "numberedListItem", + }, + // Case for BlockNote list structure. + // (e.g.: when pasting from blocknote) + { + tag: "p", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } - const parent = element.parentElement; + const parent = element.parentElement; - if (parent === null) { - return false; - } + if (parent === null) { + return false; + } - if ( - parent.getAttribute("data-content-type") === "numberedListItem" - ) { - return {}; - } + if (parent.getAttribute("data-content-type") === "numberedListItem") { + return {}; + } - return false; - }, - priority: 300, - node: "numberedListItem", + return false; }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - - return [ - "div", - mergeAttributes(HTMLAttributes, { + priority: 300, + node: "numberedListItem", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + const inlineContentDOMAttributes = + this.options.domAttributes?.inlineContent || {}; + + return [ + "div", + mergeAttributes(HTMLAttributes, { + class: mergeCSSClasses( + styles.blockContent, + blockContentDOMAttributes.class + ), + "data-content-type": this.name, + }), + // we use a

tag, because for

  • tags we'd need to add a