Skip to content

fix: placeholder improvements #1439

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/pages/docs/editor-basics/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
6 changes: 4 additions & 2 deletions examples/01-basic/11-custom-placeholder/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
});
Expand Down
6 changes: 5 additions & 1 deletion examples/01-basic/11-custom-placeholder/README.md
Original file line number Diff line number Diff line change
@@ -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:**

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ export type BlockNoteEditorOptions<
/**
* @deprecated, provide placeholders via dictionary instead
*/
placeholders: Record<string | "default", string>;
placeholders: Record<
string | "default" | "emptyDocument",
string | undefined
>;

/**
* An object containing attributes that should be added to HTML elements of the editor.
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/editor/BlockNoteExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -69,7 +69,10 @@ type ExtensionOptions<
animations: boolean;
tableHandles: boolean;
dropCursor: (opts: any) => Plugin;
placeholders: Record<string | "default", string>;
placeholders: Record<
string | "default" | "emptyDocument",
string | undefined
>;
tabBehavior?: "prefer-navigate-ui" | "prefer-indent";
sideMenuDetection: "viewport" | "editor";
};
Expand Down
123 changes: 69 additions & 54 deletions packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts
Original file line number Diff line number Diff line change
@@ -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`);
Expand All @@ -8,16 +9,23 @@ export class PlaceholderPlugin {
public readonly plugin: Plugin;
constructor(
editor: BlockNoteEditor<any, any, any>,
placeholders: Record<string | "default", string>
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 {
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure how important this is

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this relates to;

I'm not sure the current API is great (the way they're passed in via the dictionary), but I think we can address this separately

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 {
Expand All @@ -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;

Expand All @@ -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);
},
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading