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); + }, }; }