import { useEffect, useRef, type ReactNode, forwardRef, type ComponentProps, type RefObject, useImperativeHandle, } from "react"; import { Annotation, EditorSelection, EditorState, StateEffect, type Extension, } from "@codemirror/state"; import { EditorView, drawSelection, dropCursor, keymap, } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab, } from "@codemirror/commands"; import { foldGutter, syntaxHighlighting } from "@codemirror/language"; import { theme, textVariants, css, cssVar, declareCssVar, SmallIconButton, Grid, Flex, rawTheme, Kbd, Text, FloatingPanel, PanelContent, } from "@webstudio-is/design-system"; import { MaximizeIcon } from "@webstudio-is/icons"; import { ChevronDownIcon, ChevronRightIcon } from "@webstudio-is/icons/svg"; import { codeHighlightStyle } from "./code-highlight"; // This undocumented flag is required to keep contenteditable fields editable after the first activation of EditorView. // To reproduce the issue, open any Binding dialog and then try to edit a Navigation Item in the Navigation menu. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error EditorView.EDIT_CONTEXT = false; const ExternalChange = Annotation.define(); const minHeightVar = declareCssVar("--code-editor-min-height"); const maxHeightVar = declareCssVar("--code-editor-max-height"); const maximizeIconVisibilityVar = declareCssVar( "--code-editor-maximize-icon-visibility" ); export const clampEditorSelection = ( selection: EditorSelection, length: number ) => { return EditorSelection.create( selection.ranges.map((range) => EditorSelection.range( Math.min(range.anchor, length), Math.min(range.head, length) ) ), selection.mainIndex ); }; export const normalizeEditorValue = (value: undefined | string) => value ?? ""; export const getCodeEditorCssVars = ({ minHeight, maxHeight, }: { minHeight: string; maxHeight: string; }) => ({ [minHeightVar]: minHeight, [maxHeightVar]: maxHeight, }); const editorContentStyle = css({ ...textVariants.mono, // fit editor into parent if stretched display: "flex", position: "relative", minHeight: 0, boxSizing: "border-box", color: cssVar("--foreground-primary"), borderRadius: theme.borderRadius[4], border: `1px solid transparent`, background: cssVar("--background-secondary"), paddingTop: 4, paddingBottom: 2, paddingRight: theme.spacing[2], paddingLeft: theme.spacing[3], // required to support copying selected text userSelect: "text", "&:hover": { borderColor: cssVar("--border-default"), }, "&:focus-within": { borderColor: cssVar("--border-focus"), }, '&[data-chromeless="true"]': { borderRadius: 0, }, '&[data-chromeless="true"]:not([data-invalid="true"]):is(:hover, :focus-within)': { borderColor: "transparent", }, '&[data-invalid="true"]': { borderColor: cssVar("--border-negative"), outlineColor: cssVar("--border-negative"), }, "& .cm-focused": { outline: "none", }, "& .cm-cursor, & .cm-dropCursor": { borderLeftColor: "currentColor", }, "& .cm-activeLine": { backgroundColor: cssVar("--overlay-interaction-hover"), }, "& .cm-selectionBackground, & .cm-content ::selection": { backgroundColor: `${cssVar("--background-text-selection")} !important`, }, "& .cm-content ::selection": { color: `${cssVar("--foreground-on-text-selection")} !important`, }, // fix scrolls appear on mount "& .cm-scroller": { overflowX: "hidden", }, "& .cm-content": { padding: 0, // makes sure you can click to focus when editor content is smaller than the container minHeight: "100%", }, "& .cm-line": { padding: 0, }, "& .cm-editor": { width: "100%", // avoid modifying height in .cm-content // because it breaks scroll events and makes scrolling laggy minHeight: cssVar(minHeightVar, "auto"), maxHeight: cssVar(maxHeightVar, "none"), }, "fieldset[disabled] & .cm-editor": { opacity: 0.3, }, ".cm-lintRange-error": { textDecoration: `underline wavy ${cssVar("--foreground-negative")}`, backgroundColor: cssVar("--background-negative-subtle"), }, ".cm-lintRange-warning": { textDecoration: `underline wavy ${cssVar("--foreground-warning")}`, backgroundColor: cssVar("--background-warning-subtle"), }, ".cm-gutters": { backgroundColor: "transparent", border: 0, }, }); const shortcutStyle = css({ position: "absolute", left: 0, bottom: 0, width: "100%", paddingInline: theme.spacing[3], background: `oklch(from ${cssVar("--background-primary")} l c h / 50%)`, zIndex: 1, pointerEvents: "none", }); const autocompletionTooltipTheme = EditorView.theme({ ".cm-tooltip.cm-tooltip-autocomplete": { ...textVariants.mono, border: "none", backgroundColor: "transparent", // override none set on body by radix popover pointerEvents: "auto", }, ".cm-tooltip.cm-tooltip-autocomplete ul": { minWidth: "160px", maxWidth: "260px", width: "max-content", boxSizing: "border-box", borderRadius: rawTheme.borderRadius[6], backgroundColor: cssVar("--background-primary"), border: `1px solid ${cssVar("--border-default")}`, boxShadow: `${rawTheme.shadows.menuDropShadow}, inset 0 0 0 1px ${cssVar( "--border-default" )}`, padding: rawTheme.spacing[3], }, ".cm-tooltip.cm-tooltip-autocomplete ul li": { ...textVariants.labels, textTransform: "none", position: "relative", display: "flex", alignItems: "center", color: cssVar("--foreground-primary"), padding: rawTheme.spacing[3], borderRadius: rawTheme.borderRadius[3], }, ".cm-tooltip.cm-tooltip-autocomplete li[aria-selected], .cm-tooltip.cm-tooltip-autocomplete li:hover": { color: cssVar("--foreground-primary"), backgroundColor: cssVar("--overlay-interaction-hover"), }, ".cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel": { flexGrow: 1, }, ".cm-tooltip.cm-tooltip-autocomplete .cm-completionDetail": { overflow: "hidden", textOverflow: "ellipsis", fontStyle: "normal", color: cssVar("--foreground-secondary"), }, }); const keyBindings = [ ...defaultKeymap.filter((binding) => { // We are redefining it later and CodeMirror won't take an override return binding.key !== "Mod-Enter"; }), ...historyKeymap, indentWithTab, ]; export const foldGutterExtension = foldGutter({ markerDOM: (isOpen) => { const div = document.createElement("div"); div.style.width = "16px"; div.style.height = "16px"; div.style.cursor = "pointer"; div.innerHTML = isOpen ? ChevronDownIcon : ChevronRightIcon; return div; }, }); export const linterTooltipTheme = EditorView.theme({ ".cm-tooltip:has(.cm-tooltip-lint)": { backgroundColor: "transparent", borderWidth: 0, paddingTop: rawTheme.spacing[5], paddingBottom: rawTheme.spacing[5], pointerEvents: "none", }, ".cm-tooltip-lint": { backgroundColor: cssVar("--background-inverse"), color: cssVar("--foreground-on-inverse"), borderRadius: rawTheme.borderRadius[7], padding: rawTheme.spacing[5], }, ".cm-tooltip-lint .cm-diagnostic": { borderWidth: 0, padding: 0, margin: 0, ...textVariants.regular, }, }); export type EditorApi = { replaceSelection: (string: string) => void; insertTemplate: (template: { prefix: string; suffix?: string; placeholder: string; }) => void; focus: () => void; }; export const getTemplateInsertion = ({ from, selectedText, prefix, suffix = "", placeholder, }: { from: number; selectedText: string; prefix: string; suffix?: string; placeholder: string; }) => { const content = selectedText || placeholder; const selectionFrom = from + prefix.length; return { text: `${prefix}${content}${suffix}`, selection: EditorSelection.range( selectionFrom, selectionFrom + content.length ), }; }; type EditorContentProps = { editorApiRef?: RefObject; ariaLabel?: string; extensions?: Extension[]; readOnly?: boolean; autoFocus?: boolean; invalid?: boolean; showShortcuts?: boolean; chromeless?: boolean; value?: string; onChange: (value: string) => void; onChangeComplete: (value: string) => void; }; export const EditorContent = ({ editorApiRef, ariaLabel, extensions = [], readOnly = false, autoFocus = false, invalid = false, showShortcuts = false, chromeless = false, value, onChange, onChangeComplete, }: EditorContentProps) => { const editorRef = useRef(null); const viewRef = useRef(undefined); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; const onChangeCompleteRef = useRef(onChangeComplete); onChangeCompleteRef.current = onChangeComplete; useEffect(() => { const abortController = new AbortController(); document.addEventListener( // https://github.com/radix-ui/primitives/blob/dac4fd8ab0c1974020e316c865db258ab10d2279/packages/react/dismissable-layer/src/DismissableLayer.tsx#L14 "dismissableLayer.pointerDownOutside", (event) => { if ( event.target instanceof Element && // Prevent radix dialogs and popups from closing when clicking on the editor's autocomplete items event.target.closest(".cm-tooltip.cm-tooltip-autocomplete") ) { event.preventDefault(); } }, { capture: true, signal: abortController.signal, } ); return () => { abortController.abort(); }; }, []); // setup editor useEffect(() => { if (editorRef.current === null) { return; } const view = new EditorView({ doc: "", parent: editorRef.current, }); viewRef.current = view; return () => { view.destroy(); }; }, []); useEffect(() => { if (autoFocus) { viewRef.current?.focus(); } }, [autoFocus]); // update extensions whenever variables data is changed useEffect(() => { const view = viewRef.current; if (view === undefined) { return; } const hasDisabledFieldset = editorRef.current?.closest("fieldset[disabled]"); view.dispatch({ effects: StateEffect.reconfigure.of([ ...extensions, ...(hasDisabledFieldset ? [EditorView.editable.of(false)] : []), autocompletionTooltipTheme, history(), drawSelection(), dropCursor(), syntaxHighlighting(codeHighlightStyle, { fallback: true }), keymap.of([ ...keyBindings, { key: "Mod-Enter", run(view) { onChangeCompleteRef.current(view.state.doc.toString()); return true; }, }, { key: "Mod-s", run(view) { onChangeCompleteRef.current(view.state.doc.toString()); return true; }, }, ]), EditorView.lineWrapping, // Read-only editors must keep keyboard focus instead of leaving // destructive Builder shortcuts active after clicking their content. EditorView.contentAttributes.of({ tabindex: "0" }), ...(ariaLabel === undefined ? [] : [EditorView.contentAttributes.of({ "aria-label": ariaLabel })]), EditorView.editable.of(readOnly === false), EditorState.readOnly.of(readOnly === true), // https://github.com/uiwjs/react-codemirror/blob/5d7a37245ce70e61f215b77dc42a7eaf295c46e7/core/src/useCodeMirror.ts#L57-L70 EditorView.updateListener.of((update) => { if ( // prevent invoking callback when focus or selection is changed update.docChanged && // prevent invoking callback when the change came from react value update.transactions.some((trx) => trx.annotation(ExternalChange) ) === false ) { onChangeRef.current(update.state.doc.toString()); } }), EditorView.domEventHandlers({ blur() { onChangeCompleteRef.current(view.state.doc.toString()); }, cut(event) { // prevent catching cut by global copy paste // with target outside of contenteditable event.stopPropagation(); }, }), ]), }); }, [ariaLabel, readOnly, extensions]); // update editor with react value // https://github.com/uiwjs/react-codemirror/blob/5d7a37245ce70e61f215b77dc42a7eaf295c46e7/core/src/useCodeMirror.ts#L158-L169 useEffect(() => { const view = viewRef.current; if (view === undefined) { return; } const nextValue = normalizeEditorValue(value); // prevent updating when editor has the same state // and can be the source of new value if (nextValue === view.state.doc.toString()) { return; } view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextValue }, selection: clampEditorSelection(view.state.selection, nextValue.length), annotations: [ExternalChange.of(true)], }); }, [value]); useImperativeHandle(editorApiRef, () => ({ replaceSelection: (string) => { const view = viewRef.current; if (view === undefined) { return; } view.dispatch(view.state.replaceSelection(string)); view.focus(); }, insertTemplate({ prefix, suffix, placeholder }) { const view = viewRef.current; if (view === undefined) { return; } const { from, to } = view.state.selection.main; const insertion = getTemplateInsertion({ from, selectedText: view.state.doc.sliceString(from, to), prefix, suffix, placeholder, }); view.dispatch({ changes: { from, to, insert: insertion.text }, selection: insertion.selection, }); view.focus(); }, focus() { viewRef.current?.focus(); }, })); return (
{showShortcuts && ( Submit )}
); }; const editorDialogControlStyle = css({ position: "relative", "&:hover": { [maximizeIconVisibilityVar]: "visible", }, }); export const EditorDialogControl = ({ children }: { children: ReactNode }) => { return
{children}
; }; export const EditorDialogButton = forwardRef< HTMLButtonElement, Partial> >((props, ref) => { return ( } css={{ position: "absolute", top: 4, right: 4, visibility: cssVar(maximizeIconVisibilityVar, "hidden"), background: "transparent", }} /> ); }); EditorDialogButton.displayName = "EditorDialogButton"; export const EditorDialog = ({ content, children, placement = "center", width = 640, height = 480, contentPadding = true, ...panelProps }: { title: ReactNode; content: ReactNode; children: ReactNode; width?: number; height?: number; contentPadding?: boolean; placement?: ComponentProps["placement"]; resize?: ComponentProps["resize"]; open?: boolean; onOpenChange?: (newOpen: boolean) => void; }) => { return ( {content} } > {children} ); };