Skip to content

Commit fae97ee

Browse files
authored
feat: language picker for code blocks (#1176)
* feat: language picker for code blocks Adds an inline language picker chip to code blocks in both the admin rich text editor and the inline visual editor. Hover reveals the chip, clicking opens a popover with a free-text input and a curated suggestions datalist. Aliases (ts, js, c++, etc.) normalize to canonical ids on commit. The existing triple-backtick markdown shortcut continues to pre-populate the language. Storage and frontend rendering are unchanged: the language attribute round-trips through Portable Text and emits as a language-{id} class on the rendered pre/code. * refactor(editor): use Kumo Autocomplete; fix review feedback - Migrate the admin code-block language picker from a native <datalist> to Kumo's Autocomplete (landed in Kumo 2.0). Filters by label, id, or alias; renders a proper popup list rather than relying on the browser's native datalist UI. - Sanitize unknown language inputs to a single safe CSS class token in normalizeLanguage. Previously a user could type "Objective C" and the frontend would render class="language-objective c" (two classes). Now collapses runs of disallowed chars to a single hyphen and trims leading/trailing hyphens. Added unit tests for whitespace, dots, slashes, punctuation-only inputs, and other edge cases. - Use React.useId() for the inline editor's <datalist> id so multiple code blocks (or multiple inline editors) on the same page don't produce duplicate DOM ids. - When the language chip is hidden, set tabIndex={-1} and aria-hidden so keyboard users don't land on an invisible focus target. Addresses review feedback on #1176.
1 parent b9cc08e commit fae97ee

12 files changed

Lines changed: 889 additions & 0 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@emdash-cms/admin": minor
3+
"emdash": minor
4+
---
5+
6+
Code blocks in the rich text editor now have an inline language picker. Hover over any code block to reveal a chip in the corner; click it to enter a language (free-form input with curated suggestions for ~30 common languages including TypeScript, Python, Bash, Rust, Astro, SQL, and more). Aliases resolve automatically -- typing `ts` stores `typescript`, `c++` stores `cpp`, etc. The existing markdown shortcut (typing ` ```html ` followed by a space or Enter) continues to pre-populate the language. The chosen language persists on the Portable Text `language` field and is emitted as a `language-{id}` class on the rendered `<pre>` so frontend syntax highlighters can pick it up. The visual (in-place) editor gets the same picker UI.

packages/admin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@tanstack/react-router": "catalog:",
4949
"@tiptap/core": "catalog:",
5050
"@tiptap/extension-character-count": "catalog:",
51+
"@tiptap/extension-code-block": "catalog:",
5152
"@tiptap/extension-drag-handle": "catalog:",
5253
"@tiptap/extension-drag-handle-react": "catalog:",
5354
"@tiptap/extension-dropcursor": "catalog:",

packages/admin/src/components/PortableTextEditor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import type { Section } from "../lib/api";
9191
import { cn } from "../lib/utils";
9292
import { CaretNext } from "./ArrowIcons.js";
9393
import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField";
94+
import { CodeBlockExtension } from "./editor/CodeBlockNode";
9495
import { DragHandleWrapper } from "./editor/DragHandleWrapper";
9596
import { ImageExtension } from "./editor/ImageNode";
9697
import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension";
@@ -2074,6 +2075,8 @@ export function PortableTextEditor({
20742075
color: "#3b82f6",
20752076
width: 2,
20762077
},
2078+
// Replaced with CodeBlockExtension below (adds language picker node view).
2079+
codeBlock: false,
20772080
// StarterKit v3 includes Link and Underline
20782081
link: {
20792082
openOnClick: false,
@@ -2084,6 +2087,7 @@ export function PortableTextEditor({
20842087
},
20852088
underline: {},
20862089
}),
2090+
CodeBlockExtension,
20872091
ImageExtension,
20882092
MarkdownLinkExtension,
20892093
PluginBlockExtension,
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* Code block node with language picker.
3+
*
4+
* Wraps the base `@tiptap/extension-code-block` with a React node view that
5+
* overlays a small language chip in the top-right corner. Clicking the chip
6+
* opens a popover with a Kumo Autocomplete: a free-form text input plus a
7+
* filtered list of curated language suggestions. The value is persisted on
8+
* the node's `language` attribute and round-trips through Portable Text as
9+
* `block.language`.
10+
*
11+
* The picker accepts arbitrary strings (not restricted to the curated list)
12+
* so that less common languages can still be used. Free-form input is
13+
* sanitized to a single safe CSS class token via `normalizeLanguage` so the
14+
* frontend's `language-{id}` class stays well-formed.
15+
*/
16+
17+
import { Autocomplete, Button } from "@cloudflare/kumo";
18+
import { useLingui } from "@lingui/react/macro";
19+
import { Check, X } from "@phosphor-icons/react";
20+
import CodeBlock from "@tiptap/extension-code-block";
21+
import type { NodeViewProps } from "@tiptap/react";
22+
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
23+
import * as React from "react";
24+
25+
import { CODE_BLOCK_LANGUAGES, languageLabel, normalizeLanguage } from "./codeBlockLanguages";
26+
27+
const LANGUAGE_ITEMS = CODE_BLOCK_LANGUAGES.map((l) => l.label);
28+
29+
function filterLanguages(item: string, query: string) {
30+
if (!query) return true;
31+
const needle = query.toLowerCase();
32+
const lang = CODE_BLOCK_LANGUAGES.find((l) => l.label === item);
33+
if (!lang) return false;
34+
if (lang.label.toLowerCase().includes(needle)) return true;
35+
if (lang.id.toLowerCase().includes(needle)) return true;
36+
return lang.aliases?.some((alias) => alias.toLowerCase().includes(needle)) ?? false;
37+
}
38+
39+
function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) {
40+
const { t } = useLingui();
41+
const [isEditing, setIsEditing] = React.useState(false);
42+
const storedLanguage = typeof node.attrs.language === "string" ? node.attrs.language : "";
43+
const [draft, setDraft] = React.useState(() => languageLabel(storedLanguage));
44+
const popoverRef = React.useRef<HTMLDivElement>(null);
45+
46+
// Sync draft when the stored language changes from outside the node view
47+
// (e.g. another collaborator edits the attribute, or the editor reloads
48+
// content). Don't clobber an in-progress edit.
49+
React.useEffect(() => {
50+
if (!isEditing) {
51+
setDraft(languageLabel(storedLanguage));
52+
}
53+
}, [storedLanguage, isEditing]);
54+
55+
const openPicker = React.useCallback(() => {
56+
setDraft(storedLanguage ? languageLabel(storedLanguage) : "");
57+
setIsEditing(true);
58+
}, [storedLanguage]);
59+
60+
const closePicker = React.useCallback(() => {
61+
setIsEditing(false);
62+
setDraft(languageLabel(storedLanguage));
63+
}, [storedLanguage]);
64+
65+
const commit = React.useCallback(
66+
(value?: string) => {
67+
const raw = value ?? draft;
68+
const next = normalizeLanguage(raw);
69+
updateAttributes({ language: next ?? null });
70+
setIsEditing(false);
71+
},
72+
[draft, updateAttributes],
73+
);
74+
75+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
76+
if (e.key === "Enter") {
77+
e.preventDefault();
78+
commit();
79+
} else if (e.key === "Escape") {
80+
e.preventDefault();
81+
closePicker();
82+
}
83+
};
84+
85+
// Close on outside click while the popover is open.
86+
React.useEffect(() => {
87+
if (!isEditing) return undefined;
88+
const onMouseDown = (event: MouseEvent) => {
89+
const target = event.target instanceof Node ? event.target : null;
90+
if (popoverRef.current && target && !popoverRef.current.contains(target)) {
91+
closePicker();
92+
}
93+
};
94+
document.addEventListener("mousedown", onMouseDown);
95+
return () => document.removeEventListener("mousedown", onMouseDown);
96+
}, [isEditing, closePicker]);
97+
98+
const label = languageLabel(storedLanguage);
99+
// The chip is always rendered (so it can be discovered via hover) but its
100+
// opacity is controlled by CSS: invisible by default, visible on hover,
101+
// when this block is selected, when the picker is open, or when the
102+
// block already has a language set. When hidden, also remove it from the
103+
// tab order so it doesn't trap keyboard focus.
104+
const chipPersistent = isEditing || Boolean(storedLanguage) || selected;
105+
106+
return (
107+
<NodeViewWrapper className="group relative my-4" data-language={storedLanguage || undefined}>
108+
<pre className="emdash-code-block">
109+
<NodeViewContent<"code"> as="code" />
110+
</pre>
111+
112+
<div className="absolute end-2 top-2 select-none" contentEditable={false}>
113+
{isEditing ? (
114+
<div
115+
ref={popoverRef}
116+
className="flex items-center gap-1 rounded-md border bg-kumo-overlay p-1 shadow-lg"
117+
onKeyDown={handleKeyDown}
118+
>
119+
<Autocomplete
120+
items={LANGUAGE_ITEMS}
121+
value={draft}
122+
onValueChange={(next: string) => setDraft(next)}
123+
filter={filterLanguages}
124+
>
125+
<Autocomplete.InputGroup size="sm" placeholder={t`Language`} />
126+
<Autocomplete.Content sideOffset={4}>
127+
<Autocomplete.List>
128+
{(item: string) => (
129+
<Autocomplete.Item key={item} value={item}>
130+
{item}
131+
</Autocomplete.Item>
132+
)}
133+
</Autocomplete.List>
134+
<Autocomplete.Empty>{t`No matches`}</Autocomplete.Empty>
135+
</Autocomplete.Content>
136+
</Autocomplete>
137+
<Button
138+
type="button"
139+
variant="ghost"
140+
shape="square"
141+
className="h-7 w-7"
142+
onMouseDown={(e) => e.preventDefault()}
143+
onClick={() => commit()}
144+
title={t`Apply language`}
145+
aria-label={t`Apply language`}
146+
>
147+
<Check className="h-4 w-4" />
148+
</Button>
149+
<Button
150+
type="button"
151+
variant="ghost"
152+
shape="square"
153+
className="h-7 w-7"
154+
onMouseDown={(e) => e.preventDefault()}
155+
onClick={closePicker}
156+
title={t`Cancel`}
157+
aria-label={t`Cancel`}
158+
>
159+
<X className="h-4 w-4" />
160+
</Button>
161+
</div>
162+
) : (
163+
<button
164+
type="button"
165+
tabIndex={chipPersistent ? 0 : -1}
166+
onMouseDown={(e) => e.preventDefault()}
167+
onClick={openPicker}
168+
className="rounded-md border bg-kumo-overlay/90 px-2 py-1 text-xs text-kumo-subtle opacity-0 transition-opacity hover:text-kumo-strong focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-kumo-brand group-hover:opacity-100 data-[persistent=true]:opacity-100"
169+
data-persistent={chipPersistent ? "true" : "false"}
170+
title={t`Set language`}
171+
aria-label={t`Set language (current: ${label})`}
172+
aria-hidden={chipPersistent ? undefined : true}
173+
>
174+
{storedLanguage ? label : t`Set language`}
175+
</button>
176+
)}
177+
</div>
178+
</NodeViewWrapper>
179+
);
180+
}
181+
182+
/**
183+
* TipTap extension: code block with an inline language picker node view.
184+
*
185+
* Drop-in replacement for StarterKit's default `codeBlock`. Configure
186+
* `StarterKit.configure({ codeBlock: false })` and add this extension to
187+
* the editor's extensions array.
188+
*/
189+
export const CodeBlockExtension = CodeBlock.extend({
190+
addNodeView() {
191+
return ReactNodeViewRenderer(CodeBlockNodeView);
192+
},
193+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Curated list of code block languages.
3+
*
4+
* Used as suggestions in the editor's language picker. The picker accepts
5+
* free-form text, so this is a starting point, not a restriction. The `id`
6+
* is the canonical identifier persisted in the Portable Text `language`
7+
* field and emitted as a `language-{id}` CSS class on the frontend.
8+
*
9+
* Aliases let common variants ("typescript", "ts") resolve to the same id.
10+
* Frontend highlighters (shipped in a follow-up PR) will use this map to
11+
* normalize unknown inputs.
12+
*/
13+
14+
export interface CodeBlockLanguage {
15+
/** Canonical identifier persisted in storage and emitted as `language-{id}`. */
16+
id: string;
17+
/** Human-readable label shown in the picker. */
18+
label: string;
19+
/** Alternative identifiers (typed by the user) that resolve to this language. */
20+
aliases?: string[];
21+
}
22+
23+
export const CODE_BLOCK_LANGUAGES: readonly CodeBlockLanguage[] = [
24+
{ id: "plaintext", label: "Plain text", aliases: ["text", "plain", "txt"] },
25+
{ id: "astro", label: "Astro" },
26+
{ id: "bash", label: "Bash", aliases: ["sh", "shell", "zsh"] },
27+
{ id: "c", label: "C" },
28+
{ id: "cpp", label: "C++", aliases: ["c++"] },
29+
{ id: "csharp", label: "C#", aliases: ["cs", "c#"] },
30+
{ id: "css", label: "CSS" },
31+
{ id: "diff", label: "Diff", aliases: ["patch"] },
32+
{ id: "dockerfile", label: "Dockerfile", aliases: ["docker"] },
33+
{ id: "go", label: "Go", aliases: ["golang"] },
34+
{ id: "graphql", label: "GraphQL", aliases: ["gql"] },
35+
{ id: "html", label: "HTML" },
36+
{ id: "java", label: "Java" },
37+
{ id: "javascript", label: "JavaScript", aliases: ["js"] },
38+
{ id: "json", label: "JSON" },
39+
{ id: "jsx", label: "JSX" },
40+
{ id: "kotlin", label: "Kotlin", aliases: ["kt"] },
41+
{ id: "markdown", label: "Markdown", aliases: ["md"] },
42+
{ id: "mdx", label: "MDX" },
43+
{ id: "php", label: "PHP" },
44+
{ id: "python", label: "Python", aliases: ["py"] },
45+
{ id: "ruby", label: "Ruby", aliases: ["rb"] },
46+
{ id: "rust", label: "Rust", aliases: ["rs"] },
47+
{ id: "scss", label: "SCSS", aliases: ["sass"] },
48+
{ id: "sql", label: "SQL" },
49+
{ id: "svelte", label: "Svelte" },
50+
{ id: "swift", label: "Swift" },
51+
{ id: "toml", label: "TOML" },
52+
{ id: "tsx", label: "TSX" },
53+
{ id: "typescript", label: "TypeScript", aliases: ["ts"] },
54+
{ id: "vue", label: "Vue" },
55+
{ id: "xml", label: "XML" },
56+
{ id: "yaml", label: "YAML", aliases: ["yml"] },
57+
];
58+
59+
/**
60+
* Look up a language by id or alias. Case-insensitive.
61+
* Returns the canonical entry, or `null` if not found in the curated list.
62+
*/
63+
export function findLanguage(value: string | null | undefined): CodeBlockLanguage | null {
64+
if (!value) return null;
65+
const needle = value.trim().toLowerCase();
66+
if (!needle) return null;
67+
for (const lang of CODE_BLOCK_LANGUAGES) {
68+
if (lang.id === needle) return lang;
69+
if (lang.aliases?.includes(needle)) return lang;
70+
}
71+
return null;
72+
}
73+
74+
/**
75+
* Normalize a user-entered language string to a canonical id where possible.
76+
* Unknown inputs are sanitized to a single safe class token: lowercased,
77+
* trimmed, with any character outside `[a-z0-9_-]` (including whitespace,
78+
* dots, slashes, etc.) collapsed to `-`. This keeps the stored value safe
79+
* to interpolate into a `language-{id}` CSS class without splitting on
80+
* whitespace.
81+
*
82+
* Examples:
83+
* normalizeLanguage("TypeScript") -> "typescript" (canonical id)
84+
* normalizeLanguage("ts") -> "typescript" (alias)
85+
* normalizeLanguage("Objective C") -> "objective-c" (sanitized)
86+
* normalizeLanguage("F#") -> "f-" (sanitized)
87+
* normalizeLanguage("") -> undefined
88+
*/
89+
// Hoisted to module scope to avoid re-compilation on every call.
90+
const DISALLOWED_CHARS_RE = /[^a-z0-9_-]+/g;
91+
const LEADING_TRAILING_HYPHENS_RE = /^-+|-+$/g;
92+
93+
export function normalizeLanguage(value: string | null | undefined): string | undefined {
94+
if (!value) return undefined;
95+
const trimmed = value.trim();
96+
if (!trimmed) return undefined;
97+
const match = findLanguage(trimmed);
98+
if (match) return match.id;
99+
// Sanitize unknown input: lowercase, then collapse runs of disallowed
100+
// characters into a single `-` so the result is always a single CSS class
101+
// token. We deliberately don't return `undefined` on collisions here --
102+
// callers can compare the sanitized result with the input if they need to.
103+
const sanitized = trimmed
104+
.toLowerCase()
105+
.replace(DISALLOWED_CHARS_RE, "-")
106+
.replace(LEADING_TRAILING_HYPHENS_RE, "");
107+
return sanitized || undefined;
108+
}
109+
110+
/**
111+
* Human-readable label for a stored language id. Falls back to the id itself
112+
* for unknown values so the editor never shows "undefined".
113+
*/
114+
export function languageLabel(value: string | null | undefined): string {
115+
if (!value) return "Plain text";
116+
const match = findLanguage(value);
117+
if (match) return match.label;
118+
return value;
119+
}

0 commit comments

Comments
 (0)