Skip to content

Commit 21b6494

Browse files
fix: make callout blocks visually distinct from code blocks (#204) (#213)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 00c2ace commit 21b6494

3 files changed

Lines changed: 58 additions & 43 deletions

File tree

src/components/editor/callout-node.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ export type SerializedCalloutNode = Spread<
2525
>;
2626

2727
const VARIANT_CLASSES: Record<CalloutVariant, string> = {
28-
info: "bg-muted",
29-
warning: "bg-muted",
30-
success: "bg-muted",
31-
error: "bg-muted",
28+
info: "border-l-accent bg-muted",
29+
warning: "border-l-code-type bg-muted",
30+
success: "border-l-code-string bg-muted",
31+
error: "border-l-destructive bg-muted",
3232
};
3333

34+
const BASE_CLASSES = "mt-3 flex gap-3 border-l-2 p-4 text-sm";
35+
3436
export class CalloutNode extends ElementNode {
3537
__emoji: string;
3638
__variant: CalloutVariant;
@@ -69,13 +71,26 @@ export class CalloutNode extends ElementNode {
6971

7072
createDOM(): HTMLElement {
7173
const div = document.createElement("div");
72-
div.className = `mt-3 flex gap-3 p-4 text-sm ${VARIANT_CLASSES[this.__variant]}`;
74+
div.className = `${BASE_CLASSES} ${VARIANT_CLASSES[this.__variant]}`;
75+
76+
const emojiSpan = document.createElement("span");
77+
emojiSpan.className = "callout-emoji select-none text-lg shrink-0";
78+
emojiSpan.contentEditable = "false";
79+
emojiSpan.textContent = this.__emoji;
80+
div.appendChild(emojiSpan);
81+
7382
return div;
7483
}
7584

7685
updateDOM(prevNode: CalloutNode, dom: HTMLElement): boolean {
7786
if (prevNode.__variant !== this.__variant) {
78-
dom.className = `mt-3 flex gap-3 p-4 text-sm ${VARIANT_CLASSES[this.__variant]}`;
87+
dom.className = `${BASE_CLASSES} ${VARIANT_CLASSES[this.__variant]}`;
88+
}
89+
if (prevNode.__emoji !== this.__emoji) {
90+
const emojiSpan = dom.querySelector(".callout-emoji");
91+
if (emojiSpan) {
92+
emojiSpan.textContent = this.__emoji;
93+
}
7994
}
8095
return false;
8196
}

src/components/editor/callout-plugin.tsx

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import { useEffect } from "react";
44
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
55
import {
6-
$getNodeByKey,
76
$getSelection,
87
$insertNodes,
98
$isRangeSelection,
@@ -29,7 +28,6 @@ export function CalloutPlugin(): null {
2928
const [editor] = useLexicalComposerContext();
3029

3130
useEffect(() => {
32-
// Register the CalloutNode if not already registered
3331
if (!editor.hasNodes([CalloutNode])) {
3432
throw new Error(
3533
"CalloutPlugin: CalloutNode not registered on editor. Add it to initialConfig.nodes."
@@ -54,38 +52,5 @@ export function CalloutPlugin(): null {
5452
);
5553
}, [editor]);
5654

57-
// Mutation listener to render emoji prefix in callout DOM
58-
useEffect(() => {
59-
return editor.registerMutationListener(CalloutNode, (mutations) => {
60-
for (const [nodeKey, mutation] of mutations) {
61-
if (mutation === "destroyed") continue;
62-
63-
const dom = editor.getElementByKey(nodeKey);
64-
if (!dom) continue;
65-
66-
editor.read(() => {
67-
const node = $getNodeByKey(nodeKey);
68-
if (!(node instanceof CalloutNode)) return;
69-
70-
const emoji = node.getEmoji();
71-
72-
// querySelector returns Element | null; safe to narrow because we create this span ourselves
73-
let emojiSpan = dom.querySelector(
74-
".callout-emoji"
75-
) as HTMLSpanElement | null;
76-
77-
if (!emojiSpan) {
78-
emojiSpan = document.createElement("span");
79-
emojiSpan.className = "callout-emoji select-none text-lg shrink-0";
80-
emojiSpan.contentEditable = "false";
81-
dom.insertBefore(emojiSpan, dom.firstChild);
82-
}
83-
84-
emojiSpan.textContent = emoji;
85-
});
86-
}
87-
});
88-
}, [editor]);
89-
9055
return null;
9156
}

src/components/editor/design-spec-compliance.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ function readSource(relativePath: string): string {
1414
return readFileSync(resolve(__dirname, relativePath), "utf-8");
1515
}
1616

17-
describe("callout-plugin design spec compliance", () => {
18-
const source = readSource("./callout-plugin.tsx");
17+
describe("callout-node design spec compliance", () => {
18+
const source = readSource("./callout-node.tsx");
1919

2020
it("callout emoji does not use text-base (not in typography scale)", () => {
2121
// The typography scale does not include text-base. Emoji should use
@@ -32,6 +32,41 @@ describe("callout-plugin design spec compliance", () => {
3232
);
3333
expect(emojiClassLine).not.toBeNull();
3434
});
35+
36+
it("callout emoji span is created in createDOM, not via mutation listener", () => {
37+
// The emoji must render immediately on insert, not asynchronously via
38+
// a mutation listener. Verify createDOM creates the emoji span.
39+
expect(source).toContain("createDOM");
40+
const createDOMIndex = source.indexOf("createDOM");
41+
const emojiIndex = source.indexOf("callout-emoji", createDOMIndex);
42+
expect(emojiIndex).toBeGreaterThan(createDOMIndex);
43+
});
44+
45+
it("callout uses a left border to distinguish from code blocks", () => {
46+
// Design spec: callouts need visual distinction from code blocks.
47+
// A colored left border provides this.
48+
expect(source).toContain("border-l-2");
49+
});
50+
51+
it("each callout variant has a distinct border color", () => {
52+
// All four variants must map to different border color classes
53+
const variantBlock = source.match(
54+
/VARIANT_CLASSES[\s\S]*?\{([\s\S]*?)\}/
55+
);
56+
expect(variantBlock).not.toBeNull();
57+
const block = variantBlock![1];
58+
expect(block).toContain("border-l-accent");
59+
expect(block).toContain("border-l-code-type");
60+
expect(block).toContain("border-l-code-string");
61+
expect(block).toContain("border-l-destructive");
62+
});
63+
64+
it("callout plugin does not use registerMutationListener for emoji", () => {
65+
// Emoji rendering moved to createDOM — the plugin should not
66+
// manipulate DOM via mutation listeners.
67+
const pluginSource = readSource("./callout-plugin.tsx");
68+
expect(pluginSource).not.toContain("registerMutationListener");
69+
});
3570
});
3671

3772
describe("editor anchor element", () => {

0 commit comments

Comments
 (0)