diff --git a/src/components/editor/callout-node.tsx b/src/components/editor/callout-node.tsx index f9285f57..0b22b280 100644 --- a/src/components/editor/callout-node.tsx +++ b/src/components/editor/callout-node.tsx @@ -25,12 +25,14 @@ export type SerializedCalloutNode = Spread< >; const VARIANT_CLASSES: Record = { - info: "bg-muted", - warning: "bg-muted", - success: "bg-muted", - error: "bg-muted", + info: "border-l-accent bg-muted", + warning: "border-l-code-type bg-muted", + success: "border-l-code-string bg-muted", + error: "border-l-destructive bg-muted", }; +const BASE_CLASSES = "mt-3 flex gap-3 border-l-2 p-4 text-sm"; + export class CalloutNode extends ElementNode { __emoji: string; __variant: CalloutVariant; @@ -69,13 +71,26 @@ export class CalloutNode extends ElementNode { createDOM(): HTMLElement { const div = document.createElement("div"); - div.className = `mt-3 flex gap-3 p-4 text-sm ${VARIANT_CLASSES[this.__variant]}`; + div.className = `${BASE_CLASSES} ${VARIANT_CLASSES[this.__variant]}`; + + const emojiSpan = document.createElement("span"); + emojiSpan.className = "callout-emoji select-none text-lg shrink-0"; + emojiSpan.contentEditable = "false"; + emojiSpan.textContent = this.__emoji; + div.appendChild(emojiSpan); + return div; } updateDOM(prevNode: CalloutNode, dom: HTMLElement): boolean { if (prevNode.__variant !== this.__variant) { - dom.className = `mt-3 flex gap-3 p-4 text-sm ${VARIANT_CLASSES[this.__variant]}`; + dom.className = `${BASE_CLASSES} ${VARIANT_CLASSES[this.__variant]}`; + } + if (prevNode.__emoji !== this.__emoji) { + const emojiSpan = dom.querySelector(".callout-emoji"); + if (emojiSpan) { + emojiSpan.textContent = this.__emoji; + } } return false; } diff --git a/src/components/editor/callout-plugin.tsx b/src/components/editor/callout-plugin.tsx index b5fcc2ff..8c6e0f1f 100644 --- a/src/components/editor/callout-plugin.tsx +++ b/src/components/editor/callout-plugin.tsx @@ -3,7 +3,6 @@ import { useEffect } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { - $getNodeByKey, $getSelection, $insertNodes, $isRangeSelection, @@ -29,7 +28,6 @@ export function CalloutPlugin(): null { const [editor] = useLexicalComposerContext(); useEffect(() => { - // Register the CalloutNode if not already registered if (!editor.hasNodes([CalloutNode])) { throw new Error( "CalloutPlugin: CalloutNode not registered on editor. Add it to initialConfig.nodes." @@ -54,38 +52,5 @@ export function CalloutPlugin(): null { ); }, [editor]); - // Mutation listener to render emoji prefix in callout DOM - useEffect(() => { - return editor.registerMutationListener(CalloutNode, (mutations) => { - for (const [nodeKey, mutation] of mutations) { - if (mutation === "destroyed") continue; - - const dom = editor.getElementByKey(nodeKey); - if (!dom) continue; - - editor.read(() => { - const node = $getNodeByKey(nodeKey); - if (!(node instanceof CalloutNode)) return; - - const emoji = node.getEmoji(); - - // querySelector returns Element | null; safe to narrow because we create this span ourselves - let emojiSpan = dom.querySelector( - ".callout-emoji" - ) as HTMLSpanElement | null; - - if (!emojiSpan) { - emojiSpan = document.createElement("span"); - emojiSpan.className = "callout-emoji select-none text-lg shrink-0"; - emojiSpan.contentEditable = "false"; - dom.insertBefore(emojiSpan, dom.firstChild); - } - - emojiSpan.textContent = emoji; - }); - } - }); - }, [editor]); - return null; } diff --git a/src/components/editor/design-spec-compliance.test.ts b/src/components/editor/design-spec-compliance.test.ts index 721dae30..6070ebb9 100644 --- a/src/components/editor/design-spec-compliance.test.ts +++ b/src/components/editor/design-spec-compliance.test.ts @@ -14,8 +14,8 @@ function readSource(relativePath: string): string { return readFileSync(resolve(__dirname, relativePath), "utf-8"); } -describe("callout-plugin design spec compliance", () => { - const source = readSource("./callout-plugin.tsx"); +describe("callout-node design spec compliance", () => { + const source = readSource("./callout-node.tsx"); it("callout emoji does not use text-base (not in typography scale)", () => { // The typography scale does not include text-base. Emoji should use @@ -32,6 +32,41 @@ describe("callout-plugin design spec compliance", () => { ); expect(emojiClassLine).not.toBeNull(); }); + + it("callout emoji span is created in createDOM, not via mutation listener", () => { + // The emoji must render immediately on insert, not asynchronously via + // a mutation listener. Verify createDOM creates the emoji span. + expect(source).toContain("createDOM"); + const createDOMIndex = source.indexOf("createDOM"); + const emojiIndex = source.indexOf("callout-emoji", createDOMIndex); + expect(emojiIndex).toBeGreaterThan(createDOMIndex); + }); + + it("callout uses a left border to distinguish from code blocks", () => { + // Design spec: callouts need visual distinction from code blocks. + // A colored left border provides this. + expect(source).toContain("border-l-2"); + }); + + it("each callout variant has a distinct border color", () => { + // All four variants must map to different border color classes + const variantBlock = source.match( + /VARIANT_CLASSES[\s\S]*?\{([\s\S]*?)\}/ + ); + expect(variantBlock).not.toBeNull(); + const block = variantBlock![1]; + expect(block).toContain("border-l-accent"); + expect(block).toContain("border-l-code-type"); + expect(block).toContain("border-l-code-string"); + expect(block).toContain("border-l-destructive"); + }); + + it("callout plugin does not use registerMutationListener for emoji", () => { + // Emoji rendering moved to createDOM — the plugin should not + // manipulate DOM via mutation listeners. + const pluginSource = readSource("./callout-plugin.tsx"); + expect(pluginSource).not.toContain("registerMutationListener"); + }); }); describe("editor anchor element", () => {