Skip to content

Commit

Permalink
feat: Add an isMultiline attribute to TextField (#62)
Browse files Browse the repository at this point in the history
* Flesh out prosemirror-keymap definitions

* Add isMultiline attribute to TextFieldView

* Add rows option

* Update integration tests
  • Loading branch information
jonathonherbert authored Jul 28, 2021
1 parent ff70a2f commit 2b71077
Show file tree
Hide file tree
Showing 13 changed files with 183 additions and 87 deletions.
2 changes: 1 addition & 1 deletion cypress/helpers/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const changeTestDecoString = (newTestString: string) => {
};

export const getSerialisedHtml = ({
altTextValue = "<p></p>",
altTextValue = "",
captionValue = "<p></p>",
srcValue = "",
useSrcValue = "false",
Expand Down
155 changes: 86 additions & 69 deletions cypress/tests/ImageElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,97 +13,94 @@ import {
describe("ImageElement", () => {
beforeEach(visitRoot);

const rteFields = ["caption", "altText"];
const rteFieldStyles = [
{ title: "strong style", tag: "strong" },
{ title: "emphasis", tag: "em" },
];

describe("Fields", () => {
describe("Rich text field", () => {
rteFields.forEach((field) => {
it(`${field} – should accept input in an element`, () => {
addElement();
const text = `${field} text`;
typeIntoElementField(field, text);
getElementRichTextField(field).should("have.text", text);
});

it(`${field} – should create hard breaks on shift-enter`, () => {
addElement();
const text = `${field}{shift+enter}text`;
typeIntoElementField(field, text);
getElementRichTextField(field).should(($div) =>
expect($div.html()).to.equal(`<p>${field}<br>text</p>`)
);
});
it(`caption – should accept input in an element`, () => {
addElement();
const text = `caption text`;
typeIntoElementField("caption", text);
getElementRichTextField("caption").should("have.text", text);
});

it(`${field} – should render decorations passed from the parent editor`, () => {
addElement();
const text = `${field} deco `;
typeIntoElementField(field, text);
getElementRichTextField(field)
.find(".TestDecoration")
.should("have.text", "deco");
});
it(`caption – should create hard breaks on shift-enter`, () => {
addElement();
const text = `caption{shift+enter}text`;
typeIntoElementField("caption", text);
getElementRichTextField("caption").should(($div) =>
expect($div.html()).to.equal(`<p>caption<br>text</p>`)
);
});

it(`${field} – should map decorations passed from the parent editor correctly when they move`, () => {
addElement();
const text = `${field} deco{leftarrow}{leftarrow}{leftarrow}{leftarrow}{leftarrow} more text`;
typeIntoElementField(field, text);
getElementRichTextField(field)
.find(".TestDecoration")
.should("have.text", "deco");
});
it(`caption – should render decorations passed from the parent editor`, () => {
addElement();
const text = `caption deco `;
typeIntoElementField("caption", text);
getElementRichTextField("caption")
.find(".TestDecoration")
.should("have.text", "deco");
});

it(`${field} – should render new decorations, even if the document state has not changed`, () => {
addElement();
it(`caption – should map decorations passed from the parent editor correctly when they move`, () => {
addElement();
const text = `caption deco{leftarrow}{leftarrow}{leftarrow}{leftarrow}{leftarrow} more text`;
typeIntoElementField("caption", text);
getElementRichTextField("caption")
.find(".TestDecoration")
.should("have.text", "deco");
});

const oldDecoString = "deco";
const newDecoString = "decoChanged";
const text = `${field} ${oldDecoString} ${newDecoString}`;
it(`caption – should render new decorations, even if the document state has not changed`, () => {
addElement();

typeIntoElementField(field, text);
changeTestDecoString(newDecoString);
const oldDecoString = "deco";
const newDecoString = "decoChanged";
const text = `caption ${oldDecoString} ${newDecoString}`;

getElementRichTextField(field)
.find(".TestDecoration")
.should("have.text", newDecoString);
typeIntoElementField("caption", text);
changeTestDecoString(newDecoString);

changeTestDecoString(oldDecoString);
getElementRichTextField("caption")
.find(".TestDecoration")
.should("have.text", newDecoString);

getElementRichTextField(field)
.find(".TestDecoration")
.should("have.text", oldDecoString);
});
changeTestDecoString(oldDecoString);

rteFieldStyles.forEach((style) => {
it(`${field} – should toggle style of an input in an element`, () => {
addElement();
getElementMenuButton(field, `Toggle ${style.title}`).click();
typeIntoElementField(field, "Example text");
getElementRichTextField(field)
.find(style.tag)
.should("have.text", "Example text");
});
});
getElementRichTextField("caption")
.find(".TestDecoration")
.should("have.text", oldDecoString);
});

it("should serialise content as HTML within the appropriate nodes in the document", () => {
rteFieldStyles.forEach((style) => {
it(`caption – should toggle style of an input in an element`, () => {
addElement();
typeIntoElementField("caption", "Caption text");
typeIntoElementField("altText", "Alt text");
assertDocHtml(
getSerialisedHtml({
altTextValue: "<p>Alt text</p>",
captionValue: "<p>Caption text</p>",
})
);
getElementMenuButton("caption", `Toggle ${style.title}`).click();
typeIntoElementField("caption", "Example text");
getElementRichTextField("caption")
.find(style.tag)
.should("have.text", "Example text");
});
});

it("should serialise content as HTML within the appropriate nodes in the document", () => {
addElement();
typeIntoElementField("caption", "Caption text");
typeIntoElementField("altText", "Alt text");
assertDocHtml(
getSerialisedHtml({
altTextValue: "Alt text",
captionValue: "<p>Caption text</p>",
})
);
});
});

describe("Text field", () => {
it(`src – should accept input in an element`, () => {
it(`should accept input in an element`, () => {
addElement();
const text = `Src text`;
typeIntoElementField("src", text);
Expand All @@ -115,6 +112,26 @@ describe("ImageElement", () => {
typeIntoElementField("src", "Src text");
assertDocHtml(getSerialisedHtml({ srcValue: "Src text" }));
});

it(`should not create line breaks when isMultiline is not set`, () => {
addElement();
const text = `Src {enter}text`;
typeIntoElementField("src", text);
assertDocHtml(getSerialisedHtml({ srcValue: "Src text" }));
});

it(`should create line breaks when isMultiline is set`, () => {
addElement();
const text = `Alttext {enter}text`;
typeIntoElementField("altText", text);
assertDocHtml(getSerialisedHtml({ altTextValue: "Alttext <br>text" }));
});

it("should serialise content as HTML within the appropriate nodes in the document", () => {
addElement();
typeIntoElementField("src", "Src text");
assertDocHtml(getSerialisedHtml({ srcValue: "Src text" }));
});
});

describe("Checkbox field", () => {
Expand Down Expand Up @@ -151,7 +168,7 @@ describe("ImageElement", () => {

assertDocHtml(
getSerialisedHtml({
altTextValue: "<p>Example text</p>",
altTextValue: "Example text",
useSrcValue: "false",
})
);
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"emotion": "^9.2.10",
"orderedmap": "^1.1.1",
"prosemirror-collab": "^1.2.2",
"prosemirror-commands": "^1.1.10",
"prosemirror-example-setup": "^1.0.1",
"prosemirror-model": "^1.13.3",
"prosemirror-schema-basic": "^1.0.0",
Expand All @@ -47,6 +48,7 @@
"@guardian/eslint-config-typescript": "^0.5.0",
"@types/chai": "^4.2.15",
"@types/jest": "^26.0.23",
"@types/prosemirror-commands": "^1.0.4",
"@types/prosemirror-history": "^1.0.2",
"@types/prosemirror-model": "^1.11.3",
"@types/prosemirror-schema-basic": "^1.0.1",
Expand Down
3 changes: 2 additions & 1 deletion src/declarations/prosemirror-keymap.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
declare module "prosemirror-keymap" {
import type { Command } from "prosemirror-commands";
import type { Plugin } from "prosemirror-state";

const keymap: (map: Record<string, () => void>) => Plugin;
const keymap: (map: Record<string, Command>) => Plugin;
}
2 changes: 1 addition & 1 deletion src/elements/demo-image/DemoImageElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const createImageFields = (
) => {
return {
caption: createDefaultRichTextField(),
altText: createDefaultRichTextField(),
altText: createTextField({ isMultiline: true, rows: 2 }),
src: createTextField(),
mainImage: createCustomField<ImageField, ImageProps>(
{ mediaId: undefined, mediaApiUri: undefined, assets: [] },
Expand Down
2 changes: 1 addition & 1 deletion src/elements/demo-image/DemoImageElementForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export const ImageElementForm: React.FunctionComponent<Props> = ({
fieldViewSpecMap: fieldViewSpecs,
}) => (
<div data-cy={ImageElementTestId}>
<FieldView fieldViewSpec={fieldViewSpecs.altText} />
<FieldView fieldViewSpec={fieldViewSpecs.caption} />
<FieldView fieldViewSpec={fieldViewSpecs.altText} />
<FieldView fieldViewSpec={fieldViewSpecs.src} />
<FieldView fieldViewSpec={fieldViewSpecs.useSrc} />
<FieldView fieldViewSpec={fieldViewSpecs.optionDropdown} />
Expand Down
14 changes: 12 additions & 2 deletions src/plugin/__tests__/element.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,12 @@ describe("buildElementPlugin", () => {

const testElement = createNoopElement("testElement", {
field1: { type: "richText", defaultValue: "<p>Default</p>" },
field2: { type: "text", defaultValue: "Default" },
field2: {
type: "text",
defaultValue: "Default",
isMultiline: false,
rows: 1,
},
field3: { type: "checkbox", defaultValue: { value: false } },
});

Expand All @@ -278,7 +283,12 @@ describe("buildElementPlugin", () => {

const testElement = createNoopElement("testElement", {
field1: { type: "richText", defaultValue: "<p>Default</p>" },
field2: { type: "text", defaultValue: "Default" },
field2: {
type: "text",
defaultValue: "Default",
isMultiline: false,
rows: 1,
},
field3: { type: "checkbox", defaultValue: { value: false } },
});

Expand Down
2 changes: 2 additions & 0 deletions src/plugin/__tests__/elementSpec.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ describe("mount", () => {
const fieldSpec = {
field1: {
type: "text" as const,
isMultiline: false,
rows: 1,
},
};

Expand Down
2 changes: 1 addition & 1 deletion src/plugin/fieldViews/ProseMirrorFieldView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export abstract class ProseMirrorFieldView implements FieldView<string> {
// so it can be mounted by consuming elements.
public fieldViewElement = document.createElement("div");
// The editor view for this FieldView.
private innerEditorView: EditorView | undefined;
protected innerEditorView: EditorView | undefined;
// The decorations that apply to this FieldView, from the perspective
// of the inner editor.
private decorations = new DecorationSet();
Expand Down
64 changes: 55 additions & 9 deletions src/plugin/fieldViews/TextFieldView.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import type { Command } from "prosemirror-commands";
import { chainCommands } from "prosemirror-commands";
import { redo, undo } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import type { Node } from "prosemirror-model";
import type { Node, Schema } from "prosemirror-model";
import type { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import type { BaseFieldSpec } from "./FieldView";
import { ProseMirrorFieldView } from "./ProseMirrorFieldView";

export interface TextField extends BaseFieldSpec<string> {
type: typeof TextFieldView.fieldName;
// Can this field display over multiple lines? This will
// insert line breaks (<br>) when the user hits the Enter key.
isMultiline: boolean;
// The minimum number of rows this input should occupy.
// Analogous to the <textarea> `rows` attribute.
rows: number;
}

export const createTextField = (): TextField => ({
type TextFieldOptions = {
isMultiline: boolean;
rows?: number;
};

export const createTextField = (
{ isMultiline, rows = 1 }: TextFieldOptions = { isMultiline: false, rows: 1 }
): TextField => ({
type: TextFieldView.fieldName,
isMultiline,
rows,
});

export class TextFieldView extends ProseMirrorFieldView {
Expand All @@ -26,21 +43,50 @@ export class TextFieldView extends ProseMirrorFieldView {
// The offset of this node relative to its parent FieldView.
offset: number,
// The initial decorations for the FieldView.
decorations: DecorationSet | Decoration[]
decorations: DecorationSet | Decoration[],
{ isMultiline, rows }: TextField
) {
const keymapping: Record<string, Command> = {
"Mod-z": () => undo(outerView.state, outerView.dispatch),
"Mod-y": () => redo(outerView.state, outerView.dispatch),
};

const br = (node.type.schema as Schema).nodes.hard_break;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- it is possible for this to be false.
const enableMultiline = !!br && isMultiline;

if (enableMultiline) {
const cmd = chainCommands((state, dispatch) => {
dispatch?.(state.tr.replaceSelectionWith(br.create()).scrollIntoView());
return true;
});
keymapping["Enter"] = cmd;
}

super(
node,
outerView,
getPos,
offset,
decorations,
TextFieldView.fieldName,
[
keymap({
"Mod-z": () => undo(outerView.state, outerView.dispatch),
"Mod-y": () => redo(outerView.state, outerView.dispatch),
}),
]
[keymap(keymapping)]
);

if (enableMultiline) {
// We wait to ensure that the browser has applied the appropriate styles.
setTimeout(() => {
if (!this.innerEditorView) {
return;
}
const { lineHeight, paddingTop } = window.getComputedStyle(
this.innerEditorView.dom
);

(this.innerEditorView.dom as HTMLDivElement).style.minHeight = `${
parseInt(lineHeight, 10) * rows + parseInt(paddingTop) * 2
}px`;
});
}
}
}
2 changes: 1 addition & 1 deletion src/plugin/helpers/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const getElementFieldViewFromType = (
) => {
switch (field.type) {
case "text":
return new TextFieldView(node, view, getPos, offset, innerDecos);
return new TextFieldView(node, view, getPos, offset, innerDecos, field);
case "richText":
return new RichTextFieldView(
node,
Expand Down
Loading

0 comments on commit 2b71077

Please sign in to comment.