publish codemirrot helix package flow
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s

This commit is contained in:
2026-06-19 17:07:18 +02:00
parent 81c60feed9
commit 781e03c50c
18 changed files with 2265 additions and 54 deletions

View File

@@ -1,58 +1,72 @@
"use client";
import MDEditor from "@uiw/react-md-editor";
import CodeMirror from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import { autocompletion, completionStatus } from "@codemirror/autocomplete";
import { EditorView } from "@codemirror/view";
import { helix } from "codemirror-helix";
import { Maximize2, Minimize2 } from "lucide-react";
import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react";
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { ClientMdx } from "~/components/ClientMdx";
import {
InternalLinkTextarea,
type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion,
} from "./InternalLinkTextarea";
import { mdxCompletionSource } from "./codemirror/mdxAutocomplete";
export default function MdeFormField<T extends FieldValues>(params: {
control: Control<T>,
name: Path<T>,
label: string,
dataColorMode: "dark"|"light",
autocompleteSuggestions?: MdeAutocompleteSuggestion[],
triggerConfigs?: AutocompleteTriggerConfig[],
renderPreview?: (source: string) => ReactElement,
control: Control<T>;
name: Path<T>;
label: string;
dataColorMode: "dark" | "light";
autocompleteSuggestions?: MdeAutocompleteSuggestion[];
triggerConfigs?: AutocompleteTriggerConfig[];
renderPreview?: (source: string) => ReactElement;
}) {
const [fullscreen, setFullscreen] = useState(false)
const [mounted, setMounted] = useState(false)
const [fullscreen, setFullscreen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true)
}, [])
setMounted(true);
}, []);
useEffect(() => {
if (!fullscreen) return
const originalOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
if (!fullscreen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalOverflow
}
}, [fullscreen])
document.body.style.overflow = originalOverflow;
};
}, [fullscreen]);
const extensions = useMemo(
() => [
markdown(),
EditorView.lineWrapping,
helix({ escapeGuard: (state) => completionStatus(state) === "active" }),
autocompletion({
override: [mdxCompletionSource(params.autocompleteSuggestions ?? [], params.triggerConfigs)],
activateOnTyping: true,
defaultKeymap: true,
}),
],
[params.autocompleteSuggestions, params.triggerConfigs],
);
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => {
const source: string = field.value ?? "";
const editor = (
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
<div className="flex shrink-0 items-center justify-between gap-2">
<FormLabel>
{params.label}
</FormLabel>
<FormLabel>{params.label}</FormLabel>
<Button
type="button"
variant="outline"
@@ -64,37 +78,48 @@ export default function MdeFormField<T extends FieldValues>(params: {
</Button>
</div>
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
<MDEditor
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")}
height={fullscreen ? "calc(100vh - 72px)" : undefined}
visibleDragbar={!fullscreen}
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
commandsFilter={(command) => command.name === "fullscreen" ? false : command}
components={{
textarea: (props) => (
<InternalLinkTextarea
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
suggestions={params.autocompleteSuggestions ?? []}
triggerConfigs={params.triggerConfigs}
/>
),
preview: params.renderPreview
? (source) => params.renderPreview?.(source) ?? <></>
: (source) => <ClientMdx source={source} fallback={source} />,
}}
/>
<div
className={cn(
"flex flex-wrap gap-4",
fullscreen ? "min-h-0 flex-1 items-stretch" : "items-start",
)}
>
<div className="min-w-[18rem] flex-1 overflow-hidden rounded-md border">
<CodeMirror
value={source}
onChange={(value) => field.onChange(value)}
extensions={extensions}
theme={params.dataColorMode === "dark" ? "dark" : "light"}
height={fullscreen ? "calc(100vh - 96px)" : "360px"}
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
}}
/>
</div>
{params.renderPreview && (
<div
className={cn(
"prose dark:prose-invert min-w-[18rem] max-w-none flex-1 overflow-auto rounded-md border p-4",
fullscreen ? "min-h-0" : "max-h-[360px]",
)}
>
{params.renderPreview(source)}
</div>
)}
</div>
</FormControl>
</FormItem>
)
);
if (fullscreen && mounted) {
return createPortal(editor, document.body)
return createPortal(editor, document.body);
}
return editor
return editor;
}}
/>
)
);
}

View File

@@ -0,0 +1,85 @@
import { EditorSelection } from "@codemirror/state";
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import {
AUTOCOMPLETE_CURSOR_MARKER,
type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion,
} from "../InternalLinkTextarea";
// Re-implements the textarea autocomplete (trigger tokens like `<`, `[[`, `!`)
// as a CodeMirror completion source, driven by the exact same suggestion data
// produced by `useMdxEditorFieldProps`.
type ResolvedTrigger = { trigger: string };
function resolveTriggers(
suggestions: MdeAutocompleteSuggestion[],
triggerConfigs: AutocompleteTriggerConfig[] | undefined,
): (AutocompleteTriggerConfig & ResolvedTrigger)[] {
const map = new Map<string, AutocompleteTriggerConfig>();
for (const config of triggerConfigs ?? []) map.set(config.trigger, config);
for (const suggestion of suggestions) {
if (!map.has(suggestion.trigger)) {
map.set(suggestion.trigger, { trigger: suggestion.trigger, label: suggestion.trigger });
}
}
// Longer triggers first so `[[` wins over a hypothetical `[`.
return Array.from(map.values()).sort((a, b) => b.trigger.length - a.trigger.length);
}
function toCompletion(suggestion: MdeAutocompleteSuggestion, triggerStart: number): Completion {
const group = suggestion.group.toLowerCase();
const type = group === "component" ? "class" : group === "markdown" ? "keyword" : "variable";
return {
label: suggestion.label,
detail: suggestion.detail,
type,
apply: (view, _completion, _from, to) => {
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER);
const inserted =
markerIndex === -1 ? suggestion.value : suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, "");
const cursor = triggerStart + (markerIndex === -1 ? inserted.length : markerIndex);
view.dispatch({
changes: { from: triggerStart, to, insert: inserted },
selection: EditorSelection.cursor(cursor),
});
},
};
}
export function mdxCompletionSource(
suggestions: MdeAutocompleteSuggestion[],
triggerConfigs?: AutocompleteTriggerConfig[],
): CompletionSource {
const triggers = resolveTriggers(suggestions, triggerConfigs);
return (context) => {
const before = context.state.sliceDoc(0, context.pos);
const active = triggers
.map((config) => ({ config, start: before.lastIndexOf(config.trigger) }))
.filter((candidate) => candidate.start !== -1)
.sort((a, b) => b.start - a.start)[0];
if (!active) return null;
const queryStart = active.start + active.config.trigger.length;
const query = before.slice(queryStart);
if (query.includes("\n")) return null;
if (active.config.isQueryValid && !active.config.isQueryValid(query)) return null;
const options = suggestions
.filter((suggestion) => suggestion.trigger === active.config.trigger)
.map((suggestion) => toCompletion(suggestion, active.start));
if (!options.length) return null;
return {
from: queryStart,
to: context.pos,
options,
// Keep the popup open while the query stays a single token.
validFor: /^[^\s>\]\)]*$/,
};
};
}