| null>(null)
-
- return () => {
- // This simulates waiting to make an API request until the first time the suggestions are needed
- // Then, once we have made the API request we keep returning the same Promise which will already
- // be resolved with the cached data
- if (!promiseRef.current) {
- promiseRef.current = new Promise(resolve => {
- setTimeout(() => resolve(suggestions), 500)
- })
- }
-
- return promiseRef.current
- }
-}
-
-export const LazyLoadedSuggestions = ({
- disabled,
- fullHeight,
- monospace,
- minHeightLines,
- maxHeightLines,
- hideLabel,
- required,
- fileUploadsEnabled,
- onSubmit,
- savedRepliesEnabled,
- pasteUrlsAsPlainText,
-}: ArgProps) => {
- const [value, setValue] = useState('')
-
- const emojiSuggestions = useLazySuggestions(emojis)
- const mentionSuggestions = useLazySuggestions(mentionables)
- const referenceSuggestions = useLazySuggestions(references)
-
- return (
-
-
- Markdown Editor Example
-
- Note: for demo purposes, files starting with "A" will be rejected.
-
- )
-}
diff --git a/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx b/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx
deleted file mode 100644
index 9d259d5c199..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx
+++ /dev/null
@@ -1,271 +0,0 @@
-import type {Meta} from '@storybook/react'
-import React from '@storybook/react'
-import {useState} from 'react'
-import BaseStyles from '../../BaseStyles'
-import Box from '../../Box'
-import type {Emoji, Mentionable, Reference, SavedReply} from '.'
-import MarkdownEditor from '.'
-import ThemeProvider from '../../ThemeProvider'
-
-const meta: Meta = {
- title: 'Deprecated/Components/MarkdownEditor',
- decorators: [
- Story => {
- return (
-
-
- {Story()}
-
-
- )
- },
- ],
- parameters: {
- controls: {
- include: [
- 'Disabled',
- 'Full Height',
- 'Monospace Font',
- 'Minimum Height (Lines)',
- 'Maximum Height (Lines)',
- 'Hide Label',
- 'Required',
- 'Enable File Uploads',
- 'Enable Saved Replies',
- 'Enable Plain-Text URL Pasting',
- ],
- },
- },
- component: MarkdownEditor,
- args: {
- disabled: false,
- fullHeight: false,
- monospace: false,
- pasteUrlsAsPlainText: false,
- minHeightLines: 5,
- maxHeightLines: 35,
- hideLabel: false,
- required: false,
- fileUploadsEnabled: true,
- savedRepliesEnabled: true,
- },
- argTypes: {
- disabled: {
- name: 'Disabled',
- control: {
- type: 'boolean',
- },
- },
- fullHeight: {
- name: 'Full Height',
- control: {
- type: 'boolean',
- },
- },
- monospace: {
- name: 'Monospace Font',
- control: {
- type: 'boolean',
- },
- },
- pasteUrlsAsPlainText: {
- name: 'Enable Plain-Text URL Pasting',
- control: {
- type: 'boolean',
- },
- },
- minHeightLines: {
- name: 'Minimum Height (Lines)',
- control: {
- type: 'number',
- },
- },
- maxHeightLines: {
- name: 'Maximum Height (Lines)',
- control: {
- type: 'number',
- },
- },
- hideLabel: {
- name: 'Hide Label',
- control: {
- type: 'boolean',
- },
- },
- required: {
- name: 'Required',
- control: {
- type: 'boolean',
- },
- },
- fileUploadsEnabled: {
- name: 'Enable File Uploads',
- control: {
- type: 'boolean',
- },
- },
- savedRepliesEnabled: {
- name: 'Enable Saved Replies',
- control: {
- type: 'boolean',
- },
- },
- onSubmit: {
- name: 'onSubmit',
- action: 'submitted',
- },
- onDiffClick: {
- name: 'onDiffClick',
- action: 'diff-clicked',
- },
- onFooterClick: {
- name: 'onFooterClick',
- action: 'footer-clicked',
- },
- },
-}
-
-export default meta
-
-type ArgProps = {
- disabled: boolean
- fullHeight: boolean
- monospace: boolean
- minHeightLines: number
- maxHeightLines: number
- hideLabel: boolean
- required: boolean
- fileUploadsEnabled: boolean
- savedRepliesEnabled: boolean
- pasteUrlsAsPlainText: boolean
- onSubmit: () => void
- onDiffClick: () => void
- onFooterClick: () => void
-}
-
-const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
-
-const fakeFileUrl = (file: File) => `https://image-store.example/file/${encodeURIComponent(file.name)}`
-
-const mentionables: Mentionable[] = [
- {identifier: 'monalisa', description: 'Monalisa Octocat'},
- {identifier: 'github', description: 'GitHub'},
- {identifier: 'primer', description: 'Primer'},
-]
-
-const emojis: Emoji[] = [
- {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: 'π'},
- {name: 'octocat', url: 'https://github.githubassets.com/images/icons/emoji/octocat.png'},
-]
-
-const references: Reference[] = [
- {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 savedReplies: SavedReply[] = [
- {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!'},
- {
- name: 'Long Lorem Ipsum',
- content:
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam sodales ligula commodo ex venenatis molestie. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Curabitur vulputate elementum dolor ac sollicitudin. Duis tellus quam, hendrerit sit amet metus quis, pharetra consectetur eros. Duis purus justo, convallis nec velit nec, feugiat pharetra nibh. Aenean vulputate urna sollicitudin vehicula fermentum. Vestibulum semper iaculis metus, quis ullamcorper dui feugiat a. Donec nulla sapien, tincidunt ut arcu sit amet, ultrices fringilla massa. Integer ac justo lacus.\n\nFusce sed pharetra sem. Nulla rutrum turpis magna, sit amet sodales dui vehicula in. Cras lacinia, dui sit amet dictum lobortis, arcu erat semper lectus, placerat accumsan diam dolor nec quam. Vivamus accumsan ut magna eget maximus. Integer scelerisque justo et quam pharetra, nec placerat nibh auctor. Vestibulum cursus, mauris id euismod convallis, justo sapien faucibus dolor, nec dictum erat urna at velit. Quisque egestas massa eget odio consectetur vehicula. Aliquam a imperdiet lacus, eu facilisis mauris. Etiam tempor neque vitae erat elementum bibendum. Fusce ultricies nunc tortor.\n\nQuisque in posuere sapien. Nulla ornare sagittis tellus eu laoreet. Sed molestie sem in turpis blandit pretium. Vivamus gravida dui id gravida aliquam. Vestibulum vestibulum, justo vitae cursus mattis, urna mauris pulvinar dolor, eu suscipit magna libero eget diam. Praesent id rutrum libero, a feugiat nulla. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Curabitur ornare libero id augue fringilla maximus sed sed ante. Quisque finibus accumsan lorem ut lobortis. Maecenas lobortis lacus sed mattis rutrum. Aliquam a mi sodales, blandit nisi ut, volutpat ex. Duis tristique, erat quis fermentum ultricies, leo ipsum placerat nunc, eu aliquam nibh mauris vitae lectus. Proin vitae tellus nec lorem vulputate faucibus. In hac habitasse platea dictumst. Suspendisse dictum odio in faucibus mattis.',
- },
-]
-
-const onUploadFile = async (file: File) => {
- const wait = 0.0002 * file.size + 500
- await delay(wait / 2)
- // to demo file rejections:
- if (file.name.toLowerCase().startsWith('a')) throw new Error("Rejected file for starting with the letter 'a'")
- // 0.5 - 5 seconds depending on file size up to about 20 MB
- await delay(wait / 2)
- return {file, url: fakeFileUrl(file)}
-}
-
-const renderPreview = async () => {
- await delay(500)
- return 'Previewing Markdown is not supported in this example.'
-}
-
-export const Default = () => {
- const [value, setValue] = useState('')
-
- return (
- <>
-
- Markdown Editor Example
-
- Note: for demo purposes, files starting with "A" will be rejected.
- >
- )
-}
-
-export const Playground = ({
- disabled,
- fullHeight,
- monospace,
- minHeightLines,
- maxHeightLines,
- hideLabel,
- required,
- fileUploadsEnabled,
- onSubmit,
- savedRepliesEnabled,
- pasteUrlsAsPlainText,
-}: ArgProps) => {
- const [value, setValue] = useState('')
-
- return (
- <>
-
- Markdown Editor Example
-
- Note: for demo purposes, files starting with "A" will be rejected.
- >
- )
-}
diff --git a/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.test.tsx b/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.test.tsx
deleted file mode 100644
index afe65ff946b..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.test.tsx
+++ /dev/null
@@ -1,1310 +0,0 @@
-import {DiffAddedIcon} from '@primer/octicons-react'
-import {fireEvent, render as _render, waitFor, within, act} from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import type {UserEvent} from '@testing-library/user-event'
-import React, {forwardRef, useRef, useState} from 'react'
-import type {MarkdownEditorHandle, MarkdownEditorProps, Mentionable, Reference, SavedReply} from '.'
-import MarkdownEditor from '.'
-import ThemeProvider from '../../ThemeProvider'
-
-type UncontrolledEditorProps = Omit &
- Partial> & {
- hideLabel?: boolean
- }
-
-const UncontrolledEditor = forwardRef((props, forwardedRef) => {
- const [value, setValue] = useState('')
-
- const handleChange = (v: string) => {
- props.onChange?.(v)
- setValue(v)
- }
-
- const onRenderPreview = async () => 'Preview'
-
- return (
-
-
- Test Editor
-
- {props.children}
-
-
- )
-})
-
-const assertNotNull: (t: T | null) => asserts t is T = t => expect(t).not.toBeNull()
-
-const render = async (ui: React.ReactElement) => {
- const result = _render(ui)
- // eslint-disable-next-line testing-library/await-async-events
- const user = userEvent.setup()
-
- const getInput = () => result.getByRole('textbox') as HTMLTextAreaElement
-
- const getToolbar = () => result.getByRole('toolbar')
-
- const getFooter = () => result.getByRole('contentinfo')
-
- const getToolbarButton = (label: string) => within(getToolbar()).getByRole('button', {name: label})
-
- const queryForToolbarButton = (label: string) => within(getToolbar()).queryByRole('button', {name: label})
-
- const getActionButton = (label: string) => within(getFooter()).getByRole('button', {name: label})
-
- const getViewSwitch = () => {
- const button = result.queryByRole('tab', {name: 'Preview'}) || result.queryByRole('tab', {name: 'Edit'})
- if (!button) throw new Error('View switch button not found')
- return button
- }
-
- const queryForPreview = () =>
- result.queryByRole('heading', {name: 'Rendered Markdown Preview'})?.parentElement ?? null
-
- const getPreview = () => {
- const previewContainer = result.getByRole('heading', {name: 'Rendered Markdown Preview'}).parentElement
- assertNotNull(previewContainer)
- return previewContainer
- }
-
- const getEditorContainer = () => result.getByRole('group')
-
- const queryForUploadButton = () =>
- result.queryByRole('button', {name: 'Add files'}) ||
- result.queryByRole('button', {name: 'Paste, drop, or click to add files'})
-
- const queryForSuggestionsList = () => result.queryByRole('listbox')
-
- const getSuggestionsList = () => result.getByRole('listbox')
-
- const getAllSuggestions = () => within(getSuggestionsList()).queryAllByRole('option')
-
- // Wait for the double render caused by slots to complete
- await waitFor(() => result.getByText('Test Editor'))
-
- return {
- ...result,
- getInput,
- getToolbar,
- getToolbarButton,
- user,
- queryForUploadButton,
- getFooter,
- getViewSwitch,
- getPreview,
- queryForPreview,
- getActionButton,
- getEditorContainer,
- queryForSuggestionsList,
- getAllSuggestions,
- queryForToolbarButton,
- }
-}
-
-describe('MarkdownEditor', () => {
- // combobox-nav attempts to filter out 'hidden' options by checking if the option has an
- // offsetHeight or width > 0. In JSDom, all elements have offsetHeight = offsetWidth = 0,
- // so we need to override at least one to make the class recognize that any options exist.
- const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')
- beforeAll(() => {
- Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
- configurable: true,
- value: 10,
- })
- })
- afterAll(() => {
- if (originalOffsetHeight) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight)
- })
-
- beforeEach(() => {
- jest.mock('@primer/behaviors/utils', () => ({
- // for all tests, default to Non-Mac (Ctrl) keybindings
- isMacOS: jest.fn().mockReturnValue(false),
- }))
- })
-
- describe.each<
- [description: string, result: string, shortcut?: [key: string, shift: boolean], caretPosition?: number]
- >([
- ['Add header text', 'text### '],
- ['Bold', 'text****', ['b', false], 6],
- ['Italic', 'text__', ['i', false], 5],
- ['Insert a quote', 'text\n\n> ', ['.', true]],
- ['Insert code', 'text``', ['e', false], 5],
- ['Add a link', 'text[](url)', ['k', false], 5],
- ['Add a bulleted list', '- text', ['8', false]],
- ['Add a numbered list', '1. text', ['7', true]],
- ['Add a task list', 'text\n\n- [ ] ', ['l', true]],
- ['Mention a user or team (@)', 'text @'],
- ['Reference an issue, pull request, or discussion (#)', 'text #'],
- ])('formatting (%s)', (description, result, shortcut, caretPosition = result.length) => {
- it('works using toolbar button', async () => {
- const {getInput, getToolbarButton, user} = await render()
- const input = getInput()
-
- const label = `${description}${
- shortcut ? ` (Ctrl${shortcut[1] ? ' + Shift' : ''} + ${shortcut[0].toUpperCase()})` : ''
- }`
-
- await user.type(input, 'text')
- await user.click(getToolbarButton(label))
-
- expect(input).toHaveValue(result)
- expect(input).toHaveFocus()
- expect(input.selectionStart).toBe(caretPosition)
- })
-
- it('works using keyboard shortcut', async () => {
- if (!shortcut) return
-
- const [key, shift] = shortcut
-
- // userEvent is not firing the keydown event for ctrl+shift+., making the test fail. It does work, but not
- // in the test environment - most likely a bug in the user-event library
- if (key === '.') return
-
- const {getInput, user} = await render()
- const input = getInput()
-
- await user.type(input, `text{Control>}${shift ? '{Shift>}' : ''}${key}${shift ? '{/Shift}' : ''}{/Control}`)
-
- expect(input).toHaveValue(result)
- expect(input).toHaveFocus()
- expect(input.selectionStart).toBe(caretPosition)
- })
- })
-
- it('calls onPrimaryAction on ctrl+enter', async () => {
- const onPrimaryAction = jest.fn()
- const {getInput, user} = await render()
-
- await user.type(getInput(), `{Control>}{Enter}{/Control}`)
- expect(onPrimaryAction).toHaveBeenCalled()
- })
-
- it('forwards imperative handle ref', async () => {
- const ref: React.RefObject = {current: null}
- const {getInput} = await render()
-
- ref.current?.focus()
- expect(getInput()).toHaveFocus()
- })
-
- it('enables the textarea by default', async () => {
- const {getInput} = await render()
- expect(getInput()).not.toBeDisabled()
- })
-
- it('disables the textarea when disabled', async () => {
- const {getInput} = await render()
- expect(getInput()).toBeDisabled()
- })
-
- it('aria-disables the container when disabled', async () => {
- const {getEditorContainer} = await render()
- expect(getEditorContainer()).not.toBeDisabled()
- expect(getEditorContainer()).toHaveAttribute('aria-disabled', 'true')
- })
-
- it('does not require the textarea by default', async () => {
- const {getInput} = await render()
- expect(getInput()).toHaveAttribute('aria-required', 'false')
- })
-
- it('requires the textarea when required', async () => {
- const {getInput} = await render()
- expect(getInput()).toHaveAttribute('aria-required', 'true')
- })
-
- it('does not render a placeholder by default', async () => {
- const {getInput} = await render()
- expect(getInput()).not.toHaveAttribute('plceholder')
- })
-
- it('renders the placeholder', async () => {
- const {getInput} = await render()
- expect(getInput()).toHaveAttribute('placeholder', 'Test placeholder')
- })
-
- it('sets the textarea name when provided', async () => {
- const {getInput} = await render()
- expect(getInput()).toHaveAttribute('name', 'Name')
- })
-
- describe('toggles between view modes on ctrl/cmd+shift+P', () => {
- const shortcut = '{Control>}{Shift>}{P}{/Control}{/Shift}'
-
- it('enters preview mode when editing', async () => {
- const {getInput, user} = await render()
- await user.type(getInput(), shortcut)
- })
-
- it('enters edit mode when previewing', async () => {
- const {getInput, user, getViewSwitch} = await render()
- await user.click(getViewSwitch())
- await user.keyboard(shortcut)
- expect(getInput()).toHaveFocus()
- })
- })
-
- describe('action buttons', () => {
- it('renders custom action buttons', async () => {
- const {getActionButton} = await render(
-
- {/* eslint-disable-next-line primer-react/direct-slot-children */}
-
- Example
-
- ,
- )
- expect(getActionButton('Example')).toBeInTheDocument()
- })
-
- it('disables custom action buttons when the editor is disabled (unless explicitly overridden)', async () => {
- const {getActionButton} = await render(
-
- {/* eslint-disable-next-line primer-react/direct-slot-children */}
-
- A
- B
-
- ,
- )
-
- expect(getActionButton('A')).toBeDisabled()
- expect(getActionButton('B')).not.toBeDisabled()
- })
-
- it('forwards action button refs', async () => {
- const ref: React.RefObject = {current: null}
- await render(
-
- {/* eslint-disable-next-line primer-react/direct-slot-children */}
-
- Example
-
- ,
- )
- expect(ref.current).toBeInstanceOf(HTMLButtonElement)
- })
- })
-
- describe('footer', () => {
- it('renders custom buttons', async () => {
- const {getActionButton} = await render(
-
-
- Footer A
-
- Action A
-
-
- ,
- )
- expect(getActionButton('Footer A')).toBeInTheDocument()
- expect(getActionButton('Action A')).toBeInTheDocument()
- })
-
- it('disables buttons when the editor is disabled (unless explicitly overridden)', async () => {
- const {getActionButton} = await render(
-
-
- Footer A
-
- Action A
- Action B
-
-
- ,
- )
- expect(getActionButton('Footer A')).toBeDisabled()
- expect(getActionButton('Action A')).toBeDisabled()
- expect(getActionButton('Action B')).not.toBeDisabled()
- })
-
- it('forwards action button refs', async () => {
- const ref: React.RefObject = {current: null}
- await render(
-
-
- Footer A
-
- ,
- )
- expect(ref.current).toBeInstanceOf(HTMLButtonElement)
- })
- })
-
- describe('toolbar', () => {
- it('renders custom toolbar buttons', async () => {
- const {getToolbarButton} = await render(
-
- {/* eslint-disable-next-line primer-react/direct-slot-children */}
-
-
-
- ,
- )
-
- expect(() => getToolbarButton('Test Button')).not.toThrow()
- })
-
- it('forwards custom toolbar button refs', async () => {
- const ref: React.RefObject = {current: null}
- await render(
-
- {/* eslint-disable-next-line primer-react/direct-slot-children */}
-
-
-
- ,
- )
- expect(ref.current).toBeInstanceOf(HTMLButtonElement)
- })
-
- it('maintains focus on input by default when custom toolbar buttons are clicked', async () => {
- const onClick = jest.fn()
- const {getInput, getToolbarButton, user} = await render(
-
- {/* eslint-disable-next-line primer-react/direct-slot-children */}
-
-
-
- ,
- )
-
- const input = getInput()
- await user.type(input, 'text')
- await user.click(getToolbarButton('Test Button'))
-
- expect(input).toHaveFocus()
- expect(onClick).toHaveBeenCalled()
- })
-
- it('disables buttons when editor is disabled (unless explicitly overridden)', async () => {
- const {getToolbarButton} = await render(
-
- {/* eslint-disable-next-line primer-react/direct-slot-children */}
-
-
-
-
-
- ,
- )
-
- const a = getToolbarButton('Test Button A')
- const b = getToolbarButton('Test Button B')
- const bold = getToolbarButton('Bold (Ctrl + B)')
-
- expect(a).toBeDisabled()
- expect(b).not.toBeDisabled()
- expect(bold).toBeDisabled()
- })
-
- describe('keyboard navigation', () => {
- it('navigates between buttons using arrow keys / home & end', async () => {
- const {getToolbarButton, getToolbar, user} = await render()
-
- const boldButton = getToolbarButton('Bold (Ctrl + B)')
- const italicButton = getToolbarButton('Italic (Ctrl + I)')
-
- boldButton.focus()
- await user.keyboard('{ArrowRight}')
- expect(italicButton).toHaveFocus()
- await user.keyboard('{ArrowLeft}')
- expect(boldButton).toHaveFocus()
-
- const toolbar = getToolbar()
- const buttons = within(toolbar).getAllByRole('button')
-
- await user.keyboard('{Home}')
- expect(buttons[0]).toHaveFocus()
- await user.keyboard('{End}')
- expect(buttons[buttons.length - 1]).toHaveFocus()
- })
-
- it('allows skipping past toolbar with Tab', async () => {
- const {getToolbar, getInput, user} = await render()
-
- const toolbar = getToolbar()
- const buttons = within(toolbar).getAllByRole('button')
-
- buttons[0].focus()
- await user.tab()
-
- expect(getInput()).toHaveFocus()
-
- await user.tab({shift: true})
-
- expect(buttons[buttons.length - 1]).toHaveFocus()
- })
-
- it('includes custom buttons', async () => {
- const {getToolbarButton, user} = await render(
-
- {/* eslint-disable-next-line primer-react/direct-slot-children */}
-
-
-
-
-
- ,
- )
-
- const a = getToolbarButton('Test Button A')
- const b = getToolbarButton('Test Button B')
-
- b.focus()
- await user.keyboard('{ArrowLeft}')
- expect(a).toHaveFocus()
- })
- })
- })
-
- describe('list editing', () => {
- const resultOfTypingInEditor = async (text: string) => {
- const {getInput, user} = await render()
- await user.type(getInput(), text)
- return getInput().value
- }
-
- it('allows creating newlines like normal when there is no list', async () => {
- expect(await resultOfTypingInEditor('dogs{Enter}cats')).toBe('dogs\ncats')
- })
-
- it.each(['*', '-'])('creates new list item on newline when editing a simple list', async delimeter => {
- expect(await resultOfTypingInEditor(`${delimeter} dogs{Enter}cats`)).toBe(`${delimeter} dogs\n${delimeter} cats`)
- })
-
- it('increments number when editing ordered list', async () => {
- expect(await resultOfTypingInEditor(`1. dogs{Enter}cats`)).toBe(`1. dogs\n2. cats`)
- })
-
- it.each([
- ['*', '*'],
- ['-', '-'],
- ['1.', '2.'],
- ])('adds task items when editing task lists', async (firstDelimeter, secondDelimeter) => {
- expect(await resultOfTypingInEditor(`${firstDelimeter} {[} {]} task one{Enter}task two`)).toBe(
- `${firstDelimeter} [ ] task one\n${secondDelimeter} [ ] task two`,
- )
- })
-
- it('adds empty task item even if current task item is checked', async () => {
- expect(await resultOfTypingInEditor(`- {[}x{]} task one{Enter}task two`)).toBe(`- [x] task one\n- [ ] task two`)
- })
-
- it('preserves leading whitespace when adding items', async () => {
- expect(await resultOfTypingInEditor(` - dogs{Enter}cats`)).toBe(` - dogs\n - cats`)
- })
-
- it('preserves text following caret if caret is not at end of line', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, '- dogscats')
- await user.type(input, '{Enter}', {initialSelectionStart: 6})
- expect(input.value).toBe(`- dogs\n- cats`)
- expect(input.selectionStart).toBe(9)
- })
-
- it('deletes highlighted text', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, '- dogsbirdscats')
- await user.type(input, '{Enter}', {initialSelectionStart: 6, initialSelectionEnd: 11})
- expect(input.value).toBe(`- dogs\n- cats`)
- })
-
- it('deletes empty item on enter', async () => {
- expect(await resultOfTypingInEditor('- dogs{Enter}{Enter}')).toBe('- dogs\n')
- })
-
- // The following are skipped because userEvent is only typing one character before moving
- // the caret to the end of the input and continuing typing. If we can figure out how to fix
- // that, we can re-enable these.
-
- it.skip('adds new items in the middle of a list', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, '- dogs{Enter}cats')
- await user.type(input, '{Enter}birds', {initialSelectionStart: 6})
- expect(input.value).toBe(`- dogs\n- birds\n- cats`)
- })
-
- it.skip('increments following list items if there are any', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, '1. dogs{Enter}cats')
- await user.type(input, '{Enter}birds', {initialSelectionStart: 7})
- expect(input.value).toBe(`1. dogs\n2. birds\n3. cats`)
- })
-
- it.skip('does not increment following list items if a number is skipped', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, '1. dogs{Enter}{Enter}3. cats')
- await user.type(input, '{Enter}birds', {initialSelectionStart: 7})
- expect(input.value).toBe(`1. dogs\n2. birds\n3. cats`)
- })
-
- it.skip('increments multiple consecutive following list items', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, '1. dogs{Enter}birds{Enter}cats')
- await user.type(input, '{Enter}horses', {initialSelectionStart: 7})
- expect(input.value).toBe(`1. dogs\n2. horses\n3. birds\n4. cats`)
- })
-
- it('does not add a list item on shift+enter', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, '- dogs{Shift>}{Enter}{/Shift}cats')
- expect(input.value).toBe('- dogs\ncats')
- })
-
- it('does not add another list item while composing input', async () => {
- const {getInput, user} = await render()
- const input = getInput()
-
- await user.type(input, '- list item, composition: ')
- fireEvent.compositionStart(input)
- await user.keyboard('γγ')
- // jsdom doesn't know about composing. We don't want to *type* enter here, we just want to simulate key down/up
- fireEvent.keyDown(input, {key: 'Enter'})
- fireEvent.keyUp(input, {key: 'Enter'})
- fireEvent.compositionEnd(input)
- await user.keyboard('! this is more text{Enter}this is the next list item')
-
- expect(input.value).toBe('- list item, composition: γγ! this is more text\n- this is the next list item')
- })
- })
-
- describe('indenting', () => {
- it('indents selected text on Tab', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, 'hello\n world\nhello')
- await user.type(input, '{Tab}', {initialSelectionStart: 3, initialSelectionEnd: 14})
- expect(input.value).toBe(` hello\n world\n hello`)
- })
-
- it('dedents space-indented text on Shift+Tab', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, ' hello\n world\n hello')
- await user.type(input, '{Shift>}{Tab}{/Shift}', {initialSelectionStart: 3, initialSelectionEnd: 22})
- expect(input.value).toBe(`hello\n world\nhello`)
- })
-
- it('dedents tab-indented text on Shift+Tab', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, '\thello\n\t\tworld\n\thello')
- await user.type(input, '{Shift>}{Tab}{/Shift}', {initialSelectionStart: 3, initialSelectionEnd: 22})
- expect(input.value).toBe(`hello\n\tworld\nhello`)
- })
-
- it('does not indent if no text is selected', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await user.type(input, ' hello\n world\n hello{Tab}')
- expect(input.value).toBe(` hello\n world\n hello`)
- expect(input).not.toHaveFocus()
- })
- })
-
- describe('file attachment', () => {
- const imageFile = (name: string) => new File(['foo'], `${name}.png`, {type: 'image/png'})
-
- const mockUrl = (file: File) => `https://example.com/${encodeURIComponent(file.name)}`
-
- const mockUploadFile = async (file: File) => ({file, url: mockUrl(file)})
-
- describe('upload button', () => {
- it('is not rendered by default', async () => {
- const {queryForUploadButton} = await render()
- expect(queryForUploadButton()).not.toBeInTheDocument()
- })
-
- it('is rendered when a file upload handler is provided', async () => {
- const {queryForUploadButton} = await render()
- expect(queryForUploadButton()).toBeInTheDocument()
- })
-
- it('is hidden in preview mode', async () => {
- const {queryForUploadButton} = await render()
- expect(queryForUploadButton()).not.toBeInTheDocument()
- })
-
- it('is disabled when editor is disabled', async () => {
- const {queryForUploadButton} = await render()
- expect(queryForUploadButton()).toBeDisabled()
- })
-
- it('shows drop message on drag over', async () => {
- const {queryForUploadButton, getInput} = await render()
- const input = getInput()
- const button = queryForUploadButton()
-
- fireEvent.dragEnter(input, {dataTransfer: {items: [{kind: 'file'}]}})
- expect(button).toHaveTextContent('Drop to add files')
- fireEvent.dragLeave(input)
- expect(button).not.toHaveTextContent('Drop to add files')
- })
- })
-
- describe.each<['drop' | 'paste', string]>([
- ['drop', 'dataTransfer'],
- ['paste', 'clipboardData'],
- ])('selecting files by %s', (method, dataKey) => {
- const expectFilesToBeAdded = async (onChangeMock: jest.Mock, ...files: Array) => {
- for (const file of files) {
- await waitFor(() =>
- expect(onChangeMock).toHaveBeenCalledWith(expect.stringContaining(`Uploading "${file.name}"...`)),
- )
- }
-
- for (const file of files) {
- await waitFor(() => expect(onChangeMock).toHaveBeenCalledWith(expect.stringContaining(mockUrl(file))))
- }
- }
-
- it('can add a single file', async () => {
- const onChange = jest.fn()
- const {getInput} = await render()
- const input = getInput()
-
- const file = imageFile('example')
- fireEvent[method](input, {[dataKey]: {files: [file], types: ['Files']}})
-
- await expectFilesToBeAdded(onChange, file)
- })
-
- it('can add multiple files', async () => {
- const onChange = jest.fn()
- const {getInput} = await render()
- const input = getInput()
-
- const fileA = imageFile('a')
- const fileB = imageFile('b')
- fireEvent[method](input, {[dataKey]: {files: [fileA, fileB], types: ['Files']}})
-
- await expectFilesToBeAdded(onChange, fileA, fileB)
- })
-
- it('rejects disallows file types while accepting allowed ones', async () => {
- const onChange = jest.fn()
- const {getInput, getEditorContainer} = await render(
- ,
- )
- const input = getInput()
-
- const fileA = new File(['foo'], 'a.app', {type: 'application/app'})
- const fileB = imageFile('b')
- fireEvent[method](input, {[dataKey]: {files: [fileA, fileB], types: ['Files']}})
-
- await act(async () => {
- await Promise.resolve(process.nextTick)
- })
-
- await expectFilesToBeAdded(onChange, fileB)
-
- expect(getEditorContainer()).toHaveTextContent('File type not allowed: .app')
- })
-
- it('inserts "failed to upload" note on failure', async () => {
- const onChange = jest.fn()
- const {getInput} = await render(
- {
- throw new Error()
- }}
- onChange={onChange}
- acceptedFileTypes={['image/*']}
- />,
- )
-
- const input = getInput()
-
- const file = imageFile('example')
- fireEvent[method](input, {[dataKey]: {files: [file], types: ['Files']}})
-
- await waitFor(() =>
- expect(onChange).toHaveBeenCalledWith(expect.stringContaining(`Failed to upload "${file.name}"`)),
- )
- })
- })
- })
-
- describe('preview', () => {
- it('renders the preview when the editor is in preview mode', async () => {
- const {queryForPreview} = await render()
- expect(queryForPreview()).toBeInTheDocument()
- })
-
- it('does not render the preview when the editor is in edit mode', async () => {
- const {queryForPreview} = await render()
- expect(queryForPreview()).not.toBeInTheDocument()
- })
-
- it('automatically handles view modes when view mode is uncontrolled', async () => {
- const {getViewSwitch, queryForPreview, user} = await render()
- const viewSwitch = getViewSwitch()
- expect(queryForPreview()).not.toBeInTheDocument()
- await user.click(viewSwitch)
- await waitFor(() => expect(queryForPreview()).toBeInTheDocument())
- await user.click(viewSwitch)
- await waitFor(() => expect(queryForPreview()).not.toBeInTheDocument())
- })
-
- it('calls view mode change handler without automatically changing the view when the button is clicked and viewMode is controlled', async () => {
- const onViewModeChange = jest.fn()
- const {getViewSwitch, queryForPreview} = await render(
- ,
- )
- fireEvent.click(getViewSwitch())
- await act(async () => {
- await new Promise(process.nextTick)
- })
- expect(onViewModeChange).toHaveBeenCalledWith('preview')
- expect(queryForPreview()).not.toBeInTheDocument()
- })
-
- it('does not disable the preview button when the editor is disabled', async () => {
- const {getViewSwitch} = await render()
- expect(getViewSwitch()).not.toBeDisabled()
- })
-
- describe('fetching', () => {
- it('prefetches the preview when the view switch is focused', async () => {
- const renderPreviewMock = jest.fn()
- const {getViewSwitch} = await render()
-
- fireEvent.focus(getViewSwitch())
- await act(async () => {
- await new Promise(process.nextTick)
- })
-
- expect(renderPreviewMock).toHaveBeenCalled()
- })
-
- it('prefetches the preview when the view switch is hovered over', async () => {
- const renderPreviewMock = jest.fn()
- const {getViewSwitch, user} = await render()
- await user.hover(getViewSwitch())
- expect(renderPreviewMock).toHaveBeenCalled()
- })
-
- it('does not refetch the preview until the fetched preview is stale', async () => {
- const renderPreviewMock = jest.fn()
- const {getViewSwitch, user, getInput} = await render()
- await user.hover(getViewSwitch())
- await user.hover(getViewSwitch())
- expect(renderPreviewMock).toHaveBeenCalledTimes(1)
-
- await user.type(getInput(), 'hello world')
- await user.hover(getViewSwitch())
- expect(renderPreviewMock).toHaveBeenCalledTimes(2)
- })
-
- it('fetches the preview when the `viewMode` prop is changed externally', async () => {
- const renderPreviewMock = jest.fn()
- const {rerender} = await render()
-
- rerender()
- await act(async () => {
- await new Promise(process.nextTick)
- })
- expect(renderPreviewMock).toHaveBeenCalledTimes(1)
-
- rerender()
- await act(async () => {
- await new Promise(process.nextTick)
- })
- expect(renderPreviewMock).toHaveBeenCalledTimes(1)
- })
- })
-
- describe('rendering', () => {
- it('does not enable checkboxes in rendered preview', async () => {
- // eslint-disable-next-line github/unescaped-html-literal
- const html = ''
- const {getPreview} = await render( html} viewMode="preview" />)
- const checkbox = await waitFor(() => within(getPreview()).getByRole('checkbox'))
- expect(checkbox).toBeDisabled()
- })
-
- it('forces links to open in a new tab', async () => {
- // eslint-disable-next-line github/unescaped-html-literal
- const html = 'Link'
- const user = userEvent.setup()
- const windowOpenSpy = jest.spyOn(window, 'open')
- windowOpenSpy.mockImplementation(jest.fn())
- const {getPreview} = await render( html} viewMode="preview" />)
- const link = await waitFor(() => within(getPreview()).getByText('Link'))
-
- await user.click(link)
- expect(windowOpenSpy).toHaveBeenCalledWith('https://example.com/', '_blank', 'noopener noreferrer')
- })
- })
- })
-
- describe('accessible labelling', () => {
- it('renders editor as a labelled group', async () => {
- const {getEditorContainer} = await render()
-
- const container = getEditorContainer()
- expect(container).toBeInstanceOf(HTMLFieldSetElement)
- expect(container).toHaveAccessibleName('Test Editor')
- })
-
- it('hides, but still renders, the label when hidden', async () => {
- const {getByText, getEditorContainer} = await render()
-
- const container = getEditorContainer()
- expect(container).toHaveAccessibleName('Test Editor')
-
- const legend = getByText('Test Editor')
- expect(legend).toHaveStyle('clip: rect(0,0,0,0)')
- })
-
- it('labels the main input', async () => {
- const {getInput} = await render()
- expect(getInput()).toHaveAccessibleName('Markdown value')
- })
-
- it('labels the toolbar', async () => {
- const {getToolbar} = await render()
- expect(getToolbar()).toHaveAccessibleName('Formatting tools')
- })
-
- it('labels all the default formatting buttons', async () => {
- const {getToolbar} = await render()
- const buttons = within(getToolbar()).getAllByRole('button')
- for (const button of buttons) expect(button).toHaveAccessibleName()
- })
-
- it('renders and updates the accessible description when changing from view to edit mode', async () => {
- const {getEditorContainer, rerender} = await render()
- expect(getEditorContainer()).toHaveAccessibleDescription('Markdown input: edit mode selected.')
-
- rerender()
- await act(async () => {
- // Wait one tick as this switch triggers a promise that is resolved
- // within `MarkdownEditor` from `useSafeAsyncCallback`
- await new Promise(process.nextTick)
- })
-
- expect(getEditorContainer()).toHaveAccessibleDescription('Markdown input: preview mode selected.')
- })
-
- it('appends any custom describedby IDs to the default set', async () => {
- const {getEditorContainer} = await render(
- <>
- Example description.
-
- >,
- )
-
- const container = getEditorContainer()
- expect(container).toHaveAccessibleDescription('Markdown input: edit mode selected. Example description.')
- })
- })
-
- describe('suggestions', () => {
- const emojis = [
- {name: '+1', character: 'π'},
- {name: '-1', character: 'π'},
- {name: 'heart', character: 'β€οΈ'},
- {name: 'wave', character: 'π'},
- {name: 'raised_hands', character: 'π'},
- {name: 'octocat', url: 'https://github.githubassets.com/images/icons/emoji/octocat.png'},
- ]
-
- const mentionables: Mentionable[] = [
- {identifier: 'monalisa', description: 'Monalisa Octocat'},
- {identifier: 'github', description: 'GitHub'},
- {identifier: 'primer', description: 'Primer'},
- {identifier: 'actions', description: 'Actions'},
- {identifier: 'primer-css', description: ''},
- {identifier: 'mnl', description: ''},
- {identifier: 'gth', description: ''},
- {identifier: 'mla', description: ''},
- ]
-
- const references: Reference[] = [
- {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: '11', titleText: 'Add error-handling functionality', titleHtml: 'Add error-handling functionality'},
- {id: '4', titleText: 'Add a new function', titleHtml: 'Add a new function'},
- {id: '5', titleText: 'Fails to exit gracefully', titleHtml: 'Fails to exit gracefully'},
- ]
-
- const EditorWithSuggestions = () => (
-
- )
-
- it('does not initially show suggestions list', async () => {
- const {queryForSuggestionsList} = await render()
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- describe.each([
- [':', emojis[0].character],
- ['@', `@${mentionables[0].identifier}`],
- ['#', `#${references[0].id}`],
- ])('%s-suggestions', (triggerChar, first) => {
- it('does not show suggestions when no handler is defined', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
- await user.type(getInput(), `hello ${triggerChar}`)
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it('shows suggestions when trigger character typed', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
- await user.type(getInput(), `hello ${triggerChar}`)
- expect(queryForSuggestionsList()).toBeInTheDocument()
- })
-
- it('immediately renders suggestion items given synchronous handlers', async () => {
- const {getAllSuggestions, getInput, user} = await render()
- await user.type(getInput(), `hello ${triggerChar}`)
- expect(getAllSuggestions()).toHaveLength(5)
- })
-
- it('shows suggestions when the trigger character is the first character in the input', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
- await user.type(getInput(), `${triggerChar}`)
- expect(queryForSuggestionsList()).toBeInTheDocument()
- })
-
- it('shows suggestions when the trigger character is the first character in the line', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
- await user.type(getInput(), `hello\n${triggerChar}`)
- expect(queryForSuggestionsList()).toBeInTheDocument()
- })
-
- it('does not show suggestions if there is no whitespace before the suggestion', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
- await user.type(getInput(), `hello${triggerChar}`)
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it('does not show suggestions when there is a space immediately after the trigger', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
- // especially important for # suggestions since they are multiword
- await user.type(getInput(), `${triggerChar} `)
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it('hides suggestions on Escape', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
-
- await user.type(getInput(), `hello ${triggerChar}`)
- expect(queryForSuggestionsList()).toBeInTheDocument()
-
- await user.keyboard('{Escape}')
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it('hides suggestions on blur', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
-
- const input = getInput()
- await user.type(input, `hello ${triggerChar}`)
- expect(queryForSuggestionsList()).toBeInTheDocument()
-
- act(() => {
- // eslint-disable-next-line github/no-blur
- input.blur()
- })
-
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it('hides suggestions when trigger key is deleted', async () => {
- const {queryForSuggestionsList, getInput, user} = await render()
-
- const input = getInput()
- await user.type(input, `hello ${triggerChar}`)
- expect(queryForSuggestionsList()).toBeInTheDocument()
-
- await user.keyboard('{Backspace}')
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it('applies suggestion and hides list on suggestion click', async () => {
- const {queryForSuggestionsList, getAllSuggestions, getInput, user} = await render()
-
- const input = getInput()
- await user.type(input, `hello ${triggerChar}`)
- await user.click(getAllSuggestions()[0])
-
- expect(input.value).toBe(`hello ${first} `) // suggestions are inserted with a following space
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it.each(['Enter', 'Tab'])('applies suggestion and hides list on %s-press', async key => {
- const {queryForSuggestionsList, getAllSuggestions, getInput, user} = await render()
-
- const input = getInput()
- await user.type(input, `hello ${triggerChar}`)
- expect(queryForSuggestionsList()).toBeInTheDocument()
-
- await waitFor(() => expect(getAllSuggestions()[0]).toHaveAttribute('data-combobox-option-default'))
-
- await user.keyboard(`{${key}}`)
- expect(input.value).toBe(`hello ${first} `) // suggestions are inserted with a following space
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
- })
-
- it('applies suggestion and hides list on %s-press', async () => {
- const {queryForSuggestionsList, getAllSuggestions, getInput, user} = await render()
-
- const input = getInput()
- await user.type(input, `hello :`)
- expect(queryForSuggestionsList()).toBeInTheDocument()
-
- await waitFor(() => expect(getAllSuggestions()[0]).toHaveAttribute('data-combobox-option-default'))
-
- await user.keyboard(`{Enter}`)
- expect(input.value).toBe(`hello π `) // suggestions are inserted with a following space
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it('filters mention suggestions using fuzzy match against name', async () => {
- const {getInput, getAllSuggestions, user} = await render()
- await user.type(getInput(), '@octct')
- expect(getAllSuggestions()).toHaveLength(1)
- expect(getAllSuggestions()[0]).toHaveTextContent('monalisa')
- })
-
- it('filters mention suggestions using fuzzy match against ID', async () => {
- const {getInput, getAllSuggestions, user} = await render()
- await user.type(getInput(), '@git')
- expect(getAllSuggestions()).toHaveLength(1)
- expect(getAllSuggestions()[0]).toHaveTextContent('github')
- })
-
- it('filters reference suggestions using fuzzy match against name', async () => {
- const {getInput, getAllSuggestions, user} = await render()
- await user.type(getInput(), '#add err-handln')
- expect(getAllSuggestions()).toHaveLength(1)
- expect(getAllSuggestions()[0]).toHaveTextContent('Add error-handling functionality')
- })
-
- it('filters reference suggestions against ID', async () => {
- const {getInput, getAllSuggestions, user} = await render()
- await user.type(getInput(), '#1')
- expect(getAllSuggestions()).toHaveLength(2)
- expect(getAllSuggestions()[0]).toHaveTextContent('#1')
- expect(getAllSuggestions()[1]).toHaveTextContent('#11')
- })
-
- it('does not show reference suggestions if the query is in "123 ..." form', async () => {
- const {getInput, queryForSuggestionsList, user} = await render()
- await user.type(getInput(), '#1 logg')
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
-
- it('filters emoji suggestions using simple match', async () => {
- const {getInput, getAllSuggestions, user} = await render()
- await user.type(getInput(), ':1')
- expect(getAllSuggestions()).toHaveLength(2)
- expect(getAllSuggestions()[0]).toHaveTextContent('+1')
- expect(getAllSuggestions()[1]).toHaveTextContent('-1')
- })
-
- it('inserts shortcode for custom emojis', async () => {
- const {queryForSuggestionsList, getAllSuggestions, getInput, user} = await render()
-
- const input = getInput()
- await user.type(input, `Mona Lisa :octo`)
- await user.click(getAllSuggestions()[0])
-
- expect(input.value).toBe(`Mona Lisa :octocat: `)
- expect(queryForSuggestionsList()).not.toBeInTheDocument()
- })
- })
-
- describe('saved replies', () => {
- const buttonLabel = 'Add saved reply (Ctrl + .)'
-
- const replies: SavedReply[] = [
- {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!'},
- {name: 'Thx', content: 'Thank you!'},
- ]
-
- const defaultScrollTo = window.Element.prototype.scrollTo
-
- beforeEach(() => {
- Object.defineProperty(window.Element.prototype, 'scrollTo', {
- value: jest.fn(),
- writable: true,
- })
- })
-
- afterEach(() => {
- Object.defineProperty(window.Element.prototype, 'scrollTo', {
- value: defaultScrollTo,
- writable: true,
- })
- })
-
- it('does not render the saved replies button if no replies are set', async () => {
- const {queryForToolbarButton} = await render()
- expect(queryForToolbarButton(buttonLabel)).not.toBeInTheDocument()
- })
-
- it('renders the saved replies button when replies are set', async () => {
- const {queryForToolbarButton, queryByRole} = await render()
- expect(queryForToolbarButton(buttonLabel)).toBeInTheDocument()
- expect(queryByRole('listbox')).not.toBeInTheDocument()
- })
-
- it('opens the saved reply menu on button click', async () => {
- const {getToolbarButton, queryByRole, user} = await render()
- await user.click(getToolbarButton(buttonLabel))
- expect(queryByRole('listbox')).toBeInTheDocument()
- })
-
- it('opens the saved reply menu on Ctrl + .', async () => {
- const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
-
- const {getInput, queryByRole, user} = await render()
-
- await user.type(getInput(), 'test{Control>}.{/Control}')
-
- // Note: this spy is currently catching a: "Warning: An update to %s inside a test was not wrapped in act(...)."
- // error in React 18. It seems like this is triggered within the `type`
- // interaction, specifically through `useOpenAndCloseFocus` when the
- // TextInput is being opened
- //
- // At the moment, it doesn't seem clear how to appropriately wrap this
- // interaction in an act() in order to cover this warning
- expect(spy).toHaveBeenCalled()
- expect(queryByRole('listbox')).toBeInTheDocument()
-
- spy.mockClear()
- })
-
- it('does not open the saved reply menu on Ctrl + . if no replies are set', async () => {
- const {getInput, queryByRole, user} = await render()
- await user.type(getInput(), '{Control>}.{/Control}')
- expect(queryByRole('listbox')).not.toBeInTheDocument()
- })
-
- it('autofocuses filter and filters replies based only on name', async () => {
- const {getToolbarButton, getByRole, user} = await render()
- await user.click(getToolbarButton(buttonLabel))
- await user.keyboard('Thanks')
- expect(within(getByRole('listbox')).getAllByRole('option')).toHaveLength(1)
- })
-
- it('inserts the selected reply at the caret position, closes the menu, and focuses the input', async () => {
- const spy = jest.spyOn(console, 'error').mockImplementation()
- const {getToolbarButton, getInput, user, queryByRole} = await render(
- ,
- )
- const input = getInput()
-
- await user.type(getInput(), 'preceding following')
- input.setSelectionRange(10, 10)
- await user.click(getToolbarButton(buttonLabel))
- await user.keyboard('Thanks{Enter}')
-
- expect(queryByRole('listbox')).not.toBeInTheDocument()
- await waitFor(() => expect(getInput().value).toBe('preceding Thanks for your contribution! following'))
- expect(getInput()).toHaveFocus()
-
- // Note: this spy assertion for console.error() is for an act() violation.
- // It's not clear where this act() violation is located as wrapping the
- // above code does not address this.
- expect(spy).toHaveBeenCalled()
- spy.mockRestore()
- })
-
- it('inserts reply on Ctrl + number', async () => {
- const {getInput, queryByRole, user, getToolbarButton} = await render(
- ,
- )
-
- await user.click(getToolbarButton(buttonLabel))
- await user.keyboard('{Control>}2{/Control}')
-
- expect(queryByRole('listbox')).not.toBeInTheDocument()
- await waitFor(() =>
- expect(getInput().value).toBe('Welcome to the project!\n\nPlease be sure to read the contributor guidelines.'),
- )
- expect(getInput()).toHaveFocus()
- })
- })
-
- it('uses types to prevent assigning HTMLTextAreaElement ref to MarkdownEditor', () => {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const Element = () => {
- const inputRef = useRef(null)
- return (
- should not be assignable to Ref
- ref={inputRef}
- value=""
- onChange={() => {
- /*noop*/
- }}
- onRenderPreview={async () => 'preview'}
- >
- Test
-
- )
- }
- })
-
- describe('pasting URLs', () => {
- const typeAndPaste = async (user: UserEvent, input: HTMLTextAreaElement) => {
- await user.type(input, 'lorem ipsum dolor sit amet')
- input.setSelectionRange(6, 11)
-
- // userEvent.paste() doesn't seem to fire the `paste` event that paste-markdown listens for.
- // So we simulate it. This approach is somewhat fragile because it relies on the internals
- // of paste-markdown not using any other properties on the event or DataTransfer instance.
- // We can't just construct a `new DataTransfer` because that's not implemented in JSDOM.
- fireEvent.paste(input, {clipboardData: {types: ['text/plain'], getData: () => 'https://github.com'}})
- }
-
- const linkifiedResult = 'lorem [ipsum](https://github.com) dolor sit amet'
- const plainResult = 'lorem ipsum dolor sit amet' // the real-world plain text result should have "https://github.com" instead of "ipsum", but fireEvent.paste doesn't actually update the input value
-
- it('pastes URLs onto selected text as links by default', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await typeAndPaste(user, input)
- expect(input).toHaveValue(linkifiedResult)
- })
-
- it('pastes URLs onto selected text as plain text when `pasteUrlsAsPlainText` enabled', async () => {
- const {getInput, user} = await render()
- const input = getInput()
- await typeAndPaste(user, input)
- expect(input).toHaveValue(plainResult)
- })
- })
-})
diff --git a/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.tsx b/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.tsx
deleted file mode 100644
index e7d9a284ada..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/MarkdownEditor.tsx
+++ /dev/null
@@ -1,525 +0,0 @@
-import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'
-import Box from '../../Box'
-import VisuallyHidden from '../../_VisuallyHidden'
-import {useId} from '../../hooks/useId'
-import {useResizeObserver} from '../../hooks/useResizeObserver'
-import {useSlots} from '../../hooks/useSlots'
-import type {SxProp} from '../../sx'
-import MarkdownViewer from '../MarkdownViewer'
-import {useIgnoreKeyboardActionsWhileComposing} from '../hooks/useIgnoreKeyboardActionsWhileComposing'
-import {useSafeAsyncCallback} from '../hooks/useSafeAsyncCallback'
-import {useSyntheticChange} from '../hooks/useSyntheticChange'
-import type {FileType} from '../hooks/useUnifiedFileSelect'
-import {Actions} from './Actions'
-import {Label} from './Label'
-import {CoreToolbar, DefaultToolbarButtons, Toolbar} from './Toolbar'
-import {CoreFooter, Footer} from './Footer'
-import {FormattingTools} from './_FormattingTools'
-import {MarkdownEditorContext} from './_MarkdownEditorContext'
-import {MarkdownInput} from './_MarkdownInput'
-import type {SavedRepliesHandle, SavedReply} from './_SavedReplies'
-import {SavedRepliesContext} from './_SavedReplies'
-import type {MarkdownViewMode} from './_ViewSwitch'
-import {ViewSwitch} from './_ViewSwitch'
-import type {FileUploadResult} from './_useFileHandling'
-import {useFileHandling} from './_useFileHandling'
-import {useIndenting} from './_useIndenting'
-import {useListEditing} from './_useListEditing'
-import type {SuggestionOptions} from './suggestions'
-import type {Emoji} from './suggestions/_useEmojiSuggestions'
-import type {Mentionable} from './suggestions/_useMentionSuggestions'
-import type {Reference} from './suggestions/_useReferenceSuggestions'
-import {isModifierKey} from './utils'
-import {ErrorMessage} from './_ErrorMessage'
-import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect'
-
-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
- 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.
- * For lazy-loading suggestions, an async function can be provided instead.
- */
- emojiSuggestions?: SuggestionOptions
- /**
- * Array of all possible mention suggestions. Leave `undefined` to disable `@`-mention autocomplete.
- * For lazy-loading suggestions, an async function can be provided instead.
- */
- mentionSuggestions?: SuggestionOptions
- /**
- * Array of all possible references to suggest. Leave `undefined` to disable `#`-reference autocomplete.
- * For lazy-loading suggestions, an async function can be provided instead.
- */
- referenceSuggestions?: SuggestionOptions
- /**
- * Uploads a file to a hosting service and returns the URL. If not provided, file uploads
- * will be disabled.
- */
- onUploadFile?: (file: File) => Promise
- /**
- * 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[]
- /**
- * Control whether URLs are pasted as plain text instead of as formatted links (if the
- * user has selected some text before pasting). Defaults to `false` (URLs will paste as
- * links). This should typically be controlled by user settings.
- *
- * Users can always toggle this behavior by holding `shift` when pasting.
- */
- pasteUrlsAsPlainText?: boolean
-}
-
-const handleBrand = Symbol()
-
-export interface MarkdownEditorHandle {
- /** Focus on the markdown textarea (has no effect in preview mode). */
- focus: (options?: FocusOptions) => void
- /** Scroll to the editor. */
- scrollIntoView: (options?: ScrollIntoViewOptions) => void
- /**
- * This 'fake' member prevents other types from being assigned to this, thus
- * disallowing broader ref types like `HTMLTextAreaElement`.
- * @private
- */
- [handleBrand]: undefined
-}
-
-const a11yOnlyStyle = {clipPath: 'Circle(0)', position: 'absolute'} as const
-
-const CONDENSED_WIDTH_THRESHOLD = 675
-
-/**
- * We want to switch editors from preview mode on cmd/ctrl+shift+P. But in preview mode,
- * there's no input to focus so we have to bind the event to the document. If there are
- * multiple editors, we want the most recent one to switch to preview mode to be the one
- * that we switch back to edit mode, so we maintain a LIFO stack of IDs of editors in
- * preview mode.
- */
-let editorsInPreviewMode: string[] = []
-
-/**
- * Markdown textarea with controls & keyboard shortcuts.
- * @deprecated Will be removed in v37 (https://github.com/primer/react/issues/3604)
- */
-const MarkdownEditor = forwardRef(
- (
- {
- value,
- onChange,
- disabled = false,
- placeholder,
- maxLength,
- 'aria-describedby': describedBy,
- fullHeight,
- onRenderPreview,
- sx,
- onPrimaryAction,
- viewMode: controlledViewMode,
- onChangeViewMode: controlledSetViewMode,
- minHeightLines = 5,
- maxHeightLines = 35,
- emojiSuggestions,
- mentionSuggestions,
- referenceSuggestions,
- onUploadFile,
- acceptedFileTypes,
- monospace = false,
- required = false,
- name,
- children,
- savedReplies,
- pasteUrlsAsPlainText = false,
- },
- ref,
- ) => {
- const [slots, childrenWithoutSlots] = useSlots(children, {
- toolbar: Toolbar,
- actions: Actions,
- label: Label,
- footer: Footer,
- })
- const [uncontrolledViewMode, uncontrolledSetViewMode] = useState('edit')
- const [view, setView] =
- controlledViewMode === undefined
- ? [uncontrolledViewMode, uncontrolledSetViewMode]
- : [controlledViewMode, controlledSetViewMode]
-
- const [html, setHtml] = useState(null)
- const safeSetHtml = useSafeAsyncCallback(setHtml)
-
- const previewStale = useRef(true)
- useEffect(() => {
- previewStale.current = true
- }, [value])
- const loadPreview = async () => {
- if (!previewStale.current) return
- previewStale.current = false // set to false before the preview is rendered to prevent multiple concurrent calls
- safeSetHtml(null)
- safeSetHtml(await onRenderPreview(value))
- }
-
- useEffect(() => {
- // we have to be careful here - loading preview sets state which causes a render which can cause an infinite loop,
- // however that should be prevented by previewStale.current being set immediately in loadPreview
- if (view === 'preview' && previewStale.current) loadPreview()
- })
-
- const inputRef = useRef(null)
- useImperativeHandle(
- ref,
- () =>
- ({
- focus: opts => inputRef.current?.focus(opts),
- scrollIntoView: opts => containerRef.current?.scrollIntoView(opts),
- }) as MarkdownEditorHandle,
- )
-
- const inputHeight = useRef(0)
- if (inputRef.current && inputRef.current.offsetHeight) inputHeight.current = inputRef.current.offsetHeight
-
- const onInputChange = useCallback(
- (e: React.ChangeEvent) => {
- onChange(e.target.value)
- },
- [onChange],
- )
-
- const emitChange = useSyntheticChange({inputRef, fallbackEventHandler: onInputChange})
-
- const fileHandler = useFileHandling({
- emitChange,
- value,
- inputRef,
- disabled,
- onUploadFile,
- acceptedFileTypes,
- })
-
- const listEditor = useListEditing({emitChange})
- const indenter = useIndenting({emitChange})
-
- const formattingToolsRef = useRef(null)
-
- // use state instead of ref since we need to recalculate when the element mounts
- const containerRef = useRef(null)
-
- const [condensed, setCondensed] = useState(false)
- const onResize = useCallback(
- // it's fine that this isn't debounced because calling setCondensed with the current value will not trigger a render
- () => setCondensed(containerRef.current !== null && containerRef.current.clientWidth < CONDENSED_WIDTH_THRESHOLD),
- [],
- )
- useResizeObserver(onResize, containerRef)
-
- // workaround for Safari bug where layout is otherwise not recalculated
- useIsomorphicLayoutEffect(() => {
- const container = containerRef.current
- if (!container) return
-
- const parent = container.parentElement
- const nextSibling = containerRef.current.nextSibling
- parent?.removeChild(container)
- parent?.insertBefore(container, nextSibling)
- }, [condensed])
-
- // the ID must be unique for each instance while remaining constant across renders
- const id = useId()
- const descriptionId = `${id}-description`
-
- const savedRepliesRef = useRef(null)
- const onSelectSavedReply = (reply: SavedReply) => {
- // need to wait a tick to run after the selectmenu finishes closing
- requestAnimationFrame(() => emitChange(reply.content))
- }
- const savedRepliesContext = savedReplies ? {savedReplies, onSelect: onSelectSavedReply, ref: savedRepliesRef} : null
-
- const inputCompositionProps = useIgnoreKeyboardActionsWhileComposing(
- (e: React.KeyboardEvent) => {
- const format = formattingToolsRef.current
- if (disabled) return
-
- if (e.ctrlKey && e.key === '.') {
- // saved replies are always Control, even on Mac
- savedRepliesRef.current?.openMenu()
- e.preventDefault()
- e.stopPropagation()
- } else if (isModifierKey(e)) {
- if (e.key === 'Enter') onPrimaryAction?.()
- else if (e.key === 'b') format?.bold()
- else if (e.key === 'i') format?.italic()
- else if (e.shiftKey && e.key === '.') format?.quote()
- else if (e.key === 'e') format?.code()
- else if (e.key === 'k') format?.link()
- else if (e.key === '8') format?.unorderedList()
- else if (e.shiftKey && e.key === '7') format?.orderedList()
- else if (e.shiftKey && e.key === 'l') format?.taskList()
- else if (e.shiftKey && e.key === 'p') setView?.('preview')
- else return
-
- e.preventDefault()
- e.stopPropagation()
- } else {
- listEditor.onKeyDown(e)
- indenter.onKeyDown(e)
- }
- },
- )
-
- useEffect(() => {
- if (view === 'preview') {
- editorsInPreviewMode.push(id)
-
- const handler = (e: KeyboardEvent) => {
- if (
- !e.defaultPrevented &&
- editorsInPreviewMode.at(-1) === id &&
- isModifierKey(e) &&
- e.shiftKey &&
- e.key === 'p'
- ) {
- setView?.('edit')
- setTimeout(() => inputRef.current?.focus())
- e.preventDefault()
- }
- }
- document.addEventListener('keydown', handler)
-
- return () => {
- document.removeEventListener('keydown', handler)
- // Performing the filtering in the cleanup callback allows it to happen also when
- // the user clicks the toggle button, not just on keyboard shortcut
- editorsInPreviewMode = editorsInPreviewMode.filter(id_ => id_ !== id)
- }
- }
- }, [view, setView, id])
-
- // If we don't memoize the context object, every child will rerender on every render even if memoized
- const context = useMemo(
- () => ({
- disabled,
- formattingToolsRef,
- condensed,
- required,
- fileDraggedOver: fileHandler?.isDraggedOver ?? false,
- fileUploadProgress: fileHandler?.uploadProgress,
- uploadButtonProps: fileHandler?.clickTargetProps ?? null,
- errorMessage: fileHandler?.errorMessage,
- previewMode: view === 'preview',
- }),
- [
- disabled,
- condensed,
- required,
- fileHandler?.isDraggedOver,
- fileHandler?.uploadProgress,
- fileHandler?.clickTargetProps,
- fileHandler?.errorMessage,
- view,
- ],
- )
-
- // We are using MarkdownEditorContext instead of the built-in Slots context because Slots' context is not typesafe
- return (
-
-
-
- )
- },
-)
-
-export default MarkdownEditor
diff --git a/packages/react/src/drafts/MarkdownEditor/Toolbar.tsx b/packages/react/src/drafts/MarkdownEditor/Toolbar.tsx
deleted file mode 100644
index 4fc6f8a7405..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/Toolbar.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import {FocusKeys} from '@primer/behaviors'
-import {
- BoldIcon,
- CodeIcon,
- CrossReferenceIcon,
- HeadingIcon,
- ItalicIcon,
- LinkIcon,
- ListOrderedIcon,
- ListUnorderedIcon,
- MentionIcon,
- QuoteIcon,
- TasklistIcon,
-} from '@primer/octicons-react'
-import React, {memo, useContext, useRef} from 'react'
-
-import {isMacOS} from '@primer/behaviors/utils'
-import Box from '../../Box'
-import {useFocusZone} from '../../hooks/useFocusZone'
-import {MarkdownEditorContext} from './_MarkdownEditorContext'
-import {SavedRepliesButton} from './_SavedReplies'
-import {ToolbarButton} from './_ToolbarButton'
-
-const Divider = () => {
- return (
-
- )
-}
-
-export const DefaultToolbarButtons = memo(() => {
- const {condensed, formattingToolsRef} = useContext(MarkdownEditorContext)
-
- const cmdOrCtrl = isMacOS() ? 'Cmd' : 'Ctrl'
-
- // Important: do not replace `() => ref.current?.format()` with `ref.current?.format` - it will refer to an outdated ref.current!
- return (
- <>
-
- formattingToolsRef.current?.header()}
- icon={HeadingIcon}
- aria-label="Add header text"
- />
- formattingToolsRef.current?.bold()}
- icon={BoldIcon}
- aria-label={`Bold (${cmdOrCtrl} + B)`}
- />
- formattingToolsRef.current?.italic()}
- icon={ItalicIcon}
- aria-label={`Italic (${cmdOrCtrl} + I)`}
- />
-
-
-
- formattingToolsRef.current?.quote()}
- icon={QuoteIcon}
- aria-label={`Insert a quote (${cmdOrCtrl} + Shift + .)`}
- />
- formattingToolsRef.current?.code()}
- icon={CodeIcon}
- aria-label={`Insert code (${cmdOrCtrl} + E)`}
- />
- formattingToolsRef.current?.link()}
- icon={LinkIcon}
- aria-label={`Add a link (${cmdOrCtrl} + K)`}
- />
-
-
-
- formattingToolsRef.current?.unorderedList()}
- icon={ListUnorderedIcon}
- aria-label={`Add a bulleted list (${cmdOrCtrl} + 8)`}
- />
- formattingToolsRef.current?.orderedList()}
- icon={ListOrderedIcon}
- aria-label={`Add a numbered list (${cmdOrCtrl} + Shift + 7)`}
- />
- formattingToolsRef.current?.taskList()}
- icon={TasklistIcon}
- aria-label={`Add a task list (${cmdOrCtrl} + Shift + L)`}
- />
-
- {!condensed && (
-
-
- formattingToolsRef.current?.mention()}
- icon={MentionIcon}
- aria-label="Mention a user or team (@)"
- />
- formattingToolsRef.current?.reference()}
- icon={CrossReferenceIcon}
- aria-label="Reference an issue, pull request, or discussion (#)"
- />
-
- )}
-
- >
- )
-})
-DefaultToolbarButtons.displayName = 'MarkdownEditor.DefaultToolbarButtons'
-
-export const CoreToolbar = ({children}: {children?: React.ReactNode}) => {
- const containerRef = useRef(null)
-
- useFocusZone({
- containerRef,
- focusInStrategy: 'closest',
- bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd,
- focusOutBehavior: 'wrap',
- })
-
- return (
-
- {children}
-
- )
-}
-
-export const Toolbar = ({children}: {children?: React.ReactNode}) => {children}
-Toolbar.displayName = 'MarkdownEditor.Toolbar'
-
-export {ToolbarButton}
diff --git a/packages/react/src/drafts/MarkdownEditor/_ErrorMessage.tsx b/packages/react/src/drafts/MarkdownEditor/_ErrorMessage.tsx
deleted file mode 100644
index 4e6c9675fe4..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_ErrorMessage.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React, {memo} from 'react'
-import Flash from '../../Flash'
-
-export const ErrorMessage = memo(({message}: {message: string}) => (
-
- {message}
-
-))
diff --git a/packages/react/src/drafts/MarkdownEditor/_FormattingTools.tsx b/packages/react/src/drafts/MarkdownEditor/_FormattingTools.tsx
deleted file mode 100644
index a0c12c60c2c..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_FormattingTools.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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/packages/react/src/drafts/MarkdownEditor/_MarkdownEditorContext.ts b/packages/react/src/drafts/MarkdownEditor/_MarkdownEditorContext.ts
deleted file mode 100644
index 4a0ac5534ef..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_MarkdownEditorContext.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type {RefObject} from 'react'
-import {createContext} from 'react'
-import type {FormattingTools} from './_FormattingTools'
-import type {ButtonProps} from '../../Button'
-
-// 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
- uploadButtonProps: Partial | null
- fileUploadProgress?: [number, number]
- fileDraggedOver: boolean
- errorMessage?: string
- previewMode: boolean
-}
-
-export const MarkdownEditorContext = createContext({
- disabled: false,
- condensed: false,
- required: false,
- formattingToolsRef: {current: null},
- uploadButtonProps: null,
- fileDraggedOver: false,
- previewMode: false,
-})
diff --git a/packages/react/src/drafts/MarkdownEditor/_MarkdownInput.tsx b/packages/react/src/drafts/MarkdownEditor/_MarkdownInput.tsx
deleted file mode 100644
index 993a712cbe8..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_MarkdownInput.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-import {subscribe as subscribeToMarkdownPasting} from '@github/paste-markdown'
-import React, {forwardRef, useEffect, useMemo, useRef, useState} from 'react'
-import {useDynamicTextareaHeight} from '../hooks/useDynamicTextareaHeight'
-import type {ShowSuggestionsEvent, Suggestions} from '../InlineAutocomplete'
-import InlineAutocomplete from '../InlineAutocomplete'
-import type {TextareaProps} from '../../Textarea'
-import Textarea from '../../Textarea'
-import type {Emoji} from './suggestions/_useEmojiSuggestions'
-import {useEmojiSuggestions} from './suggestions/_useEmojiSuggestions'
-import type {Mentionable} from './suggestions/_useMentionSuggestions'
-import {useMentionSuggestions} from './suggestions/_useMentionSuggestions'
-import type {Reference} from './suggestions/_useReferenceSuggestions'
-import {useReferenceSuggestions} from './suggestions/_useReferenceSuggestions'
-import {useRefObjectAsForwardedRef} from '../../hooks'
-import type {SuggestionOptions} from './suggestions'
-
-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?: SuggestionOptions
- mentionSuggestions?: SuggestionOptions
- referenceSuggestions?: SuggestionOptions
- minHeightLines: number
- maxHeightLines: number
- monospace: boolean
- pasteUrlsAsPlainText: boolean
- /** Use this prop to control visibility instead of unmounting, so the undo stack and custom height are preserved. */
- visible: boolean
-}
-
-const emptyArray: [] = [] // constant reference to avoid re-running effects
-
-export const MarkdownInput = forwardRef(
- (
- {
- value,
- onChange,
- disabled,
- placeholder,
- id,
- maxLength,
- onKeyDown,
- fullHeight,
- isDraggedOver,
- emojiSuggestions,
- mentionSuggestions,
- referenceSuggestions,
- minHeightLines,
- maxHeightLines,
- visible,
- monospace,
- pasteUrlsAsPlainText,
- ...props
- },
- forwardedRef,
- ) => {
- const [suggestions, setSuggestions] = useState(null)
- const [event, setEvent] = useState(null)
-
- const {trigger: emojiTrigger, calculateSuggestions: calculateEmojiSuggestions} = useEmojiSuggestions(
- emojiSuggestions ?? emptyArray,
- )
- const {trigger: mentionsTrigger, calculateSuggestions: calculateMentionSuggestions} = useMentionSuggestions(
- mentionSuggestions ?? emptyArray,
- )
- const {trigger: referencesTrigger, calculateSuggestions: calculateReferenceSuggestions} = useReferenceSuggestions(
- referenceSuggestions ?? emptyArray,
- )
-
- const triggers = useMemo(
- () => [mentionsTrigger, referencesTrigger, emojiTrigger],
- [mentionsTrigger, referencesTrigger, emojiTrigger],
- )
-
- const lastEventRef = useRef(null)
-
- const onHideSuggestions = () => {
- setEvent(null)
- setSuggestions(null) // the effect would do this anyway, but this allows React to batch the update
- }
-
- // running the calculation in an effect (rather than in the onShowSuggestions handler) allows us
- // to automatically recalculate if the suggestions change while the menu is open
- useEffect(() => {
- if (!event) {
- setSuggestions(null)
- return
- }
-
- ;(async function () {
- lastEventRef.current = event
- setSuggestions('loading')
- if (event.trigger.triggerChar === emojiTrigger.triggerChar) {
- setSuggestions(await calculateEmojiSuggestions(event.query))
- } else if (event.trigger.triggerChar === mentionsTrigger.triggerChar) {
- setSuggestions(await calculateMentionSuggestions(event.query))
- } else if (event.trigger.triggerChar === referencesTrigger.triggerChar) {
- setSuggestions(await calculateReferenceSuggestions(event.query))
- }
- })()
- }, [
- event,
- calculateEmojiSuggestions,
- calculateMentionSuggestions,
- calculateReferenceSuggestions,
- // The triggers never actually change because they are statically defined
- emojiTrigger,
- mentionsTrigger,
- referencesTrigger,
- ])
-
- const ref = useRef(null)
- useRefObjectAsForwardedRef(forwardedRef, ref)
-
- useEffect(() => {
- const subscription =
- ref.current &&
- subscribeToMarkdownPasting(ref.current, {defaultPlainTextPaste: {urlLinks: pasteUrlsAsPlainText}})
- return subscription?.unsubscribe
- }, [pasteUrlsAsPlainText])
-
- const heightStyles = useDynamicTextareaHeight({
- // if fullHeight is enabled, there is no need to compute a dynamic height (for perfs reasons)
- disabled: fullHeight,
- maxHeightLines,
- minHeightLines,
- elementRef: ref,
- value,
- })
-
- return (
-
-
- )
- },
-)
-MarkdownInput.displayName = 'MarkdownInput'
diff --git a/packages/react/src/drafts/MarkdownEditor/_SavedReplies.tsx b/packages/react/src/drafts/MarkdownEditor/_SavedReplies.tsx
deleted file mode 100644
index 17def4ad618..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_SavedReplies.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import {ReplyIcon} from '@primer/octicons-react'
-import type {KeyboardEventHandler, RefObject} from 'react'
-import React, {createContext, useContext, useEffect, useImperativeHandle, useState} from 'react'
-import type {SelectPanelProps} from '../../SelectPanel'
-import {SelectPanel} from '../../SelectPanel'
-import {ToolbarButton} from './_ToolbarButton'
-
-export type SavedReply = {
- name: string
- content: string
-}
-
-export type SavedRepliesHandle = {
- openMenu: () => void
-}
-
-type SavedRepliesContext = null | {
- onSelect: (savedReply: SavedReply) => void
- savedReplies: SavedReply[]
- /** Ref to the button for clicking via keyboard shortcut. */
- ref: RefObject
-}
-
-type Item = SelectPanelProps['items'][number]
-
-// SavedRepliesContext is separate from MarkdownEditorContext because the saved replies array is practically guarunteed to change
-// on every render. If it was provided in the MarkdownEditorContext, it would cause the whole editor to rerender on every render.
-export const SavedRepliesContext = createContext(null)
-
-export const SavedRepliesButton = () => {
- const context = useContext(SavedRepliesContext)
-
- useImperativeHandle(context?.ref, () => ({
- openMenu: () => {
- setOpen(true)
- },
- }))
-
- const [open, setOpen] = useState(false)
- useEffect(() => setFilter(''), [open])
-
- const [filter, setFilter] = useState('')
-
- // there's not much point in memoizing this since the savedReplies array is likely to change on every render
- const items = context?.savedReplies
- .filter(({name}) => name.toLowerCase().includes(filter.toLowerCase()))
- .map(
- (reply, i): Item => ({
- text: reply.name,
- description: reply.content,
- descriptionVariant: 'block',
- trailingVisual: i < 9 ? `Ctrl + ${i + 1}` : undefined,
- sx: {
- // hide the leading visual container since we don't use the checkboxes
- '& [class*=BaseVisualContainer]:first-child': {display: 'none'},
- '& [class*=DescriptionContainer]': {
- whiteSpace: 'nowrap',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- maxWidth: '100%',
- },
- },
- }),
- )
-
- const onSelectItem = (item: Item | undefined) => {
- setOpen(false)
- const reply = context?.savedReplies.find(({name}) => name === item?.text)
- if (reply) context?.onSelect(reply)
- }
-
- const onKeyDown: KeyboardEventHandler = event => {
- const keyInt = parseInt(event.key, 10)
- if (items && event.ctrlKey && !Number.isNaN(keyInt) && keyInt >= 1 && keyInt <= 9) {
- event.stopPropagation()
- event.preventDefault()
- onSelectItem(items[keyInt - 1])
- }
- }
-
- return items ? (
- (
-
- )}
- open={open}
- onOpenChange={setOpen}
- items={items}
- filterValue={filter}
- onFilterChange={setFilter}
- placeholderText="Search saved replies"
- selected={undefined}
- onSelectedChange={(selection: Item | Item[] | undefined) => {
- onSelectItem(Array.isArray(selection) ? selection[0] : selection)
- }}
- overlayProps={{width: 'small', maxHeight: 'small', anchorSide: 'outside-right', onKeyDown}}
- />
- ) : (
- <>>
- )
-}
diff --git a/packages/react/src/drafts/MarkdownEditor/_ToolbarButton.tsx b/packages/react/src/drafts/MarkdownEditor/_ToolbarButton.tsx
deleted file mode 100644
index cd2e8788550..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_ToolbarButton.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React, {forwardRef, useContext} from 'react'
-import type {IconButtonProps} from '../../Button'
-import {IconButton} from '../../Button'
-import {MarkdownEditorContext} from './_MarkdownEditorContext'
-
-export const ToolbarButton = forwardRef((props, ref) => {
- const {disabled, condensed} = useContext(MarkdownEditorContext)
-
- return (
- // eslint-disable-next-line primer-react/a11y-remove-disable-tooltip
- e.preventDefault()}
- {...props}
- sx={{color: 'fg.muted', ...props.sx}}
- // Keeping the tooltip disable since it is not maintained anymore and its tests were failing.
- />
- )
-})
-ToolbarButton.displayName = 'MarkdownEditor.ToolbarButton'
diff --git a/packages/react/src/drafts/MarkdownEditor/_ViewSwitch.tsx b/packages/react/src/drafts/MarkdownEditor/_ViewSwitch.tsx
deleted file mode 100644
index f1fb08482af..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_ViewSwitch.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react'
-
-import Box from '../../Box'
-import TabNav from '../../TabNav'
-
-export type MarkdownViewMode = 'preview' | 'edit'
-
-type ViewSwitchProps = {
- selectedView: MarkdownViewMode
- onViewSelect?: (view: MarkdownViewMode) => void
- disabled?: boolean
- /** Called when the preview should be loaded. */
- onLoadPreview: () => void
-}
-
-// no point in memoizing this component because onLoadPreview depends on value, so it would still re-render on every change
-export const ViewSwitch = ({selectedView, onViewSelect, onLoadPreview, disabled}: ViewSwitchProps) => {
- // don't get disabled from context - the switch is not disabled when the editor is disabled
-
- const sharedProps =
- selectedView === 'preview'
- ? {
- onClick: () => onViewSelect?.('edit'),
- }
- : {
- onClick: () => {
- onLoadPreview()
- onViewSelect?.('preview')
- },
- onMouseOver: () => onLoadPreview(),
- onFocus: () => onLoadPreview(),
- }
-
- return (
-
-
-
- Write
-
-
- Preview
-
-
-
- )
-}
diff --git a/packages/react/src/drafts/MarkdownEditor/_useFileHandling.ts b/packages/react/src/drafts/MarkdownEditor/_useFileHandling.ts
deleted file mode 100644
index 0d028f8f3a5..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_useFileHandling.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import {useCallback, useEffect, useRef, useState} from 'react'
-import type {FileType, UnifiedFileSelectResult} from '../hooks/useUnifiedFileSelect'
-import {useUnifiedFileSelect} from '../hooks/useUnifiedFileSelect'
-import {useSafeAsyncCallback} from '../hooks/useSafeAsyncCallback'
-import type {SyntheticChangeEmitter} from '../hooks/useSyntheticChange'
-import {markdownComment, markdownImage, markdownLink} from './utils'
-export type {FileType} from '../hooks/useUnifiedFileSelect'
-
-const placeholder = (file: File) => markdownComment(`Uploading "${file.name}"...`)
-
-const markdown = (file: File, url: string | null) => {
- if (!url) return markdownComment(`Failed to upload "${file.name}"`)
- if (file.type.startsWith('video/')) return url
- if (file.type.startsWith('image/')) return markdownImage('Image', url)
- return markdownLink(file.name, url)
-}
-
-type UploadProgress = [current: number, total: number]
-
-type UseFileHandlingResult = UnifiedFileSelectResult & {
- errorMessage?: string
- uploadProgress?: UploadProgress
-}
-
-type UseFileHandlingProps = {
- repositoryId?: number
- inputRef: React.RefObject
- emitChange: SyntheticChangeEmitter
- disabled?: boolean
- value: string
- onUploadFile?: (file: File) => Promise
- acceptedFileTypes?: FileType[]
-}
-
-export type FileUploadResult = {
- /** The URL of the uploaded file. `null` if the upoad failed (or reject the promise). */
- url: string
- /**
- * The file that was uploaded. Typically the client-side detected name, size, and content
- * type can be unreliable, so your file upload service may provide more accurate data. By
- * receiving an updated File instance with the more accurate data, the Markdown editor can
- * make better decisions.
- */
- file: File
-}
-
-type OptFileUploadResult = FileUploadResult | {url: null; file: File}
-
-const noop = () => {
- /*noop*/
-}
-
-export const useFileHandling = ({
- emitChange,
- value,
- inputRef,
- disabled,
- onUploadFile,
- acceptedFileTypes,
-}: UseFileHandlingProps): UseFileHandlingResult | null => {
- const [errorMessage, setErrorMessage] = useState(undefined)
-
- const errorVisibleForEnoughTime = useRef(false)
- useEffect(() => {
- if (errorMessage) {
- errorVisibleForEnoughTime.current = false
- const id = setTimeout(() => (errorVisibleForEnoughTime.current = true), 1000)
- return () => clearTimeout(id)
- }
- }, [errorMessage])
- useEffect(() => {
- // clears the error message when the user types and enough time has passed
- if (errorVisibleForEnoughTime.current) setErrorMessage(undefined)
- }, [value])
-
- const safeSetRejectedFiles = useSafeAsyncCallback((files: Array) => {
- const types = new Set(
- files
- .map(({name}) => {
- const parts = name.split('.')
- return parts.length > 1 ? `.${parts.at(-1)}` : ''
- })
- .filter(s => s !== ''),
- )
- if (types.size > 0) setErrorMessage(`File type${types.size > 1 ? 's' : ''} not allowed: ${[...types].join(', ')}`)
- })
-
- const [uploadProgress, setUploadProgress] = useState(undefined)
- const safeClearUploadProgress = useSafeAsyncCallback(() => setUploadProgress(undefined))
-
- const insertPlaceholder = useCallback(
- (files: Array) => {
- if (!inputRef.current) return
- const placeholders = `\n\n${files.map(placeholder).join('\n')}\n\n`
-
- emitChange(placeholders)
- },
- [inputRef, emitChange],
- )
-
- const replacePlaceholderWithMarkdown = (file: File, url: string | null) => {
- if (!inputRef.current) return
- const placeholderStr = placeholder(file)
- const placeholderIndex = inputRef.current.value.indexOf(placeholderStr)
- if (placeholderIndex === -1) return
-
- emitChange(markdown(file, url), [placeholderIndex, placeholderIndex + placeholderStr.length])
- }
-
- // It's crucial that this is done safely because file uploads can take a long time - there's
- // a very good chance that the references will be outdated or the component unmounted by the time this is called.
- const safeHandleCompletedFileUpload = useSafeAsyncCallback(({file, url}: OptFileUploadResult) => {
- setUploadProgress(progress => progress && [progress[0] + 1, progress[1]])
- replacePlaceholderWithMarkdown(file, url)
- })
-
- const uploadFiles = useCallback(
- (files: Array): Array> =>
- files.map(async file => {
- let result: OptFileUploadResult = {url: null, file}
- try {
- result = (await onUploadFile?.(file)) ?? {file, url: null}
- } catch (e) {
- result = {file, url: null}
- }
-
- safeHandleCompletedFileUpload(result)
- }),
- [onUploadFile, safeHandleCompletedFileUpload],
- )
-
- const onSelectFiles = useCallback(
- async (accepted: Array, rejected: Array) => {
- if (accepted.length > 0) {
- setUploadProgress([1, accepted.length])
- insertPlaceholder(accepted)
-
- await Promise.all(uploadFiles(accepted))
-
- safeClearUploadProgress()
- }
- // setting rejected files will hide upload progress, replacing it with an error message
- // so only call it after successful files are uploaded
- safeSetRejectedFiles(rejected)
- },
- [safeSetRejectedFiles, insertPlaceholder, uploadFiles, safeClearUploadProgress],
- )
-
- let fileSelect = useUnifiedFileSelect({
- acceptedFileTypes,
- multi: true,
- onSelect: onSelectFiles,
- })
-
- if (disabled) {
- fileSelect = {
- clickTargetProps: {
- onClick: noop,
- },
- dropTargetProps: {
- onDragEnter: noop,
- onDragLeave: noop,
- onDrop: noop,
- onDragOver: noop,
- },
- pasteTargetProps: {
- onPaste: noop,
- },
- isDraggedOver: false,
- }
- }
-
- return onUploadFile ? {...fileSelect, errorMessage, uploadProgress} : null
-}
diff --git a/packages/react/src/drafts/MarkdownEditor/_useIndenting.ts b/packages/react/src/drafts/MarkdownEditor/_useIndenting.ts
deleted file mode 100644
index ed21b9253f8..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_useIndenting.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import {useCallback} from 'react'
-import type {SyntheticChangeEmitter} from '../hooks/useSyntheticChange'
-
-import {getSelectedLineRange} from './utils'
-
-type UseIndentingSettings = {
- emitChange: SyntheticChangeEmitter
-}
-
-type UseIndentingResult = {
- onKeyDown: React.KeyboardEventHandler
-}
-
-const indentationRegex = /^(?:\t| ? ?)(.*)/
-
-const dedent = (line: string) => indentationRegex.exec(line)?.[1] ?? ''
-const indent = (line: string) => ` ${line}`
-
-/**
- * Provides functionality for indenting and dedenting selected lines in the Markdown editor.
- */
-export const useIndenting = ({emitChange}: UseIndentingSettings): UseIndentingResult => {
- const onKeyDown = useCallback(
- (event: React.KeyboardEvent) => {
- const textarea = event.currentTarget
- if (event.defaultPrevented || event.key !== 'Tab' || textarea.selectionEnd - textarea.selectionStart === 0) return
- event.preventDefault()
-
- const [start, end] = getSelectedLineRange(textarea)
- const updatedLines = textarea.value
- .slice(start, end)
- .split(/\r?\n/)
- .map(line => (event.shiftKey ? dedent(line) : indent(line)))
- .join('\n')
-
- emitChange(updatedLines, [start, end], [start, start + updatedLines.length])
- },
- [emitChange],
- )
-
- return {onKeyDown}
-}
diff --git a/packages/react/src/drafts/MarkdownEditor/_useListEditing.ts b/packages/react/src/drafts/MarkdownEditor/_useListEditing.ts
deleted file mode 100644
index bd7bdf4b512..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/_useListEditing.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import {useCallback} from 'react'
-import type {SyntheticChangeEmitter} from '../hooks/useSyntheticChange'
-import {getSelectedLineRange} from './utils'
-
-type UseListEditingSettings = {
- emitChange: SyntheticChangeEmitter
-}
-
-type UseListEditingResult = {
- onKeyDown: React.KeyboardEventHandler
-}
-
-const calculateNextListItemStarter = ({leadingWhitespace = '', delimeter, taskBox, text}: ListItem) => {
- if (!text) return null // Delete the current list item if the user presses enter without typing anything
-
- const updatedDelimeter = typeof delimeter === 'number' ? `${delimeter + 1}.` : delimeter
- const maybeEmptyTaskBox = taskBox ? ' [ ]' : ''
- return `\n${leadingWhitespace}${updatedDelimeter}${maybeEmptyTaskBox} `
-}
-
-/**
- * Adapted from: https://github.com/github/github/blob/ef649172de6802a699638e22798396ca78d61dc8/app/assets/modules/github/behaviors/task-list.ts#L404
- *
- * Groups:
- * 0. Leading whitespace
- * 1. Delimeter
- * 2. Item number (optional)
- * - Note that we don't have item letter - we don't do autocomplete for lettered lists like (a, b, c) or (i, ii, iii) because it's too complex
- * 3. Task box (optional)
- * 4. Everything following
- */
-export const listItemRegex = /^(\s*)([*-]|(\d+)\.)(\s{1,4})(?:(\[[\sx]\])\s)?(.*)/i
-
-export type ListItem = {
- leadingWhitespace: string
- middleWhitespace: string
- text: string
- delimeter: '-' | '*' | number
- taskBox: '[ ]' | '[x]' | null
-}
-
-type NumericListItem = ListItem & {delimeter: number}
-
-const isNumericListItem = (item: ListItem | null): item is NumericListItem => typeof item?.delimeter === 'number'
-
-export const parseListItem = (line: string): ListItem | null => {
- const result = listItemRegex.exec(line)
- if (!result) return null
- const [, leadingWhitespace = '', fullDelimeter, itemNumberStr = '', middleWhitespace, taskBox = null, text] = result
- const itemNumber = Number.parseInt(itemNumberStr, 10)
- const delimeter = Number.isNaN(itemNumber) ? (fullDelimeter as '*' | '-') : itemNumber
-
- return {
- leadingWhitespace,
- text,
- delimeter,
- middleWhitespace,
- taskBox: taskBox as '[ ]' | '[x]' | null,
- }
-}
-
-export const listItemToString = (item: ListItem) =>
- typeof item.delimeter === 'number'
- ? `${item.leadingWhitespace}${`${item.delimeter}.`}${item.middleWhitespace}${item.text}`
- : `${item.leadingWhitespace}${item.delimeter}${item.middleWhitespace}${item.taskBox || ''} ${item.text}`
-
-/**
- * Provides support for list editing in the Markdown editor. This includes inserting new
- * list items and auto-incrementing numeric lists.
- */
-export const useListEditing = ({emitChange}: UseListEditingSettings): UseListEditingResult => {
- const incrementFollowingNumericLines = useCallback(
- (textarea: HTMLTextAreaElement) => {
- // this must be recalculated instead of passed because we are on a new line now
- const [currentLineStart, currentLineEnd] = getSelectedLineRange(textarea)
- const currentLineText = textarea.value.slice(currentLineStart, currentLineEnd)
- const currentLineItem = parseListItem(currentLineText)
- if (!isNumericListItem(currentLineItem)) return
-
- // Strip off the leading newline by adding 1
- const followingText = textarea.value.slice(currentLineEnd + 1)
- const followingLines = followingText.split(/\r?\n/)
-
- const followingNumericListItems: Array = []
- let prevItemNumber = currentLineItem.delimeter
- for (const line of followingLines) {
- const listItem = parseListItem(line)
-
- if (!isNumericListItem(listItem) || listItem.delimeter !== prevItemNumber) break
-
- followingNumericListItems.push(listItem)
- prevItemNumber++
- }
-
- if (followingNumericListItems.length === 0) return
-
- // don't forget to re-add the leading newline stripped off earlier
- const updatedItems = `\n${followingNumericListItems
- .map(item => listItemToString({...item, delimeter: item.delimeter + 1}))
- .join('\n')}`
-
- emitChange(updatedItems, [currentLineEnd, currentLineEnd + updatedItems.length + 1], textarea.selectionStart)
- },
- [emitChange],
- )
-
- const onKeyDown = useCallback(
- (event: React.KeyboardEvent) => {
- if (event.key === 'Enter' && !event.shiftKey && !event.defaultPrevented) {
- const textarea = event.currentTarget
-
- const [activeLineStart, activeLineEnd] = getSelectedLineRange(textarea)
-
- // current line text without any of the selected text
- const activeLineValue =
- textarea.value.slice(activeLineStart, textarea.selectionStart) +
- textarea.value.slice(textarea.selectionEnd, activeLineEnd)
-
- const listItem = parseListItem(activeLineValue)
- if (!listItem) return // not currently editing a list - let the browser handle the event
-
- event.preventDefault()
-
- const nextItemStarter = calculateNextListItemStarter(listItem)
-
- if (nextItemStarter === null) {
- emitChange('', [activeLineStart, textarea.selectionEnd])
- } else {
- emitChange(nextItemStarter)
- // increment following lines as a separate event so the user can separately undo the change
- incrementFollowingNumericLines(textarea)
- }
- }
- },
- [emitChange, incrementFollowingNumericLines],
- )
-
- return {onKeyDown}
-}
diff --git a/packages/react/src/drafts/MarkdownEditor/index.ts b/packages/react/src/drafts/MarkdownEditor/index.ts
deleted file mode 100644
index 93066a269bc..00000000000
--- a/packages/react/src/drafts/MarkdownEditor/index.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import _MarkdownEditor from './MarkdownEditor'
-import {DefaultToolbarButtons, Toolbar, ToolbarButton} from './Toolbar'
-import {ActionButton, Actions} from './Actions'
-import {Footer, FooterButton} from './Footer'
-import {Label} from './Label'
-
-export type {MarkdownEditorHandle} from './MarkdownEditor'
-
-/** @deprecated Will be removed in v37 (https://github.com/primer/react/issues/3604) */
-const MarkdownEditor = Object.assign(_MarkdownEditor, {
- /** 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,
- /** An optional custom footer to show below the editor. */
- Footer,
- /** A button to show in the editor footer before the `DefaultFooterButtons`, i.e.
- * the "Markdown is supported" button and file upload button in a standardized order. */
- FooterButton,
- /**
- * Optionally define a set of custom buttons to show in the footer. Often if you
- * are defining custom buttons you should also wrap the editor in a `