Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7e8be35
Add placeholderPlugin
dancormier Apr 21, 2022
bae1d6e
Add conditions for empty
dancormier Apr 21, 2022
244585f
Abstract firstChildType for tidiness
dancormier Apr 21, 2022
ec8e9a8
Cleanup
dancormier Apr 21, 2022
36538d5
Support placeholder for commonmark editor
dancormier Apr 22, 2022
221d780
Add styling for markdown placeholder
dancormier Apr 22, 2022
b8bf5cb
Update src/rich-text/plugins/placeholder.ts
dancormier May 3, 2022
24a2b39
Don't make this placeholder param optional
dancormier May 3, 2022
bd1b81b
Merge branch 'main' into dc/placeholder-plugin
dancormier May 13, 2022
cf9ccf2
chore: move placeholder plugin to shared dir
dancormier May 13, 2022
b119800
Change less vars to css custom props
dancormier May 13, 2022
8d27d57
Replace `.js-markdown` with `.md-editor` base class
dancormier May 13, 2022
c4cabe2
Add tests
dancormier May 17, 2022
9d9ad06
Merge branch 'main' into dc/placeholder-plugin
b-kelly May 23, 2022
813f522
fix: add the data-placeholder attribute to the first node so it inher…
b-kelly May 23, 2022
fdaa746
Update tests to target first child
dancormier Jun 9, 2022
d0c15c0
Prevent placeholder in empty markdown codeblock
dancormier Jun 13, 2022
92b08eb
Merge branch 'main' into dc/placeholder-plugin
b-kelly Jun 16, 2022
0975a90
refactor(placeholder): fix eslint/type error
b-kelly Jun 16, 2022
01eb21f
docs(placeholder): cleanup docs
b-kelly Jun 16, 2022
350695a
refactor(placeholder): minor opinionated code cleanup
b-kelly Jun 16, 2022
25ad61b
test(placeholder): fix test file name
b-kelly Jun 16, 2022
95594f3
test(placeholder): update tests to cover a broader range of behavior
b-kelly Jun 16, 2022
762b002
fix(placeholder): fix nre when placeholder text is unspecified
b-kelly Jun 16, 2022
afe2d6c
fix(style): change placeholder text color so it works across all themes
b-kelly Jun 16, 2022
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
1 change: 1 addition & 0 deletions site/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ domReady(() => {
},
},
},
placeholderText: "This is placeholder text, so start typing…",
richTextOptions: {
linkPreviewProviders: [
ExampleTextOnlyLinkPreviewProvider,
Expand Down
3 changes: 3 additions & 0 deletions src/commonmark/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,6 +59,7 @@ export class CommonmarkEditor extends BaseView {
this.options.imageUpload,
this.options.parserFeatures.validateLink
),
placeholderPlugin(this.options.placeholderText),
readonlyPlugin(),
],
}),
Expand All @@ -77,6 +79,7 @@ export class CommonmarkEditor extends BaseView {
editorHelpLink: null,
menuParentContainer: null,
parserFeatures: defaultParserFeatures,
placeholderText: null,
imageUpload: {
handler: defaultImageUploadHandler,
},
Expand Down
2 changes: 2 additions & 0 deletions src/rich-text/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions src/shared/prosemirror-plugins/placeholder.ts
Original file line number Diff line number Diff line change
@@ -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<DecorationSet>({
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<DecorationSet>, state) {
return this.getState(state);
},
},
view(view) {
view.dom.setAttribute("aria-placeholder", placeholder);

return {
destroy() {
view.dom.removeAttribute("aria-placeholder");
},
};
},
});
}
2 changes: 2 additions & 0 deletions src/shared/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/styles/custom-components.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
130 changes: 130 additions & 0 deletions test/shared/plugins/placeholder.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});