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,85 +134,31 @@ const markdownContent = [
|
|||
|
||||
function MarkdownShowcase() {
|
||||
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 controllerRef = useRef<InputController | null>(null);
|
||||
|
||||
return (
|
||||
<EmojiProvider registry={emojiRegistry}>
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<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
|
||||
className="min-h-28"
|
||||
value={inputValue}
|
||||
setValue={setInputValue}
|
||||
placeholder="Write Markdown or type :fire:"
|
||||
emojiFrequencies={{ ":methanium:": 20, ":fire:": 10 }}
|
||||
onSubmit={() => setSubmissions((count) => count + 1)}
|
||||
onControllerChange={(controller) => {
|
||||
controllerRef.current = controller;
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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} />
|
||||
</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>
|
||||
<div className="flex min-w-0 flex-col gap-3 min-w-0 rounded-lg border bg-muted/20 p-4">
|
||||
<p>Cool input box</p>
|
||||
<Input
|
||||
className="min-h-28 "
|
||||
value={inputValue}
|
||||
setValue={setInputValue}
|
||||
placeholder="Write Markdown or type :fire:"
|
||||
emojiFrequencies={{ ":methanium:": 20, ":fire:": 10 }}
|
||||
onSubmit={() => setSubmissions((count) => count + 1)}
|
||||
onControllerChange={(controller) => {
|
||||
controllerRef.current = controller;
|
||||
}}
|
||||
/>
|
||||
<p>And it's output</p>
|
||||
<Text value={inputValue} />
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Markdown content={markdownContent} className="mx-auto max-w-4xl" />
|
||||
</EmojiProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans), sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--font-sans: "Public Sans", system-ui, sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
|
||||
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 { Button } from "../cmp/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { highlightCode, isSupportedLanguage, shikiPreClassName } from "./shiki";
|
||||
import {
|
||||
highlightCode,
|
||||
highlightedLineClassNames,
|
||||
isSupportedLanguage,
|
||||
shikiLineClassNames,
|
||||
shikiPreClassName,
|
||||
} from "./shiki";
|
||||
|
||||
type CodeBlockProps = {
|
||||
code: string;
|
||||
|
|
@ -20,7 +26,7 @@ function CodeBlock({
|
|||
filename,
|
||||
highlightedLines,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
showLineNumbers = true,
|
||||
}: CodeBlockProps) {
|
||||
const [highlighted, setHighlighted] = useState<ReactNode>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
@ -29,7 +35,7 @@ function CodeBlock({
|
|||
.sort((left, right) => left - right)
|
||||
.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
let active = true;
|
||||
setHighlighted(null);
|
||||
if (!language || !supported) return () => void (active = false);
|
||||
|
|
@ -40,7 +46,7 @@ function CodeBlock({
|
|||
void highlightCode(code, language, lines).then((tree) => {
|
||||
if (!active || !tree) return;
|
||||
const rendered = toJsxRuntime(tree, { Fragment, jsx, jsxs });
|
||||
startTransition(() => setHighlighted(rendered));
|
||||
setHighlighted(rendered);
|
||||
});
|
||||
return () => void (active = false);
|
||||
}, [code, highlightedLineKey, language, supported]);
|
||||
|
|
@ -65,11 +71,11 @@ function CodeBlock({
|
|||
data-slot="code-block"
|
||||
data-line-numbers={showLineNumbers || 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
|
||||
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">
|
||||
{filename ?? language ?? "Plain text"}
|
||||
|
|
@ -83,15 +89,19 @@ function CodeBlock({
|
|||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
data-slot="code-block-copy"
|
||||
className={cn("ms-auto", !supported && language && "ms-0")}
|
||||
aria-label={copied ? "Code copied" : "Copy code"}
|
||||
className={cn(
|
||||
"ms-auto h-[22.2px]!",
|
||||
!supported && language && "ms-0",
|
||||
)}
|
||||
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>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{copied ? "Code copied to clipboard" : ""}
|
||||
|
|
@ -106,7 +116,23 @@ function CodeBlock({
|
|||
className={cn(shikiPreClassName, !supported && "is-plain-text")}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ function MarkdownParagraph({
|
|||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"my-4 group-data-[variant=compact]/markdown:my-0",
|
||||
"my-4 group-data-[variant=compact]/markdown:my-[0.45rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -239,6 +239,7 @@ function MarkdownImage({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (!props.src) return alt ? <span>{alt}</span> : null;
|
||||
|
||||
return (
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export default function Emoji({
|
|||
className = "size-6",
|
||||
registry: registryOverride,
|
||||
shortcode,
|
||||
tooltip = true,
|
||||
}: EmojiProps) {
|
||||
const registry = useEmojiRegistry(registryOverride);
|
||||
const emoji = registry.resolve(shortcode);
|
||||
|
|
@ -23,26 +22,27 @@ export default function Emoji({
|
|||
|
||||
useEffect(() => setFailedSource(undefined), [emoji?.src]);
|
||||
|
||||
if (!emoji || failedSource === emoji.src) return <span>{shortcode}</span>;
|
||||
|
||||
const image = (
|
||||
<img
|
||||
alt={emoji.shortcode}
|
||||
className={className}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
src={emoji.src}
|
||||
onError={() => setFailedSource(emoji.src)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) return image;
|
||||
const content =
|
||||
emoji?.src && failedSource !== emoji.src ? (
|
||||
<img
|
||||
alt={emoji.shortcode}
|
||||
className={className}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
src={emoji.src}
|
||||
onError={() => setFailedSource(emoji.src)}
|
||||
/>
|
||||
) : (
|
||||
<span>{shortcode}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={image} />
|
||||
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent>
|
||||
<TooltipTrigger render={content} />
|
||||
<TooltipContent sideOffset={8}>
|
||||
{emoji?.shortcode ?? shortcode}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,253 +1,294 @@
|
|||
@import "katex/dist/katex.min.css";
|
||||
|
||||
@layer components {
|
||||
[data-slot="markdown"] {
|
||||
--markdown-code-background: color-mix(
|
||||
in oklch,
|
||||
var(--muted) 72%,
|
||||
var(--background)
|
||||
);
|
||||
--markdown-code-foreground: var(--foreground);
|
||||
--markdown-code-token-constant: var(--primary-foreground-alt);
|
||||
--markdown-code-token-string: color-mix(
|
||||
in oklch,
|
||||
var(--chart-2) 78%,
|
||||
var(--foreground)
|
||||
);
|
||||
--markdown-code-token-comment: var(--muted-foreground);
|
||||
--markdown-code-token-keyword: color-mix(
|
||||
in oklch,
|
||||
var(--primary) 72%,
|
||||
var(--foreground)
|
||||
);
|
||||
--markdown-code-token-parameter: var(--chart-3);
|
||||
--markdown-code-token-function: var(--chart-4);
|
||||
--markdown-code-token-string-expression: var(--chart-2);
|
||||
--markdown-code-token-punctuation: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 72%,
|
||||
var(--muted-foreground)
|
||||
);
|
||||
--markdown-code-token-link: var(--primary-foreground-alt);
|
||||
}
|
||||
[data-slot="markdown"],
|
||||
.tm-md-root {
|
||||
--markdown-code-background: color-mix(
|
||||
in oklch,
|
||||
var(--muted) 72%,
|
||||
var(--background)
|
||||
);
|
||||
--markdown-code-foreground: var(--foreground);
|
||||
--markdown-code-token-constant: var(--primary-foreground-alt);
|
||||
--markdown-code-token-string: color-mix(
|
||||
in oklch,
|
||||
var(--chart-2) 78%,
|
||||
var(--foreground)
|
||||
);
|
||||
--markdown-code-token-comment: var(--muted-foreground);
|
||||
--markdown-code-token-keyword: color-mix(
|
||||
in oklch,
|
||||
var(--primary) 72%,
|
||||
var(--foreground)
|
||||
);
|
||||
--markdown-code-token-parameter: var(--chart-3);
|
||||
--markdown-code-token-function: var(--chart-4);
|
||||
--markdown-code-token-string-expression: var(--chart-2);
|
||||
--markdown-code-token-punctuation: color-mix(
|
||||
in oklch,
|
||||
var(--foreground) 72%,
|
||||
var(--muted-foreground)
|
||||
);
|
||||
--markdown-code-token-link: var(--primary-foreground-alt);
|
||||
}
|
||||
|
||||
.dark [data-slot="markdown"]:not([data-code-theme="light"]),
|
||||
[data-slot="markdown"][data-code-theme="dark"] {
|
||||
--markdown-code-background: color-mix(
|
||||
in oklch,
|
||||
var(--muted) 76%,
|
||||
black 24%
|
||||
);
|
||||
--markdown-code-token-constant: var(--chart-1);
|
||||
--markdown-code-token-keyword: color-mix(
|
||||
in oklch,
|
||||
var(--primary) 62%,
|
||||
var(--foreground)
|
||||
);
|
||||
--markdown-code-token-function: color-mix(
|
||||
in oklch,
|
||||
var(--chart-4) 72%,
|
||||
var(--foreground)
|
||||
);
|
||||
}
|
||||
.dark [data-slot="markdown"]:not([data-code-theme="light"]),
|
||||
[data-slot="markdown"][data-code-theme="dark"],
|
||||
.dark .tm-md-root {
|
||||
--markdown-code-background: color-mix(
|
||||
in oklch,
|
||||
var(--muted) 76%,
|
||||
black 24%
|
||||
);
|
||||
--markdown-code-token-constant: var(--chart-1);
|
||||
--markdown-code-token-keyword: color-mix(
|
||||
in oklch,
|
||||
var(--primary) 62%,
|
||||
var(--foreground)
|
||||
);
|
||||
--markdown-code-token-function: color-mix(
|
||||
in oklch,
|
||||
var(--chart-4) 72%,
|
||||
var(--foreground)
|
||||
);
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor {
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
.cm-editor.tm-md-editor {
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor.cm-focused {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cm-editor.tm-md-editor.cm-focused {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .cm-cursor,
|
||||
.cm-editor.tm-md-editor .cm-dropCursor {
|
||||
border-left-color: var(--foreground) !important;
|
||||
}
|
||||
.cm-editor.tm-md-editor .cm-cursor,
|
||||
.cm-editor.tm-md-editor .cm-dropCursor {
|
||||
border-left-color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .cm-scroller {
|
||||
max-height: 30vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: "Public Sans", system-ui, sans-serif !important;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.cm-editor.tm-md-editor .cm-scroller {
|
||||
max-height: 30vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: "Public Sans", system-ui, sans-serif !important;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .cm-content {
|
||||
min-height: 2rem;
|
||||
padding: var(--tm-md-content-padding, 0.25rem 0.625rem);
|
||||
caret-color: var(--foreground) !important;
|
||||
}
|
||||
.cm-editor.tm-md-editor .cm-content {
|
||||
min-height: 2rem;
|
||||
padding: var(--tm-md-content-padding, 0.25rem 0.625rem);
|
||||
caret-color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .cm-line {
|
||||
padding: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.cm-editor.tm-md-editor .cm-line {
|
||||
padding: 0 0 0 0 !important;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-editor-emoji {
|
||||
display: inline-block;
|
||||
width: 1.15em;
|
||||
height: 1.15em;
|
||||
object-fit: contain;
|
||||
vertical-align: -0.18em;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-editor-emoji {
|
||||
display: inline-block;
|
||||
width: 1.15em;
|
||||
height: 1.15em;
|
||||
object-fit: contain;
|
||||
vertical-align: -0.18em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-code-line {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--muted);
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-code-line {
|
||||
box-sizing: border-box;
|
||||
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-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
padding-right: 1rem !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-heading {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.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-h1 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
.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-h2 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
.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-h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.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-h4 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-rendered-code-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-heading {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-h6 {
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-h1 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-h2 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-em {
|
||||
font-style: italic;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-del {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-h4 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-code {
|
||||
border: var(--ui-border-width) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--muted);
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-code-active {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--muted);
|
||||
box-shadow: inset 0 0 0 var(--ui-border-width) var(--border);
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-h6 {
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-code-active .tm-md-code {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: inherit;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cm-editor.tm-md-editor .tm-md-link {
|
||||
color: var(--primary-foreground-alt);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.14rem;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete {
|
||||
min-width: 18rem;
|
||||
max-width: min(26rem, calc(100vw - 1rem));
|
||||
overflow: hidden;
|
||||
border: var(--ui-border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--popover);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 10%),
|
||||
0 2px 4px -2px rgb(0 0 0 / 10%);
|
||||
color: var(--popover-foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-del {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul {
|
||||
max-height: min(20rem, 45vh);
|
||||
padding: 0.25rem;
|
||||
font-family: var(--font-sans);
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-code {
|
||||
border: var(--ui-border-width) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--muted);
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-code-active {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--muted);
|
||||
box-shadow: inset 0 0 0 var(--ui-border-width) var(--border);
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-code-active .tm-md-code {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
background: var(--border);
|
||||
}
|
||||
.cm-editor.tm-md-editor .tm-md-link {
|
||||
color: var(--primary-foreground-alt);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.14rem;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li {
|
||||
display: flex;
|
||||
min-height: 2.25rem;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
.cm-tooltip.cm-tooltip-autocomplete {
|
||||
min-width: 18rem;
|
||||
max-width: min(26rem, calc(100vw - 1rem));
|
||||
overflow: hidden;
|
||||
border: var(--ui-border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--popover);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 10%),
|
||||
0 2px 4px -2px rgb(0 0 0 / 10%);
|
||||
color: var(--popover-foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul {
|
||||
max-height: min(20rem, 45vh);
|
||||
padding: 0.25rem;
|
||||
font-family: var(--font-sans);
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon {
|
||||
display: none;
|
||||
}
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete .tm-md-completion-emoji {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li {
|
||||
display: flex;
|
||||
min-height: 2.25rem;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
|
||||
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete .tm-md-completion-emoji {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
EditorSelection,
|
||||
EditorState,
|
||||
Prec,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Transaction,
|
||||
type Extension,
|
||||
type Range,
|
||||
|
|
@ -42,6 +44,7 @@ import { useEffect, useRef, type CSSProperties } from "react";
|
|||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import Emoji from "./emoji";
|
||||
import { useEmojiRegistry } from "./emoji-context";
|
||||
import {
|
||||
|
|
@ -52,6 +55,8 @@ import {
|
|||
searchEmojis,
|
||||
type EmojiRegistry,
|
||||
} from "./emoji-data";
|
||||
import { parseCodeMeta } from "./markdown-meta";
|
||||
import { highlightCodeTokens } from "./shiki";
|
||||
|
||||
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 codeDecoration = Decoration.mark({ class: "tm-md-code" });
|
||||
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
|
||||
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
|
||||
const externalValueSync = Annotation.define<boolean>();
|
||||
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 = {
|
||||
from: number;
|
||||
shortcode: string;
|
||||
|
|
@ -250,8 +498,7 @@ class EmojiWidget extends WidgetType {
|
|||
}
|
||||
|
||||
destroy(dom: HTMLElement) {
|
||||
widgetRoots.get(dom)?.unmount();
|
||||
widgetRoots.delete(dom);
|
||||
destroyWidgetRoot(dom);
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
|
|
@ -396,17 +643,123 @@ const markdownDecorations = ViewPlugin.fromClass(
|
|||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildMarkdownDecorations(view.state);
|
||||
this.decorations = buildMarkdownDecorations(view.state, view.hasFocus);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = buildMarkdownDecorations(update.state);
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.selectionSet ||
|
||||
update.viewportChanged ||
|
||||
update.focusChanged
|
||||
) {
|
||||
this.decorations = buildMarkdownDecorations(
|
||||
update.state,
|
||||
update.view.hasFocus,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 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) {
|
||||
const registry = useEmojiRegistry(props.registry);
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -510,7 +863,7 @@ export default function Input(props: InputProps) {
|
|||
<div
|
||||
ref={elementRef}
|
||||
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,
|
||||
)}
|
||||
style={
|
||||
|
|
@ -548,6 +901,7 @@ function createEditorExtensions(
|
|||
run: (view) => {
|
||||
if (completionStatus(view.state) === "active")
|
||||
return acceptCompletion(view);
|
||||
if (selectionTouchesFencedCode(view.state)) return false;
|
||||
if (!getInvertEnterBehavior()) return false;
|
||||
onSubmit();
|
||||
return true;
|
||||
|
|
@ -558,6 +912,7 @@ function createEditorExtensions(
|
|||
run: (view) => {
|
||||
if (completionStatus(view.state) === "active")
|
||||
return acceptCompletion(view);
|
||||
if (selectionTouchesFencedCode(view.state)) return false;
|
||||
if (getInvertEnterBehavior()) return false;
|
||||
onSubmit();
|
||||
return true;
|
||||
|
|
@ -573,6 +928,16 @@ function createEditorExtensions(
|
|||
: false,
|
||||
},
|
||||
]);
|
||||
const codeBlockNavigationKeymap = keymap.of([
|
||||
{
|
||||
key: "ArrowDown",
|
||||
run: (view) => enterAdjacentFencedCode(view, "down"),
|
||||
},
|
||||
{
|
||||
key: "ArrowUp",
|
||||
run: (view) => enterAdjacentFencedCode(view, "up"),
|
||||
},
|
||||
]);
|
||||
const emojiDeletionKeymap = keymap.of([
|
||||
{
|
||||
key: "Backspace",
|
||||
|
|
@ -587,10 +952,12 @@ function createEditorExtensions(
|
|||
return [
|
||||
history(),
|
||||
markdown(),
|
||||
codeBlockDecorations,
|
||||
emojiCompartment.of(
|
||||
createEmojiExtensions(emojiFrequencies, onEmojiSelect, registry),
|
||||
),
|
||||
keymap.of(editorKeymap),
|
||||
Prec.highest(codeBlockNavigationKeymap),
|
||||
Prec.highest(completionTabKeymap),
|
||||
Prec.highest(emojiDeletionKeymap),
|
||||
Prec.highest(customEnterKeymap),
|
||||
|
|
@ -616,7 +983,16 @@ function createEditorExtensions(
|
|||
spellcheck: "true",
|
||||
"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,
|
||||
codeHighlightDecorations,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -756,13 +1132,56 @@ export function createEmojiCompletionSource(
|
|||
|
||||
export const emojiCompletionSource = createEmojiCompletionSource();
|
||||
|
||||
export function buildMarkdownDecorations(state: EditorState): DecorationSet {
|
||||
export function buildMarkdownDecorations(
|
||||
state: EditorState,
|
||||
revealSelection = true,
|
||||
): DecorationSet {
|
||||
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,
|
||||
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) {
|
||||
const line = state.doc.line(lineNumber);
|
||||
|
|
@ -775,21 +1194,24 @@ export function buildMarkdownDecorations(state: EditorState): DecorationSet {
|
|||
revealTo: line.to,
|
||||
to,
|
||||
});
|
||||
const fence = text.match(/^```\s*([^`]*)$/);
|
||||
if (fence) {
|
||||
const ticksStart = lineFrom + text.indexOf("```");
|
||||
const ticksEnd = ticksStart + 3;
|
||||
addHiddenToken(builder, selections, lineToken(ticksStart, ticksEnd));
|
||||
if (trimmed.length > 3) {
|
||||
addHiddenToken(builder, selections, lineToken(ticksEnd, line.to));
|
||||
}
|
||||
codeFenceOpen = !codeFenceOpen;
|
||||
continue;
|
||||
}
|
||||
if (codeFenceOpen) {
|
||||
builder.push(codeLineDecoration.range(lineFrom));
|
||||
const codeLine = codeLines.get(lineNumber);
|
||||
if (codeLine) {
|
||||
builder.push(
|
||||
Decoration.line({
|
||||
attributes: {
|
||||
"data-code-line":
|
||||
codeLine.number === undefined ? "" : String(codeLine.number),
|
||||
},
|
||||
class: cn(
|
||||
"tm-md-code-line",
|
||||
codeLine.start && "tm-md-code-line-start",
|
||||
codeLine.end && "tm-md-code-line-end",
|
||||
),
|
||||
}).range(lineFrom),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (fencedLines.has(lineNumber)) continue;
|
||||
|
||||
const heading = text.match(/^(#{1,6})\s+/);
|
||||
if (heading) {
|
||||
|
|
@ -900,15 +1322,23 @@ function addHiddenToken(
|
|||
token: InlineTokenRange,
|
||||
) {
|
||||
if (token.from >= token.to) return false;
|
||||
const overlapsSelection = selections.some((selection) => {
|
||||
const selectionFrom = Math.min(selection.from, selection.to);
|
||||
const selectionTo = Math.max(selection.from, selection.to);
|
||||
return selectionFrom === selectionTo
|
||||
? selectionFrom >= token.revealFrom && selectionFrom <= token.revealTo
|
||||
: selectionFrom < token.revealTo && selectionTo > token.revealFrom;
|
||||
});
|
||||
const overlapsSelection = selections.some((selection) =>
|
||||
overlapsRange(selection, token.revealFrom, token.revealTo),
|
||||
);
|
||||
if (!overlapsSelection) {
|
||||
builder.push(hiddenTokenDecoration.range(token.from, token.to));
|
||||
}
|
||||
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 {
|
||||
filename: filenameMatch?.[1] ?? filenameMatch?.[2],
|
||||
highlightedLines,
|
||||
showLineNumbers: /(?:^|\s)showLineNumbers(?=\s|$)/.test(meta ?? ""),
|
||||
showLineNumbers: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,9 +145,9 @@ function Markdown({
|
|||
> = [
|
||||
remarkGfm,
|
||||
remarkMath,
|
||||
remarkBreaks,
|
||||
remarkCodeMeta,
|
||||
[remarkEmoji, { registry }],
|
||||
...(variant === "compact" ? [remarkBreaks] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,11 +10,18 @@ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
|||
const THEME_NAME = "methanium-css";
|
||||
const CACHE_LIMIT = 200;
|
||||
|
||||
export type HighlightedCodeToken = {
|
||||
color?: string;
|
||||
content: string;
|
||||
fontStyle?: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
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]";
|
||||
const shikiLineClassNames =
|
||||
"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]";
|
||||
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)]";
|
||||
const highlightedLineClassNames =
|
||||
export const highlightedLineClassNames =
|
||||
"bg-primary/12 shadow-[inset_0.18rem_0_var(--primary-foreground-alt)]";
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
|
|
@ -162,6 +169,27 @@ export function isSupportedLanguage(language?: string) {
|
|||
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(
|
||||
code: string,
|
||||
language: string,
|
||||
|
|
|
|||
Loading…
Reference in a new issue