UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

319 lines (309 loc) • 14.5 kB
import React__default, { forwardRef, useState, useRef, useEffect, useImperativeHandle, useCallback, useLayoutEffect, useMemo } from 'react'; import VisuallyHidden from '../../_VisuallyHidden.js'; import { useId } from '../../hooks/useId.js'; import { useResizeObserver } from '../../hooks/useResizeObserver.js'; import { useSlots } from '../../hooks/useSlots.js'; import MarkdownViewer from '../MarkdownViewer/MarkdownViewer.js'; import { useIgnoreKeyboardActionsWhileComposing } from '../hooks/useIgnoreKeyboardActionsWhileComposing.js'; import { useSafeAsyncCallback } from '../hooks/useSafeAsyncCallback.js'; import { useSyntheticChange } from '../hooks/useSyntheticChange.js'; import { Actions } from './Actions.js'; import { Label } from './Label.js'; import { Toolbar, CoreToolbar, DefaultToolbarButtons } from './Toolbar.js'; import { Footer } from './_Footer.js'; import { FormattingTools } from './_FormattingTools.js'; import { MarkdownEditorContext } from './_MarkdownEditorContext.js'; import { MarkdownInput } from './_MarkdownInput.js'; import { SavedRepliesContext } from './_SavedReplies.js'; import { ViewSwitch } from './_ViewSwitch.js'; import { useFileHandling } from './_useFileHandling.js'; import { useIndenting } from './_useIndenting.js'; import { useListEditing } from './_useListEditing.js'; import { isModifierKey } from './utils.js'; import Box from '../../Box/Box.js'; function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } const a11yOnlyStyle = { clipPath: 'Circle(0)', position: 'absolute' }; 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 = []; /** * Markdown textarea with controls & keyboard shortcuts. */ const MarkdownEditor = /*#__PURE__*/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) => { var _slots$toolbar, _fileHandler$isDragge, _fileHandler$isDragge2, _fileHandler$clickTar; const [slots, childrenWithoutSlots] = useSlots(children, { toolbar: Toolbar, actions: Actions, label: Label }); 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 => { var _inputRef$current; return (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 ? void 0 : _inputRef$current.focus(opts); }, scrollIntoView: opts => { var _containerRef$current; return (_containerRef$current = containerRef.current) === null || _containerRef$current === void 0 ? void 0 : _containerRef$current.scrollIntoView(opts); } })); const inputHeight = useRef(0); if (inputRef.current && inputRef.current.offsetHeight) inputHeight.current = inputRef.current.offsetHeight; const onInputChange = useCallback(e => { 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 useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const parent = container.parentElement; const nextSibling = containerRef.current.nextSibling; parent === null || parent === void 0 ? void 0 : parent.removeChild(container); parent === null || parent === void 0 ? void 0 : 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 => { // 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 => { const format = formattingToolsRef.current; if (disabled) return; if (e.ctrlKey && e.key === '.') { var _savedRepliesRef$curr; // saved replies are always Control, even on Mac (_savedRepliesRef$curr = savedRepliesRef.current) === null || _savedRepliesRef$curr === void 0 ? void 0 : _savedRepliesRef$curr.openMenu(); e.preventDefault(); e.stopPropagation(); } else if (isModifierKey(e)) { if (e.key === 'Enter') onPrimaryAction === null || onPrimaryAction === void 0 ? void 0 : onPrimaryAction();else if (e.key === 'b') format === null || format === void 0 ? void 0 : format.bold();else if (e.key === 'i') format === null || format === void 0 ? void 0 : format.italic();else if (e.shiftKey && e.key === '.') format === null || format === void 0 ? void 0 : format.quote();else if (e.key === 'e') format === null || format === void 0 ? void 0 : format.code();else if (e.key === 'k') format === null || format === void 0 ? void 0 : format.link();else if (e.key === '8') format === null || format === void 0 ? void 0 : format.unorderedList();else if (e.shiftKey && e.key === '7') format === null || format === void 0 ? void 0 : format.orderedList();else if (e.shiftKey && e.key === 'l') format === null || format === void 0 ? void 0 : format.taskList();else if (e.shiftKey && e.key === 'p') setView === null || setView === void 0 ? void 0 : 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 => { if (!e.defaultPrevented && editorsInPreviewMode.at(-1) === id && isModifierKey(e) && e.shiftKey && e.key === 'p') { setView === null || setView === void 0 ? void 0 : setView('edit'); setTimeout(() => { var _inputRef$current2; return (_inputRef$current2 = inputRef.current) === null || _inputRef$current2 === void 0 ? void 0 : _inputRef$current2.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 }), [disabled, formattingToolsRef, condensed, required]); // We are using MarkdownEditorContext instead of the built-in Slots context because Slots' context is not typesafe return /*#__PURE__*/React__default.createElement(MarkdownEditorContext.Provider, { value: context }, /*#__PURE__*/React__default.createElement("fieldset", { "aria-disabled": disabled /* if we set disabled={true}, we can't enable the buttons that should be enabled */, "aria-describedby": describedBy ? `${descriptionId} ${describedBy}` : descriptionId, style: { appearance: 'none', border: 'none', minInlineSize: 'auto' } }, /*#__PURE__*/React__default.createElement(FormattingTools, { ref: formattingToolsRef, forInputId: id }), /*#__PURE__*/React__default.createElement("div", { style: { display: 'none' } }, childrenWithoutSlots), slots.label, /*#__PURE__*/React__default.createElement(Box, { sx: { display: 'flex', flexDirection: 'column', width: '100%', borderColor: 'border.default', borderWidth: 1, borderStyle: 'solid', borderRadius: 2, p: 2, height: fullHeight ? '100%' : undefined, minInlineSize: 'auto', bg: 'canvas.default', color: disabled ? 'fg.subtle' : 'fg.default', ...sx }, ref: containerRef }, /*#__PURE__*/React__default.createElement(VisuallyHidden, { id: descriptionId, "aria-live": "polite" }, "Markdown input:", view === 'preview' ? ' preview mode selected.' : ' edit mode selected.'), /*#__PURE__*/React__default.createElement(Box, { sx: { display: 'flex', pb: 2, gap: 2, justifyContent: 'space-between' }, as: "header" }, /*#__PURE__*/React__default.createElement(ViewSwitch, { selectedView: view, onViewSelect: setView, disabled: (fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.uploadProgress) !== undefined, onLoadPreview: loadPreview }), /*#__PURE__*/React__default.createElement(Box, { sx: { display: 'flex' } }, /*#__PURE__*/React__default.createElement(SavedRepliesContext.Provider, { value: savedRepliesContext }, view === 'edit' && ((_slots$toolbar = slots.toolbar) !== null && _slots$toolbar !== void 0 ? _slots$toolbar : /*#__PURE__*/React__default.createElement(CoreToolbar, null, /*#__PURE__*/React__default.createElement(DefaultToolbarButtons, null)))))), /*#__PURE__*/React__default.createElement(MarkdownInput, _extends({ value: value, onChange: onInputChange, emojiSuggestions: emojiSuggestions, mentionSuggestions: mentionSuggestions, referenceSuggestions: referenceSuggestions, disabled: disabled, placeholder: placeholder, id: id, maxLength: maxLength, ref: inputRef, fullHeight: fullHeight, isDraggedOver: (_fileHandler$isDragge = fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.isDraggedOver) !== null && _fileHandler$isDragge !== void 0 ? _fileHandler$isDragge : false, minHeightLines: minHeightLines, maxHeightLines: maxHeightLines, visible: view === 'edit', monospace: monospace, required: required, name: name, pasteUrlsAsPlainText: pasteUrlsAsPlainText }, inputCompositionProps, fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.pasteTargetProps, fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.dropTargetProps)), view === 'preview' && /*#__PURE__*/React__default.createElement(Box, { sx: { p: 1, overflow: 'auto', height: fullHeight ? '100%' : undefined, minHeight: inputHeight.current, boxSizing: 'border-box' }, "aria-live": "polite", tabIndex: -1 }, /*#__PURE__*/React__default.createElement("h2", { style: a11yOnlyStyle }, "Rendered Markdown Preview"), /*#__PURE__*/React__default.createElement(MarkdownViewer, { dangerousRenderedHTML: { __html: html || 'Nothing to preview' }, loading: html === null, openLinksInNewTab: true })), /*#__PURE__*/React__default.createElement(Footer, { actionButtons: slots.actions, fileDraggedOver: (_fileHandler$isDragge2 = fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.isDraggedOver) !== null && _fileHandler$isDragge2 !== void 0 ? _fileHandler$isDragge2 : false, fileUploadProgress: fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.uploadProgress, uploadButtonProps: (_fileHandler$clickTar = fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.clickTargetProps) !== null && _fileHandler$clickTar !== void 0 ? _fileHandler$clickTar : null, errorMessage: fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.errorMessage, previewMode: view === 'preview' })))); }); var _MarkdownEditor = MarkdownEditor; export { _MarkdownEditor as default };