publish codemirrot helix package flow
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s
This commit is contained in:
@@ -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;
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>\]\)]*$/,
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user