feat(markdown): BIG ui improvements
Some checks failed
/ deploy-and-package (push) Has been cancelled
Some checks failed
/ deploy-and-package (push) Has been cancelled
This commit is contained in:
parent
0193880cb0
commit
61d27b0e96
10 changed files with 861 additions and 388 deletions
|
|
@ -134,25 +134,17 @@ const markdownContent = [
|
||||||
|
|
||||||
function MarkdownShowcase() {
|
function MarkdownShowcase() {
|
||||||
const [inputValue, setInputValue] = useState(
|
const [inputValue, setInputValue] = useState(
|
||||||
"Hello **Markdown** :fire:\n\nTry the custom :methanium: emoji or type another `:shortcode:`.",
|
'Hello **Markdown** :fire:\n\nTry the custom :methanium: emoji or type another `:shortcode:`. \n```ts\nconst stuff = "stuff";\n```',
|
||||||
);
|
);
|
||||||
const [submissions, setSubmissions] = useState(0);
|
const [submissions, setSubmissions] = useState(0);
|
||||||
const controllerRef = useRef<InputController | null>(null);
|
const controllerRef = useRef<InputController | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmojiProvider registry={emojiRegistry}>
|
<EmojiProvider registry={emojiRegistry}>
|
||||||
<Card>
|
<div className="flex min-w-0 flex-col gap-3 min-w-0 rounded-lg border bg-muted/20 p-4">
|
||||||
<CardHeader className="border-b">
|
<p>Cool input box</p>
|
||||||
<CardTitle>Live Markdown input</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
CodeMirror editing with hidden syntax, local emoji autocomplete, and
|
|
||||||
custom emoji from the shared registry.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-5 lg:grid-cols-2">
|
|
||||||
<div className="flex min-w-0 flex-col gap-3">
|
|
||||||
<Input
|
<Input
|
||||||
className="min-h-28"
|
className="min-h-28 "
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
setValue={setInputValue}
|
setValue={setInputValue}
|
||||||
placeholder="Write Markdown or type :fire:"
|
placeholder="Write Markdown or type :fire:"
|
||||||
|
|
@ -162,57 +154,11 @@ function MarkdownShowcase() {
|
||||||
controllerRef.current = controller;
|
controllerRef.current = controller;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p>And it's output</p>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => controllerRef.current?.focus()}
|
|
||||||
>
|
|
||||||
Focus editor
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
controllerRef.current?.insertText(" :methanium:")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Insert custom emoji
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Submissions: {submissions}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 rounded-lg border bg-muted/20 p-4">
|
|
||||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
|
||||||
Compact Text output
|
|
||||||
</p>
|
|
||||||
<Text value={inputValue} />
|
<Text value={inputValue} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 lg:col-span-2">
|
|
||||||
<Emoji shortcode=":fire:" />
|
|
||||||
<Emoji shortcode=":white_check_mark:" />
|
|
||||||
<Emoji shortcode=":methanium:" />
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Twemoji SVGs are packaged locally; the last image is custom.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<CardTitle>Markdown</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
GFM, math, syntax highlighting, code metadata, and semantic element
|
|
||||||
styling.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Markdown content={markdownContent} className="mx-auto max-w-4xl" />
|
<Markdown content={markdownContent} className="mx-auto max-w-4xl" />
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</EmojiProvider>
|
</EmojiProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-sans), sans-serif;
|
--font-heading: var(--font-sans), sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||||
--font-sans: "Public Sans", system-ui, sans-serif;
|
--font-sans: "Public Sans", system-ui, sans-serif;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
|
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import { startTransition, useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useLayoutEffect, useState, type ReactNode } from "react";
|
||||||
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
||||||
|
|
||||||
import { Button } from "../cmp/button";
|
import { Button } from "../cmp/button";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { highlightCode, isSupportedLanguage, shikiPreClassName } from "./shiki";
|
import {
|
||||||
|
highlightCode,
|
||||||
|
highlightedLineClassNames,
|
||||||
|
isSupportedLanguage,
|
||||||
|
shikiLineClassNames,
|
||||||
|
shikiPreClassName,
|
||||||
|
} from "./shiki";
|
||||||
|
|
||||||
type CodeBlockProps = {
|
type CodeBlockProps = {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
@ -20,7 +26,7 @@ function CodeBlock({
|
||||||
filename,
|
filename,
|
||||||
highlightedLines,
|
highlightedLines,
|
||||||
language,
|
language,
|
||||||
showLineNumbers = false,
|
showLineNumbers = true,
|
||||||
}: CodeBlockProps) {
|
}: CodeBlockProps) {
|
||||||
const [highlighted, setHighlighted] = useState<ReactNode>(null);
|
const [highlighted, setHighlighted] = useState<ReactNode>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
@ -29,7 +35,7 @@ function CodeBlock({
|
||||||
.sort((left, right) => left - right)
|
.sort((left, right) => left - right)
|
||||||
.join(",");
|
.join(",");
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setHighlighted(null);
|
setHighlighted(null);
|
||||||
if (!language || !supported) return () => void (active = false);
|
if (!language || !supported) return () => void (active = false);
|
||||||
|
|
@ -40,7 +46,7 @@ function CodeBlock({
|
||||||
void highlightCode(code, language, lines).then((tree) => {
|
void highlightCode(code, language, lines).then((tree) => {
|
||||||
if (!active || !tree) return;
|
if (!active || !tree) return;
|
||||||
const rendered = toJsxRuntime(tree, { Fragment, jsx, jsxs });
|
const rendered = toJsxRuntime(tree, { Fragment, jsx, jsxs });
|
||||||
startTransition(() => setHighlighted(rendered));
|
setHighlighted(rendered);
|
||||||
});
|
});
|
||||||
return () => void (active = false);
|
return () => void (active = false);
|
||||||
}, [code, highlightedLineKey, language, supported]);
|
}, [code, highlightedLineKey, language, supported]);
|
||||||
|
|
@ -65,11 +71,11 @@ function CodeBlock({
|
||||||
data-slot="code-block"
|
data-slot="code-block"
|
||||||
data-line-numbers={showLineNumbers || undefined}
|
data-line-numbers={showLineNumbers || undefined}
|
||||||
data-language={language || undefined}
|
data-language={language || undefined}
|
||||||
className="group/code-block my-4 overflow-hidden rounded-lg border-[length:var(--ui-border-width)] border-border bg-[var(--markdown-code-background)] group-data-[variant=compact]/markdown:my-[0.45rem]"
|
className="group/code-block overflow-hidden rounded-lg border-[length:var(--ui-border-width)] border-border bg-[var(--markdown-code-background)] group-data-[variant=compact]/markdown:my-[0.45rem]"
|
||||||
>
|
>
|
||||||
<figcaption
|
<figcaption
|
||||||
data-slot="code-block-header"
|
data-slot="code-block-header"
|
||||||
className="flex min-h-9 items-center gap-2 border-b-[length:var(--ui-border-width)] border-border px-2 py-[0.3rem] ps-[0.8rem] font-mono text-xs text-muted-foreground group-data-[variant=compact]/markdown:hidden"
|
className="flex items-center gap-2 ps-[0.6rem] px-0.5 font-mono text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{filename ?? language ?? "Plain text"}
|
{filename ?? language ?? "Plain text"}
|
||||||
|
|
@ -83,15 +89,19 @@ function CodeBlock({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
data-slot="code-block-copy"
|
className={cn(
|
||||||
className={cn("ms-auto", !supported && language && "ms-0")}
|
"ms-auto h-[22.2px]!",
|
||||||
aria-label={copied ? "Code copied" : "Copy code"}
|
!supported && language && "ms-0",
|
||||||
|
)}
|
||||||
onClick={copyCode}
|
onClick={copyCode}
|
||||||
>
|
>
|
||||||
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
|
{copied ? (
|
||||||
|
<Check className="size-3" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3" aria-hidden="true" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="sr-only" aria-live="polite">
|
<span className="sr-only" aria-live="polite">
|
||||||
{copied ? "Code copied to clipboard" : ""}
|
{copied ? "Code copied to clipboard" : ""}
|
||||||
|
|
@ -106,7 +116,23 @@ function CodeBlock({
|
||||||
className={cn(shikiPreClassName, !supported && "is-plain-text")}
|
className={cn(shikiPreClassName, !supported && "is-plain-text")}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<code>{code}</code>
|
<code>
|
||||||
|
{code.split("\n").map((line, index, lines) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
shikiLineClassNames,
|
||||||
|
highlightedLines.has(index + 1) &&
|
||||||
|
highlightedLineClassNames,
|
||||||
|
)}
|
||||||
|
data-line={index + 1}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
{index < lines.length - 1 ? "\n" : null}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ function MarkdownParagraph({
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"my-4 group-data-[variant=compact]/markdown:my-0",
|
"my-4 group-data-[variant=compact]/markdown:my-[0.45rem]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -239,6 +239,7 @@ function MarkdownImage({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!props.src) return alt ? <span>{alt}</span> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ export default function Emoji({
|
||||||
className = "size-6",
|
className = "size-6",
|
||||||
registry: registryOverride,
|
registry: registryOverride,
|
||||||
shortcode,
|
shortcode,
|
||||||
tooltip = true,
|
|
||||||
}: EmojiProps) {
|
}: EmojiProps) {
|
||||||
const registry = useEmojiRegistry(registryOverride);
|
const registry = useEmojiRegistry(registryOverride);
|
||||||
const emoji = registry.resolve(shortcode);
|
const emoji = registry.resolve(shortcode);
|
||||||
|
|
@ -23,9 +22,8 @@ export default function Emoji({
|
||||||
|
|
||||||
useEffect(() => setFailedSource(undefined), [emoji?.src]);
|
useEffect(() => setFailedSource(undefined), [emoji?.src]);
|
||||||
|
|
||||||
if (!emoji || failedSource === emoji.src) return <span>{shortcode}</span>;
|
const content =
|
||||||
|
emoji?.src && failedSource !== emoji.src ? (
|
||||||
const image = (
|
|
||||||
<img
|
<img
|
||||||
alt={emoji.shortcode}
|
alt={emoji.shortcode}
|
||||||
className={className}
|
className={className}
|
||||||
|
|
@ -35,14 +33,16 @@ export default function Emoji({
|
||||||
src={emoji.src}
|
src={emoji.src}
|
||||||
onError={() => setFailedSource(emoji.src)}
|
onError={() => setFailedSource(emoji.src)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{shortcode}</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!tooltip) return image;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={image} />
|
<TooltipTrigger render={content} />
|
||||||
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent>
|
<TooltipContent sideOffset={8}>
|
||||||
|
{emoji?.shortcode ?? shortcode}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
@import "katex/dist/katex.min.css";
|
@import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
[data-slot="markdown"] {
|
[data-slot="markdown"],
|
||||||
|
.tm-md-root {
|
||||||
--markdown-code-background: color-mix(
|
--markdown-code-background: color-mix(
|
||||||
in oklch,
|
in oklch,
|
||||||
var(--muted) 72%,
|
var(--muted) 72%,
|
||||||
|
|
@ -32,7 +33,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-slot="markdown"]:not([data-code-theme="light"]),
|
.dark [data-slot="markdown"]:not([data-code-theme="light"]),
|
||||||
[data-slot="markdown"][data-code-theme="dark"] {
|
[data-slot="markdown"][data-code-theme="dark"],
|
||||||
|
.dark .tm-md-root {
|
||||||
--markdown-code-background: color-mix(
|
--markdown-code-background: color-mix(
|
||||||
in oklch,
|
in oklch,
|
||||||
var(--muted) 76%,
|
var(--muted) 76%,
|
||||||
|
|
@ -82,7 +84,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-editor.tm-md-editor .cm-line {
|
.cm-editor.tm-md-editor .cm-line {
|
||||||
padding: 0;
|
padding: 0 0 0 0 !important;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,11 +98,50 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-editor.tm-md-editor .tm-md-code-line {
|
.cm-editor.tm-md-editor .tm-md-code-line {
|
||||||
border-radius: var(--radius-sm);
|
box-sizing: border-box;
|
||||||
background: var(--muted);
|
border-right: var(--ui-border-width) solid var(--border);
|
||||||
|
border-left: var(--ui-border-width) solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--markdown-code-background);
|
||||||
|
color: var(--markdown-code-foreground);
|
||||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
padding-right: 1rem !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor.tm-md-editor .tm-md-code-line::before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2.5rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
content: attr(data-code-line);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor.tm-md-editor .tm-md-code-line-start {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
border-top: var(--ui-border-width) solid var(--border);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor.tm-md-editor .tm-md-code-line-end {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
border-bottom: var(--ui-border-width) solid var(--border);
|
||||||
|
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||||
|
padding-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor.tm-md-editor .tm-md-code-line-start.tm-md-code-line-end {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor.tm-md-editor .tm-md-rendered-code-block {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-editor.tm-md-editor .tm-md-heading {
|
.cm-editor.tm-md-editor .tm-md-heading {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
EditorSelection,
|
EditorSelection,
|
||||||
EditorState,
|
EditorState,
|
||||||
Prec,
|
Prec,
|
||||||
|
StateEffect,
|
||||||
|
StateField,
|
||||||
Transaction,
|
Transaction,
|
||||||
type Extension,
|
type Extension,
|
||||||
type Range,
|
type Range,
|
||||||
|
|
@ -42,6 +44,7 @@ import { useEffect, useRef, type CSSProperties } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { CodeBlock } from "./code-block";
|
||||||
import Emoji from "./emoji";
|
import Emoji from "./emoji";
|
||||||
import { useEmojiRegistry } from "./emoji-context";
|
import { useEmojiRegistry } from "./emoji-context";
|
||||||
import {
|
import {
|
||||||
|
|
@ -52,6 +55,8 @@ import {
|
||||||
searchEmojis,
|
searchEmojis,
|
||||||
type EmojiRegistry,
|
type EmojiRegistry,
|
||||||
} from "./emoji-data";
|
} from "./emoji-data";
|
||||||
|
import { parseCodeMeta } from "./markdown-meta";
|
||||||
|
import { highlightCodeTokens } from "./shiki";
|
||||||
|
|
||||||
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
||||||
|
|
||||||
|
|
@ -204,10 +209,253 @@ const emDecoration = Decoration.mark({ class: "tm-md-em" });
|
||||||
const delDecoration = Decoration.mark({ class: "tm-md-del" });
|
const delDecoration = Decoration.mark({ class: "tm-md-del" });
|
||||||
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
|
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
|
||||||
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
|
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
|
||||||
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
|
|
||||||
const externalValueSync = Annotation.define<boolean>();
|
const externalValueSync = Annotation.define<boolean>();
|
||||||
const widgetRoots = new WeakMap<HTMLElement, Root>();
|
const widgetRoots = new WeakMap<HTMLElement, Root>();
|
||||||
|
|
||||||
|
function destroyWidgetRoot(dom: HTMLElement) {
|
||||||
|
const root = widgetRoots.get(dom);
|
||||||
|
if (!root) return;
|
||||||
|
widgetRoots.delete(dom);
|
||||||
|
setTimeout(() => root.unmount(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FencedCodeRange = {
|
||||||
|
closingMark?: { from: number; to: number };
|
||||||
|
code?: { from: number; to: number };
|
||||||
|
info?: { from: number; to: number };
|
||||||
|
openingMark: { from: number; to: number };
|
||||||
|
revealFrom: number;
|
||||||
|
revealTo: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fencedCodeRanges(state: EditorState): FencedCodeRange[] {
|
||||||
|
const ranges: FencedCodeRange[] = [];
|
||||||
|
syntaxTree(state).iterate({
|
||||||
|
enter(node) {
|
||||||
|
if (node.name !== "FencedCode") return;
|
||||||
|
const marks = node.node.getChildren("CodeMark");
|
||||||
|
const openingMark = marks[0];
|
||||||
|
if (!openingMark) return false;
|
||||||
|
const info = node.node.getChild("CodeInfo");
|
||||||
|
const code = node.node.getChild("CodeText");
|
||||||
|
ranges.push({
|
||||||
|
closingMark: marks[1]
|
||||||
|
? { from: marks[1].from, to: marks[1].to }
|
||||||
|
: undefined,
|
||||||
|
code: code ? { from: code.from, to: code.to } : undefined,
|
||||||
|
info: info ? { from: info.from, to: info.to } : undefined,
|
||||||
|
openingMark: { from: openingMark.from, to: openingMark.to },
|
||||||
|
revealFrom: state.doc.lineAt(node.from).from,
|
||||||
|
revealTo: state.doc.lineAt(node.to).to,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionTouchesFencedCode(state: EditorState) {
|
||||||
|
const blocks = fencedCodeRanges(state);
|
||||||
|
return state.selection.ranges.some((selection) =>
|
||||||
|
blocks.some(
|
||||||
|
(block) =>
|
||||||
|
selection.from <= block.revealTo && selection.to >= block.revealFrom,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterAdjacentFencedCode(view: EditorView, direction: "down" | "up") {
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
if (!selection.empty || view.state.selection.ranges.length !== 1)
|
||||||
|
return false;
|
||||||
|
const currentLine = view.state.doc.lineAt(selection.head).number;
|
||||||
|
const block = fencedCodeRanges(view.state).find((candidate) => {
|
||||||
|
const firstLine = view.state.doc.lineAt(candidate.revealFrom).number;
|
||||||
|
const lastLine = view.state.doc.lineAt(candidate.revealTo).number;
|
||||||
|
return direction === "down"
|
||||||
|
? firstLine === currentLine + 1
|
||||||
|
: lastLine === currentLine - 1;
|
||||||
|
});
|
||||||
|
if (!block) return false;
|
||||||
|
|
||||||
|
const cursor =
|
||||||
|
direction === "down"
|
||||||
|
? block.openingMark.from
|
||||||
|
: (block.closingMark?.from ?? block.code?.to ?? block.openingMark.to);
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(cursor),
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CodeBlockWidget extends WidgetType {
|
||||||
|
readonly code: string;
|
||||||
|
readonly codeFrom: number;
|
||||||
|
readonly cursor: number;
|
||||||
|
readonly filename?: string;
|
||||||
|
readonly highlightedLines: ReadonlySet<number>;
|
||||||
|
readonly highlightedLineKey: string;
|
||||||
|
readonly language?: string;
|
||||||
|
readonly showLineNumbers: boolean;
|
||||||
|
|
||||||
|
constructor(state: EditorState, block: FencedCodeRange) {
|
||||||
|
super();
|
||||||
|
const info = block.info
|
||||||
|
? state.sliceDoc(block.info.from, block.info.to).trim()
|
||||||
|
: "";
|
||||||
|
const meta = parseCodeMeta(info);
|
||||||
|
this.code = block.code
|
||||||
|
? state.sliceDoc(block.code.from, block.code.to)
|
||||||
|
: "";
|
||||||
|
this.codeFrom = block.code?.from ?? block.openingMark.to;
|
||||||
|
this.cursor = block.code?.from ?? block.openingMark.to;
|
||||||
|
this.filename = meta.filename;
|
||||||
|
this.highlightedLines = meta.highlightedLines;
|
||||||
|
this.highlightedLineKey = [...meta.highlightedLines]
|
||||||
|
.sort((left, right) => left - right)
|
||||||
|
.join(",");
|
||||||
|
this.language = info.split(/\s+/, 1)[0] || undefined;
|
||||||
|
this.showLineNumbers = meta.showLineNumbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: CodeBlockWidget) {
|
||||||
|
return (
|
||||||
|
this.code === other.code &&
|
||||||
|
this.codeFrom === other.codeFrom &&
|
||||||
|
this.cursor === other.cursor &&
|
||||||
|
this.filename === other.filename &&
|
||||||
|
this.highlightedLineKey === other.highlightedLineKey &&
|
||||||
|
this.language === other.language &&
|
||||||
|
this.showLineNumbers === other.showLineNumbers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "group/markdown tm-md-rendered-code-block";
|
||||||
|
container.dataset.variant = "compact";
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<CodeBlock
|
||||||
|
code={this.code}
|
||||||
|
filename={this.filename}
|
||||||
|
highlightedLines={this.highlightedLines}
|
||||||
|
language={this.language}
|
||||||
|
showLineNumbers={this.showLineNumbers}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
container.addEventListener("mousedown", (event) => {
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target.closest('[data-slot="code-block-copy"]')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const codeOffset = codeOffsetAtPoint(container, event);
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(
|
||||||
|
codeOffset === undefined
|
||||||
|
? this.cursor
|
||||||
|
: this.codeFrom + Math.min(codeOffset, this.code.length),
|
||||||
|
),
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
});
|
||||||
|
widgetRoots.set(container, root);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(dom: HTMLElement) {
|
||||||
|
destroyWidgetRoot(dom);
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeOffsetAtPoint(container: HTMLElement, event: MouseEvent) {
|
||||||
|
const code = container.querySelector("code");
|
||||||
|
if (!code) return undefined;
|
||||||
|
const position = document.caretPositionFromPoint?.(
|
||||||
|
event.clientX,
|
||||||
|
event.clientY,
|
||||||
|
);
|
||||||
|
let node = position?.offsetNode;
|
||||||
|
let offset = position?.offset;
|
||||||
|
if (!node || offset === undefined) {
|
||||||
|
const range = (
|
||||||
|
document as Document & {
|
||||||
|
caretRangeFromPoint?: (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
) => ReturnType<Document["createRange"]> | null;
|
||||||
|
}
|
||||||
|
).caretRangeFromPoint?.(event.clientX, event.clientY);
|
||||||
|
node = range?.startContainer;
|
||||||
|
offset = range?.startOffset;
|
||||||
|
}
|
||||||
|
if (!node || offset === undefined || !code.contains(node)) return undefined;
|
||||||
|
|
||||||
|
const prefix = document.createRange();
|
||||||
|
prefix.selectNodeContents(code);
|
||||||
|
prefix.setEnd(node, offset);
|
||||||
|
return prefix.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodeBlockDecorations(state: EditorState, focused: boolean) {
|
||||||
|
const builder: Range<Decoration>[] = [];
|
||||||
|
const selections = state.selection.ranges.map((range) => ({
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
}));
|
||||||
|
for (const block of fencedCodeRanges(state)) {
|
||||||
|
if (
|
||||||
|
focused &&
|
||||||
|
selections.some((selection) =>
|
||||||
|
overlapsRange(selection, block.revealFrom, block.revealTo),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
builder.push(
|
||||||
|
Decoration.replace({
|
||||||
|
block: true,
|
||||||
|
widget: new CodeBlockWidget(state, block),
|
||||||
|
}).range(block.revealFrom, block.revealTo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Decoration.set(builder, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeBlockFocusEffect = StateEffect.define<boolean>();
|
||||||
|
type CodeBlockDecorationState = {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
focused: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeBlockDecorations = StateField.define<CodeBlockDecorationState>({
|
||||||
|
create: (state) => ({
|
||||||
|
decorations: buildCodeBlockDecorations(state, false),
|
||||||
|
focused: false,
|
||||||
|
}),
|
||||||
|
update: (value, transaction) => {
|
||||||
|
const focusEffect = transaction.effects.find((effect) =>
|
||||||
|
effect.is(codeBlockFocusEffect),
|
||||||
|
);
|
||||||
|
const focused = focusEffect?.value ?? value.focused;
|
||||||
|
return {
|
||||||
|
decorations: buildCodeBlockDecorations(transaction.state, focused),
|
||||||
|
focused,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
provide: (field) =>
|
||||||
|
EditorView.decorations.from(field, (value) => value.decorations),
|
||||||
|
});
|
||||||
|
|
||||||
type EmojiRange = {
|
type EmojiRange = {
|
||||||
from: number;
|
from: number;
|
||||||
shortcode: string;
|
shortcode: string;
|
||||||
|
|
@ -250,8 +498,7 @@ class EmojiWidget extends WidgetType {
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(dom: HTMLElement) {
|
destroy(dom: HTMLElement) {
|
||||||
widgetRoots.get(dom)?.unmount();
|
destroyWidgetRoot(dom);
|
||||||
widgetRoots.delete(dom);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ignoreEvent() {
|
ignoreEvent() {
|
||||||
|
|
@ -396,17 +643,123 @@ const markdownDecorations = ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildMarkdownDecorations(view.state);
|
this.decorations = buildMarkdownDecorations(view.state, view.hasFocus);
|
||||||
}
|
}
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
if (
|
||||||
this.decorations = buildMarkdownDecorations(update.state);
|
update.docChanged ||
|
||||||
|
update.selectionSet ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
update.focusChanged
|
||||||
|
) {
|
||||||
|
this.decorations = buildMarkdownDecorations(
|
||||||
|
update.state,
|
||||||
|
update.view.hasFocus,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ decorations: (instance) => instance.decorations },
|
{ decorations: (instance) => instance.decorations },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const codeHighlightEffect = StateEffect.define<{
|
||||||
|
decorations: DecorationSet;
|
||||||
|
document: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
async function buildCodeHighlightDecorations(state: EditorState) {
|
||||||
|
const highlighted = await Promise.all(
|
||||||
|
fencedCodeRanges(state).map(async (block) => {
|
||||||
|
if (!block.code || !block.info) return [];
|
||||||
|
const language = state
|
||||||
|
.sliceDoc(block.info.from, block.info.to)
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/, 1)[0];
|
||||||
|
if (!language) return [];
|
||||||
|
const tokens = await highlightCodeTokens(
|
||||||
|
state.sliceDoc(block.code.from, block.code.to),
|
||||||
|
language,
|
||||||
|
);
|
||||||
|
if (!tokens) return [];
|
||||||
|
|
||||||
|
return tokens.flatMap((token) => {
|
||||||
|
if (!token.content || token.content.includes("\n")) return [];
|
||||||
|
const from = block.code!.from + token.offset;
|
||||||
|
const to = from + token.content.length;
|
||||||
|
if (from >= to || to > block.code!.to) return [];
|
||||||
|
const styles = token.color ? [`color:${token.color}`] : [];
|
||||||
|
if (token.fontStyle && token.fontStyle > 0) {
|
||||||
|
if (token.fontStyle & 1) styles.push("font-style:italic");
|
||||||
|
if (token.fontStyle & 2) styles.push("font-weight:bold");
|
||||||
|
if (token.fontStyle & 4) styles.push("text-decoration:underline");
|
||||||
|
}
|
||||||
|
if (styles.length === 0) return [];
|
||||||
|
return Decoration.mark({
|
||||||
|
attributes: { style: styles.join(";") },
|
||||||
|
}).range(from, to);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Decoration.set(highlighted.flat(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeHighlightDecorations = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations = Decoration.none;
|
||||||
|
private request = 0;
|
||||||
|
private timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.schedule(view, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
for (const transaction of update.transactions) {
|
||||||
|
for (const effect of transaction.effects) {
|
||||||
|
if (
|
||||||
|
effect.is(codeHighlightEffect) &&
|
||||||
|
effect.value.document === update.state.doc.toString()
|
||||||
|
) {
|
||||||
|
this.decorations = effect.value.decorations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!update.docChanged) return;
|
||||||
|
this.decorations = this.decorations.map(update.changes);
|
||||||
|
this.schedule(update.view, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.request += 1;
|
||||||
|
if (this.timeout !== undefined) clearTimeout(this.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(view: EditorView, delay: number) {
|
||||||
|
this.request += 1;
|
||||||
|
const request = this.request;
|
||||||
|
if (this.timeout !== undefined) clearTimeout(this.timeout);
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.timeout = undefined;
|
||||||
|
const state = view.state;
|
||||||
|
const document = state.doc.toString();
|
||||||
|
void buildCodeHighlightDecorations(state).then((decorations) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (
|
||||||
|
request !== this.request ||
|
||||||
|
view.state.doc.toString() !== document
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
view.dispatch({
|
||||||
|
effects: codeHighlightEffect.of({ decorations, document }),
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ decorations: (instance) => instance.decorations },
|
||||||
|
);
|
||||||
|
|
||||||
export default function Input(props: InputProps) {
|
export default function Input(props: InputProps) {
|
||||||
const registry = useEmojiRegistry(props.registry);
|
const registry = useEmojiRegistry(props.registry);
|
||||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -510,7 +863,7 @@ export default function Input(props: InputProps) {
|
||||||
<div
|
<div
|
||||||
ref={elementRef}
|
ref={elementRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"tm-md-root min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
"tm-md-root min-h-8 w-full min-w-0 text-base outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
|
|
@ -548,6 +901,7 @@ function createEditorExtensions(
|
||||||
run: (view) => {
|
run: (view) => {
|
||||||
if (completionStatus(view.state) === "active")
|
if (completionStatus(view.state) === "active")
|
||||||
return acceptCompletion(view);
|
return acceptCompletion(view);
|
||||||
|
if (selectionTouchesFencedCode(view.state)) return false;
|
||||||
if (!getInvertEnterBehavior()) return false;
|
if (!getInvertEnterBehavior()) return false;
|
||||||
onSubmit();
|
onSubmit();
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -558,6 +912,7 @@ function createEditorExtensions(
|
||||||
run: (view) => {
|
run: (view) => {
|
||||||
if (completionStatus(view.state) === "active")
|
if (completionStatus(view.state) === "active")
|
||||||
return acceptCompletion(view);
|
return acceptCompletion(view);
|
||||||
|
if (selectionTouchesFencedCode(view.state)) return false;
|
||||||
if (getInvertEnterBehavior()) return false;
|
if (getInvertEnterBehavior()) return false;
|
||||||
onSubmit();
|
onSubmit();
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -573,6 +928,16 @@ function createEditorExtensions(
|
||||||
: false,
|
: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const codeBlockNavigationKeymap = keymap.of([
|
||||||
|
{
|
||||||
|
key: "ArrowDown",
|
||||||
|
run: (view) => enterAdjacentFencedCode(view, "down"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ArrowUp",
|
||||||
|
run: (view) => enterAdjacentFencedCode(view, "up"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
const emojiDeletionKeymap = keymap.of([
|
const emojiDeletionKeymap = keymap.of([
|
||||||
{
|
{
|
||||||
key: "Backspace",
|
key: "Backspace",
|
||||||
|
|
@ -587,10 +952,12 @@ function createEditorExtensions(
|
||||||
return [
|
return [
|
||||||
history(),
|
history(),
|
||||||
markdown(),
|
markdown(),
|
||||||
|
codeBlockDecorations,
|
||||||
emojiCompartment.of(
|
emojiCompartment.of(
|
||||||
createEmojiExtensions(emojiFrequencies, onEmojiSelect, registry),
|
createEmojiExtensions(emojiFrequencies, onEmojiSelect, registry),
|
||||||
),
|
),
|
||||||
keymap.of(editorKeymap),
|
keymap.of(editorKeymap),
|
||||||
|
Prec.highest(codeBlockNavigationKeymap),
|
||||||
Prec.highest(completionTabKeymap),
|
Prec.highest(completionTabKeymap),
|
||||||
Prec.highest(emojiDeletionKeymap),
|
Prec.highest(emojiDeletionKeymap),
|
||||||
Prec.highest(customEnterKeymap),
|
Prec.highest(customEnterKeymap),
|
||||||
|
|
@ -616,7 +983,16 @@ function createEditorExtensions(
|
||||||
spellcheck: "true",
|
spellcheck: "true",
|
||||||
"aria-label": "Markdown input",
|
"aria-label": "Markdown input",
|
||||||
}),
|
}),
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
focus(_event, view) {
|
||||||
|
view.dispatch({ effects: codeBlockFocusEffect.of(true) });
|
||||||
|
},
|
||||||
|
blur(_event, view) {
|
||||||
|
view.dispatch({ effects: codeBlockFocusEffect.of(false) });
|
||||||
|
},
|
||||||
|
}),
|
||||||
markdownDecorations,
|
markdownDecorations,
|
||||||
|
codeHighlightDecorations,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -756,13 +1132,56 @@ export function createEmojiCompletionSource(
|
||||||
|
|
||||||
export const emojiCompletionSource = createEmojiCompletionSource();
|
export const emojiCompletionSource = createEmojiCompletionSource();
|
||||||
|
|
||||||
export function buildMarkdownDecorations(state: EditorState): DecorationSet {
|
export function buildMarkdownDecorations(
|
||||||
|
state: EditorState,
|
||||||
|
revealSelection = true,
|
||||||
|
): DecorationSet {
|
||||||
const builder: Range<Decoration>[] = [];
|
const builder: Range<Decoration>[] = [];
|
||||||
const selections = state.selection.ranges.map((range: SelectionRange) => ({
|
const selections = revealSelection
|
||||||
|
? state.selection.ranges.map((range: SelectionRange) => ({
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const codeSelections = state.selection.ranges.map((range) => ({
|
||||||
from: range.from,
|
from: range.from,
|
||||||
to: range.to,
|
to: range.to,
|
||||||
}));
|
}));
|
||||||
let codeFenceOpen = false;
|
const codeLines = new Map<
|
||||||
|
number,
|
||||||
|
{ end: boolean; number?: number; start: boolean }
|
||||||
|
>();
|
||||||
|
const fencedLines = new Set<number>();
|
||||||
|
|
||||||
|
for (const block of fencedCodeRanges(state)) {
|
||||||
|
const active = codeSelections.some((selection) =>
|
||||||
|
overlapsRange(selection, block.revealFrom, block.revealTo),
|
||||||
|
);
|
||||||
|
const firstLine = state.doc.lineAt(block.revealFrom).number;
|
||||||
|
const lastLine = state.doc.lineAt(block.revealTo).number;
|
||||||
|
const firstCodeLine = block.code
|
||||||
|
? state.doc.lineAt(block.code.from).number
|
||||||
|
: undefined;
|
||||||
|
const lastCodeLine = block.code
|
||||||
|
? state.doc.lineAt(Math.max(block.code.from, block.code.to - 1)).number
|
||||||
|
: undefined;
|
||||||
|
for (let line = firstLine; line <= lastLine; line += 1) {
|
||||||
|
fencedLines.add(line);
|
||||||
|
if (active) {
|
||||||
|
codeLines.set(line, {
|
||||||
|
end: line === lastLine,
|
||||||
|
number:
|
||||||
|
firstCodeLine !== undefined &&
|
||||||
|
lastCodeLine !== undefined &&
|
||||||
|
line >= firstCodeLine &&
|
||||||
|
line <= lastCodeLine
|
||||||
|
? line - firstCodeLine + 1
|
||||||
|
: undefined,
|
||||||
|
start: line === firstLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) {
|
for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) {
|
||||||
const line = state.doc.line(lineNumber);
|
const line = state.doc.line(lineNumber);
|
||||||
|
|
@ -775,21 +1194,24 @@ export function buildMarkdownDecorations(state: EditorState): DecorationSet {
|
||||||
revealTo: line.to,
|
revealTo: line.to,
|
||||||
to,
|
to,
|
||||||
});
|
});
|
||||||
const fence = text.match(/^```\s*([^`]*)$/);
|
const codeLine = codeLines.get(lineNumber);
|
||||||
if (fence) {
|
if (codeLine) {
|
||||||
const ticksStart = lineFrom + text.indexOf("```");
|
builder.push(
|
||||||
const ticksEnd = ticksStart + 3;
|
Decoration.line({
|
||||||
addHiddenToken(builder, selections, lineToken(ticksStart, ticksEnd));
|
attributes: {
|
||||||
if (trimmed.length > 3) {
|
"data-code-line":
|
||||||
addHiddenToken(builder, selections, lineToken(ticksEnd, line.to));
|
codeLine.number === undefined ? "" : String(codeLine.number),
|
||||||
}
|
},
|
||||||
codeFenceOpen = !codeFenceOpen;
|
class: cn(
|
||||||
continue;
|
"tm-md-code-line",
|
||||||
}
|
codeLine.start && "tm-md-code-line-start",
|
||||||
if (codeFenceOpen) {
|
codeLine.end && "tm-md-code-line-end",
|
||||||
builder.push(codeLineDecoration.range(lineFrom));
|
),
|
||||||
|
}).range(lineFrom),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (fencedLines.has(lineNumber)) continue;
|
||||||
|
|
||||||
const heading = text.match(/^(#{1,6})\s+/);
|
const heading = text.match(/^(#{1,6})\s+/);
|
||||||
if (heading) {
|
if (heading) {
|
||||||
|
|
@ -900,15 +1322,23 @@ function addHiddenToken(
|
||||||
token: InlineTokenRange,
|
token: InlineTokenRange,
|
||||||
) {
|
) {
|
||||||
if (token.from >= token.to) return false;
|
if (token.from >= token.to) return false;
|
||||||
const overlapsSelection = selections.some((selection) => {
|
const overlapsSelection = selections.some((selection) =>
|
||||||
const selectionFrom = Math.min(selection.from, selection.to);
|
overlapsRange(selection, token.revealFrom, token.revealTo),
|
||||||
const selectionTo = Math.max(selection.from, selection.to);
|
);
|
||||||
return selectionFrom === selectionTo
|
|
||||||
? selectionFrom >= token.revealFrom && selectionFrom <= token.revealTo
|
|
||||||
: selectionFrom < token.revealTo && selectionTo > token.revealFrom;
|
|
||||||
});
|
|
||||||
if (!overlapsSelection) {
|
if (!overlapsSelection) {
|
||||||
builder.push(hiddenTokenDecoration.range(token.from, token.to));
|
builder.push(hiddenTokenDecoration.range(token.from, token.to));
|
||||||
}
|
}
|
||||||
return !overlapsSelection;
|
return !overlapsSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function overlapsRange(
|
||||||
|
selection: { from: number; to: number },
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) {
|
||||||
|
const selectionFrom = Math.min(selection.from, selection.to);
|
||||||
|
const selectionTo = Math.max(selection.from, selection.to);
|
||||||
|
return selectionFrom === selectionTo
|
||||||
|
? selectionFrom >= from && selectionFrom <= to
|
||||||
|
: selectionFrom < to && selectionTo > from;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function parseCodeMeta(meta?: string): CodeMeta {
|
||||||
return {
|
return {
|
||||||
filename: filenameMatch?.[1] ?? filenameMatch?.[2],
|
filename: filenameMatch?.[1] ?? filenameMatch?.[2],
|
||||||
highlightedLines,
|
highlightedLines,
|
||||||
showLineNumbers: /(?:^|\s)showLineNumbers(?=\s|$)/.test(meta ?? ""),
|
showLineNumbers: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,9 +145,9 @@ function Markdown({
|
||||||
> = [
|
> = [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkMath,
|
remarkMath,
|
||||||
|
remarkBreaks,
|
||||||
remarkCodeMeta,
|
remarkCodeMeta,
|
||||||
[remarkEmoji, { registry }],
|
[remarkEmoji, { registry }],
|
||||||
...(variant === "compact" ? [remarkBreaks] : []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,18 @@ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
||||||
const THEME_NAME = "methanium-css";
|
const THEME_NAME = "methanium-css";
|
||||||
const CACHE_LIMIT = 200;
|
const CACHE_LIMIT = 200;
|
||||||
|
|
||||||
|
export type HighlightedCodeToken = {
|
||||||
|
color?: string;
|
||||||
|
content: string;
|
||||||
|
fontStyle?: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const shikiPreClassName =
|
export const shikiPreClassName =
|
||||||
"shiki m-0 w-max min-w-full bg-[var(--markdown-code-background)]! p-4 font-mono text-[0.82rem]/[1.7] text-[var(--markdown-code-foreground)]! [tab-size:2]";
|
"shiki m-0 w-max min-w-full bg-[var(--markdown-code-background)]! font-mono text-[0.82rem]/[1.7] text-[var(--markdown-code-foreground)]! [tab-size:2]";
|
||||||
const shikiLineClassNames =
|
export const shikiLineClassNames =
|
||||||
"line -mx-4 inline-block min-w-full px-4 group-data-[line-numbers]/code-block:before:inline-block group-data-[line-numbers]/code-block:before:w-10 group-data-[line-numbers]/code-block:before:pe-4 group-data-[line-numbers]/code-block:before:text-end group-data-[line-numbers]/code-block:before:text-muted-foreground group-data-[line-numbers]/code-block:before:select-none group-data-[line-numbers]/code-block:before:content-[attr(data-line)]";
|
"line -mx-4 inline-block min-w-full px-4 group-data-[line-numbers]/code-block:before:inline-block group-data-[line-numbers]/code-block:before:w-10 group-data-[line-numbers]/code-block:before:pe-4 group-data-[line-numbers]/code-block:before:text-end group-data-[line-numbers]/code-block:before:text-muted-foreground group-data-[line-numbers]/code-block:before:select-none group-data-[line-numbers]/code-block:before:content-[attr(data-line)]";
|
||||||
const highlightedLineClassNames =
|
export const highlightedLineClassNames =
|
||||||
"bg-primary/12 shadow-[inset_0.18rem_0_var(--primary-foreground-alt)]";
|
"bg-primary/12 shadow-[inset_0.18rem_0_var(--primary-foreground-alt)]";
|
||||||
|
|
||||||
const aliases: Record<string, string> = {
|
const aliases: Record<string, string> = {
|
||||||
|
|
@ -162,6 +169,27 @@ export function isSupportedLanguage(language?: string) {
|
||||||
return normalized !== undefined && supportedLanguages.has(normalized);
|
return normalized !== undefined && supportedLanguages.has(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function highlightCodeTokens(
|
||||||
|
code: string,
|
||||||
|
language: string,
|
||||||
|
): Promise<HighlightedCodeToken[] | null> {
|
||||||
|
const normalized = normalizeLanguage(language);
|
||||||
|
if (!normalized || !supportedLanguages.has(normalized)) return null;
|
||||||
|
|
||||||
|
const highlighter = await getHighlighter();
|
||||||
|
await ensureLanguage(highlighter, normalized);
|
||||||
|
return highlighter
|
||||||
|
.codeToTokens(code, { lang: normalized, theme: THEME_NAME })
|
||||||
|
.tokens.flatMap((line) =>
|
||||||
|
line.map(({ color, content, fontStyle, offset }) => ({
|
||||||
|
color,
|
||||||
|
content,
|
||||||
|
fontStyle,
|
||||||
|
offset,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function highlightCode(
|
export async function highlightCode(
|
||||||
code: string,
|
code: string,
|
||||||
language: string,
|
language: string,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue