Files
webstudio/apps/builder/app/shared/code-editor.tsx
T
Oleg Isonen cfba520e6d feat: Open text assets in code editor (#5918)
## What changed

- open every textual asset type from the upload registry in an editable
CodeMirror dialog
- select installed CodeMirror language support by MIME type and use
plain text editing as the fallback
- support `txt`, `csv`, `md`, `js`, `css`, `json`, `html`, `xml`, and
`svg` without adding asset-specific language branches to the shared
editor
- keep the canvas mounted and interactive behind the dialog
- persist edits on blur, `Cmd/Ctrl+S`, or `Cmd/Ctrl+Enter` through the
asset replacement pipeline
- preserve folder and description metadata and repoint supported asset
references
- clean up failed replacement uploads instead of leaving orphan assets

## Impact

Users can open, edit, and persist all text-based upload formats directly
in the builder without replacing or reloading the canvas. New `text/*`
upload types will automatically become editable as plain text; installed
syntax support is selected by MIME type.

## Validation

- `pnpm --filter @webstudio-is/builder test --
app/builder/features/text-file-editor/text-file-utils.test.ts
app/builder/shared/assets/replace-asset.test.ts
app/builder/shared/asset-manager/asset-manager.test.tsx`
- `pnpm --filter @webstudio-is/builder typecheck`
- `pnpm exec oxlint --deny-warnings <changed files>`
- `pnpm exec oxfmt --check <changed files>`
- `git diff --check`
- verified the running dev server applied the changes through Vite HMR

Ref #5824
2026-07-20 13:42:06 +02:00

235 lines
5.7 KiB
TypeScript

import {
forwardRef,
useMemo,
type ComponentProps,
useEffect,
type ReactNode,
} from "react";
import type { Extension } from "@codemirror/state";
import { styleTags, tags } from "@lezer/highlight";
import {
keymap,
tooltips,
highlightSpecialChars,
highlightActiveLine,
} from "@codemirror/view";
import {
bracketMatching,
indentOnInput,
LanguageSupport,
LRLanguage,
} from "@codemirror/language";
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from "@codemirror/autocomplete";
import { html } from "@codemirror/lang-html";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { css } from "@webstudio-is/design-system";
import {
EditorContent,
EditorDialog,
EditorDialogButton,
EditorDialogControl,
foldGutterExtension,
getCodeEditorCssVars,
} from "~/shared/code-editor-base";
import { cssCompletionSource, cssLanguage } from "@codemirror/lang-css";
const wrapperStyle = css({
position: "relative",
variants: {
size: {
default: getCodeEditorCssVars({ minHeight: "160px", maxHeight: "320px" }),
small: getCodeEditorCssVars({ minHeight: "16px", maxHeight: "120px" }),
full: {
...getCodeEditorCssVars({ minHeight: "100%", maxHeight: "100%" }),
height: "100%",
"& > div": { height: "100%" },
"& > div > div:first-child": { height: "100%" },
"& .cm-editor, & .cm-scroller": { height: "100%" },
},
},
},
defaultVariants: {
size: "default",
},
});
const noLanguageExtensions: Extension[] = [];
const getHtmlExtensions = () => [
highlightActiveLine(),
highlightSpecialChars(),
indentOnInput(),
html({}),
bracketMatching(),
closeBrackets(),
// render autocomplete in body
// to prevent popover scroll overflow
tooltips({ parent: document.body }),
autocompletion({ icons: false }),
keymap.of([...closeBracketsKeymap, ...completionKeymap]),
];
const getMarkdownExtensions = () => [
highlightActiveLine(),
highlightSpecialChars(),
indentOnInput(),
markdown({
extensions: [
{
props: [
styleTags({
HorizontalRule: tags.separator,
HeaderMark: tags.annotation,
QuoteMark: tags.annotation,
ListMark: tags.annotation,
LinkMark: tags.annotation,
EmphasisMark: tags.annotation,
CodeMark: tags.annotation,
InlineCode: tags.string,
URL: tags.url,
}),
],
},
],
}),
bracketMatching(),
closeBrackets(),
keymap.of(closeBracketsKeymap),
];
const getJsonExtensions = () => [
highlightActiveLine(),
highlightSpecialChars(),
indentOnInput(),
javascript(),
bracketMatching(),
closeBrackets(),
keymap.of(closeBracketsKeymap),
];
const cssPropertiesLanguage = LRLanguage.define({
name: "css",
parser: cssLanguage.configure({ top: "Styles" }).parser,
});
const cssProperties = new LanguageSupport(
cssPropertiesLanguage,
cssPropertiesLanguage.data.of({
autocomplete: cssCompletionSource,
})
);
const getCssPropertiesExtensions = () => [
highlightActiveLine(),
highlightSpecialChars(),
indentOnInput(),
cssProperties,
// render autocomplete in body
// to prevent popover scroll overflow
tooltips({ parent: document.body }),
autocompletion({ icons: false }),
];
export const CodeEditor = forwardRef<
HTMLDivElement,
Omit<ComponentProps<typeof EditorContent>, "extensions"> & {
lang?: "html" | "json" | "markdown" | "css-properties";
languageExtensions?: Extension[];
title?: ReactNode;
size?: "default" | "small" | "full";
expandable?: boolean;
}
>((props, ref) => {
const {
lang,
languageExtensions = noLanguageExtensions,
title,
size,
expandable = true,
...editorContentProps
} = props;
const builtInExtensions = useMemo(() => {
if (lang === "html") {
return getHtmlExtensions();
}
if (lang === "markdown") {
return getMarkdownExtensions();
}
if (lang === "json") {
return getJsonExtensions();
}
if (lang === "css-properties") {
return getCssPropertiesExtensions();
}
if (lang === undefined) {
return [];
}
lang satisfies never;
return [];
}, [lang]);
const extensions = useMemo(
() => [...builtInExtensions, ...languageExtensions],
[builtInExtensions, languageExtensions]
);
const dialogExtensions = useMemo(
() => [...extensions, foldGutterExtension],
[extensions]
);
// prevent clicking on autocomplete options propagating to body
// and closing dialogs and popovers
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof HTMLElement &&
event.target.closest(".cm-tooltip-autocomplete")
) {
event.stopPropagation();
}
};
const options = { capture: true };
document.addEventListener("pointerdown", handlePointerDown, options);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, options);
};
}, []);
return (
<div className={wrapperStyle({ size })} ref={ref}>
{expandable === false ? (
<EditorContent {...editorContentProps} extensions={extensions} />
) : (
<EditorDialogControl>
<EditorContent {...editorContentProps} extensions={extensions} />
<EditorDialog
title={title}
content={
<EditorContent
{...editorContentProps}
extensions={dialogExtensions}
/>
}
>
<EditorDialogButton />
</EditorDialog>
</EditorDialogControl>
)}
</div>
);
});
CodeEditor.displayName = "CodeEditor";