UNPKG

@octopusdeploy/design-system-components

Version:
261 lines (260 loc) 13.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MarkdownEditor = void 0; const jsx_runtime_1 = require("react/jsx-runtime"); const css_1 = require("@emotion/css"); const design_system_icons_1 = require("@octopusdeploy/design-system-icons"); const design_system_tokens_1 = require("@octopusdeploy/design-system-tokens"); const react_1 = require("react"); const prefersReducedMotion_1 = require("../../../utils/prefersReducedMotion"); const Button_1 = require("../../Button"); const Stack_1 = require("../../Stack"); const Textarea_1 = require("../Textarea"); const MarkdownProvider_1 = require("./MarkdownProvider"); /** * A textarea with a markdown formatting toolbar and a toggle to show/hide it. * The toolbar is rendered inside the field's border, above the textarea input. * * @param ref - Receives a {@link MarkdownEditorHandle} for imperative focus and cursor insertion. */ exports.MarkdownEditor = (0, react_1.forwardRef)(function MarkdownEditor({ onChange, onValidate, hideMarkdownToggle, label, description, error, value, disabled, placeholder, controlsInitiallyVisible = false, autoFocus = false, hasRequiredMarker = false, hasOptionalMarker = false, popover }, ref) { const handleChange = (0, react_1.useCallback)((newValue) => { const valid = onValidate?.(newValue) ?? true; if (valid) { onChange?.(newValue); } }, [onValidate, onChange]); const { inputRef, actions: markdownActions, insertAtCursor } = useTextareaMarkdownActions(value, handleChange); (0, react_1.useImperativeHandle)(ref, () => ({ focus: () => inputRef.current?.focus(), insertAtCursor, }), [inputRef, insertAtCursor]); //We currently treat the visibility for the markdown controls as a non-controlled prop. Ideally this //would be controlled, however, that would also mean that every consumer has to manage this state. const [showMarkdownControls, setShowMarkdownControls] = (0, react_1.useState)(controlsInitiallyVisible); //Kept true while the toolbar plays its exit animation so it stays mounted until animationend. const [isToolbarExiting, setIsToolbarExiting] = (0, react_1.useState)(false); //Whether the controls have been toggled since mount; the text-shift transition only plays for //actual toggles, not when the toolbar is present on initial render (controlsInitiallyVisible). const [hasToggled, setHasToggled] = (0, react_1.useState)(false); const toggleMarkdownControls = () => { const next = !showMarkdownControls; setShowMarkdownControls(next); setHasToggled(true); //Skip the exit phase for reduced motion: the exit animation is disabled there, so //animationend would never fire and the toolbar would never unmount. setIsToolbarExiting(!next && !(0, prefersReducedMotion_1.prefersReducedMotion)()); }; const markdownTitle = `${showMarkdownControls ? "Hide" : "Show"} markdown controls`; const showMarkdownControlsToggle = hideMarkdownToggle ? null : ((0, jsx_runtime_1.jsx)(Stack_1.StackItem, { shrink: true, grow: false, children: (0, jsx_runtime_1.jsx)("div", { className: styles.toggleContainer, children: (0, jsx_runtime_1.jsx)(Button_1.Button, { label: markdownTitle, importance: "tertiary", onClick: (e) => { e.preventDefault(); toggleMarkdownControls(); } }) }) })); const isToolbarRendered = showMarkdownControls || isToolbarExiting; const toolbar = isToolbarRendered ? ((0, jsx_runtime_1.jsx)("div", { className: (0, css_1.cx)(styles.toolbar, hasToggled && styles.toolbarToggled, isToolbarExiting && styles.toolbarExiting), onAnimationEnd: () => { if (isToolbarExiting) { setIsToolbarExiting(false); } }, children: markdownActions.map(({ type, action }, index) => ((0, jsx_runtime_1.jsx)(MarkdownEditorButton, { type: type, onClick: action }, `${index}_${type}`))) })) : undefined; return ((0, jsx_runtime_1.jsx)("div", { className: (0, css_1.cx)(styles.markdownEditorContainer, !isToolbarRendered && styles.reservedToolbarSpace), children: (0, jsx_runtime_1.jsxs)(Stack_1.Stack, { direction: "vertical", gap: "small", children: [(0, jsx_runtime_1.jsx)(Textarea_1.Textarea, { ref: inputRef, toolbar: toolbar, value: value ? value : "", onChange: handleChange, validationMessage: error, label: label, description: description, hasRequiredMarker: hasRequiredMarker, hasOptionalMarker: hasOptionalMarker, popover: popover, placeholder: placeholder, rows: 3, autoFocus: autoFocus, disabled: disabled, autoResize: true }), showMarkdownControlsToggle] }) })); }); function useTextareaMarkdownActions(value, onValueChange) { const inputRef = (0, react_1.useRef)(null); //The caret position to restore once a change round-trips through onChange and comes back as //the controlled value. Remembering the value it belongs to lets a rejected or transformed //change discard the stale caret instead of applying it to some later unrelated value. const pendingSelection = (0, react_1.useRef)(null); (0, react_1.useLayoutEffect)(() => { const pending = pendingSelection.current; if (!pending) { return; } pendingSelection.current = null; if (pending.value !== value) { return; } const input = inputRef.current; if (input) { input.selectionStart = pending.start; input.selectionEnd = pending.end; } }, [value]); const getSelection = (0, react_1.useCallback)(() => { const input = inputRef.current; return { start: input?.selectionStart ?? null, end: input?.selectionEnd ?? null }; }, []); //The textarea stays fully controlled: nothing writes the DOM value directly. The change is //driven through onValueChange (where validation can reject it), and the caret is restored by //the layout effect above once the new value renders. const setInputState = (0, react_1.useCallback)((newValue, selectionStart, selectionEnd) => { pendingSelection.current = { value: newValue, start: selectionStart, end: selectionEnd }; inputRef.current?.focus(); onValueChange(newValue); }, [onValueChange]); const insertAtCursor = (0, react_1.useCallback)((insertValue) => { //The DOM value always equals the controlled value (nothing writes it behind React), //so reading it here keeps this callback stable across value changes. const input = inputRef.current; const currentValue = input?.value ?? ""; const start = input?.selectionStart ?? currentValue.length; const end = input?.selectionEnd ?? start; const newValue = currentValue.substring(0, start) + insertValue + currentValue.substring(end); setInputState(newValue, start + insertValue.length, start + insertValue.length); }, [setInputState]); const getInputState = () => { const selection = getSelection(); return new MarkdownProvider_1.MarkdownProvider(value ? value : "", selection.start ?? 0, selection.end ?? 0); }; const bold = () => { getInputState().insertBefore("**").insertAfter("**").emptySelectionText("Bold text").apply(setInputState); }; const italic = () => { getInputState().insertBefore("_").insertAfter("_").emptySelectionText("Italic text").apply(setInputState); }; const bullet = () => { getInputState().surroundWithNewlines().insertBefore("- ").emptySelectionText("List item").apply(setInputState); }; const number = () => { getInputState().surroundWithNewlines().insertBefore("1. ").emptySelectionText("List item").apply(setInputState); }; const quote = () => { getInputState().surroundWithNewlines().insertBefore("> ").insertAfterNewlineInSelection("> ").emptySelectionText("Quoted text").apply(setInputState); }; const code = () => { getInputState().surroundWithNewlines().insertBefore("```\n").insertAfter("\n```").emptySelectionText("Code").apply(setInputState); }; const link = () => { const url = prompt("Please enter a url for the link:", "http://"); if (url) { getInputState().insertBefore("[").insertAfter(`](${url})`).emptySelectionText("Enter link description here").apply(setInputState); } }; const image = () => { const url = prompt("Please enter a url for the image:", "http://"); if (url) { getInputState().insertBefore("![").insertAfter(`](${url})`).emptySelectionText("Enter image description here").apply(setInputState); } }; return { inputRef, insertAtCursor, actions: [ { type: "Bold", action: bold }, { type: "Bulleted List", action: bullet }, { type: "Code", action: code }, { type: "Link", action: link }, { type: "Numeric list", action: number }, { type: "Italic", action: italic }, { type: "Image", action: image }, { type: "Quotes", action: quote }, ], }; } const styles = { markdownEditorContainer: (0, css_1.css)({ width: "100%", }), // With the markdown controls hidden, reserve the space the toolbar occupies as bottom padding, // so the field height matches the toolbar variant no matter how far the content has grown. reservedToolbarSpace: (0, css_1.css)({ "& textarea": { paddingBottom: "3rem", // input padding (8px) + toolbar height (2.5rem) }, }), toggleContainer: (0, css_1.css)({ // Reserve enough width for the widest of the "Show"/"Hide" labels so the button doesn't change size when toggled. "& > button": { minWidth: "12rem", }, }), toolbar: (0, css_1.css)({ boxSizing: "border-box", height: "2.5rem", color: design_system_tokens_1.themeTokens.color.icon.secondary, display: "flex", flexDirection: "row", justifyContent: "flex-end", alignItems: "center", backgroundColor: design_system_tokens_1.themeTokens.color.background.secondary.default, padding: `0 ${design_system_tokens_1.space[8]}`, gap: design_system_tokens_1.space[12], borderBottom: `${design_system_tokens_1.borderWidth[1]} solid ${design_system_tokens_1.themeTokens.color.border.primary}`, zIndex: 1, }), // Entrance transition, applied only once the controls have been toggled so the initial render // is completely static. The toolbar slides in (clipped by the field container's // overflow: hidden), and the textarea's text — shifted down by the toolbar's height (2.5rem) toolbarToggled: (0, css_1.css)({ animation: "markdownToolbarSlideIn 200ms ease-out", "@keyframes markdownToolbarSlideIn": { from: { opacity: 0, transform: "translateY(-100%)", }, to: { opacity: 1, transform: "translateY(0)", }, }, "& ~ textarea": { animation: "markdownTextShiftIn 200ms ease-out", "@media (prefers-reduced-motion: reduce)": { animation: "none", }, }, "@keyframes markdownTextShiftIn": { from: { transform: "translateY(-2.5rem)", }, to: { transform: "translateY(0)", }, }, "@media (prefers-reduced-motion: reduce)": { animation: "none", }, }), toolbarExiting: (0, css_1.css)({ animation: "markdownToolbarSlideOut 200ms ease-in forwards", "@keyframes markdownToolbarSlideOut": { from: { opacity: 1, transform: "translateY(0)", }, to: { opacity: 0, transform: "translateY(-100%)", }, }, "& ~ textarea": { animation: "markdownTextShiftOut 200ms ease-in forwards", "@media (prefers-reduced-motion: reduce)": { animation: "none", }, }, "@keyframes markdownTextShiftOut": { from: { transform: "translateY(0)", }, to: { transform: "translateY(-2.5rem)", }, }, "@media (prefers-reduced-motion: reduce)": { animation: "none", }, }), }; const markdownEditorIconMap = { "Bulleted List": (0, jsx_runtime_1.jsx)(design_system_icons_1.ListIcon, { size: 20 }), Bold: (0, jsx_runtime_1.jsx)(design_system_icons_1.BoldIcon, { size: 20 }), Code: (0, jsx_runtime_1.jsx)(design_system_icons_1.CodeIcon, { size: 20 }), Italic: (0, jsx_runtime_1.jsx)(design_system_icons_1.ItalicIcon, { size: 20 }), Link: (0, jsx_runtime_1.jsx)(design_system_icons_1.LinkIcon, { size: 20 }), "Numeric list": (0, jsx_runtime_1.jsx)(design_system_icons_1.ListOlIcon, { size: 20 }), Image: (0, jsx_runtime_1.jsx)(design_system_icons_1.ImageIcon, { size: 20 }), Quotes: (0, jsx_runtime_1.jsx)(design_system_icons_1.QuotesIcon, { size: 20 }), }; function MarkdownEditorButton({ onClick, type }) { return (0, jsx_runtime_1.jsx)(Button_1.Button, { importance: "ghost", onClick: onClick, accessibleName: type, icon: markdownEditorIconMap[type], title: type }); }