(wip): add reactions
Some checks failed
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled

(qol): update todo
This commit is contained in:
Alois 2026-07-11 14:53:12 +02:00
commit 7ba08090fe
20 changed files with 4533 additions and 5894 deletions

View file

@ -5,7 +5,8 @@
"type": "module",
"exports": {
"./text": "./src/text.tsx",
"./input": "./src/input.tsx"
"./input": "./src/input.tsx",
"./emoji": "./src/emoji.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
@ -13,10 +14,15 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.41.1",
"@tensamin/ui": "*",
"@twemoji/api": "^17.0.3",
"emojibase-data": "^17.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}

View file

@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
vi.mock("@tensamin/ui", () => ({
Tooltip: ({ children }: { children: ReactNode }) => children,
TooltipContent: ({ children }: { children: ReactNode }) => children,
TooltipTrigger: ({ render }: { render: ReactNode }) => render,
}));
import { EditorState } from "@codemirror/state";
import { CompletionContext } from "@codemirror/autocomplete";
import { markdown } from "@codemirror/lang-markdown";
import { normalizeShortcode, resolveEmoji, searchEmojis } from "./emojiData";
import { parseEmojiText, parseInlineNodes } from "./markdown";
import {
createEmojiCompletionSource,
findEmojiRanges,
MAX_RENDERED_EMOJI_OPTIONS,
} from "./input";
describe("emoji shortcodes", () => {
it("normalizes aliases to their canonical shortcode", () => {
expect(normalizeShortcode(":flame:")).toBe(":fire:");
expect(normalizeShortcode("+1")).toBe(":thumbsup:");
});
it("resolves every search result to a Twemoji hexcode", () => {
const results = searchEmojis("fire");
expect(results[0]?.shortcode).toBe(":fire:");
expect(results.every((emoji) => emoji.hexcode.length > 0)).toBe(true);
});
it("shows all emojis for an empty query", () => {
expect(searchEmojis("").length).toBeGreaterThan(1000);
});
it("bounds the number of mounted autocomplete rows", () => {
expect(MAX_RENDERED_EMOJI_OPTIONS).toBeLessThanOrEqual(100);
});
it("parses known shortcodes and preserves unknown ones", () => {
expect(parseEmojiText("a :fire: b :not_an_emoji:")).toEqual([
{ type: "text", value: "a " },
{ type: "emoji", shortcode: ":fire:" },
{ type: "text", value: " b :not_an_emoji:" },
]);
});
it("recognizes a valid shortcode sharing an unknown closing colon", () => {
expect(parseEmojiText(":bla:thumbsup:")).toEqual([
{ type: "text", value: ":bla" },
{ type: "emoji", shortcode: ":thumbsup:" },
]);
const state = EditorState.create({
doc: ":bla:thumbsup:",
extensions: [markdown()],
});
expect(findEmojiRanges(state)[0]?.from).toBe(4);
});
it("does not parse underscores inside emoji shortcodes as emphasis", () => {
expect(parseInlineNodes("before :white_check_mark: after")).toEqual([
{ type: "text", value: "before " },
{ type: "emoji", shortcode: ":white_check_mark:" },
{ type: "text", value: " after" },
]);
});
it("contains the picker defaults", () => {
expect(resolveEmoji(":thumbsup:")).toBeDefined();
expect(resolveEmoji(":white_check_mark:")).toBeDefined();
});
it("finds completed emoji shortcodes in editor state", () => {
const state = EditorState.create({
doc: "before :fire: after :not_an_emoji:",
extensions: [markdown()],
});
expect(
findEmojiRanges(state).map(({ from, shortcode, to }) => ({
from,
shortcode,
to,
})),
).toEqual([{ from: 7, shortcode: ":fire:", to: 13 }]);
});
it("does not replace emoji shortcodes inside code", () => {
const state = EditorState.create({
doc: "`:fire:`\n\n```\n:fire:\n```\n\n:fire:",
extensions: [markdown()],
});
expect(findEmojiRanges(state)).toHaveLength(1);
expect(findEmojiRanges(state)[0]?.from).toBe(26);
});
it("ranks frequently used emojis first for a bare colon", async () => {
const state = EditorState.create({ doc: ":", extensions: [markdown()] });
const result = await createEmojiCompletionSource({
":fire:": 50,
":thumbsup:": 2,
})(new CompletionContext(state, 1, false));
expect(result?.options[0]?.displayLabel).toBe(":fire:");
expect(result?.options[1]?.displayLabel).toBe(":thumbsup:");
});
it("keeps typed relevance above usage frequency", async () => {
const state = EditorState.create({
doc: ":fire",
extensions: [markdown()],
});
const result = await createEmojiCompletionSource({
":fire_engine:": 10000,
":fire:": 1,
})(new CompletionContext(state, 5, false));
expect(result?.options[0]?.displayLabel).toBe(":fire:");
expect(result?.options[0]?.boost).toBeGreaterThan(
result?.options[1]?.boost ?? 0,
);
});
it("merges alias frequencies into canonical completions", async () => {
const state = EditorState.create({ doc: ":", extensions: [markdown()] });
const result = await createEmojiCompletionSource({
":fire:": 2,
":flame:": 3,
})(new CompletionContext(state, 1, false));
const fire = result?.options.find(
(option) => option.displayLabel === ":fire:",
);
expect(fire?.boost).toBe(15);
});
});

View file

@ -0,0 +1,50 @@
import twemoji from "@twemoji/api";
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
import { resolveEmoji } from "./emojiData";
export {
emojis,
findEmojiShortcodes,
normalizeShortcode,
resolveEmoji,
searchEmojis,
} from "./emojiData";
export type { EmojiDefinition } from "./emojiData";
export function getEmojiUrl(shortcode: string): string | undefined {
const emoji = resolveEmoji(shortcode);
return emoji ? `${twemoji.base}svg/${emoji.hexcode}.svg` : undefined;
}
export default function Emoji({
className = "h-6 w-6",
shortcode,
tooltip = true,
}: {
className?: string;
shortcode: string;
tooltip?: boolean;
}) {
const emoji = resolveEmoji(shortcode);
if (!emoji) return <span>{shortcode}</span>;
const image = (
<img
alt={emoji.shortcode}
className={className}
decoding="async"
draggable={false}
loading="lazy"
src={`${twemoji.base}svg/${emoji.hexcode}.svg`}
/>
);
if (!tooltip) return image;
return (
<Tooltip>
<TooltipTrigger render={image} />
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,96 @@
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
type ShortcodeValue = string | string[];
export type EmojiDefinition = {
aliases: readonly string[];
hexcode: string;
name: string;
shortcode: string;
};
function normalizeName(value: string) {
return value
.trim()
.replace(/^:+|:+$/g, "")
.toLowerCase();
}
export const emojis: readonly EmojiDefinition[] = Object.entries(
shortcodeData as Record<string, ShortcodeValue>,
).map(([hexcode, value]) => {
const aliases = Array.isArray(value) ? value : [value];
const name = aliases[0];
return {
aliases,
hexcode: hexcode.toLowerCase().replaceAll("_", "-"),
name,
shortcode: `:${name}:`,
};
});
const emojiByName = new Map<string, EmojiDefinition>();
for (const emoji of emojis) {
for (const alias of emoji.aliases) {
emojiByName.set(normalizeName(alias), emoji);
}
}
export function resolveEmoji(value: string): EmojiDefinition | undefined {
return emojiByName.get(normalizeName(value));
}
export function normalizeShortcode(value: string): string | undefined {
return resolveEmoji(value)?.shortcode;
}
export function findEmojiShortcodes(value: string) {
const matches: Array<{
emoji: EmojiDefinition;
from: number;
to: number;
}> = [];
let searchFrom = 0;
while (searchFrom < value.length) {
const from = value.indexOf(":", searchFrom);
if (from === -1) break;
const candidate = value.slice(from).match(/^:([a-z0-9_+-]+):/i);
if (!candidate) {
searchFrom = from + 1;
continue;
}
const emoji = resolveEmoji(candidate[1]);
if (!emoji) {
// The closing colon may also open the next valid shortcode.
searchFrom = from + candidate[0].length - 1;
continue;
}
const to = from + candidate[0].length;
matches.push({ emoji, from, to });
searchFrom = to;
}
return matches;
}
export function searchEmojis(query: string): EmojiDefinition[] {
const normalizedQuery = normalizeName(query);
if (!normalizedQuery) return [...emojis];
return emojis
.map((emoji) => {
const names = emoji.aliases.map(normalizeName);
const exact = names.includes(normalizedQuery);
const prefix = names.some((name) => name.startsWith(normalizedQuery));
const contains = names.some((name) => name.includes(normalizedQuery));
return { emoji, rank: exact ? 0 : prefix ? 1 : contains ? 2 : 3 };
})
.filter(({ rank }) => rank < 3)
.sort((a, b) => a.rank - b.rank || a.emoji.name.localeCompare(b.emoji.name))
.map(({ emoji }) => emoji);
}

View file

@ -1,7 +1,22 @@
import { markdown } from "@codemirror/lang-markdown";
import { syntaxTree } from "@codemirror/language";
import {
acceptCompletion,
autocompletion,
completionStatus,
pickedCompletion,
startCompletion,
type Completion,
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import {
EditorState,
EditorSelection,
Annotation,
Compartment,
Prec,
Transaction,
type Extension,
type Range,
type SelectionRange,
@ -12,6 +27,7 @@ import {
keymap,
placeholder,
ViewPlugin,
WidgetType,
type DecorationSet,
type KeyBinding,
type ViewUpdate,
@ -24,8 +40,17 @@ import {
} from "@codemirror/commands";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import { createRoot, type Root } from "react-dom/client";
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
import Emoji, {
findEmojiShortcodes,
getEmojiUrl,
resolveEmoji,
searchEmojis,
} from "./emoji";
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
export type InputProps = {
ref?: HTMLDivElement;
@ -39,6 +64,8 @@ export type InputProps = {
paddingX?: CSSProperties["padding"];
paddingY?: CSSProperties["padding"];
className?: string;
emojiFrequencies?: Readonly<Record<string, number>>;
onEmojiSelect?: (shortcode: string) => void;
};
type InputStyle = CSSProperties & {
@ -76,6 +103,180 @@ const delDecoration = Decoration.mark({ class: "tm-md-del" });
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
const externalValueSync = Annotation.define<boolean>();
const widgetRoots = new WeakMap<HTMLElement, Root>();
type EmojiRange = {
from: number;
shortcode: string;
to: number;
url: string;
};
class EmojiWidget extends WidgetType {
readonly shortcode: string;
readonly url: string;
constructor(shortcode: string, url: string) {
super();
this.shortcode = shortcode;
this.url = url;
}
eq(other: EmojiWidget) {
return other.shortcode === this.shortcode && other.url === this.url;
}
toDOM() {
const container = document.createElement("span");
const root = createRoot(container);
root.render(
<Emoji className="tm-md-editor-emoji" shortcode={this.shortcode} />,
);
widgetRoots.set(container, root);
return container;
}
destroy(dom: HTMLElement) {
widgetRoots.get(dom)?.unmount();
widgetRoots.delete(dom);
}
ignoreEvent() {
return true;
}
}
function codeRanges(state: EditorState) {
const ranges: Array<{ from: number; to: number }> = [];
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "InlineCode" ||
node.name === "FencedCode" ||
node.name === "CodeBlock"
) {
ranges.push({ from: node.from, to: node.to });
return false;
}
},
});
return ranges;
}
export function findEmojiRanges(state: EditorState): EmojiRange[] {
const document = state.doc.toString();
const excluded = codeRanges(state);
const ranges: EmojiRange[] = [];
for (const match of findEmojiShortcodes(document)) {
const { from, to } = match;
const inCode = excluded.some((range) => from < range.to && to > range.from);
const emoji = inCode ? undefined : match.emoji;
const url = emoji ? getEmojiUrl(emoji.shortcode) : undefined;
if (emoji && url) {
ranges.push({ from, shortcode: emoji.shortcode, to, url });
}
}
return ranges;
}
class EmojiPluginValue {
decorations: DecorationSet;
ranges: EmojiRange[];
constructor(view: EditorView) {
this.ranges = findEmojiRanges(view.state);
this.decorations = this.buildDecorations();
}
update(update: ViewUpdate) {
if (
update.docChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
this.ranges = findEmojiRanges(update.state);
this.decorations = this.buildDecorations();
}
}
private buildDecorations() {
return Decoration.set(
this.ranges.map((range) =>
Decoration.replace({
inclusive: false,
widget: new EmojiWidget(range.shortcode, range.url),
}).range(range.from, range.to),
),
true,
);
}
}
const emojiDecorations = ViewPlugin.fromClass(EmojiPluginValue, {
decorations: (instance) => instance.decorations,
provide: (plugin) =>
EditorView.atomicRanges.of(
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
),
});
function deleteEmoji(view: EditorView, direction: "backward" | "forward") {
const ranges = view.plugin(emojiDecorations)?.ranges ?? [];
const deletions: Array<{ from: number; to: number }> = [];
for (const selection of view.state.selection.ranges) {
if (selection.empty) {
const emoji = ranges.find((range) =>
direction === "backward"
? selection.from > range.from && selection.from <= range.to
: selection.from >= range.from && selection.from < range.to,
);
if (emoji) deletions.push({ from: emoji.from, to: emoji.to });
continue;
}
let from = selection.from;
let to = selection.to;
let changed = false;
for (const emoji of ranges) {
if (from < emoji.to && to > emoji.from) {
from = Math.min(from, emoji.from);
to = Math.max(to, emoji.to);
changed = true;
}
}
if (changed) deletions.push({ from, to });
}
if (deletions.length === 0) return false;
const merged = deletions
.sort((a, b) => a.from - b.from)
.reduce<Array<{ from: number; to: number }>>((result, deletion) => {
const previous = result.at(-1);
if (previous && deletion.from <= previous.to) {
previous.to = Math.max(previous.to, deletion.to);
} else {
result.push({ ...deletion });
}
return result;
}, []);
view.dispatch({
changes: merged.map((range) => ({ from: range.from, to: range.to })),
selection: EditorSelection.cursor(merged[0].from),
scrollIntoView: true,
userEvent: direction === "backward" ? "delete.backward" : "delete.forward",
});
return true;
}
/**
* Builds markdown styling decorations every time the document or cursor selection changes.
@ -115,14 +316,20 @@ export default function Input(props: InputProps) {
const elementRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | undefined>(undefined);
const ignoreSyncRef = useRef(false);
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
props.onEmojiSelect,
);
const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior));
const completionCompartmentRef = useRef<Compartment | null>(null);
completionCompartmentRef.current ??= new Compartment();
const completionCompartment = completionCompartmentRef.current;
useEffect(() => {
onSubmitRef.current = props.onSubmit;
onEmojiSelectRef.current = props.onEmojiSelect;
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
}, [props.onSubmit, props.invertEnterBehavior]);
}, [props.onEmojiSelect, props.onSubmit, props.invertEnterBehavior]);
useEffect(() => {
if (!elementRef.current) return;
@ -131,12 +338,14 @@ export default function Input(props: InputProps) {
doc: props.value,
extensions: createEditorExtensions(
(value) => {
ignoreSyncRef.current = true;
props.setValue(value);
},
() => props.placeholder,
() => invertEnterBehaviorRef.current,
() => onSubmitRef.current?.(),
completionCompartment,
props.emojiFrequencies,
(shortcode) => onEmojiSelectRef.current?.(shortcode),
),
});
@ -160,11 +369,6 @@ export default function Input(props: InputProps) {
const next = props.value;
const current = editor.state.doc.toString();
if (ignoreSyncRef.current) {
ignoreSyncRef.current = false;
return;
}
if (next === current) return;
editor.dispatch({
@ -173,9 +377,30 @@ export default function Input(props: InputProps) {
to: current.length,
insert: next,
},
annotations: [
externalValueSync.of(true),
Transaction.addToHistory.of(false),
],
filter: false,
});
}, [props.value]);
useEffect(() => {
const editor = viewRef.current;
const compartment = completionCompartmentRef.current;
if (!editor || !compartment) return;
const wasActive = completionStatus(editor.state) === "active";
editor.dispatch({
effects: compartment.reconfigure(
createEmojiAutocomplete(props.emojiFrequencies, (shortcode) =>
onEmojiSelectRef.current?.(shortcode),
),
),
});
if (wasActive) startCompletion(editor);
}, [props.emojiFrequencies]);
return (
<div
ref={elementRef}
@ -207,6 +432,9 @@ function createEditorExtensions(
getPlaceholder: () => string | undefined,
getInvertEnterBehavior: () => boolean,
onSubmit: () => void,
completionCompartment: Compartment,
emojiFrequencies: Readonly<Record<string, number>> | undefined,
onEmojiSelect: (shortcode: string) => void,
): Extension[] {
const editorKeymap = [
...defaultKeymap,
@ -217,7 +445,10 @@ function createEditorExtensions(
const customEnterKeymap = keymap.of([
{
key: "Shift-Enter",
run: () => {
run: (view) => {
if (completionStatus(view.state) === "active") {
return acceptCompletion(view);
}
if (!getInvertEnterBehavior()) {
return false;
}
@ -228,7 +459,10 @@ function createEditorExtensions(
},
{
key: "Enter",
run: () => {
run: (view) => {
if (completionStatus(view.state) === "active") {
return acceptCompletion(view);
}
if (getInvertEnterBehavior()) {
return false;
}
@ -238,16 +472,48 @@ function createEditorExtensions(
},
},
]);
const completionTabKeymap = keymap.of([
{
key: "Tab",
run: (view) =>
completionStatus(view.state) === "active"
? acceptCompletion(view)
: false,
},
]);
const emojiDeletionKeymap = keymap.of([
{
key: "Backspace",
run: (view) => deleteEmoji(view, "backward"),
},
{
key: "Delete",
run: (view) => deleteEmoji(view, "forward"),
},
]);
return [
history(),
markdown(),
completionCompartment.of(
createEmojiAutocomplete(emojiFrequencies, onEmojiSelect),
),
emojiDecorations,
keymap.of(editorKeymap),
Prec.highest(completionTabKeymap),
Prec.highest(emojiDeletionKeymap),
Prec.highest(customEnterKeymap),
EditorView.lineWrapping,
placeholder(getPlaceholder() ?? ""),
EditorView.updateListener.of((update: ViewUpdate) => {
if (!update.docChanged) return;
if (
update.transactions.some(
(transaction) => transaction.annotation(externalValueSync) === true,
)
) {
return;
}
onChange(update.state.doc.toString());
}),
EditorView.theme({
@ -267,6 +533,126 @@ function createEditorExtensions(
];
}
function createEmojiAutocomplete(
frequencies: Readonly<Record<string, number>> | undefined,
onEmojiSelect: (shortcode: string) => void,
) {
return autocompletion({
activateOnTyping: true,
addToOptions: [
{
position: 20,
render(completion) {
const container = document.createElement("span");
createRoot(container).render(
<Emoji
className="tm-md-completion-emoji"
shortcode={completion.label}
/>,
);
return container;
},
},
],
maxRenderedOptions: MAX_RENDERED_EMOJI_OPTIONS,
override: [createEmojiCompletionSource(frequencies, onEmojiSelect)],
});
}
function normalizedFrequencies(
frequencies: Readonly<Record<string, number>> | undefined,
) {
const normalized = new Map<string, number>();
for (const [value, frequency] of Object.entries(frequencies ?? {})) {
const shortcode = resolveEmoji(value)?.shortcode;
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) continue;
normalized.set(shortcode, (normalized.get(shortcode) ?? 0) + frequency);
}
return normalized;
}
export function createEmojiCompletionSource(
frequencies?: Readonly<Record<string, number>>,
onEmojiSelect: (shortcode: string) => void = () => undefined,
) {
const normalized = normalizedFrequencies(frequencies);
const maxFrequency = Math.max(0, ...normalized.values());
return (context: CompletionContext): CompletionResult | null => {
const token = context.matchBefore(/:[a-z0-9_+-]*$/i);
if (!token) return null;
if (
codeRanges(context.state).some(
(range) => token.from < range.to && token.to > range.from,
)
) {
return null;
}
const characterBefore = context.state.sliceDoc(
Math.max(0, token.from - 1),
token.from,
);
if (characterBefore && /[a-z0-9_]/i.test(characterBefore)) return null;
const query = token.text.slice(1).toLowerCase();
const options: Completion[] = searchEmojis(query)
.map((emoji) => {
const aliases = emoji.aliases.map((alias) => alias.toLowerCase());
const matchedAlias =
aliases.find((alias) => alias === query) ??
aliases.find((alias) => alias.startsWith(query)) ??
aliases.find((alias) => alias.includes(query)) ??
emoji.name;
const relevance = !query
? 0
: matchedAlias === query
? 80
: matchedAlias.startsWith(query)
? 40
: 0;
const frequency = normalized.get(emoji.shortcode) ?? 0;
const usage =
maxFrequency > 0
? (15 * Math.log1p(frequency)) / Math.log1p(maxFrequency)
: 0;
return {
apply(view, completion, from, to) {
view.dispatch({
annotations: pickedCompletion.of(completion),
changes: { from, insert: `${emoji.shortcode} `, to },
selection: EditorSelection.cursor(
from + emoji.shortcode.length + 1,
),
});
onEmojiSelect(emoji.shortcode);
},
boost: relevance + usage,
displayLabel: emoji.shortcode,
label: `:${matchedAlias}:`,
type: "text",
frequency,
relevance,
} satisfies Completion & { frequency: number; relevance: number };
})
.sort(
(a, b) =>
b.relevance - a.relevance ||
b.frequency - a.frequency ||
(a.displayLabel ?? a.label).localeCompare(b.displayLabel ?? b.label),
);
return {
from: token.from,
options,
validFor: /^:[a-z0-9_+-]*$/i,
};
};
}
export const emojiCompletionSource = createEmojiCompletionSource();
/**
* Executes buildDecorations.
* @param view Parameter view.

View file

@ -1,7 +1,10 @@
import * as React from "react";
import Emoji from "./emoji";
import { findEmojiShortcodes } from "./emojiData";
type InlineNode =
| { type: "text"; value: string }
| { type: "emoji"; shortcode: string }
| { type: "strong"; value: string }
| { type: "em"; value: string }
| { type: "del"; value: string }
@ -73,14 +76,14 @@ type MarkdownBlock =
| TableBlock;
const INLINE_TOKEN_REGEX =
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|_([^_\n]+)_/g;
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
/**
* Executes parseInlineNodes.
* @param input Parameter input.
* @returns InlineNode[].
*/
function parseInlineNodes(input: string): InlineNode[] {
export function parseInlineNodes(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
@ -91,7 +94,7 @@ function parseInlineNodes(input: string): InlineNode[] {
const raw = match[0];
if (index > cursor) {
nodes.push({ type: "text", value: input.slice(cursor, index) });
nodes.push(...parseEmojiText(input.slice(cursor, index)));
}
if (match[1] !== undefined && match[2] !== undefined) {
@ -111,7 +114,7 @@ function parseInlineNodes(input: string): InlineNode[] {
} else if (match[9] !== undefined || match[10] !== undefined) {
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
} else {
nodes.push({ type: "text", value: raw });
nodes.push(...parseEmojiText(raw));
}
cursor = index + raw.length;
@ -119,13 +122,33 @@ function parseInlineNodes(input: string): InlineNode[] {
}
if (cursor < input.length) {
nodes.push({ type: "text", value: input.slice(cursor) });
nodes.push(...parseEmojiText(input.slice(cursor)));
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return nodes;
}
export function parseEmojiText(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
for (const match of findEmojiShortcodes(input)) {
if (match.from > cursor) {
nodes.push({ type: "text", value: input.slice(cursor, match.from) });
}
nodes.push({ type: "emoji", shortcode: match.emoji.shortcode });
cursor = match.to;
}
if (cursor < input.length) {
nodes.push({ type: "text", value: input.slice(cursor) });
}
return nodes;
}
/**
* Executes collectInlineRanges.
* @param input Parameter input.
@ -375,10 +398,16 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
return node.value;
}
if (node.type === "emoji") {
return (
<Emoji key={index} className="tm-md-emoji" shortcode={node.shortcode} />
);
}
if (node.type === "strong") {
return (
<strong key={index} className="tm-md-strong">
{node.value}
{renderInline(parseEmojiText(node.value))}
</strong>
);
}
@ -386,7 +415,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
if (node.type === "em") {
return (
<em key={index} className="tm-md-em">
{node.value}
{renderInline(parseEmojiText(node.value))}
</em>
);
}
@ -394,7 +423,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
if (node.type === "del") {
return (
<del key={index} className="tm-md-del">
{node.value}
{renderInline(parseEmojiText(node.value))}
</del>
);
}
@ -416,7 +445,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
target="_blank"
rel="noreferrer"
>
{node.label}
{renderInline(parseEmojiText(node.label))}
</a>
);
}
@ -637,7 +666,7 @@ function readTable(
}
const markdownStyles = `
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.55; font-size: 0.95rem; }
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.65; font-size: 1rem; }
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
.tm-md-h1 { font-size: 1.65rem; }
.tm-md-h2 { font-size: 1.45rem; }
@ -655,6 +684,7 @@ const markdownStyles = `
.tm-md-del { text-decoration: line-through; }
.tm-md-link { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 0.14rem; }
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; }
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
.tm-md-li { margin: 0.2rem 0; }
.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; }
@ -670,8 +700,21 @@ const markdownStyles = `
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { background: transparent; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { border-radius: 9999px; background: var(--border); }
.cm-tooltip.cm-tooltip-autocomplete > ul > li { display: flex; min-height: 2.25rem; align-items: center; border-radius: calc(var(--radius) * 0.8); padding: 0.3rem 0.5rem; color: var(--popover-foreground); }
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent); color: var(--accent-foreground); }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { display: none; }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { overflow: hidden; text-overflow: ellipsis; }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { color: inherit; text-decoration: none; font-weight: 600; }
.cm-tooltip-autocomplete .tm-md-completion-emoji { display: inline-block; width: 1.35rem; height: 1.35rem; flex: 0 0 auto; margin-right: 0.5rem; vertical-align: middle; }
`;
/**

View file

@ -22,6 +22,7 @@ export default function Text(props: TextProps) {
() => parseMarkdownBlocks(props.value),
[props.value],
);
const renderedBlocks = React.useMemo(() => renderBlocks(blocks), [blocks]);
return <div className="tm-md-root">{renderBlocks(blocks)}</div>;
return <div className="tm-md-root">{renderedBlocks}</div>;
}