From 61d27b0e96e57cd52410a7b642220288e71ca80e Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 9 Aug 2026 22:22:17 +0200 Subject: [PATCH] feat(markdown): BIG ui improvements --- apps/showcase/src/MarkdownShowcase.tsx | 90 +---- src/index.css | 1 + src/markdown/code-block.tsx | 52 ++- src/markdown/components.tsx | 3 +- src/markdown/emoji.tsx | 36 +- src/markdown/index.css | 471 +++++++++++++----------- src/markdown/input.tsx | 490 +++++++++++++++++++++++-- src/markdown/markdown-meta.ts | 2 +- src/markdown/markdown.tsx | 2 +- src/markdown/shiki.ts | 34 +- 10 files changed, 827 insertions(+), 354 deletions(-) diff --git a/apps/showcase/src/MarkdownShowcase.tsx b/apps/showcase/src/MarkdownShowcase.tsx index 49edeb6..5b07dce 100644 --- a/apps/showcase/src/MarkdownShowcase.tsx +++ b/apps/showcase/src/MarkdownShowcase.tsx @@ -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(null); return ( - - - Live Markdown input - - CodeMirror editing with hidden syntax, local emoji autocomplete, and - custom emoji from the shared registry. - - - -
- setSubmissions((count) => count + 1)} - onControllerChange={(controller) => { - controllerRef.current = controller; - }} - /> -
- - - - Submissions: {submissions} - -
-
-
-

- Compact Text output -

- -
-
- - - - - Twemoji SVGs are packaged locally; the last image is custom. - -
-
-
+
+

Cool input box

+ setSubmissions((count) => count + 1)} + onControllerChange={(controller) => { + controllerRef.current = controller; + }} + /> +

And it's output

+ +
- - - Markdown - - GFM, math, syntax highlighting, code metadata, and semantic element - styling. - - - - - - +
); } diff --git a/src/index.css b/src/index.css index f13d7b8..5d69834 100644 --- a/src/index.css +++ b/src/index.css @@ -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); diff --git a/src/markdown/code-block.tsx b/src/markdown/code-block.tsx index 8e64a5c..7105a69 100644 --- a/src/markdown/code-block.tsx +++ b/src/markdown/code-block.tsx @@ -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(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]" >
{filename ?? language ?? "Plain text"} @@ -83,15 +89,19 @@ function CodeBlock({ ) : null} {copied ? "Code copied to clipboard" : ""} @@ -106,7 +116,23 @@ function CodeBlock({ className={cn(shikiPreClassName, !supported && "is-plain-text")} tabIndex={0} > - {code} + + {code.split("\n").map((line, index, lines) => ( + + + {line} + + {index < lines.length - 1 ? "\n" : null} + + ))} + )} diff --git a/src/markdown/components.tsx b/src/markdown/components.tsx index 8ff2dc2..3a70e44 100644 --- a/src/markdown/components.tsx +++ b/src/markdown/components.tsx @@ -117,7 +117,7 @@ function MarkdownParagraph({ return (

); } + if (!props.src) return alt ? {alt} : null; return ( setFailedSource(undefined), [emoji?.src]); - if (!emoji || failedSource === emoji.src) return {shortcode}; - - const image = ( - {emoji.shortcode} setFailedSource(emoji.src)} - /> - ); - - if (!tooltip) return image; + const content = + emoji?.src && failedSource !== emoji.src ? ( + {emoji.shortcode} setFailedSource(emoji.src)} + /> + ) : ( + {shortcode} + ); return ( - - {emoji.shortcode} + + + {emoji?.shortcode ?? shortcode} + ); } diff --git a/src/markdown/index.css b/src/markdown/index.css index b9a5b83..3de99dc 100644 --- a/src/markdown/index.css +++ b/src/markdown/index.css @@ -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; + } } diff --git a/src/markdown/input.tsx b/src/markdown/input.tsx index f6d48c6..e576a57 100644 --- a/src/markdown/input.tsx +++ b/src/markdown/input.tsx @@ -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(); const widgetRoots = new WeakMap(); +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; + 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( + , + ); + 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 | 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[] = []; + 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(); +type CodeBlockDecorationState = { + decorations: DecorationSet; + focused: boolean; +}; + +const codeBlockDecorations = StateField.define({ + 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 | 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(null); @@ -510,7 +863,7 @@ export default function Input(props: InputProps) {

{ 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[] = []; - 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(); + + 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; +} diff --git a/src/markdown/markdown-meta.ts b/src/markdown/markdown-meta.ts index 1b9a18c..ea3114a 100644 --- a/src/markdown/markdown-meta.ts +++ b/src/markdown/markdown-meta.ts @@ -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, }; } diff --git a/src/markdown/markdown.tsx b/src/markdown/markdown.tsx index 7dec149..07caf8c 100644 --- a/src/markdown/markdown.tsx +++ b/src/markdown/markdown.tsx @@ -145,9 +145,9 @@ function Markdown({ > = [ remarkGfm, remarkMath, + remarkBreaks, remarkCodeMeta, [remarkEmoji, { registry }], - ...(variant === "compact" ? [remarkBreaks] : []), ]; return ( diff --git a/src/markdown/shiki.ts b/src/markdown/shiki.ts index c926a87..6f707c4 100644 --- a/src/markdown/shiki.ts +++ b/src/markdown/shiki.ts @@ -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 = { @@ -162,6 +169,27 @@ export function isSupportedLanguage(language?: string) { return normalized !== undefined && supportedLanguages.has(normalized); } +export async function highlightCodeTokens( + code: string, + language: string, +): Promise { + 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,