Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions src/components/editor/callout-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ export type SerializedCalloutNode = Spread<
>;

const VARIANT_CLASSES: Record<CalloutVariant, string> = {
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;
Expand Down Expand Up @@ -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;
}
Expand Down
35 changes: 0 additions & 35 deletions src/components/editor/callout-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
$getNodeByKey,
$getSelection,
$insertNodes,
$isRangeSelection,
Expand All @@ -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."
Expand All @@ -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;
}
39 changes: 37 additions & 2 deletions src/components/editor/design-spec-compliance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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", () => {
Expand Down
Loading