diff --git a/.changeset/fluffy-cycles-shave.md b/.changeset/fluffy-cycles-shave.md new file mode 100644 index 00000000000..3b10427fe15 --- /dev/null +++ b/.changeset/fluffy-cycles-shave.md @@ -0,0 +1,6 @@ +--- +"@primer/react": patch +--- + +- Add `MarkdownEditor` and `MarkdownViewer` draft components. The `MarkdownEditor` is also known as the `CommentBox` component +- Add `useUnifiedFileSelect`, `useIgnoreKeyboardInputWhileComposing`, `useDynamicTextareaHeight`, and `useSafeAsyncCallback` draft hooks diff --git a/@types/fzy-js/index.d.ts b/@types/fzy-js/index.d.ts new file mode 100644 index 00000000000..4fffc82343c --- /dev/null +++ b/@types/fzy-js/index.d.ts @@ -0,0 +1,36 @@ +declare module 'fzy.js' { + // as defined by https://github.com/jhawthorn/fzy.js/blob/master/index.js#L189 + export const SCORE_MIN: typeof Infinity // for -Infinity + export const SCORE_MAX: typeof Infinity + + export const SCORE_GAP_LEADING: number + export const SCORE_GAP_TRAILING: number + export const SCORE_GAP_INNER: number + export const SCORE_MATCH_CONSECUTIVE: number + export const SCORE_MATCH_SLASH: number + export const SCORE_MATCH_WORD: number + export const SCORE_MATCH_CAPITAL: number + export const SCORE_MATCH_DOT: number + + /** + * score + * @param searchQuery - the user filter (the "needle") + * @param text - full text of the item being matched (the "haystack") + * @returns the score + */ + export function score(searchQuery: string, text: string): number + /** + * positions + * @param searchQuery - the user filter (the "needle") + * @param text - full text of the item being matched (the "haystack") + * @returns the position for each character match in the sequence + */ + export function positions(searchQuery: string, text: string): Array + /** + * hasMatch + * @param searchQuery - the user filter (the "needle") + * @param text - full text of the item being matched (the "haystack") + * @returns whether or not there is a match in the sequence + */ + export function hasMatch(searchQuery: string, text: string): boolean +} diff --git a/docs/content/drafts/MarkdownEditor.mdx b/docs/content/drafts/MarkdownEditor.mdx new file mode 100644 index 00000000000..3f45498ce9a --- /dev/null +++ b/docs/content/drafts/MarkdownEditor.mdx @@ -0,0 +1,162 @@ +--- +componentId: markdown_editor +title: MarkdownEditor +status: Draft +description: Full-featured Markdown input. +storybook: '/react/storybook?path=/story/forms-markdowneditor--default' +--- + +```js +import {MarkdownEditor} from '@primer/react/drafts' +``` + +`MarkdownEditor` is a full-featured editor for GitHub Flavored Markdown, with support for: + +- Formatting (keyboard shortcuts & toolbar buttons) +- File uploads (drag & drop, paste, click to upload) +- Inline suggestions (emojis, `@` mentions, and `#` references) +- Saved replies +- Markdown pasting (ie, paste URL onto selected text to create a link) +- List editing (create a new list item on `Enter`) +- Indenting selected text + +## Examples + +### Minimal Example + +A `Label` is always required for accessibility: + +```javascript live noinline drafts +const renderMarkdown = async (markdown) => { + // In production code, this would make a query to some external API endpoint to render + return "Rendered Markdown." +} + +const MinimalExample = () => { + const [value, setValue] = React.useState('') + + return ( + + Minimal Example + + ) +} + +render(MinimalExample) +``` + +### Suggestions, File Uploads, and Saved Replies + +```javascript live noinline drafts +const renderMarkdown = async (markdown) => "Rendered Markdown." + +const uploadFile = async (file) => ({ + url: `https://example.com/${encodeURIComponent(file.name)}`, + file +}) + +const emojis = [ + {name: '+1', character: '👍'}, + {name: '-1', character: '👎'}, + {name: 'heart', character: '❤️'}, + {name: 'wave', character: '👋'}, + {name: 'raised_hands', character: '🙌'}, + {name: 'pray', character: '🙏'}, + {name: 'clap', character: '👏'}, + {name: 'ok_hand', character: '👌'}, + {name: 'point_up', character: '☝️'}, + {name: 'point_down', character: '👇'}, + {name: 'point_left', character: '👈'}, + {name: 'point_right', character: '👉'}, + {name: 'raised_hand', character: '✋'}, + {name: 'thumbsup', character: '👍'}, + {name: 'thumbsdown', character: '👎'} +] + +const references = [ + {id: '1', titleText: 'Add logging functionality', titleHtml: 'Add logging functionality'}, + { + id: '2', + titleText: 'Error: `Failed to install` when installing', + titleHtml: 'Error: Failed to install when installing' + }, + {id: '3', titleText: 'Add error-handling functionality', titleHtml: 'Add error-handling functionality'} +] + +const mentionables = [ + {identifier: 'monalisa', description: 'Monalisa Octocat'}, + {identifier: 'github', description: 'GitHub'}, + {identifier: 'primer', description: 'Primer'} +] + +const savedReplies = [ + {name: 'Duplicate', content: 'Duplicate of #'}, + {name: 'Welcome', content: 'Welcome to the project!\n\nPlease be sure to read the contributor guidelines.'}, + {name: 'Thanks', content: 'Thanks for your contribution!'} +] + +const MinimalExample = () => { + const [value, setValue] = React.useState('') + + return ( + + Suggestions, File Uploads, and Saved Replies Example + + ) +} + +render(MinimalExample) +``` + +### Custom Buttons + +```javascript live noinline drafts +const renderMarkdown = async (markdown) => "Rendered Markdown." + +const MinimalExample = () => { + const [value, setValue] = React.useState('') + + return ( + + Custom Buttons + + + + + + + + + + Cancel + + + Submit + + + + ) +} + +render(MinimalExample) +``` diff --git a/docs/content/drafts/MarkdownViewer.mdx b/docs/content/drafts/MarkdownViewer.mdx new file mode 100644 index 00000000000..58d7e1d87a1 --- /dev/null +++ b/docs/content/drafts/MarkdownViewer.mdx @@ -0,0 +1,97 @@ +--- +componentId: markdown_viewer +title: MarkdownViewer +status: Draft +description: Displays rendered Markdown and facilitates interaction. +--- + +```js +import {MarkdownViewer} from '@primer/react/drafts' +``` + +The `MarkdownViewer` displays rendered Markdown with appropriate styling and handles interaction (link clicking and checkbox checking/unchecking) with that content. + +## Examples + +### Simple Example + +```javascript live noinline drafts +const MarkdownViewerExample = () => { + return ( + // eslint-disable-next-line github/unescaped-html-literal + Lorem ipsum dolor sit amet.'}} /> + ) +} + +render(MarkdownViewerExample) +``` + +### Link-Handling Example + +```javascript live noinline drafts +const MarkdownViewerExample = () => { + return ( + Example link"}} + onLinkClick={ev => console.log(ev)} + /> + ) +} + +render(MarkdownViewerExample) +``` + +### Checkbox Interaction Example + +```javascript live noinline drafts +const markdownSource = ` +text before list + +- [ ] item 1 +- [ ] item 2 + +text after list` + +const renderedHtml = ` +

text before list

+
    +
  • item 1
  • +
  • item 2
  • +
+

text after list

` + +const MarkdownViewerExample = () => { + return ( + console.log(value) /* save the value to the server */} + disabled={false} + /> + ) +} + +render(MarkdownViewerExample) +``` + +## Status + + diff --git a/docs/src/@primer/gatsby-theme-doctocat/nav.yml b/docs/src/@primer/gatsby-theme-doctocat/nav.yml index 5e965fbe9c1..7bc831a050a 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml +++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml @@ -155,6 +155,10 @@ url: /drafts/Dialog - title: InlineAutocomplete url: /drafts/InlineAutocomplete + - title: MarkdownEditor + url: /drafts/MarkdownEditor + - title: MarkdownViewer + url: /drafts/MarkdownViewer - title: Deprecated children: - title: ActionList (legacy) diff --git a/jest.config.js b/jest.config.js index 22eebe81b14..2a7a7e70923 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,5 +10,7 @@ module.exports = { '/src/utils/test-helpers.tsx' ], testMatch: ['/(src|codemods)/**/*.test.[jt]s?(x)', '!**/*.types.test.[jt]s?(x)'], - transformIgnorePatterns: ['node_modules/(?!@github/combobox-nav|@koddsson/textarea-caret)'] + transformIgnorePatterns: [ + 'node_modules/(?!@github/combobox-nav|@koddsson/textarea-caret|@github/markdown-toolbar-element)' + ] } diff --git a/package-lock.json b/package-lock.json index bf45cc50173..55d3b7dd4af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "@github/combobox-nav": "^2.1.5", + "@github/markdown-toolbar-element": "^2.1.0", + "@github/paste-markdown": "^1.3.1", "@koddsson/textarea-caret": "^4.0.1", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", @@ -27,6 +29,7 @@ "color2k": "^1.2.4", "deepmerge": "^4.2.2", "focus-visible": "^5.2.0", + "fzy.js": "0.4.1", "history": "^5.0.0", "styled-system": "^5.1.5" }, @@ -3112,6 +3115,16 @@ "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz", "integrity": "sha512-dmG1PuppNKHnBBEcfylWDwj9SSxd/E/qd8mC1G/klQC3s7ps5q6JZ034mwkkG0LKfI+Y+UgEua/ROD776N400w==" }, + "node_modules/@github/markdown-toolbar-element": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@github/markdown-toolbar-element/-/markdown-toolbar-element-2.1.1.tgz", + "integrity": "sha512-J++rpd5H9baztabJQB82h26jtueOeBRSTqetk9Cri+Lj/s28ndu6Tovn0uHQaOKtBWDobFunk9b5pP5vcqt7cA==" + }, + "node_modules/@github/paste-markdown": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.3.3.tgz", + "integrity": "sha512-l/nlZ8cEWB/XlvvplA0GGF7lde7DNbgaNKTKwb7bvANMQXNb6YivLJiwNGxkrkdBNz45ZRbz3FIqZIiliNLtQg==" + }, "node_modules/@github/prettier-config": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@github/prettier-config/-/prettier-config-0.0.4.tgz", @@ -20160,6 +20173,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fzy.js": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fzy.js/-/fzy.js-0.4.1.tgz", + "integrity": "sha512-4sPVXf+9oGhzg2tYzgWe4hgAY0wEbkqeuKVEgdnqX8S8VcLosQsDjb0jV+f5uoQlf8INWId1w0IGoufAoik1TA==" + }, "node_modules/gauge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", @@ -39555,6 +39573,16 @@ "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz", "integrity": "sha512-dmG1PuppNKHnBBEcfylWDwj9SSxd/E/qd8mC1G/klQC3s7ps5q6JZ034mwkkG0LKfI+Y+UgEua/ROD776N400w==" }, + "@github/markdown-toolbar-element": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@github/markdown-toolbar-element/-/markdown-toolbar-element-2.1.1.tgz", + "integrity": "sha512-J++rpd5H9baztabJQB82h26jtueOeBRSTqetk9Cri+Lj/s28ndu6Tovn0uHQaOKtBWDobFunk9b5pP5vcqt7cA==" + }, + "@github/paste-markdown": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.3.3.tgz", + "integrity": "sha512-l/nlZ8cEWB/XlvvplA0GGF7lde7DNbgaNKTKwb7bvANMQXNb6YivLJiwNGxkrkdBNz45ZRbz3FIqZIiliNLtQg==" + }, "@github/prettier-config": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@github/prettier-config/-/prettier-config-0.0.4.tgz", @@ -52412,6 +52440,11 @@ "integrity": "sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA==", "dev": true }, + "fzy.js": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fzy.js/-/fzy.js-0.4.1.tgz", + "integrity": "sha512-4sPVXf+9oGhzg2tYzgWe4hgAY0wEbkqeuKVEgdnqX8S8VcLosQsDjb0jV+f5uoQlf8INWId1w0IGoufAoik1TA==" + }, "gauge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", diff --git a/package.json b/package.json index 2489a4a4605..492e556aba3 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "lint": "eslint '**/*.{js,ts,tsx,md,mdx}' --max-warnings=0", "lint:fix": "npm run lint -- --fix", "test": "jest", - "test:storybook":"test-storybook", + "test:storybook": "test-storybook", "test:update": "npm run test -- --updateSnapshot", "test:type-check": "tsc --noEmit", "release": "npm run build && changeset publish", @@ -81,6 +81,8 @@ }, "dependencies": { "@github/combobox-nav": "^2.1.5", + "@github/markdown-toolbar-element": "^2.1.0", + "@github/paste-markdown": "^1.3.1", "@koddsson/textarea-caret": "^4.0.1", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", @@ -98,6 +100,7 @@ "color2k": "^1.2.4", "deepmerge": "^4.2.2", "focus-visible": "^5.2.0", + "fzy.js": "0.4.1", "history": "^5.0.0", "styled-system": "^5.1.5" }, diff --git a/src/_InputLabel.tsx b/src/_InputLabel.tsx index 3bee967643b..35bc3b584a9 100644 --- a/src/_InputLabel.tsx +++ b/src/_InputLabel.tsx @@ -3,25 +3,41 @@ import {Box} from '.' import {SxProp} from './sx' import VisuallyHidden from './_VisuallyHidden' -interface Props extends React.HTMLProps { +type BaseProps = SxProp & { disabled?: boolean required?: boolean visuallyHidden?: boolean + id?: string } -const InputLabel: React.FC> = ({ +type LabelProps = BaseProps & { + htmlFor?: string + as?: 'label' +} + +type LegendProps = BaseProps & { + as: 'legend' + htmlFor?: undefined +} + +type Props = LabelProps | LegendProps + +const InputLabel: React.FC> = ({ children, disabled, htmlFor, id, required, visuallyHidden, - sx + sx, + as = 'label' }) => { return ( | 'loading' -export type Coordinates = { - top: number - left: number -} - export type TextInputElement = HTMLInputElement | HTMLTextAreaElement export type TextInputCompatibleChild = React.ReactElement< diff --git a/src/drafts/InlineAutocomplete/utils.ts b/src/drafts/InlineAutocomplete/utils.ts index c6a14703439..c5376bd5237 100644 --- a/src/drafts/InlineAutocomplete/utils.ts +++ b/src/drafts/InlineAutocomplete/utils.ts @@ -1,7 +1,6 @@ -import getCaretCoordinates from '@koddsson/textarea-caret' import {Children, EventHandler, SyntheticEvent} from 'react' -import {Coordinates, ShowSuggestionsEvent, Suggestion, TextInputCompatibleChild, Trigger} from './types' +import {ShowSuggestionsEvent, Suggestion, TextInputCompatibleChild, Trigger} from './types' const singleWordTriggerTerminators = new Set([' ', '\n']) const multiWordTriggerTerminators = new Set(['.', '\n']) @@ -42,67 +41,6 @@ export const calculateSuggestionsQuery = ( return null } -/** - * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the - * top-left corner of the input itself. - * @param input The target input element. - * @param index The index of the character to calculate for. - * @param adjustForScroll Control whether the returned value is adjusted based on scroll position. - */ -export const getCharacterCoordinates = ( - input: HTMLTextAreaElement | HTMLInputElement | null, - index: number, - adjustForScroll = true -): Coordinates => { - if (!input) return {top: 0, left: 0} - - // word-wrap:break-word breaks the getCaretCoordinates calculations (a bug), and word-wrap has - // no effect on input element anyway - if (input instanceof HTMLInputElement) input.style.wordWrap = '' - - let coords = getCaretCoordinates(input, index) - - // The library calls parseInt on the computed line-height of the element, failing to account for - // the possibility of it being 'normal' (another bug). In that case, fall back to a rough guess - // of 1.2 based on MDN: "Desktop browsers use a default value of roughly 1.2". - if (isNaN(coords.height)) coords.height = parseInt(getComputedStyle(input).fontSize) * 1.2 - - // Sometimes top is negative, incorrectly, because of the wierd line-height calculations around - // border-box sized single-line inputs. - coords.top = Math.abs(coords.top) - - // For some single-line inputs, the rightmost character can be accidentally wrapped even with the - // wordWrap fix above. If this happens, go back to the last usable index - let adjustedIndex = index - while (input instanceof HTMLInputElement && coords.top > coords.height) { - coords = getCaretCoordinates(input, --adjustedIndex) - } - - const scrollTopOffset = adjustForScroll ? -input.scrollTop : 0 - const scrollLeftOffset = adjustForScroll ? -input.scrollLeft : 0 - - return {top: coords.top + coords.height + scrollTopOffset, left: coords.left + scrollLeftOffset} -} - -/** - * Obtain the coordinates of the bottom left of a character in an input relative to the top-left - * of the page. - * @param input The target input element. - * @param index The index of the character to calculate for. - */ -export const getAbsoluteCharacterCoordinates = ( - input: HTMLTextAreaElement | HTMLInputElement | null, - index: number -): Coordinates => { - const {top: relativeTop, left: relativeLeft} = getCharacterCoordinates(input, index, true) - const {top: viewportOffsetTop, left: viewportOffsetLeft} = input?.getBoundingClientRect() ?? {top: 0, left: 0} - - return { - top: viewportOffsetTop + relativeTop, - left: viewportOffsetLeft + relativeLeft - } -} - export const getSuggestionValue = (suggestion: Suggestion): string => typeof suggestion === 'string' ? suggestion : suggestion.value diff --git a/src/drafts/MarkdownEditor/Actions.tsx b/src/drafts/MarkdownEditor/Actions.tsx new file mode 100644 index 00000000000..5a0c8f3f97d --- /dev/null +++ b/src/drafts/MarkdownEditor/Actions.tsx @@ -0,0 +1,15 @@ +import React, {forwardRef, useContext} from 'react' +import {Button, ButtonProps} from '../../Button' +import {MarkdownEditorSlot} from './MarkdownEditor' +import {MarkdownEditorContext} from './_MarkdownEditorContext' + +export const Actions = ({children}: {children?: React.ReactNode}) => ( + {children} +) +Actions.displayName = 'MarkdownEditor.Actions' + +export const ActionButton = forwardRef((props, ref) => { + const {disabled} = useContext(MarkdownEditorContext) + return + ) +}) + +const VisualSeparator = memo(() => ( + +)) + +const MarkdownSupportedHint = memo(() => { + const {condensed} = useContext(MarkdownEditorContext) + + return ( + + {!condensed && Markdown is supported} + + ) +}) diff --git a/src/drafts/MarkdownEditor/_FormattingTools.tsx b/src/drafts/MarkdownEditor/_FormattingTools.tsx new file mode 100644 index 00000000000..2c0444c1049 --- /dev/null +++ b/src/drafts/MarkdownEditor/_FormattingTools.tsx @@ -0,0 +1,74 @@ +import React, {forwardRef, useImperativeHandle, useRef, useEffect} from 'react' + +export type FormattingTools = { + header: () => void + bold: () => void + italic: () => void + quote: () => void + code: () => void + link: () => void + unorderedList: () => void + orderedList: () => void + taskList: () => void + mention: () => void + reference: () => void +} + +let hasRegisteredToolbarElement = false + +/** + * Renders an invisible `markdown-toolbar-element` that provides formatting actions to the + * editor. This is a hacky way of using the library, but it allows us to use the built-in + * behavior without having to actually display the inflexible toolbar element. It also means + * we can still use the formatting tools even if the consumer hides the default toolbar + * buttons (ie, by keyboard shortcut). + */ +export const FormattingTools = forwardRef(({forInputId}, forwadedRef) => { + useEffect(() => { + // requiring this module will register the custom element; we don't want to do that until the component mounts in the DOM + if (!hasRegisteredToolbarElement) require('@github/markdown-toolbar-element') + hasRegisteredToolbarElement = true + }, []) + + const headerRef = useRef(null) + const boldRef = useRef(null) + const italicRef = useRef(null) + const quoteRef = useRef(null) + const codeRef = useRef(null) + const linkRef = useRef(null) + const unorderedListRef = useRef(null) + const orderedListRef = useRef(null) + const taskListRef = useRef(null) + const mentionRef = useRef(null) + const referenceRef = useRef(null) + + useImperativeHandle(forwadedRef, () => ({ + header: () => headerRef.current?.click(), + bold: () => boldRef.current?.click(), + italic: () => italicRef.current?.click(), + quote: () => quoteRef.current?.click(), + code: () => codeRef.current?.click(), + link: () => linkRef.current?.click(), + unorderedList: () => unorderedListRef.current?.click(), + orderedList: () => orderedListRef.current?.click(), + taskList: () => taskListRef.current?.click(), + mention: () => mentionRef.current?.click(), + reference: () => referenceRef.current?.click() + })) + + return ( + + + + + + + + + + + + + + ) +}) diff --git a/src/drafts/MarkdownEditor/_MarkdownEditorContext.ts b/src/drafts/MarkdownEditor/_MarkdownEditorContext.ts new file mode 100644 index 00000000000..feaa69033d5 --- /dev/null +++ b/src/drafts/MarkdownEditor/_MarkdownEditorContext.ts @@ -0,0 +1,18 @@ +import {createContext, RefObject} from 'react' +import {FormattingTools} from './_FormattingTools' + +// For performance, the properties in context MUST NOT be values that change often - every time +// any of the properties change, all components including memoized ones will be re-rendered +type MarkdownEditorContextProps = { + disabled: boolean + condensed: boolean + required: boolean + formattingToolsRef: RefObject +} + +export const MarkdownEditorContext = createContext({ + disabled: false, + condensed: false, + required: false, + formattingToolsRef: {current: null} +}) diff --git a/src/drafts/MarkdownEditor/_MarkdownInput.tsx b/src/drafts/MarkdownEditor/_MarkdownInput.tsx new file mode 100644 index 00000000000..98770fbf3d0 --- /dev/null +++ b/src/drafts/MarkdownEditor/_MarkdownInput.tsx @@ -0,0 +1,128 @@ +import {subscribe as subscribeToMarkdownPasting} from '@github/paste-markdown' +import React, {forwardRef, useEffect, useMemo, useRef, useState} from 'react' +import {useDynamicTextareaHeight} from '../hooks/useDynamicTextareaHeight' +import InlineAutocomplete, {ShowSuggestionsEvent, Suggestions} from '../InlineAutocomplete' +import Textarea, {TextareaProps} from '../../Textarea' +import {Emoji, useEmojiSuggestions} from './suggestions/_useEmojiSuggestions' +import {Mentionable, useMentionSuggestions} from './suggestions/_useMentionSuggestions' +import {Reference, useReferenceSuggestions} from './suggestions/_useReferenceSuggestions' +import {useRefObjectAsForwardedRef} from '../../hooks' + +interface MarkdownInputProps extends Omit { + value: string + onChange: React.ChangeEventHandler + onKeyDown?: React.KeyboardEventHandler + disabled?: boolean + placeholder?: string + id: string + maxLength?: number + fullHeight?: boolean + isDraggedOver: boolean + emojiSuggestions?: Array + mentionSuggestions?: Array + referenceSuggestions?: Array + minHeightLines: number + maxHeightLines: number + monospace: boolean + /** Use this prop to control visibility instead of unmounting, so the undo stack and custom height are preserved. */ + visible: boolean +} + +export const MarkdownInput = forwardRef( + ( + { + value, + onChange, + disabled, + placeholder, + id, + maxLength, + onKeyDown, + fullHeight, + isDraggedOver, + emojiSuggestions, + mentionSuggestions, + referenceSuggestions, + minHeightLines, + maxHeightLines, + visible, + monospace, + ...props + }, + forwardedRef + ) => { + const [suggestions, setSuggestions] = useState(null) + + const {trigger: emojiTrigger, calculateSuggestions: calculateEmojiSuggestions} = useEmojiSuggestions( + emojiSuggestions ?? [] + ) + const {trigger: mentionsTrigger, calculateSuggestions: calculateMentionSuggestions} = useMentionSuggestions( + mentionSuggestions ?? [] + ) + const {trigger: referencesTrigger, calculateSuggestions: calculateReferenceSuggestions} = useReferenceSuggestions( + referenceSuggestions ?? [] + ) + + const triggers = useMemo( + () => [mentionsTrigger, referencesTrigger, emojiTrigger], + [mentionsTrigger, referencesTrigger, emojiTrigger] + ) + + const onShowSuggestions = (event: ShowSuggestionsEvent) => { + if (event.trigger.triggerChar === emojiTrigger.triggerChar) { + setSuggestions(calculateEmojiSuggestions(event.query)) + } else if (event.trigger.triggerChar === mentionsTrigger.triggerChar) { + setSuggestions(calculateMentionSuggestions(event.query)) + } else if (event.trigger.triggerChar === referencesTrigger.triggerChar) { + setSuggestions(calculateReferenceSuggestions(event.query)) + } + } + + const ref = useRef(null) + useRefObjectAsForwardedRef(forwardedRef, ref) + + useEffect(() => (ref.current ? subscribeToMarkdownPasting(ref.current).unsubscribe : undefined), []) + + const dynamicHeightStyles = useDynamicTextareaHeight({maxHeightLines, minHeightLines, element: ref.current, value}) + const heightStyles = fullHeight ? {} : dynamicHeightStyles + + return ( + setSuggestions(null)} + sx={{flex: 'auto'}} + tabInsertsSuggestions + > +