From ab3a82c12d7c37f7e291930daaae375eb58e8ffb Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Fri, 28 May 2021 10:09:00 -0500 Subject: [PATCH 1/9] Upstream Trix/RichTextField. --- package.json | 2 + src/Css.ts | 3 +- src/components/RichTextEditor.stories.tsx | 22 +++ src/components/RichTextEditor.tsx | 158 ++++++++++++++++++++++ truss/index.ts | 9 +- yarn.lock | 20 ++- 6 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 src/components/RichTextEditor.stories.tsx create mode 100644 src/components/RichTextEditor.tsx diff --git a/package.json b/package.json index 501b08804..5999b8808 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/Css.ts b/src/Css.ts index 97b107b90..fa751d68c 100644 --- a/src/Css.ts +++ b/src/Css.ts @@ -812,7 +812,8 @@ class CssBuilder { 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.$); } diff --git a/src/components/RichTextEditor.stories.tsx b/src/components/RichTextEditor.stories.tsx new file mode 100644 index 000000000..d3265fed3 --- /dev/null +++ b/src/components/RichTextEditor.stories.tsx @@ -0,0 +1,22 @@ +import { Meta } from "@storybook/react"; +import { useState } from "react"; +import { RichTextEditor } from "src/components/RichTextEditor"; + +export default { + component: RichTextEditor, + title: "Components/Rich Text Editor", +} as Meta; + +export function RichTextEditors() { + return ; +} + +function TestEditor() { + const [value, setValue] = useState(""); + return ( + <> + + value: {value} + + ); +} diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx new file mode 100644 index 000000000..85c7abee4 --- /dev/null +++ b/src/components/RichTextEditor.tsx @@ -0,0 +1,158 @@ +import { Global } from "@emotion/react"; +import { useTextField } from "@react-aria/textfield"; +import * as React from "react"; +import { ChangeEvent, 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 RichTextEditorProps { + /** The initial html value to show in the trix editor. */ + value: string; + onChange: (html: string, text: 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; +} + +// trix + react-trix do not appear to have any typings, so roll with editor: any for now. +type Editor = { + expandSelectionInDirection?: (direction: "forward" | "backward") => void; + insertString(s: string): void; + loadHTML(html: string): void; +}; + +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: () => ``, + selectTemplate: ({ original: { value } }) => `@${value}`, + 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 {} +} + +/** + * Glues together trix and tributejs to provide a simple rich text editor. + * + * See [trix]{@link https://github.com/basecamp/trix} and [tributejs]{@link https://github.com/zurb/tribute}. + * */ +export function RichTextEditor(props: RichTextEditorProps) { + const { mergeTags, label, value, onChange } = props; + const id = useId(); + + // We get a reference to the Editor instance after trix-init fires + const editor = useRef(undefined); + + // Disclaimer I'm kinda guessing at whether this aria setup is right + const inputRef = useRef(null); + const { labelProps, inputProps } = useTextField({ label }, inputRef); + + // 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(undefined); + + 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. + editor.current.loadHTML(value); + + function trixChange(e: ChangeEvent) { + const { textContent, innerHTML } = e.target; + currentHtml.current = innerHTML; + onChange && onChange(innerHTML, textContent || ""); + } + + editorElement.addEventListener("trix-change", trixChange as any, false); + return () => { + editorElement.removeEventListener("trix-change", trixChange as any); + }; + }, []); + + 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 ( +
+ {label &&
+ ); +} + +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.$, +}; diff --git a/truss/index.ts b/truss/index.ts index 66a046cba..bbf969815 100644 --- a/truss/index.ts +++ b/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; @@ -30,7 +30,9 @@ const fonts: Record `${property} 200ms`).join(", "); +const transition: string = ["background-color", "border-color", "box-shadow", "left", "right"] + .map((property) => `${property} 200ms`) + .join(", "); // Custom rules const sections: Sections = { @@ -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.$); }`, ], diff --git a/yarn.lock b/yarn.lock index 0ecfcfe69..162ef499b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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" @@ -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" From 4ac2d4d87218121971b2cdab5ac9a3458ccdf352 Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Fri, 28 May 2021 10:15:52 -0500 Subject: [PATCH 2/9] Fix comment, use inputProps. --- src/components/RichTextEditor.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 85c7abee4..3435837ae 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -25,10 +25,8 @@ export interface RichTextEditorProps { placeholder?: string; } -// trix + react-trix do not appear to have any typings, so roll with editor: any for now. +// There aren't types for trix, so add our own. For now `loadHTML` is all we call anyway. type Editor = { - expandSelectionInDirection?: (direction: "forward" | "backward") => void; - insertString(s: string): void; loadHTML(html: string): void; }; @@ -121,7 +119,7 @@ export function RichTextEditor(props: RichTextEditorProps) { ...(autoFocus ? { autoFocus } : {}), ...(placeholder ? { placeholder } : {}), })} - + From 1ff0103a5a9211eb86b1b2f1a25e3634e1f97331 Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Fri, 28 May 2021 10:17:58 -0500 Subject: [PATCH 3/9] Move helper function, remove useTextField b/c its not a text field. --- src/components/RichTextEditor.tsx | 48 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 3435837ae..8e6c4c55f 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -1,5 +1,4 @@ import { Global } from "@emotion/react"; -import { useTextField } from "@react-aria/textfield"; import * as React from "react"; import { ChangeEvent, useEffect, useRef } from "react"; import { useId } from "react-aria"; @@ -30,25 +29,6 @@ type Editor = { loadHTML(html: string): void; }; -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: () => ``, - selectTemplate: ({ original: { value } }) => `@${value}`, - 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 {} -} - /** * Glues together trix and tributejs to provide a simple rich text editor. * @@ -61,10 +41,6 @@ export function RichTextEditor(props: RichTextEditorProps) { // We get a reference to the Editor instance after trix-init fires const editor = useRef(undefined); - // Disclaimer I'm kinda guessing at whether this aria setup is right - const inputRef = useRef(null); - const { labelProps, inputProps } = useTextField({ label }, inputRef); - // 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. @@ -111,7 +87,8 @@ export function RichTextEditor(props: RichTextEditorProps) { return (
- {label &&
); } +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: () => ``, + selectTemplate: ({ original: { value } }) => `@${value}`, + 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 From 691311a49b9af642110f511017616cad9ce3eeaf Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Fri, 28 May 2021 11:08:09 -0500 Subject: [PATCH 4/9] PR feedback. --- src/components/RichTextEditor.stories.tsx | 6 +++--- src/components/RichTextEditor.tsx | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/RichTextEditor.stories.tsx b/src/components/RichTextEditor.stories.tsx index d3265fed3..5c715cd5d 100644 --- a/src/components/RichTextEditor.stories.tsx +++ b/src/components/RichTextEditor.stories.tsx @@ -1,13 +1,13 @@ import { Meta } from "@storybook/react"; import { useState } from "react"; -import { RichTextEditor } from "src/components/RichTextEditor"; +import { RichTextEditor as RichTextEditorComponent } from "src/components/RichTextEditor"; export default { component: RichTextEditor, title: "Components/Rich Text Editor", } as Meta; -export function RichTextEditors() { +export function RichTextEditor() { return ; } @@ -15,7 +15,7 @@ function TestEditor() { const [value, setValue] = useState(""); return ( <> - + value: {value} ); diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 8e6c4c55f..67649d092 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -1,6 +1,5 @@ import { Global } from "@emotion/react"; -import * as React from "react"; -import { ChangeEvent, useEffect, useRef } from "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"; @@ -90,7 +89,7 @@ export function RichTextEditor(props: RichTextEditorProps) { {/* TODO: Not sure what to pass to labelProps. */} {label &&