Skip to content

Commit 67d08a8

Browse files
authored
fix: placeholder improvements (#1439)
* placeholder improvements * fix types
1 parent 8429d62 commit 67d08a8

File tree

7 files changed

+90
-63
lines changed

7 files changed

+90
-63
lines changed

docs/pages/docs/editor-basics/setup.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ The hook takes two optional parameters:
4545

4646
`collaboration`: Options for enabling real-time collaboration. See [Real-time Collaboration](/docs/advanced/real-time-collaboration) for more info.
4747

48-
`dictionary`: Provide strings for localization. See the [Localization / i18n example](/examples/basic/localization).
48+
`dictionary`: Provide strings for localization. See the [Localization / i18n example](/examples/basic/localization) and [Custom Placeholders](/examples/basic/custom-placeholder).
4949

5050
`schema` (_advanced_): The editor schema if you want to extend your editor with custom blocks, styles, or inline content [Custom Schemas](/docs/custom-schemas).
5151

examples/01-basic/11-custom-placeholder/App.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ export default function App() {
1515
...locale,
1616
placeholders: {
1717
...locale.placeholders,
18+
// We override the empty document placeholder
19+
emptyDocument: "Start typing..",
1820
// We override the default placeholder
19-
default: "This is a custom placeholder",
21+
default: "Custom default placeholder",
2022
// We override the heading placeholder
21-
heading: "This is a custom heading",
23+
heading: "Custom heading placeholder",
2224
},
2325
},
2426
});

examples/01-basic/11-custom-placeholder/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Change placeholder text
22

3-
In this example, we show how to change the default placeholder and the placeholder for Headings by overriding the placeholders in the dictionary.
3+
In this example, we show how to change the placeholders:
4+
5+
- For an empty document, we show a placeholder `Start typing..` (by default, this is not set)
6+
- the default placeholder in this editor shows `Custom default placeholder` instead of the default (`Enter text or type '/' for commands`)
7+
- for Headings, the placeholder shows `Custom heading placeholder` instead of the default (`Heading`). Try adding a Heading to see the change
48

59
**Relevant Docs:**
610

packages/core/src/editor/BlockNoteEditor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ export type BlockNoteEditorOptions<
130130
/**
131131
* @deprecated, provide placeholders via dictionary instead
132132
*/
133-
placeholders: Record<string | "default", string>;
133+
placeholders: Record<
134+
string | "default" | "emptyDocument",
135+
string | undefined
136+
>;
134137

135138
/**
136139
* An object containing attributes that should be added to HTML elements of the editor.

packages/core/src/editor/BlockNoteExtensions.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { Text } from "@tiptap/extension-text";
77
import { Plugin } from "prosemirror-state";
88
import * as Y from "yjs";
99

10-
import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js";
1110
import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDropExtension.js";
1211
import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js";
1312
import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js";
1413
import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension.js";
14+
import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js";
1515
import { FilePanelProsemirrorPlugin } from "../extensions/FilePanel/FilePanelPlugin.js";
1616
import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin.js";
1717
import { KeyboardShortcutsExtension } from "../extensions/KeyboardShortcuts/KeyboardShortcutsExtension.js";
@@ -40,7 +40,7 @@ import {
4040
StyleSchema,
4141
StyleSpecs,
4242
} from "../schema/index.js";
43-
import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js";
43+
import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js";
4444

4545
type ExtensionOptions<
4646
BSchema extends BlockSchema,
@@ -69,7 +69,10 @@ type ExtensionOptions<
6969
animations: boolean;
7070
tableHandles: boolean;
7171
dropCursor: (opts: any) => Plugin;
72-
placeholders: Record<string | "default", string>;
72+
placeholders: Record<
73+
string | "default" | "emptyDocument",
74+
string | undefined
75+
>;
7376
tabBehavior?: "prefer-navigate-ui" | "prefer-indent";
7477
sideMenuDetection: "viewport" | "editor";
7578
};

packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts

Lines changed: 69 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Plugin, PluginKey } from "prosemirror-state";
22
import { Decoration, DecorationSet } from "prosemirror-view";
3+
import { v4 } from "uuid";
34
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
45

56
const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`);
@@ -8,16 +9,23 @@ export class PlaceholderPlugin {
89
public readonly plugin: Plugin;
910
constructor(
1011
editor: BlockNoteEditor<any, any, any>,
11-
placeholders: Record<string | "default", string>
12+
placeholders: Record<
13+
string | "default" | "emptyDocument",
14+
string | undefined
15+
>
1216
) {
1317
this.plugin = new Plugin({
1418
key: PLUGIN_KEY,
15-
view: () => {
19+
view: (view) => {
20+
const uniqueEditorSelector = `placeholder-selector-${v4()}`;
21+
view.dom.classList.add(uniqueEditorSelector);
1622
const styleEl = document.createElement("style");
23+
1724
const nonce = editor._tiptapEditor.options.injectNonce;
1825
if (nonce) {
1926
styleEl.setAttribute("nonce", nonce);
2027
}
28+
2129
if (editor.prosemirrorView?.root instanceof ShadowRoot) {
2230
editor.prosemirrorView.root.append(styleEl);
2331
} else {
@@ -26,54 +34,50 @@ export class PlaceholderPlugin {
2634

2735
const styleSheet = styleEl.sheet!;
2836

29-
const getBaseSelector = (additionalSelectors = "") =>
30-
`.bn-block-content${additionalSelectors} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`;
37+
const getSelector = (additionalSelectors = "") =>
38+
`.${uniqueEditorSelector} .bn-block-content${additionalSelectors} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`;
3139

32-
const getSelector = (
33-
blockType: string | "default",
34-
mustBeFocused = true
35-
) => {
36-
const mustBeFocusedSelector = mustBeFocused
37-
? `[data-is-empty-and-focused]`
38-
: ``;
40+
try {
41+
// FIXME: the names "default" and "emptyDocument" are hardcoded
42+
const {
43+
default: defaultPlaceholder,
44+
emptyDocument: emptyPlaceholder,
45+
...rest
46+
} = placeholders;
3947

40-
if (blockType === "default") {
41-
return getBaseSelector(mustBeFocusedSelector);
42-
}
43-
44-
const blockTypeSelector = `[data-content-type="${blockType}"]`;
45-
return getBaseSelector(mustBeFocusedSelector + blockTypeSelector);
46-
};
48+
// add block specific placeholders
49+
for (const [blockType, placeholder] of Object.entries(rest)) {
50+
const blockTypeSelector = `[data-content-type="${blockType}"]`;
4751

48-
for (const [blockType, placeholder] of Object.entries(placeholders)) {
49-
const mustBeFocused = blockType === "default";
50-
51-
try {
5252
styleSheet.insertRule(
53-
`${getSelector(
54-
blockType,
55-
mustBeFocused
56-
)} { content: ${JSON.stringify(placeholder)}; }`
57-
);
58-
59-
// For some reason, the placeholders which show when the block is focused
60-
// take priority over ones which show depending on block type, so we need
61-
// to make sure the block specific ones are also used when the block is
62-
// focused.
63-
if (!mustBeFocused) {
64-
styleSheet.insertRule(
65-
`${getSelector(blockType, true)} { content: ${JSON.stringify(
66-
placeholder
67-
)}; }`
68-
);
69-
}
70-
} catch (e) {
71-
// eslint-disable-next-line no-console
72-
console.warn(
73-
`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)`,
74-
e
53+
`${getSelector(blockTypeSelector)} { content: ${JSON.stringify(
54+
placeholder
55+
)}; }`
7556
);
7657
}
58+
59+
const onlyBlockSelector = `[data-is-only-empty-block]`;
60+
const mustBeFocusedSelector = `[data-is-empty-and-focused]`;
61+
62+
// placeholder for when there's only one empty block
63+
styleSheet.insertRule(
64+
`${getSelector(onlyBlockSelector)} { content: ${JSON.stringify(
65+
emptyPlaceholder
66+
)}; }`
67+
);
68+
69+
// placeholder for default blocks, only when the cursor is in the block (mustBeFocused)
70+
styleSheet.insertRule(
71+
`${getSelector(mustBeFocusedSelector)} { content: ${JSON.stringify(
72+
defaultPlaceholder
73+
)}; }`
74+
);
75+
} catch (e) {
76+
// eslint-disable-next-line no-console
77+
console.warn(
78+
`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)`,
79+
e
80+
);
7781
}
7882

7983
return {
@@ -87,7 +91,6 @@ export class PlaceholderPlugin {
8791
};
8892
},
8993
props: {
90-
// TODO: maybe also add placeholder for empty document ("e.g.: start writing..")
9194
decorations: (state) => {
9295
const { doc, selection } = state;
9396

@@ -104,20 +107,32 @@ export class PlaceholderPlugin {
104107
return;
105108
}
106109

107-
const $pos = selection.$anchor;
108-
const node = $pos.parent;
110+
const decs = [];
109111

110-
if (node.content.size > 0) {
111-
return null;
112+
// decoration for when there's only one empty block
113+
// positions are hardcoded for now
114+
if (state.doc.content.size === 6) {
115+
decs.push(
116+
Decoration.node(2, 4, {
117+
"data-is-only-empty-block": "true",
118+
})
119+
);
112120
}
113121

114-
const before = $pos.before();
122+
const $pos = selection.$anchor;
123+
const node = $pos.parent;
124+
125+
if (node.content.size === 0) {
126+
const before = $pos.before();
115127

116-
const dec = Decoration.node(before, before + node.nodeSize, {
117-
"data-is-empty-and-focused": "true",
118-
});
128+
decs.push(
129+
Decoration.node(before, before + node.nodeSize, {
130+
"data-is-empty-and-focused": "true",
131+
})
132+
);
133+
}
119134

120-
return DecorationSet.create(doc, [dec]);
135+
return DecorationSet.create(doc, decs);
121136
},
122137
},
123138
});

packages/core/src/i18n/locales/en.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export const en = {
129129
bulletListItem: "List",
130130
numberedListItem: "List",
131131
checkListItem: "List",
132-
},
132+
} as Record<"default" | "emptyDocument" | string, string | undefined>,
133133
file_blocks: {
134134
image: {
135135
add_button_text: "Add image",

0 commit comments

Comments
 (0)