UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

325 lines (312 loc) • 15 kB
'use strict'; var React = require('react'); var _VisuallyHidden = require('../../_VisuallyHidden.js'); var useId = require('../../hooks/useId.js'); var useResizeObserver = require('../../hooks/useResizeObserver.js'); var useSlots = require('../../hooks/useSlots.js'); var MarkdownViewer = require('../MarkdownViewer/MarkdownViewer.js'); var useIgnoreKeyboardActionsWhileComposing = require('../hooks/useIgnoreKeyboardActionsWhileComposing.js'); var useSafeAsyncCallback = require('../hooks/useSafeAsyncCallback.js'); var useSyntheticChange = require('../hooks/useSyntheticChange.js'); var Actions = require('./Actions.js'); var Label = require('./Label.js'); var Toolbar = require('./Toolbar.js'); var _Footer = require('./_Footer.js'); var _FormattingTools = require('./_FormattingTools.js'); var _MarkdownEditorContext = require('./_MarkdownEditorContext.js'); var _MarkdownInput = require('./_MarkdownInput.js'); var _SavedReplies = require('./_SavedReplies.js'); var _ViewSwitch = require('./_ViewSwitch.js'); var _useFileHandling = require('./_useFileHandling.js'); var _useIndenting = require('./_useIndenting.js'); var _useListEditing = require('./_useListEditing.js'); var utils = require('./utils.js'); var Box = require('../../Box/Box.js'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var React__default = /*#__PURE__*/_interopDefault(React); 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__*/React.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.useSlots(children, { toolbar: Toolbar.Toolbar, actions: Actions.Actions, label: Label.Label }); const [uncontrolledViewMode, uncontrolledSetViewMode] = React.useState('edit'); const [view, setView] = controlledViewMode === undefined ? [uncontrolledViewMode, uncontrolledSetViewMode] : [controlledViewMode, controlledSetViewMode]; const [html, setHtml] = React.useState(null); const safeSetHtml = useSafeAsyncCallback.useSafeAsyncCallback(setHtml); const previewStale = React.useRef(true); React.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)); }; React.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 = React.useRef(null); React.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 = React.useRef(0); if (inputRef.current && inputRef.current.offsetHeight) inputHeight.current = inputRef.current.offsetHeight; const onInputChange = React.useCallback(e => { onChange(e.target.value); }, [onChange]); const emitChange = useSyntheticChange.useSyntheticChange({ inputRef, fallbackEventHandler: onInputChange }); const fileHandler = _useFileHandling.useFileHandling({ emitChange, value, inputRef, disabled, onUploadFile, acceptedFileTypes }); const listEditor = _useListEditing.useListEditing({ emitChange }); const indenter = _useIndenting.useIndenting({ emitChange }); const formattingToolsRef = React.useRef(null); // use state instead of ref since we need to recalculate when the element mounts const containerRef = React.useRef(null); const [condensed, setCondensed] = React.useState(false); const onResize = React.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.useResizeObserver(onResize, containerRef); // workaround for Safari bug where layout is otherwise not recalculated React.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.useId(); const descriptionId = `${id}-description`; const savedRepliesRef = React.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.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 (utils.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); } }); React.useEffect(() => { if (view === 'preview') { editorsInPreviewMode.push(id); const handler = e => { if (!e.defaultPrevented && editorsInPreviewMode.at(-1) === id && utils.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 = React.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.default.createElement(_MarkdownEditorContext.MarkdownEditorContext.Provider, { value: context }, /*#__PURE__*/React__default.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.default.createElement(_FormattingTools.FormattingTools, { ref: formattingToolsRef, forInputId: id }), /*#__PURE__*/React__default.default.createElement("div", { style: { display: 'none' } }, childrenWithoutSlots), slots.label, /*#__PURE__*/React__default.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.default.createElement(_VisuallyHidden, { id: descriptionId, "aria-live": "polite" }, "Markdown input:", view === 'preview' ? ' preview mode selected.' : ' edit mode selected.'), /*#__PURE__*/React__default.default.createElement(Box, { sx: { display: 'flex', pb: 2, gap: 2, justifyContent: 'space-between' }, as: "header" }, /*#__PURE__*/React__default.default.createElement(_ViewSwitch.ViewSwitch, { selectedView: view, onViewSelect: setView, disabled: (fileHandler === null || fileHandler === void 0 ? void 0 : fileHandler.uploadProgress) !== undefined, onLoadPreview: loadPreview }), /*#__PURE__*/React__default.default.createElement(Box, { sx: { display: 'flex' } }, /*#__PURE__*/React__default.default.createElement(_SavedReplies.SavedRepliesContext.Provider, { value: savedRepliesContext }, view === 'edit' && ((_slots$toolbar = slots.toolbar) !== null && _slots$toolbar !== void 0 ? _slots$toolbar : /*#__PURE__*/React__default.default.createElement(Toolbar.CoreToolbar, null, /*#__PURE__*/React__default.default.createElement(Toolbar.DefaultToolbarButtons, null)))))), /*#__PURE__*/React__default.default.createElement(_MarkdownInput.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.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.default.createElement("h2", { style: a11yOnlyStyle }, "Rendered Markdown Preview"), /*#__PURE__*/React__default.default.createElement(MarkdownViewer, { dangerousRenderedHTML: { __html: html || 'Nothing to preview' }, loading: html === null, openLinksInNewTab: true })), /*#__PURE__*/React__default.default.createElement(_Footer.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; module.exports = _MarkdownEditor;