diff --git a/site/index.ts b/site/index.ts index 5c2c7d72..335949a1 100644 --- a/site/index.ts +++ b/site/index.ts @@ -199,6 +199,7 @@ domReady(() => { }, }, }, + placeholderText: "This is placeholder text, so start typing…", richTextOptions: { linkPreviewProviders: [ ExampleTextOnlyLinkPreviewProvider, diff --git a/src/commonmark/editor.ts b/src/commonmark/editor.ts index 0ba60a6f..ce81af7a 100644 --- a/src/commonmark/editor.ts +++ b/src/commonmark/editor.ts @@ -9,6 +9,7 @@ import { defaultImageUploadHandler, } from "../shared/prosemirror-plugins/image-upload"; import { interfaceManagerPlugin } from "../shared/prosemirror-plugins/interface-manager"; +import { placeholderPlugin } from "../shared/prosemirror-plugins/placeholder"; import { editableCheck, readonlyPlugin, @@ -58,6 +59,7 @@ export class CommonmarkEditor extends BaseView { this.options.imageUpload, this.options.parserFeatures.validateLink ), + placeholderPlugin(this.options.placeholderText), readonlyPlugin(), ], }), @@ -77,6 +79,7 @@ export class CommonmarkEditor extends BaseView { editorHelpLink: null, menuParentContainer: null, parserFeatures: defaultParserFeatures, + placeholderText: null, imageUpload: { handler: defaultImageUploadHandler, }, diff --git a/src/rich-text/editor.ts b/src/rich-text/editor.ts index 57a20d6b..cbf4d418 100644 --- a/src/rich-text/editor.ts +++ b/src/rich-text/editor.ts @@ -39,6 +39,7 @@ import { codePasteHandler } from "./plugins/code-paste-handler"; import { linkPasteHandler } from "./plugins/link-paste-handler"; import { linkPreviewPlugin, LinkPreviewProvider } from "./plugins/link-preview"; import { linkTooltipPlugin } from "./plugins/link-editor"; +import { placeholderPlugin } from "../shared/prosemirror-plugins/placeholder"; import { plainTextPasteHandler } from "./plugins/plain-text-paste-handler"; import { spoilerToggle } from "./plugins/spoiler-toggle"; import { tables } from "./plugins/tables"; @@ -96,6 +97,7 @@ export class RichTextEditor extends BaseView { this.options.pluginParentContainer ), linkTooltipPlugin(this.options.parserFeatures), + placeholderPlugin(this.options.placeholderText), richTextImageUpload( this.options.imageUpload, this.options.parserFeatures.validateLink diff --git a/src/shared/prosemirror-plugins/placeholder.ts b/src/shared/prosemirror-plugins/placeholder.ts new file mode 100644 index 00000000..c8885cac --- /dev/null +++ b/src/shared/prosemirror-plugins/placeholder.ts @@ -0,0 +1,63 @@ +import { Node } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +/** + * Creates a placeholder decoration on the document's first child + * @param doc The root document node + * @param placeholder The placeholder text + */ +function createPlaceholderDecoration(doc: Node, placeholder: string) { + // TODO check for image upload placeholder + const showPlaceholder = + !doc.textContent && + doc.childCount === 1 && + doc.firstChild.childCount === 0; + + if (!showPlaceholder) { + return DecorationSet.empty; + } + + const $pos = doc.resolve(1); + return DecorationSet.create(doc, [ + Decoration.node($pos.before(), $pos.after(), { + "data-placeholder": placeholder, + }), + ]); +} + +/** Plugin that adds placeholder text to the editor when it's empty */ +export function placeholderPlugin(placeholder: string): Plugin { + if (!placeholder?.trim()) { + return new Plugin({}); + } + + return new Plugin({ + key: new PluginKey("placeholder"), + state: { + init: (_, state) => + createPlaceholderDecoration(state.doc, placeholder), + apply: (tr, value) => { + if (!tr.docChanged) { + return value.map(tr.mapping, tr.doc); + } + + return createPlaceholderDecoration(tr.doc, placeholder); + }, + }, + props: { + decorations(this: Plugin, state) { + return this.getState(state); + }, + }, + view(view) { + view.dom.setAttribute("aria-placeholder", placeholder); + + return { + destroy() { + view.dom.removeAttribute("aria-placeholder"); + }, + }; + }, + }); +} diff --git a/src/shared/view.ts b/src/shared/view.ts index 9af39dec..cbcda165 100644 --- a/src/shared/view.ts +++ b/src/shared/view.ts @@ -15,6 +15,8 @@ export interface CommonViewOptions { editorHelpLink?: string; /** The features to allow/disallow on the markdown parser */ parserFeatures?: CommonmarkParserFeatures; + /** The placeholder text for an empty editor */ + placeholderText?: string; /** * Function to get the container to place the menu bar; * defaults to returning this editor's target's parentNode diff --git a/src/styles/custom-components.less b/src/styles/custom-components.less index a9a1bcc1..6df2b978 100644 --- a/src/styles/custom-components.less +++ b/src/styles/custom-components.less @@ -209,6 +209,21 @@ font-variant-ligatures: none; } + & [data-placeholder] { + position: relative; + + &::before { + color: var(--fc-light); + position: absolute; + content: attr(data-placeholder); + pointer-events: none; + } + + .s-input__readonly &::before { + color: inherit; + } + } + // taken from prosemirror.css for compatibility .ProseMirror-hideselection *::selection { background: transparent; diff --git a/test/shared/plugins/placeholder.test.ts b/test/shared/plugins/placeholder.test.ts new file mode 100644 index 00000000..4311f5d9 --- /dev/null +++ b/test/shared/plugins/placeholder.test.ts @@ -0,0 +1,130 @@ +import { CommonmarkEditor } from "../../../src/commonmark/editor"; +import { RichTextEditor } from "../../../src/rich-text/editor"; +import { placeholderPlugin } from "../../../src/shared/prosemirror-plugins/placeholder"; + +const PLACEHOLDER_TEXT = "This is a placeholder"; + +function commonmarkView( + markdown: string, + placeholderText: string +): CommonmarkEditor { + return new CommonmarkEditor(document.createElement("div"), markdown, { + placeholderText, + }); +} + +function richView(markdownInput: string, placeholderText: string) { + return new RichTextEditor(document.createElement("div"), markdownInput, { + placeholderText, + }); +} + +describe("placeholder plugin", () => { + describe("commonmark", () => { + it("should add placeholder when the editor is empty", () => { + const view = commonmarkView("", PLACEHOLDER_TEXT); + const elAttr = + view.editorView.dom.firstElementChild.getAttribute( + "data-placeholder" + ); + const ariaAttr = + view.editorView.dom.getAttribute("aria-placeholder"); + + expect(elAttr).toBe(PLACEHOLDER_TEXT); + expect(ariaAttr).toBe(PLACEHOLDER_TEXT); + }); + + it("should not add placeholder when the editor is populated", () => { + const view = commonmarkView("Hello world", PLACEHOLDER_TEXT); + const elAttr = + view.editorView.dom.firstElementChild.getAttribute( + "data-placeholder" + ); + const ariaAttr = + view.editorView.dom.getAttribute("aria-placeholder"); + + expect(elAttr).toBeNull(); + expect(ariaAttr).toBe(PLACEHOLDER_TEXT); + }); + + it("should remove placeholder when text is added", () => { + const view = commonmarkView("", PLACEHOLDER_TEXT); + let elAttr = + view.editorView.dom.firstElementChild.getAttribute( + "data-placeholder" + ); + + expect(elAttr).toBe(PLACEHOLDER_TEXT); + + view.editorView.dispatch( + view.editorView.state.tr.insertText("Hello world") + ); + + elAttr = + view.editorView.dom.firstElementChild.getAttribute( + "data-placeholder" + ); + expect(elAttr).toBeNull(); + }); + }); + + describe("rich-text", () => { + it("should add placeholder when the editor is empty", () => { + const view = richView("", PLACEHOLDER_TEXT); + const elAttr = + view.editorView.dom.firstElementChild.getAttribute( + "data-placeholder" + ); + const ariaAttr = + view.editorView.dom.getAttribute("aria-placeholder"); + + expect(elAttr).toBe(PLACEHOLDER_TEXT); + expect(ariaAttr).toBe(PLACEHOLDER_TEXT); + }); + + it("should not add placeholder when the editor is populated", () => { + const view = richView("# Hello", PLACEHOLDER_TEXT); + const elAttr = + view.editorView.dom.firstElementChild.getAttribute( + "data-placeholder" + ); + const ariaAttr = + view.editorView.dom.getAttribute("aria-placeholder"); + + expect(elAttr).toBeNull(); + expect(ariaAttr).toBe(PLACEHOLDER_TEXT); + }); + + it("should remove placeholder when text is added", () => { + const view = richView("", PLACEHOLDER_TEXT); + let elAttr = + view.editorView.dom.firstElementChild.getAttribute( + "data-placeholder" + ); + + expect(elAttr).toBe(PLACEHOLDER_TEXT); + + view.editorView.dispatch( + view.editorView.state.tr.insertText("Hello world") + ); + + elAttr = + view.editorView.dom.firstElementChild.getAttribute( + "data-placeholder" + ); + expect(elAttr).toBeNull(); + }); + }); + + describe("plugin", () => { + it("should not activate when the placeholder text is invalid", () => { + // start with a valid option to set the base case + let plugin = placeholderPlugin(PLACEHOLDER_TEXT); + expect(plugin.spec.state).toBeDefined(); + + // now try with an invalid option + plugin = placeholderPlugin(""); + expect(plugin.spec.state).toBeUndefined(); + }); + }); +});