@octopusdeploy/design-system-components
Version:
The design systems component library.
261 lines (260 loc) • 13.6 kB
JavaScript
;
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("`).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 });
}