Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MarkdownEditor and MarkdownViewer components #2182

Merged
merged 139 commits into from
Aug 10, 2022

Conversation

iansan5653
Copy link
Contributor

@iansan5653 iansan5653 commented Jul 22, 2022

Screenshot of the editor component

Closes https://github.com/github/memex/issues/10460 1

Components

MarkdownEditor

MarkdownEditor is an input for writing Markdown and previewing the result. Features include:

  • A built-in toolbar with Markdown formatting tools and support for customization
  • Standard keyboard shortcuts for formatting (ie, ⌘B to bold selected text)
  • Inline autocompletion for @-mentions, #-references, and emojis (:)
  • Previewing Markdown using an async function (ie, an API request)
  • Optional file upload through drag-and-drop, pasting, or clicking a button
  • Pasting links and tables
  • List editing: new list items are automatically inserted upon Enter when editing a list
  • Indenting and dedenting selected text with Tab and Shift + Tab
  • Automatic resizing of the editor height as the user types
  • Formatting and other actions preserve the undo history
  • Inserting saved replies
  • Compositional API for custom toolbar buttons, action buttons, and label

❌ Not planned for this PR:

Screen Recording of Usage
Screen.Recording.2022-07-21.at.12.29.08.PM.mov
Props

export type MarkdownEditorProps = SxProp & {
/** Current value of the editor as a multiline markdown string. */
value: string
/** Called when the value changes. */
onChange: (newMarkdown: string) => void
/**
* Accepts Markdown and returns rendered HTML. To prevent XSS attacks,
* the HTML should be sanitized and/or come from a trusted source.
*/
onRenderPreview: (markdown: string) => Promise<string>
children: React.ReactNode
/** Disable the editor and all related buttons. Users can still switch between preview & edit modes. */
disabled?: boolean
/** Placeholder text to show when the editor is empty. By default, no placeholder will be shown. */
placeholder?: string
/** Maximum number of characters the markdown can hold (includes formatting characters like `*`). */
maxLength?: number
/**
* Force the editor to take up the full height of the container and disallow resizing. Only
* use when the container height is tall enough that the user will never want to expand the
* input further, ie when it takes the full height of the viewport.
*/
fullHeight?: boolean
/** ID of the describing element. */
'aria-describedby'?: string
/** Optionally control the view mode. If uncontrolled, leave this `undefined`. */
viewMode?: MarkdownViewMode
/** If `viewMode` is controlled, this will be called on change. */
onChangeViewMode?: (newViewMode: MarkdownViewMode) => void
/**
* Called when the user presses `Ctrl`/`Cmd` + `Enter`. Should almost always be wired to
* the same event as clicking the primary `actionButton`.
*/
onPrimaryAction?: () => void
/**
* Minimum number of visible lines of text in the editor.
* @default 5
*/
minHeightLines?: number
/**
* Maximum number of visible lines of text in the editor. Has no effect if `fullHeight = true`.
* @default 35
*/
maxHeightLines?: number
/** Array of all possible emojis to suggest. Leave `undefined` to disable emoji autocomplete. */
emojiSuggestions?: Array<Emoji>
/** Array of all possible mention suggestions. Leave `undefined` to disable `@`-mention autocomplete. */
mentionSuggestions?: Array<Mentionable>
/** Array of all possible references to suggest. Leave `undefined` to disable `#`-reference autocomplete. */
referenceSuggestions?: Array<Reference>
/**
* Uploads a file to a hosting service and returns the URL. If not provided, file uploads
* will be disabled.
*/
onUploadFile?: (file: File) => Promise<FileUploadResult>
/**
* Array of allowed file types. If `onUploadFile` is defined but this array is not, all
* file types will be accepted. You can still reject file types by rejecting the `onUploadFile`
* promise, but setting this array provides a better user experience by preventing the
* upload in the first place.
*/
acceptedFileTypes?: FileType[]
/** Control whether the editor font is monospace. */
monospace?: boolean
/** Control whether the input is required. */
required?: boolean
/** The name that will be given to the `textarea`. */
name?: string
/** To enable the saved replies feature, provide an array of replies. */
savedReplies?: SavedReply[]
}

Sub-Components

/** REQUIRED: An accessible label for the editor. */
Label,
/**
* An optional custom toolbar. The toolbar should contain `ToolbarButton`s before
* and/or after a `DefaultToolbarButtons` instance. To create groups of buttons, wrap
* them in an unstyled `Box`.
*/
Toolbar,
/**
* A custom toolbar button. This takes `IconButton` props. Every toolbar button should
* have an `aria-label` defined.
*/
ToolbarButton,
/**
* The full set of default toolbar buttons. This is all the basic formatting tools in a
* standardized order.
*/
DefaultToolbarButtons,
/**
* Optionally define a set of custom buttons to show in the editor footer. Often if you
* are defining custom buttons you should also wrap the editor in a `<form>`. This
* component should only contain `ActionButton`s.
*/
Actions,
/** A button to show in the editor footer. */
ActionButton

Usage Example

<MarkdownEditor
value={value}
onChange={setValue}
onPrimaryAction={onSubmit}
disabled={disabled}
fullHeight={fullHeight}
monospace={monospace}
minHeightLines={minHeightLines}
maxHeightLines={maxHeightLines}
placeholder="Enter some Markdown..."
onRenderPreview={renderPreview}
onUploadFile={fileUploadsEnabled ? onUploadFile : undefined}
emojiSuggestions={emojis}
mentionSuggestions={mentionables}
referenceSuggestions={references}
required={required}
savedReplies={savedRepliesEnabled ? savedReplies : undefined}
>
<MarkdownEditor.Label visuallyHidden={hideLabel}>Markdown Editor Example</MarkdownEditor.Label>
<MarkdownEditor.Toolbar>
<MarkdownEditor.ToolbarButton icon={DiffIcon} onClick={onDiffClick} aria-label="Custom Button" />
<MarkdownEditor.DefaultToolbarButtons />
</MarkdownEditor.Toolbar>
<MarkdownEditor.Actions>
<MarkdownEditor.ActionButton variant="danger" onClick={() => setValue('')}>
Reset
</MarkdownEditor.ActionButton>
<MarkdownEditor.ActionButton variant="primary" onClick={onSubmit}>
Submit
</MarkdownEditor.ActionButton>
</MarkdownEditor.Actions>
</MarkdownEditor>

MarkdownViewer

MarkdownViewer is a wrapper for displaying rendered Markdown HTML. Features:

  • Checking and unchecking task list items
  • Optionally links to open in a new tab
  • Intercept links to change routing behavior

Additional Work

In addition to the two components, this PR contains many dependent changes & new functionality.

New Hooks

  • useUnifiedFileSelect: This hook adds an easy to use API for unifying various methods of selecting files (dragging and dropping, pasting, and clicking a button). It combines all the necessary events into a single file select event
  • useIgnoreKeyboardInputWhileComposing: When listening for Enter keypresses (ie, to submit forms), it is important to filter out keypresses made while the user is inputting using IME. This is frustratingly difficult to do using browser APIs because the key press events look the same, so this hook aims to make the process much easier. For more details, see https://github.com/github/memex/issues/10800 1
  • useDynamicTextareaHeight: Calculates the optimal height for a textarea based on the contents, automatically adjusting as the user types
  • useSafeAsyncCallback: Makes it easier to manipulate state in async callbacks without memory leaks or accidentally calling outdated references.

Other Changes

  • Extracted the character coordinates utilities from the InlineAutocomplete component so they can be used by other code (this is necessary for useDynamicTextareaHeight)
  • Updated useResizeObserver to allow attaching the observer to elements other than the document root
  • Updated createSlots to use layout effects when getting slots (reduces the number of renders)

New Dependencies

  • fzy.js: Powers fuzzy matching in inline suggestions
  • @github/markdown-toolbar-element: Provides Markdown formatting tools (I eventually want to remove this dependency and just build the logic into the component; I don't think this element is a great implementation for the use case)
  • @github/paste-markdown: Markdown pasting support (pasting tables & links)

Merge checklist

  • Added/updated tests
  • Added/updated documentation
  • Tested in Chrome
  • Tested in Firefox
  • Tested in Safari
  • Tested in Edge

Footnotes

  1. :octocat: Link only accessible to GitHub employees 2

Fixes not having an `aria-activedescendant` initially defined
…s a combobox when no suggestions are available
@iansan5653 iansan5653 temporarily deployed to github-pages August 4, 2022 19:39 Inactive
@iansan5653 iansan5653 temporarily deployed to github-pages August 4, 2022 19:41 Inactive
@iansan5653 iansan5653 temporarily deployed to github-pages August 5, 2022 15:05 Inactive
@iansan5653 iansan5653 temporarily deployed to github-pages August 5, 2022 15:05 Inactive
@@ -3,25 +3,41 @@ import {Box} from '.'
import {SxProp} from './sx'
import VisuallyHidden from './_VisuallyHidden'

interface Props extends React.HTMLProps<HTMLLabelElement> {
type BaseProps = SxProp & {
Copy link
Member

@siddharthkp siddharthkp Aug 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(sorry, I could be wrong): Would removing HTMLProps here throw warning for other html attributes like className or data-testid? Do we need to worry about that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would, but those props weren't getting forwarded to the underlying element anyway - so if you passed className you'd expect it to work but it wouldn't have actually worked. In that sense, this type was actually misleading.

In addition, this is a private (internal) component and there wasn't anywhere we were using those types of props internally, so this shouldn't have any effect.

Copy link
Member

@siddharthkp siddharthkp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost there, left a couple a question for clarification

Copy link
Member

@siddharthkp siddharthkp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback implemented, ship it :shipit:

@iansan5653 iansan5653 temporarily deployed to github-pages August 10, 2022 16:54 Inactive
@iansan5653 iansan5653 enabled auto-merge (squash) August 10, 2022 20:08
@iansan5653 iansan5653 temporarily deployed to github-pages August 10, 2022 20:10 Inactive
@iansan5653 iansan5653 temporarily deployed to github-pages August 10, 2022 20:10 Inactive
@iansan5653 iansan5653 merged commit 47725a9 into main Aug 10, 2022
@iansan5653 iansan5653 deleted the add-markdown-editor-component branch August 10, 2022 20:13
@primer-css primer-css mentioned this pull request Aug 11, 2022
@iansan5653 iansan5653 mentioned this pull request Aug 15, 2022
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
💓collab a vibrant hub of collaboration react
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants