From f148b230f1b73365610dd2aa3f4ed09158eb0ddf Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 12:06:34 +0100 Subject: [PATCH 1/2] placeholder improvements --- docs/pages/docs/editor-basics/setup.mdx | 2 +- .../01-basic/11-custom-placeholder/App.tsx | 6 +- .../01-basic/11-custom-placeholder/README.md | 6 +- packages/core/src/editor/BlockNoteEditor.ts | 5 +- .../core/src/editor/BlockNoteExtensions.ts | 9 +- .../Placeholder/PlaceholderPlugin.ts | 123 ++++++++++-------- 6 files changed, 89 insertions(+), 62 deletions(-) diff --git a/docs/pages/docs/editor-basics/setup.mdx b/docs/pages/docs/editor-basics/setup.mdx index eb7809c9be..76d1ebe0ef 100644 --- a/docs/pages/docs/editor-basics/setup.mdx +++ b/docs/pages/docs/editor-basics/setup.mdx @@ -45,7 +45,7 @@ The hook takes two optional parameters: `collaboration`: Options for enabling real-time collaboration. See [Real-time Collaboration](/docs/advanced/real-time-collaboration) for more info. -`dictionary`: Provide strings for localization. See the [Localization / i18n example](/examples/basic/localization). +`dictionary`: Provide strings for localization. See the [Localization / i18n example](/examples/basic/localization) and [Custom Placeholders](/examples/basic/custom-placeholder). `schema` (_advanced_): The editor schema if you want to extend your editor with custom blocks, styles, or inline content [Custom Schemas](/docs/custom-schemas). diff --git a/examples/01-basic/11-custom-placeholder/App.tsx b/examples/01-basic/11-custom-placeholder/App.tsx index c2ed79f309..2919ce8e0d 100644 --- a/examples/01-basic/11-custom-placeholder/App.tsx +++ b/examples/01-basic/11-custom-placeholder/App.tsx @@ -15,10 +15,12 @@ export default function App() { ...locale, placeholders: { ...locale.placeholders, + // We override the empty document placeholder + emptyDocument: "Start typing..", // We override the default placeholder - default: "This is a custom placeholder", + default: "Custom default placeholder", // We override the heading placeholder - heading: "This is a custom heading", + heading: "Custom heading placeholder", }, }, }); diff --git a/examples/01-basic/11-custom-placeholder/README.md b/examples/01-basic/11-custom-placeholder/README.md index 10b44a0cef..8655e4641b 100644 --- a/examples/01-basic/11-custom-placeholder/README.md +++ b/examples/01-basic/11-custom-placeholder/README.md @@ -1,6 +1,10 @@ # Change placeholder text -In this example, we show how to change the default placeholder and the placeholder for Headings by overriding the placeholders in the dictionary. +In this example, we show how to change the placeholders: + +- For an empty document, we show a placeholder `Start typing..` (by default, this is not set) +- the default placeholder in this editor shows `Custom default placeholder` instead of the default (`Enter text or type '/' for commands`) +- for Headings, the placeholder shows `Custom heading placeholder` instead of the default (`Heading`). Try adding a Heading to see the change **Relevant Docs:** diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index e2ae4a7256..8b1fe23005 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -130,7 +130,10 @@ export type BlockNoteEditorOptions< /** * @deprecated, provide placeholders via dictionary instead */ - placeholders: Record; + placeholders: Record< + string | "default" | "emptyDocument", + string | undefined + >; /** * An object containing attributes that should be added to HTML elements of the editor. diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 28459bb456..c1e7a006cb 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -7,11 +7,11 @@ import { Text } from "@tiptap/extension-text"; import { Plugin } from "prosemirror-state"; import * as Y from "yjs"; -import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js"; import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDropExtension.js"; import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js"; import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js"; import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension.js"; +import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js"; import { FilePanelProsemirrorPlugin } from "../extensions/FilePanel/FilePanelPlugin.js"; import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin.js"; import { KeyboardShortcutsExtension } from "../extensions/KeyboardShortcuts/KeyboardShortcutsExtension.js"; @@ -40,7 +40,7 @@ import { StyleSchema, StyleSpecs, } from "../schema/index.js"; -import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js"; +import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js"; type ExtensionOptions< BSchema extends BlockSchema, @@ -69,7 +69,10 @@ type ExtensionOptions< animations: boolean; tableHandles: boolean; dropCursor: (opts: any) => Plugin; - placeholders: Record; + placeholders: Record< + string | "default" | "emptyDocument", + string | undefined + >; tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; sideMenuDetection: "viewport" | "editor"; }; diff --git a/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts b/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts index 440457eb29..ebb4c27b18 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts @@ -1,5 +1,6 @@ import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; +import { v4 } from "uuid"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); @@ -8,16 +9,23 @@ export class PlaceholderPlugin { public readonly plugin: Plugin; constructor( editor: BlockNoteEditor, - placeholders: Record + placeholders: Record< + string | "default" | "emptyDocument", + string | undefined + > ) { this.plugin = new Plugin({ key: PLUGIN_KEY, - view: () => { + view: (view) => { + const uniqueEditorSelector = `placeholder-selector-${v4()}`; + view.dom.classList.add(uniqueEditorSelector); const styleEl = document.createElement("style"); + const nonce = editor._tiptapEditor.options.injectNonce; if (nonce) { styleEl.setAttribute("nonce", nonce); } + if (editor.prosemirrorView?.root instanceof ShadowRoot) { editor.prosemirrorView.root.append(styleEl); } else { @@ -26,54 +34,50 @@ export class PlaceholderPlugin { const styleSheet = styleEl.sheet!; - const getBaseSelector = (additionalSelectors = "") => - `.bn-block-content${additionalSelectors} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`; + const getSelector = (additionalSelectors = "") => + `.${uniqueEditorSelector} .bn-block-content${additionalSelectors} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`; - const getSelector = ( - blockType: string | "default", - mustBeFocused = true - ) => { - const mustBeFocusedSelector = mustBeFocused - ? `[data-is-empty-and-focused]` - : ``; + try { + // FIXME: the names "default" and "emptyDocument" are hardcoded + const { + default: defaultPlaceholder, + emptyDocument: emptyPlaceholder, + ...rest + } = placeholders; - if (blockType === "default") { - return getBaseSelector(mustBeFocusedSelector); - } - - const blockTypeSelector = `[data-content-type="${blockType}"]`; - return getBaseSelector(mustBeFocusedSelector + blockTypeSelector); - }; + // add block specific placeholders + for (const [blockType, placeholder] of Object.entries(rest)) { + const blockTypeSelector = `[data-content-type="${blockType}"]`; - for (const [blockType, placeholder] of Object.entries(placeholders)) { - const mustBeFocused = blockType === "default"; - - try { styleSheet.insertRule( - `${getSelector( - blockType, - mustBeFocused - )} { content: ${JSON.stringify(placeholder)}; }` - ); - - // For some reason, the placeholders which show when the block is focused - // take priority over ones which show depending on block type, so we need - // to make sure the block specific ones are also used when the block is - // focused. - if (!mustBeFocused) { - styleSheet.insertRule( - `${getSelector(blockType, true)} { content: ${JSON.stringify( - placeholder - )}; }` - ); - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn( - `Failed to insert placeholder CSS rule - this is likely due to the browser not supporting certain CSS pseudo-element selectors (:has, :only-child:, or :before)`, - e + `${getSelector(blockTypeSelector)} { content: ${JSON.stringify( + placeholder + )}; }` ); } + + const onlyBlockSelector = `[data-is-only-empty-block]`; + const mustBeFocusedSelector = `[data-is-empty-and-focused]`; + + // placeholder for when there's only one empty block + styleSheet.insertRule( + `${getSelector(onlyBlockSelector)} { content: ${JSON.stringify( + emptyPlaceholder + )}; }` + ); + + // placeholder for default blocks, only when the cursor is in the block (mustBeFocused) + styleSheet.insertRule( + `${getSelector(mustBeFocusedSelector)} { content: ${JSON.stringify( + defaultPlaceholder + )}; }` + ); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `Failed to insert placeholder CSS rule - this is likely due to the browser not supporting certain CSS pseudo-element selectors (:has, :only-child:, or :before)`, + e + ); } return { @@ -87,7 +91,6 @@ export class PlaceholderPlugin { }; }, props: { - // TODO: maybe also add placeholder for empty document ("e.g.: start writing..") decorations: (state) => { const { doc, selection } = state; @@ -104,20 +107,32 @@ export class PlaceholderPlugin { return; } - const $pos = selection.$anchor; - const node = $pos.parent; + const decs = []; - if (node.content.size > 0) { - return null; + // decoration for when there's only one empty block + // positions are hardcoded for now + if (state.doc.content.size === 6) { + decs.push( + Decoration.node(2, 4, { + "data-is-only-empty-block": "true", + }) + ); } - const before = $pos.before(); + const $pos = selection.$anchor; + const node = $pos.parent; + + if (node.content.size === 0) { + const before = $pos.before(); - const dec = Decoration.node(before, before + node.nodeSize, { - "data-is-empty-and-focused": "true", - }); + decs.push( + Decoration.node(before, before + node.nodeSize, { + "data-is-empty-and-focused": "true", + }) + ); + } - return DecorationSet.create(doc, [dec]); + return DecorationSet.create(doc, decs); }, }, }); From 41b513beda375670a13e26f84f6fdc8a619b84ed Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 13:13:49 +0100 Subject: [PATCH 2/2] fix types --- packages/core/src/i18n/locales/en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index 442f7a3895..450eb46c69 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -129,7 +129,7 @@ export const en = { bulletListItem: "List", numberedListItem: "List", checkListItem: "List", - }, + } as Record<"default" | "emptyDocument" | string, string | undefined>, file_blocks: { image: { add_button_text: "Add image",