diff --git a/app/menus/TableOfContentsMenu.tsx b/app/menus/TableOfContentsMenu.tsx
index 32daac5c5c..4adf176f47 100644
--- a/app/menus/TableOfContentsMenu.tsx
+++ b/app/menus/TableOfContentsMenu.tsx
@@ -3,6 +3,7 @@ import { TableOfContentsIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
+import { EmojiText } from "@shared/components/EmojiText";
import { createAction, createActionGroup } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import Button from "~/components/Button";
@@ -26,7 +27,7 @@ function TableOfContentsMenu() {
createAction({
name: (
- {t(heading.title)}
+ {heading.title}
),
section: ActiveDocumentSection,
@@ -38,7 +39,7 @@ function TableOfContentsMenu() {
),
})
),
- [t, headings, minHeading]
+ [headings, minHeading]
);
const actions = useMemo(() => {
diff --git a/app/scenes/Document/components/Contents.tsx b/app/scenes/Document/components/Contents.tsx
index 3818a1b107..7a19e7c5ba 100644
--- a/app/scenes/Document/components/Contents.tsx
+++ b/app/scenes/Document/components/Contents.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
+import { EmojiText } from "@shared/components/EmojiText";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, hideScrollbars, s } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
@@ -80,7 +81,9 @@ function Contents() {
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
- {heading.title}
+
+ {heading.title}
+
))}
diff --git a/shared/components/EmojiText.tsx b/shared/components/EmojiText.tsx
new file mode 100644
index 0000000000..dbc01ae1c8
--- /dev/null
+++ b/shared/components/EmojiText.tsx
@@ -0,0 +1,66 @@
+import { isUUID } from "validator";
+import { getEmojiFromName } from "../editor/lib/emoji";
+import { CustomEmoji } from "./CustomEmoji";
+
+type Props = {
+ /** The text to render, which may contain emoji shortcodes like :smile: or :uuid: */
+ children: string;
+ /** Size of rendered custom emojis */
+ emojiSize?: number | string;
+};
+
+// Matches emoji shortcodes like :smile: or :550e8400-e29b-41d4-a716-446655440000:
+const emojiShortcodeRegex = /:([a-zA-Z0-9_-]+):/g;
+
+/**
+ * Renders text with embedded emoji shortcodes. Standard emoji shortcodes like
+ * :smile: are converted to native emoji characters, while UUID shortcodes are
+ * rendered as custom emoji images.
+ *
+ * @param props.children - the text to render, which may contain emoji shortcodes.
+ * @param props.emojiSize - size of rendered custom emojis.
+ * @returns a React element with text and inline emojis.
+ */
+export function EmojiText({ children, emojiSize = "1em" }: Props) {
+ const parts: (string | JSX.Element)[] = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+ let key = 0;
+
+ while ((match = emojiShortcodeRegex.exec(children)) !== null) {
+ // Add text before the match
+ if (match.index > lastIndex) {
+ parts.push(children.slice(lastIndex, match.index));
+ }
+
+ const shortcode = match[1];
+
+ if (isUUID(shortcode)) {
+ // Custom emoji - render as image
+ parts.push(
+
+ );
+ } else {
+ // Standard emoji - convert to native character
+ const emoji = getEmojiFromName(shortcode);
+ if (emoji !== "?") {
+ parts.push(emoji);
+ } else {
+ // Unknown shortcode, keep original text
+ parts.push(match[0]);
+ }
+ }
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ // Add remaining text after last match
+ if (lastIndex < children.length) {
+ parts.push(children.slice(lastIndex));
+ }
+
+ // Reset regex state for next call
+ emojiShortcodeRegex.lastIndex = 0;
+
+ return <>{parts}>;
+}
diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx
index 46702db433..f42dc5b673 100644
--- a/shared/editor/nodes/Emoji.tsx
+++ b/shared/editor/nodes/Emoji.tsx
@@ -63,7 +63,15 @@ export default class Emoji extends Extension {
getEmojiFromName(name),
];
},
- leafText: (node) => getEmojiFromName(node.attrs["data-name"]),
+ leafText: (node) => {
+ const name = node.attrs["data-name"];
+ // Custom emojis are stored as UUIDs, preserve the shortcode format
+ // so they can be rendered by EmojiText component
+ if (isUUID(name)) {
+ return `:${name}:`;
+ }
+ return getEmojiFromName(name);
+ },
};
}