Skip to content

Commit

Permalink
feat: Upstream Trix/RichTextField. (#90)
Browse files Browse the repository at this point in the history
* Upstream Trix/RichTextField.

* Fix comment, use inputProps.

* Move helper function, remove useTextField b/c its not a text field.

* PR feedback.

* Rename to RichTextField instead of editor, add BoundRichTextField.

* Update comment.

* Use a ref for onChange and disable the hooks warning.

* Include the tag matching.

* Fix comment.
  • Loading branch information
stephenh committed Jun 1, 2021
1 parent ca0d787 commit 24fdcf5
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 9 deletions.
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -42,6 +42,8 @@
"react-aria": "^3.6.0",
"react-day-picker": "^7.4.10",
"react-stately": "^3.5.0",
"tributejs": "^5.1.3",
"trix": "^1.3.1",
"framer-motion": "^4.1.11"
},
"peerDependencies": {
Expand Down
3 changes: 2 additions & 1 deletion src/Css.ts
Expand Up @@ -812,7 +812,8 @@ class CssBuilder<T extends Properties1> {
get childGap7() { return this.childGap(7); }
get childGap8() { return this.childGap(8); }
childGap(inc: number | string) {
const p = this.opts.rules["flexDirection"] === "column" ? "marginTop" : "marginLeft";
const direction = this.opts.rules["flexDirection"];
const p = direction === "column" ? "marginTop" : direction === "column-reverse" ? "marginBottom" : "marginLeft";
return this.addIn("& > * + *", Css.add(p, maybeInc(inc)).important.$);
}

Expand Down
32 changes: 32 additions & 0 deletions src/components/RichTextField.stories.tsx
@@ -0,0 +1,32 @@
import { Meta } from "@storybook/react";
import { useState } from "react";
import { RichTextField as RichTextFieldComponent } from "src/components/RichTextField";

export default {
component: RichTextFieldComponent,
title: "Components/Rich Text Field",
} as Meta;

export function RichTextField() {
return <TestField />;
}

function TestField() {
const [value, setValue] = useState<string | undefined>();
const [tags, setTags] = useState<string[]>([]);
return (
<>
<RichTextFieldComponent
label="Comment"
value={value}
onChange={(html, text, tags) => {
setValue(html);
setTags(tags);
}}
mergeTags={["foo", "bar", "zaz"]}
/>
<div>value: {value === undefined ? "undefined" : value}</div>
<div>tags: {tags.join(", ")}</div>
</>
);
}
175 changes: 175 additions & 0 deletions src/components/RichTextField.tsx
@@ -0,0 +1,175 @@
import { Global } from "@emotion/react";
import { ChangeEvent, createElement, useEffect, useRef } from "react";
import { useId } from "react-aria";
import { Label } from "src/components/Label";
import { Css, Palette } from "src/Css";
import Tribute from "tributejs";
import "tributejs/dist/tribute.css";
import "trix/dist/trix";
import "trix/dist/trix.css";

export interface RichTextFieldProps {
/** The initial html value to show in the trix editor. */
value: string | undefined;
onChange: (html: string | undefined, text: string | undefined, mergeTags: string[]) => void;
/**
* A list of tags/names to show in a popup when the user `@`-s.
*
* Currently we don't support mergeTags being updated.
*/
mergeTags?: string[];
label?: string;
autoFocus?: boolean;
placeholder?: string;
}

// There aren't types for trix, so add our own. For now `loadHTML` is all we call anyway.
type Editor = {
loadHTML(html: string): void;
};

/**
* Provides a simple rich text editor based on trix.
*
* See [trix]{@link https://github.com/basecamp/trix}.
*
* We also integrate [tributejs]{@link https://github.com/zurb/tribute} for @ mentions.
*/
export function RichTextField(props: RichTextFieldProps) {
const { mergeTags, label, value, onChange } = props;
const id = useId();

// We get a reference to the Editor instance after trix-init fires
const editor = useRef<Editor | undefined>(undefined);

// Keep track of what we pass to onChange, so that we can make ourselves keep looking
// like a controlled input, i.e. by only calling loadHTML if a new incoming `value` !== `currentHtml`,
// otherwise we'll constantly call loadHTML and reset the user's cursor location.
const currentHtml = useRef<string | undefined>(undefined);

// Use a ref for onChange b/c so trixChange always has the latest
const onChangeRef = useRef<RichTextFieldProps["onChange"]>(onChange);
onChangeRef.current = onChange;

useEffect(
() => {
const editorElement = document.getElementById(`editor-${id}`);
if (!editorElement) {
throw new Error("editorElement not found");
}

editor.current = (editorElement as any).editor;
if (!editor.current) {
throw new Error("editor not found");
}
if (mergeTags !== undefined) {
attachTributeJs(mergeTags, editorElement!);
}

// We have a 2nd useEffect to call loadHTML when value changes, but
// we do this here b/c we assume the 2nd useEffect's initial evaluation
// "missed" having editor.current set b/c trix-initialize hadn't fired.
currentHtml.current = value;
editor.current.loadHTML(value || "");

function trixChange(e: ChangeEvent) {
const { textContent, innerHTML } = e.target;
const onChange = onChangeRef.current;
// If the user only types whitespace, treat that as undefined
if ((textContent || "").trim() === "") {
currentHtml.current = undefined;
onChange && onChange(undefined, undefined, []);
} else {
currentHtml.current = innerHTML;
const mentions = extractIdsFromMentions(mergeTags || [], textContent || "");
onChange && onChange(innerHTML, textContent || undefined, mentions);
}
}

editorElement.addEventListener("trix-change", trixChange as any, false);
return () => {
editorElement.removeEventListener("trix-change", trixChange as any);
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);

useEffect(() => {
// If our value prop changes (without the change coming from us), reload it
if (editor.current && value !== currentHtml.current) {
editor.current.loadHTML(value || "");
}
}, [value]);

const { placeholder, autoFocus } = props;

return (
<div css={Css.w100.maxw("550px").$}>
{/* TODO: Not sure what to pass to labelProps. */}
{label && <Label labelProps={{}} label={label} />}
<div css={trixCssOverrides}>
{createElement("trix-editor", {
id: `editor-${id}`,
input: `input-${id}`,
...(autoFocus ? { autoFocus } : {}),
...(placeholder ? { placeholder } : {}),
})}
<input type="hidden" id={`input-${id}`} value={value} />
</div>
<Global styles={[tributeOverrides]} />
</div>
);
}

function attachTributeJs(mergeTags: string[], editorElement: HTMLElement) {
const values = mergeTags.map((value) => ({ value }));
const tribute = new Tribute({
trigger: "@",
lookup: "value",
allowSpaces: true,
/** {@link https://github.com/zurb/tribute#hide-menu-when-no-match-is-returned} */
noMatchTemplate: () => `<span style:"visibility: hidden;"></span>`,
selectTemplate: ({ original: { value } }) => `<span style="color: ${Palette.LightBlue700};">@${value}</span>`,
values,
});
// In dev mode, this fails because jsdom doesn't support contentEditable. Note that
// before create-react-app 4.x / a newer jsdom, the trix-initialize event wasn't
// even fired during unit tests anyway.
try {
tribute.attach(editorElement!);
} catch {}
}

const trixCssOverrides = {
...Css.relative.add({ wordBreak: "break-word" }).$,
// Put the toolbar on the bottom
...Css.df.flexColumnReverse.childGap1.$,
// Some basic copy/paste from TextFieldBase
"& trix-editor": Css.bgWhite.sm.gray900.br4.bGray300.$,
"& trix-editor:focus": Css.bLightBlue700.$,
// Make the buttons closer to ours
"& .trix-button": Css.bgWhite.sm.$,
// We don't support file attachment yet, so hide that control for now.
"& .trix-button-group--file-tools": Css.dn.$,
// Other things that are unused and we want to hide
"& .trix-button--icon-heading-1": Css.dn.$,
"& .trix-button--icon-code": Css.dn.$,
"& .trix-button--icon-quote": Css.dn.$,
"& .trix-button--icon-increase-nesting-level": Css.dn.$,
"& .trix-button--icon-decrease-nesting-level": Css.dn.$,
"& .trix-button-group--history-tools": Css.dn.$,
// Put back list styles that CssReset is probably too aggressive with
"& ul": Css.ml2.add("listStyleType", "disc").$,
"& ol": Css.ml2.add("listStyleType", "decimal").$,
};

// Style the @ mention box
const tributeOverrides = {
".tribute-container": Css.add({ minWidth: "300px" }).$,
".tribute-container > ul": Css.sm.bgWhite.ba.br4.bLightBlue700.overflowHidden.$,
};

function extractIdsFromMentions(mergeTags: string[], content: string): string[] {
return mergeTags.filter((tag) => content.includes(`@${tag}`));
}
32 changes: 32 additions & 0 deletions src/forms/BoundRichTextField.tsx
@@ -0,0 +1,32 @@
import { FieldState } from "@homebound/form-state";
import { Observer } from "mobx-react";
import { RichTextField, RichTextFieldProps } from "src/components/RichTextField";
import { useTestIds } from "src/utils";
import { defaultLabel } from "src/utils/defaultLabel";

export type BoundRichTextFieldProps = Omit<RichTextFieldProps, "value" | "onChange"> & {
field: FieldState<string | null | undefined>;
// Optional in case the page wants extra behavior
onChange?: (value: string | undefined) => void;
};

/** Wraps `RichTextField` and binds it to a form field. */
export function BoundRichTextField(props: BoundRichTextFieldProps) {
const { field, onChange = (value) => field.set(value), label = defaultLabel(field.key), ...others } = props;
const testId = useTestIds(props, field.key);
return (
<Observer>
{() => (
<RichTextField
label={label}
value={field.value || undefined}
onChange={onChange}
// errorMsg={field.touched ? field.errors.join(" ") : undefined}
// onBlur={() => field.blur()}
{...testId}
{...others}
/>
)}
</Observer>
);
}
9 changes: 6 additions & 3 deletions truss/index.ts
@@ -1,4 +1,4 @@
import { generate, newMethod, newIncrementDelegateMethods, newMethodsForProp, Sections } from "@homebound/truss";
import { generate, newIncrementDelegateMethods, newMethod, newMethodsForProp, Sections } from "@homebound/truss";
import { palette } from "./palette";

const increment = 8;
Expand Down Expand Up @@ -30,7 +30,9 @@ const fonts: Record<string, { fontWeight: 400 | 500 | 600, fontSize: string; lin
xl5Em: { fontWeight: 600, fontSize: "48px", lineHeight: "48px" },
};

const transition: string = ["background-color", "border-color", "box-shadow", "left", "right"].map((property) => `${property} 200ms`).join(", ");
const transition: string = ["background-color", "border-color", "box-shadow", "left", "right"]
.map((property) => `${property} 200ms`)
.join(", ");

// Custom rules
const sections: Sections = {
Expand Down Expand Up @@ -64,7 +66,8 @@ const sections: Sections = {
childGap: (config) => [
...newIncrementDelegateMethods("childGap", config.numberOfIncrements),
`childGap(inc: number | string) {
const p = this.opts.rules["flexDirection"] === "column" ? "marginTop" : "marginLeft";
const direction = this.opts.rules["flexDirection"];
const p = direction === "column" ? "marginTop" : direction === "column-reverse" ? "marginBottom" : "marginLeft";
return this.addIn("& > * + *", Css.add(p, maybeInc(inc)).important.$);
}`,
],
Expand Down
20 changes: 15 additions & 5 deletions yarn.lock
Expand Up @@ -8515,11 +8515,6 @@ he@^1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==

hey-listen@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==

header-case@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/header-case/-/header-case-2.0.4.tgz#5a42e63b55177349cf405beb8d775acabb92c063"
Expand All @@ -8528,6 +8523,11 @@ header-case@^2.0.4:
capital-case "^1.0.4"
tslib "^2.0.3"

hey-listen@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==

highlight.js@^10.1.1, highlight.js@~10.7.0:
version "10.7.2"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360"
Expand Down Expand Up @@ -14810,6 +14810,11 @@ treeverse@^1.0.4:
resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-1.0.4.tgz#a6b0ebf98a1bca6846ddc7ecbc900df08cb9cd5f"
integrity sha512-whw60l7r+8ZU8Tu/Uc2yxtc4ZTZbR/PF3u1IPNKGQ6p8EICLb3Z2lAgoqw9bqYd8IkgnsaOcLzYHFckjqNsf0g==

tributejs@^5.1.3:
version "5.1.3"
resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-5.1.3.tgz#980600fc72865be5868893078b4bfde721129eae"
integrity sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==

trim-newlines@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30"
Expand All @@ -14830,6 +14835,11 @@ trim@0.0.1:
resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd"
integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0=

trix@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/trix/-/trix-1.3.1.tgz#ccce8d9e72bf0fe70c8c019ff558c70266f8d857"
integrity sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA==

trough@^1.0.0:
version "1.0.5"
resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406"
Expand Down

0 comments on commit 24fdcf5

Please sign in to comment.