diff --git a/.changeset/add-composer-markdown-preview-prosemirror.md b/.changeset/add-composer-markdown-preview-prosemirror.md
new file mode 100644
index 0000000000..01c3ff9ac1
--- /dev/null
+++ b/.changeset/add-composer-markdown-preview-prosemirror.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+Add a live markdown preview to the composer: markdown syntax characters stay visible but dimmed, with their content styled inline while typing (bold, italic, underline, strikethrough, spoilers, links, per-level headings, and fenced code blocks syntax-highlighted like sent messages), plus an optional rendered preview box that can be enabled above the composer in Settings. Also lets Enter insert a newline in the composer and stops the timeline from snapping back to the bottom when the composer grows while scrolling up.
diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts
index 79bf483fdc..291cbc0373 100644
--- a/src/app/components/editor/Editor.css.ts
+++ b/src/app/components/editor/Editor.css.ts
@@ -120,3 +120,86 @@ export const EditorToolbarBase = style({
export const EditorToolbar = style({
padding: config.space.S100,
});
+
+export const EditorMarkdownToken = style({
+ opacity: 0.4,
+});
+
+// Matches the dimmed block markers and the sent renderer's
border.
+export const EditorMarkdownDivider = style({
+ opacity: 0.4,
+ borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
+});
+
+export const EditorMarkdownItalic = style({
+ fontStyle: 'italic',
+});
+
+export const EditorMarkdownUnderline = style({
+ textDecoration: 'underline',
+});
+
+export const EditorMarkdownStrikeThrough = style({
+ textDecoration: 'line-through',
+});
+
+export const EditorMarkdownLink = style({
+ color: color.Primary.OnContainer,
+});
+
+export const EditorMarkdownCode = style([
+ DefaultReset,
+ {
+ fontFamily: 'var(--font-monospace)',
+ fontSize: '0.9em',
+ color: color.SurfaceVariant.OnContainer,
+ background: color.SurfaceVariant.Container,
+ border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
+ borderRadius: config.radii.R300,
+ padding: `0 ${config.space.S100}`,
+ },
+]);
+
+export const EditorMarkdownCodeBlock = style({
+ fontFamily: 'var(--font-monospace)',
+ fontSize: '0.9em',
+ lineHeight: 1.3,
+ letterSpacing: '-0.01em',
+ fontVariantLigatures: 'contextual',
+ background: color.SurfaceVariant.Container,
+});
+
+// Heading levels reuse folds' own heading sizes/weights so the preview matches
+// the sent renderer exactly (the message parser maps `#`…`######` onto
+// Text sizes H2…H6, all at weight 600).
+const heading = (size: 'H2' | 'H3' | 'H4' | 'H5' | 'H6') =>
+ style({
+ fontSize: config.fontSize[size],
+ lineHeight: config.lineHeight[size],
+ letterSpacing: config.letterSpacing[size],
+ fontWeight: config.fontWeight.W600,
+ });
+
+export const EditorMarkdownHeading1 = heading('H2');
+export const EditorMarkdownHeading2 = heading('H3');
+export const EditorMarkdownHeading3 = heading('H4');
+export const EditorMarkdownHeading4 = heading('H4');
+export const EditorMarkdownHeading5 = heading('H5');
+export const EditorMarkdownHeading6 = heading('H6');
+
+// Declared after the heading classes so its 700 weight beats a heading's 600
+// when `# **bold**` stacks both classes onto one span (as the sent renderer's
+// `` inside the heading does).
+export const EditorMarkdownBold = style({
+ fontWeight: 700,
+});
+
+export const EditorMarkdownSpoiler = style({
+ backgroundColor: `color-mix(in srgb, ${color.SurfaceVariant.ContainerLine} 30%, transparent)`,
+ borderRadius: config.radii.R300,
+});
+
+export const EditorMarkdownPreviewContent = style({
+ maxHeight: toRem(220),
+ overscrollBehavior: 'contain',
+});
diff --git a/src/app/components/editor/ProseMirrorEditable.tsx b/src/app/components/editor/ProseMirrorEditable.tsx
index 93af51a0e1..d93d7b6666 100644
--- a/src/app/components/editor/ProseMirrorEditable.tsx
+++ b/src/app/components/editor/ProseMirrorEditable.tsx
@@ -3,6 +3,18 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'rea
import type { EditorDocument } from './model';
import type { ProseMirrorEditorController } from './prosemirrorController';
+const hostConsumedEvents = new WeakSet();
+
+/** Hosts mark keydowns they fully handled (e.g. sending on Enter) so the
+ * editable won't also act on them. The view preventDefaults every Enter
+ * (captureKeyDown in prosemirror-view), so defaultPrevented is not a
+ * reliable host-consumption signal. */
+export const markEventConsumedByHost = (event: Event): void => {
+ hostConsumedEvents.add(event);
+};
+
+const isEventConsumedByHost = (event: Event): boolean => hostConsumedEvents.has(event);
+
export type ProseMirrorEditableHandle = {
clear: () => void;
focus: () => void;
@@ -37,12 +49,12 @@ export const ProseMirrorEditable = forwardRef {
const rootRef = useRef(null);
- // ProseMirror does not bind Enter; a consumer that sends calls
- // preventDefault, so anything left over is a line break.
+ // The view preventDefaults every Enter, so defaultPrevented cannot tell
+ // host consumption apart from the view; the host marks the event instead.
const handleKeyDown: KeyboardEventHandler = (event) => {
onKeyDown?.(event);
- if (event.defaultPrevented || event.key !== 'Enter') return;
- if (event.nativeEvent.isComposing) return;
+ if (event.key !== 'Enter' || event.nativeEvent.isComposing) return;
+ if (isEventConsumedByHost(event.nativeEvent)) return;
event.preventDefault();
controller.insertNewline();
};
diff --git a/src/app/components/editor/markdown.test.ts b/src/app/components/editor/markdown.test.ts
new file mode 100644
index 0000000000..26b0a4ad6f
--- /dev/null
+++ b/src/app/components/editor/markdown.test.ts
@@ -0,0 +1,649 @@
+import { describe, expect, it, beforeAll, vi } from 'vitest';
+import { EditorState } from 'prosemirror-state';
+import { EditorView } from 'prosemirror-view';
+import {
+ tokenizeMarkdown,
+ markdownDecorations,
+ markdownPreviewPlugin,
+ setMarkdownPreviewDispatch,
+ type MarkdownToken,
+} from './markdown';
+import { highlightCode } from '$plugins/arborium';
+import { toProseMirrorDocument } from './prosemirrorSchema';
+import type { EditorDocument } from './model';
+import { BlockType } from './types';
+import * as editorCss from './Editor.css';
+
+vi.mock('$plugins/arborium', () => ({
+ highlightCode:
+ vi.fn<() => Promise<{ mode: 'highlighted' | 'plain'; html: string; language?: string }>>(),
+}));
+
+beforeAll(() => {
+ Element.prototype.getClientRects ??=
+ (() => []) as unknown as typeof Element.prototype.getClientRects;
+});
+
+const findToken = (tokens: MarkdownToken[], predicate: (t: MarkdownToken) => unknown) =>
+ tokens.find(predicate);
+
+const expectRange = (token: MarkdownToken | undefined, start: number, end: number) => {
+ expect(token?.start).toBe(start);
+ expect(token?.end).toBe(end);
+};
+
+describe('tokenizeMarkdown', () => {
+ it('dims bold delimiters and bolds the content', () => {
+ const tokens = tokenizeMarkdown('**hello**');
+ expect(tokens).toHaveLength(3);
+ expectRange(tokens[0], 0, 2);
+ expect(tokens[0]!.markdownToken).toBe(true);
+ expectRange(tokens[1], 2, 7);
+ expect(tokens[1]!.markdownBold).toBe(true);
+ expectRange(tokens[2], 7, 9);
+ expect(tokens[2]!.markdownToken).toBe(true);
+ });
+
+ it('styles italic content with single asterisks', () => {
+ const tokens = tokenizeMarkdown('an *italic* word');
+ const italic = findToken(tokens, (t) => t.markdownItalic);
+ expect(italic?.start).toBe(4);
+ expect(italic?.end).toBe(10);
+ const openAsterisk = findToken(tokens, (t) => t.markdownToken && t.start === 3);
+ expect(openAsterisk?.start).toBe(3);
+ expect(openAsterisk?.end).toBe(4);
+ const closeAsterisk = findToken(tokens, (t) => t.markdownToken && t.end === 11);
+ expect(closeAsterisk?.start).toBe(10);
+ expect(closeAsterisk?.end).toBe(11);
+ });
+
+ it('matches bold before italic for double asterisks', () => {
+ const tokens = tokenizeMarkdown('**bold**');
+ const hasItalic = tokens.some((t) => t.markdownItalic);
+ expect(hasItalic).toBe(false);
+ });
+
+ it('handles strikethrough, underline, and spoiler delimiters', () => {
+ const strike = findToken(tokenizeMarkdown('~~gone~~'), (t) => t.markdownStrikeThrough);
+ expect(strike?.start).toBe(2);
+ expect(strike?.end).toBe(6);
+ const under = findToken(tokenizeMarkdown('__under__'), (t) => t.markdownUnderline);
+ expect(under?.start).toBe(2);
+ expect(under?.end).toBe(7);
+ const spoiler = findToken(tokenizeMarkdown('||secret||'), (t) => t.markdownSpoiler);
+ expect(spoiler?.start).toBe(2);
+ expect(spoiler?.end).toBe(8);
+ });
+
+ it('styles inline code and dims the backticks', () => {
+ const tokens = tokenizeMarkdown('use `code` here');
+ const code = findToken(tokens, (t) => t.markdownCode);
+ expect(code?.start).toBe(5);
+ expect(code?.end).toBe(9);
+ const openTick = findToken(tokens, (t) => t.markdownToken && t.start === 4);
+ expect(openTick?.start).toBe(4);
+ expect(openTick?.end).toBe(5);
+ });
+
+ it('does not treat unclosed delimiters as spans', () => {
+ const tokens = tokenizeMarkdown('*unclosed');
+ expect(tokens).toHaveLength(0);
+ });
+
+ it('does not treat empty delimiters as spans', () => {
+ const tokens = tokenizeMarkdown('****');
+ expect(tokens).toHaveLength(0);
+ });
+
+ it('previews multiple spans on one line', () => {
+ const tokens = tokenizeMarkdown('**a** and *b*');
+ const bold = findToken(tokens, (t) => t.markdownBold);
+ expect(bold?.start).toBe(2);
+ expect(bold?.end).toBe(3);
+ const italic = findToken(tokens, (t) => t.markdownItalic);
+ expect(italic?.start).toBe(11);
+ expect(italic?.end).toBe(12);
+ });
+
+ it('styles link labels and dims link punctuation', () => {
+ const tokens = tokenizeMarkdown('[label](https://example.com)');
+ const link = findToken(tokens, (t) => t.markdownLink);
+ expect(link?.start).toBe(1);
+ expect(link?.end).toBe(6);
+ const openBracket = findToken(tokens, (t) => t.markdownToken && t.start === 0);
+ expect(openBracket?.start).toBe(0);
+ expect(openBracket?.end).toBe(1);
+ const closeBracket = findToken(tokens, (t) => t.markdownToken && t.start === 6);
+ expect(closeBracket?.start).toBe(6);
+ expect(closeBracket?.end).toBe(28);
+ });
+
+ it('dims heading and list markers at line starts', () => {
+ expectRange(tokenizeMarkdown('# title')[0], 0, 2);
+ expect(tokenizeMarkdown('# title')[0]!.markdownToken).toBe(true);
+ expectRange(tokenizeMarkdown('- item')[0], 0, 2);
+ expectRange(tokenizeMarkdown('1. item')[0], 0, 3);
+ expectRange(tokenizeMarkdown('> quote')[0], 0, 2);
+ });
+
+ it('dims markers on subsequent lines too', () => {
+ const tokens = tokenizeMarkdown('plain\n> quote');
+ const quote = findToken(tokens, (t) => t.markdownToken && t.start === 6);
+ expect(quote?.start).toBe(6);
+ expect(quote?.end).toBe(8);
+ });
+
+ it('stacks nested formatting so inner spans keep the outer marks', () => {
+ const tokens = tokenizeMarkdown('**||test||**');
+ const stacked = findToken(tokens, (t) => t.markdownBold && t.markdownSpoiler);
+ expect(stacked?.start).toBe(4);
+ expect(stacked?.end).toBe(8);
+ expect(tokenizeMarkdown('**||test||**').filter((t) => t.markdownToken)).toHaveLength(4);
+ });
+
+ it('stacks underline, bold, and italic from nested delimiters', () => {
+ const tokens = tokenizeMarkdown('__**_test_**__');
+ const stacked = findToken(
+ tokens,
+ (t) => t.markdownUnderline && t.markdownBold && t.markdownItalic
+ );
+ expect(stacked?.start).toBe(5);
+ expect(stacked?.end).toBe(9);
+ });
+
+ it('supports single-underscore italics like the sent renderer', () => {
+ const tokens = tokenizeMarkdown('_italic_');
+ const italic = findToken(tokens, (t) => t.markdownItalic);
+ expect(italic?.start).toBe(1);
+ expect(italic?.end).toBe(7);
+ });
+
+ it('keeps intraword underscores literal like the sent renderer', () => {
+ expect(tokenizeMarkdown('1_test_1').some((t) => t.markdownItalic)).toBe(false);
+ expect(tokenizeMarkdown('foo_bar_baz').some((t) => t.markdownItalic)).toBe(false);
+ expect(tokenizeMarkdown('x_foo_y_').some((t) => t.markdownItalic)).toBe(false);
+ expect(tokenizeMarkdown('a_b_').some((t) => t.markdownItalic)).toBe(false);
+ expect(tokenizeMarkdown('_ foo_').some((t) => t.markdownItalic)).toBe(false);
+ expect(tokenizeMarkdown('foo_.bar_').some((t) => t.markdownItalic)).toBe(false);
+ });
+
+ it('skips an intraword underscore when looking for the closer', () => {
+ const tokens = tokenizeMarkdown('_foo_bar_');
+ const italics = tokens.filter((t) => t.markdownItalic);
+ expect(italics.map((t) => t.start)).toEqual([1, 4]);
+ expect(italics.map((t) => t.end)).toEqual([4, 8]);
+ });
+
+ it('applies the flanking rule to strong underscores too', () => {
+ const tokens = tokenizeMarkdown('__bar__');
+ expect(tokens.some((t) => t.markdownUnderline)).toBe(true);
+ expect(tokenizeMarkdown('foo__bar__baz').some((t) => t.markdownUnderline)).toBe(false);
+ });
+
+ it('applies intraword emphasis to asterisks but not underscores', () => {
+ const tokens = tokenizeMarkdown('foo*bar*baz');
+ const italic = findToken(tokens, (t) => t.markdownItalic);
+ expect(italic?.start).toBe(4);
+ expect(italic?.end).toBe(7);
+ expect(tokenizeMarkdown('foo_bar_baz').some((t) => t.markdownItalic)).toBe(false);
+ });
+
+ it('opens an underscore after punctuation like the sent renderer', () => {
+ const tokens = tokenizeMarkdown('foo_._bar_');
+ const italic = findToken(tokens, (t) => t.markdownItalic);
+ expect(italic?.start).toBe(6);
+ expect(italic?.end).toBe(9);
+ });
+
+ it('treats code spans as literal so markers inside do not style', () => {
+ const tokens = tokenizeMarkdown('`**x**`');
+ const code = findToken(tokens, (t) => t.markdownCode);
+ expect(code?.start).toBe(1);
+ expect(code?.end).toBe(6);
+ expect(tokens.some((t) => t.markdownBold)).toBe(false);
+ });
+
+ it('stacks link styling with the enclosing formatting', () => {
+ const tokens = tokenizeMarkdown('**[x](https://example.com)**');
+ const stacked = findToken(tokens, (t) => t.markdownLink && t.markdownBold);
+ expect(stacked?.start).toBe(3);
+ expect(stacked?.end).toBe(4);
+ });
+
+ it('returns no tokens for plain text', () => {
+ expect(tokenizeMarkdown('just plain text')).toHaveLength(0);
+ });
+
+ it('dims code fences and styles the block content', () => {
+ const tokens = tokenizeMarkdown('```\ncode\n```');
+ expect(
+ tokens.map((t) => [t.start, t.end, t.markdownToken ?? false, t.markdownCodeBlock ?? false])
+ ).toEqual([
+ [0, 3, true, false],
+ [4, 8, false, true],
+ [9, 12, true, false],
+ ]);
+ });
+
+ it('dims the language tag of an opening fence', () => {
+ const tokens = tokenizeMarkdown('```ts\ncode\n```');
+ const opening = tokens.find((t) => t.start === 0);
+ expect(opening?.markdownToken).toBe(true);
+ expect(opening?.end).toBe(5);
+ });
+
+ it('treats fenced content as literal', () => {
+ const tokens = tokenizeMarkdown('```\n**bold**\nhttps://x.com\n# heading\n```');
+ expect(tokens.some((t) => t.markdownBold)).toBe(false);
+ expect(tokens.some((t) => t.markdownLink)).toBe(false);
+ expect(tokens.every((t) => t.markdownToken || t.markdownCodeBlock)).toBe(true);
+ });
+
+ it('keeps inline formatting on both sides of a code block', () => {
+ const tokens = tokenizeMarkdown('**a**\n```\ncode\n```\n**b**');
+ const bolds = tokens.filter((t) => t.markdownBold);
+ expect(bolds.map((t) => t.start)).toEqual([2, 21]);
+ });
+
+ it('returns no tokens for empty text', () => {
+ expect(tokenizeMarkdown('')).toHaveLength(0);
+ });
+
+ it('styles heading content after the dimmed marker', () => {
+ const tokens = tokenizeMarkdown('# hi');
+ expectRange(tokens[0], 0, 2);
+ expect(tokens[0]!.markdownToken).toBe(true);
+ const heading = findToken(tokens, (t) => t.markdownHeading);
+ expectRange(heading, 2, 4);
+ expect(heading?.markdownHeading).toBe(1);
+ });
+
+ it('reports the hash count as the heading level', () => {
+ expect(tokenizeMarkdown('### hi').find((t) => t.markdownHeading)?.markdownHeading).toBe(3);
+ expect(tokenizeMarkdown('###### hi').find((t) => t.markdownHeading)?.markdownHeading).toBe(6);
+ expect(tokenizeMarkdown('> hi').some((t) => t.markdownHeading)).toBe(false);
+ });
+
+ it('does not style heading content inside a code block', () => {
+ const tokens = tokenizeMarkdown('```\n# not a heading\n```');
+ expect(tokens.some((t) => t.markdownHeading)).toBe(false);
+ });
+
+ it('styles bare URLs like linkified messages', () => {
+ const tokens = tokenizeMarkdown('see https://x.com now');
+ const link = findToken(tokens, (t) => t.markdownLink);
+ expectRange(link, 4, 17);
+ expect(link?.url).toBe('https://x.com');
+ });
+
+ it('keeps underscores inside bare URLs', () => {
+ const tokens = tokenizeMarkdown('https://example.com/foo_bar_baz');
+ const link = findToken(tokens, (t) => t.markdownLink);
+ expectRange(link, 0, 31);
+ expect(link?.url).toBe('https://example.com/foo_bar_baz');
+ });
+
+ it('trims trailing punctuation from a bare URL with an underscore', () => {
+ const link = findToken(tokenizeMarkdown('https://x.com/a_b,'), (t) => t.markdownLink);
+ expectRange(link, 0, 17);
+ expect(link?.url).toBe('https://x.com/a_b');
+ });
+
+ it('trims trailing punctuation from bare URLs', () => {
+ const link = findToken(tokenizeMarkdown('https://x.com.'), (t) => t.markdownLink);
+ expectRange(link, 0, 13);
+ expect(link?.url).toBe('https://x.com');
+ });
+
+ it('does not treat mid-word schemes as URLs', () => {
+ expect(tokenizeMarkdown('abchttps://x.com')).toHaveLength(0);
+ });
+
+ it('does not restyle a bare URL used as a link label', () => {
+ const tokens = tokenizeMarkdown('[https://x.com](https://y.com)');
+ const link = findToken(tokens, (t) => t.markdownLink);
+ expect(link?.url).toBe('https://y.com');
+ });
+
+ it('keeps escaped delimiters literal', () => {
+ const tokens = tokenizeMarkdown('test\\*beep\\*');
+ expect(tokens.some((t) => t.markdownItalic)).toBe(false);
+ expect(tokens.some((t) => t.markdownToken)).toBe(false);
+ });
+
+ it('keeps a leading escaped opener literal', () => {
+ expect(tokenizeMarkdown('\\*beep\\*')).toHaveLength(0);
+ });
+
+ it('styles a real opener after an escaped backslash pair', () => {
+ const tokens = tokenizeMarkdown('\\\\*beep*');
+ const italic = findToken(tokens, (t) => t.markdownItalic);
+ expect(italic?.start).toBe(3);
+ expect(italic?.end).toBe(7);
+ });
+
+ it('keeps escaped delimiters literal inside styled spans', () => {
+ const tokens = tokenizeMarkdown('**a\\*b**');
+ expect(tokens.some((t) => t.markdownItalic)).toBe(false);
+ expect(tokens.filter((t) => t.markdownBold)).toHaveLength(1);
+ expect(tokens.find((t) => t.markdownBold)?.start).toBe(2);
+ expect(tokens.find((t) => t.markdownBold)?.end).toBe(6);
+ });
+
+ it('does not treat an escaped character as a closer', () => {
+ const tokens = tokenizeMarkdown('*a\\*b*');
+ const italic = findToken(tokens, (t) => t.markdownItalic);
+ expect(italic?.start).toBe(1);
+ expect(italic?.end).toBe(5);
+ });
+
+ it('does not start a heading from an escaped hash', () => {
+ expect(tokenizeMarkdown('\\# heading')).toHaveLength(0);
+ });
+
+ it('treats escapes as literal inside code spans', () => {
+ const tokens = tokenizeMarkdown('`a\\*b`');
+ const code = findToken(tokens, (t) => t.markdownCode);
+ expect(code?.start).toBe(1);
+ expect(code?.end).toBe(5);
+ });
+});
+
+const decorationFinder = (texts: string[]) => {
+ const editorDocument: EditorDocument = texts.map((text) => ({
+ type: BlockType.Paragraph,
+ children: [{ text }],
+ }));
+ const doc = toProseMirrorDocument(editorDocument);
+ const state = EditorState.create({ doc });
+ return markdownDecorations(state).find(1, doc.content.size);
+};
+
+const renderEditor = (texts: string[]) => {
+ const editorDocument: EditorDocument = texts.map((text) => ({
+ type: BlockType.Paragraph,
+ children: [{ text }],
+ }));
+ const doc = toProseMirrorDocument(editorDocument);
+ const state = EditorState.create({ doc, plugins: [markdownPreviewPlugin] });
+ const container = document.createElement('div');
+ const view = new EditorView(container, { state });
+ return { container, view };
+};
+
+const decorationSpan = (container: HTMLElement, cls: string) =>
+ Array.from(container.querySelectorAll('span')).find((span) => span.className.includes(cls));
+
+describe('markdownPreviewPlugin decorations', () => {
+ it('maps bold tokens onto doc positions', () => {
+ const found = decorationFinder(['**hi**']);
+ expect(found.map((d) => [d.from, d.to])).toEqual([
+ [1, 3],
+ [3, 5],
+ [5, 7],
+ ]);
+ });
+
+ it('maps tokens in later paragraphs onto their own doc positions', () => {
+ const found = decorationFinder(['plain', '`code`']);
+ expect(found.map((d) => [d.from, d.to])).toEqual([
+ [8, 9],
+ [9, 13],
+ [13, 14],
+ ]);
+ });
+
+ it('maps fenced code blocks onto their own doc positions', () => {
+ const found = decorationFinder(['```', 'code', '```']);
+ expect(found.map((d) => [d.from, d.to])).toEqual([
+ [1, 4],
+ [6, 10],
+ [12, 15],
+ ]);
+ });
+
+ it('renders fenced code content with the code block style', () => {
+ const { container, view } = renderEditor(['```', 'code', '```']);
+ expect(decorationSpan(container, editorCss.EditorMarkdownCodeBlock)?.textContent).toBe('code');
+ view.destroy();
+ });
+
+ it('dims bold delimiters and bolds the content', () => {
+ const { container, view } = renderEditor(['**hi**']);
+ expect(decorationSpan(container, editorCss.EditorMarkdownBold)?.textContent).toBe('hi');
+ expect(decorationSpan(container, editorCss.EditorMarkdownToken)?.textContent).toBe('**');
+ view.destroy();
+ });
+
+ it('styles inline code and spoilers', () => {
+ const { container, view } = renderEditor(['a `code` and ||spoiler||']);
+ expect(decorationSpan(container, editorCss.EditorMarkdownCode)?.textContent).toBe('code');
+ expect(decorationSpan(container, editorCss.EditorMarkdownSpoiler)?.textContent).toBe('spoiler');
+ view.destroy();
+ });
+
+ it('renders stacked formatting on one span', () => {
+ const { container, view } = renderEditor(['**||secret||**']);
+ const span = decorationSpan(container, editorCss.EditorMarkdownBold);
+ expect(span?.textContent).toBe('secret');
+ expect(span?.className.includes(editorCss.EditorMarkdownSpoiler)).toBe(true);
+ view.destroy();
+ });
+
+ it('marks link spans so underline-links can target them', () => {
+ const { container, view } = renderEditor(['[hi](https://example.com)']);
+ const span = decorationSpan(container, editorCss.EditorMarkdownLink);
+ expect(span?.textContent).toBe('hi');
+ expect(span?.hasAttribute('data-markdown-preview-link')).toBe(true);
+ expect(span?.getAttribute('data-markdown-preview-href')).toBe('https://example.com');
+ view.destroy();
+ });
+
+ it('styles bare URLs and keeps the URL as the href', () => {
+ const { container, view } = renderEditor(['see https://x.com now']);
+ const span = decorationSpan(container, editorCss.EditorMarkdownLink);
+ expect(span?.textContent).toBe('https://x.com');
+ expect(span?.getAttribute('data-markdown-preview-href')).toBe('https://x.com');
+ view.destroy();
+ });
+
+ it('renders heading content with the per-level heading style', () => {
+ const { container, view } = renderEditor(['# hi']);
+ const span = decorationSpan(container, editorCss.EditorMarkdownHeading1);
+ expect(span?.textContent).toBe('hi');
+ view.destroy();
+ });
+
+ it('uses a smaller style for deeper heading levels', () => {
+ const { container, view } = renderEditor(['### hi']);
+ expect(decorationSpan(container, editorCss.EditorMarkdownHeading3)?.textContent).toBe('hi');
+ expect(decorationSpan(container, editorCss.EditorMarkdownHeading1)).toBeUndefined();
+ view.destroy();
+ });
+
+ it('keeps bold inside headings really bold, not just heading weight', () => {
+ const { container, view } = renderEditor(['# **bold**']);
+ const headingSpans = Array.from(container.querySelectorAll('span')).filter((s) =>
+ s.className.includes(editorCss.EditorMarkdownHeading1)
+ );
+ expect(headingSpans.map((s) => s.textContent).join('')).toBe('**bold**');
+ const bold = decorationSpan(container, editorCss.EditorMarkdownBold);
+ expect(bold?.textContent).toBe('bold');
+ // The bold content stacks both marks on one span; the bold class is declared
+ // after the heading classes so its 700 weight wins the cascade (mirroring
+ // the sent renderer's `` inside the heading).
+ expect(bold?.className.includes(editorCss.EditorMarkdownHeading1)).toBe(true);
+ view.destroy();
+ });
+
+ it('opens preview links on ctrl+click only', () => {
+ const { container, view } = renderEditor(['[hi](https://example.com)']);
+ const span = decorationSpan(container, editorCss.EditorMarkdownLink);
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ span?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(openSpy).not.toHaveBeenCalled();
+ span?.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }));
+ expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer');
+ openSpy.mockRestore();
+ view.destroy();
+ });
+
+ it('produces no decorations for plain text', () => {
+ const { container, view } = renderEditor(['just plain text']);
+ expect(decorationSpan(container, editorCss.EditorMarkdownBold)).toBeUndefined();
+ expect(decorationSpan(container, editorCss.EditorMarkdownToken)).toBeUndefined();
+ view.destroy();
+ });
+
+ it('keeps heading styling on text nodes after an atom', () => {
+ const editorDocument: EditorDocument = [
+ {
+ type: BlockType.Paragraph,
+ children: [
+ { text: '# hi ' },
+ {
+ type: BlockType.Mention,
+ id: '@alice:server',
+ highlight: false,
+ name: 'Alice',
+ children: [],
+ },
+ { text: 'there' },
+ ],
+ },
+ ];
+ const doc = toProseMirrorDocument(editorDocument);
+ const state = EditorState.create({ doc, plugins: [markdownPreviewPlugin] });
+ const container = document.createElement('div');
+ const view = new EditorView(container, { state });
+ const headings = Array.from(container.querySelectorAll('span')).filter((s) =>
+ s.className.includes(editorCss.EditorMarkdownHeading1)
+ );
+ expect(headings.map((s) => s.textContent).join('')).toBe('hi there');
+ view.destroy();
+ });
+
+ it('renders fenced code token colours once highlighting resolves', async () => {
+ vi.mocked(highlightCode).mockResolvedValue({
+ mode: 'highlighted',
+ html: 'const x',
+ language: 'ts',
+ });
+ const editorDocument: EditorDocument = [
+ { type: BlockType.Paragraph, children: [{ text: '```ts' }] },
+ { type: BlockType.Paragraph, children: [{ text: 'const x' }] },
+ { type: BlockType.Paragraph, children: [{ text: '```' }] },
+ ];
+ const doc = toProseMirrorDocument(editorDocument);
+ const state = EditorState.create({ doc, plugins: [markdownPreviewPlugin] });
+ const container = document.createElement('div');
+ let view: EditorView;
+ // The first decoration pass runs during construction, so register the
+ // dispatch before the view exists for highlighting to schedule.
+ setMarkdownPreviewDispatch(() => {
+ view.dispatch(view.state.tr);
+ });
+ view = new EditorView(container, { state });
+ await vi.waitFor(() => {
+ expect(container.querySelector('a-k')).toBeTruthy();
+ });
+ const token = container.querySelector('a-k') as HTMLElement;
+ expect(token.textContent).toBe('const');
+ // Token elements keep the code-block tint via the overlapping base
+ // decoration, while the CDN arborium CSS colours the element selector.
+ expect(token.className.includes(editorCss.EditorMarkdownCodeBlock)).toBe(true);
+ expect(vi.mocked(highlightCode)).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'const x', language: 'ts' })
+ );
+ view.destroy();
+ setMarkdownPreviewDispatch(null);
+ });
+
+ it('asks arborium to detect the language of unlabelled fences', async () => {
+ vi.mocked(highlightCode).mockResolvedValue({ mode: 'plain', html: 'let x' });
+ const editorDocument: EditorDocument = [
+ { type: BlockType.Paragraph, children: [{ text: '```' }] },
+ { type: BlockType.Paragraph, children: [{ text: 'let x' }] },
+ { type: BlockType.Paragraph, children: [{ text: '```' }] },
+ ];
+ const doc = toProseMirrorDocument(editorDocument);
+ const state = EditorState.create({ doc, plugins: [markdownPreviewPlugin] });
+ const container = document.createElement('div');
+ let view: EditorView;
+ setMarkdownPreviewDispatch(() => {
+ view.dispatch(view.state.tr);
+ });
+ view = new EditorView(container, { state });
+ await vi.waitFor(() => {
+ expect(vi.mocked(highlightCode)).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'let x', language: null, allowDetect: true })
+ );
+ });
+ view.destroy();
+ setMarkdownPreviewDispatch(null);
+ });
+
+ it('decodes entities when mapping highlight html onto the line text', async () => {
+ vi.mocked(highlightCode).mockResolvedValue({
+ mode: 'highlighted',
+ html: '< 2',
+ language: 'js',
+ });
+ const editorDocument: EditorDocument = [
+ { type: BlockType.Paragraph, children: [{ text: '```js' }] },
+ { type: BlockType.Paragraph, children: [{ text: '< 2' }] },
+ { type: BlockType.Paragraph, children: [{ text: '```' }] },
+ ];
+ const doc = toProseMirrorDocument(editorDocument);
+ const state = EditorState.create({ doc, plugins: [markdownPreviewPlugin] });
+ const container = document.createElement('div');
+ let view: EditorView;
+ setMarkdownPreviewDispatch(() => {
+ view.dispatch(view.state.tr);
+ });
+ view = new EditorView(container, { state });
+ await vi.waitFor(() => {
+ expect(container.querySelector('a-op')).toBeTruthy();
+ });
+ expect(container.querySelector('a-op')?.textContent).toBe('<');
+ view.destroy();
+ setMarkdownPreviewDispatch(null);
+ });
+
+ describe('thematic breaks', () => {
+ it('turns a standalone --- line into a divider block decoration', () => {
+ expect(decorationFinder(['---'])).toHaveLength(1);
+ });
+
+ it('recognizes ***, ___, and spaced marker runs as dividers', () => {
+ for (const line of ['***', '___', '- - -']) {
+ expect(decorationFinder([line])).toHaveLength(1);
+ }
+ });
+
+ it('does not treat mixed or suffixed marker runs as dividers', () => {
+ for (const line of ['*-*', '---x', '--']) {
+ const { container, view } = renderEditor([line]);
+ const p = container.querySelector('p');
+ expect(p?.className.includes(editorCss.EditorMarkdownDivider)).toBe(false);
+ view.destroy();
+ }
+ });
+
+ it('leaves --- inside a code fence literal', () => {
+ const { container, view } = renderEditor(['```', '---', '```']);
+ const ps = Array.from(container.querySelectorAll('p'));
+ expect(ps[1]?.className.includes(editorCss.EditorMarkdownDivider)).toBe(false);
+ expect(decorationSpan(container, editorCss.EditorMarkdownCodeBlock)?.textContent).toBe('---');
+ view.destroy();
+ });
+
+ it('styles the divider paragraph in the DOM', () => {
+ const { container, view } = renderEditor(['text', '---']);
+ const ps = Array.from(container.querySelectorAll('p'));
+ expect(ps[1]?.className.includes(editorCss.EditorMarkdownDivider)).toBe(true);
+ expect(ps[0]?.className.includes(editorCss.EditorMarkdownDivider)).toBe(false);
+ view.destroy();
+ });
+ });
+});
diff --git a/src/app/components/editor/markdown.ts b/src/app/components/editor/markdown.ts
new file mode 100644
index 0000000000..5d08d1da81
--- /dev/null
+++ b/src/app/components/editor/markdown.ts
@@ -0,0 +1,598 @@
+import { Plugin } from 'prosemirror-state';
+import type { EditorState } from 'prosemirror-state';
+import type { Node as ProseMirrorNode } from 'prosemirror-model';
+import { Decoration, DecorationSet } from 'prosemirror-view';
+import { highlightCode } from '$plugins/arborium';
+import * as css from './Editor.css';
+
+export type MarkdownLeafMarks = {
+ markdownToken?: boolean;
+ markdownBold?: boolean;
+ markdownItalic?: boolean;
+ markdownUnderline?: boolean;
+ markdownStrikeThrough?: boolean;
+ markdownCode?: boolean;
+ markdownCodeBlock?: boolean;
+ markdownHeading?: number;
+ markdownSpoiler?: boolean;
+ markdownLink?: boolean;
+ url?: string;
+};
+
+export type MarkdownToken = {
+ start: number;
+ end: number;
+} & MarkdownLeafMarks;
+
+const token = (start: number, end: number, marks: MarkdownLeafMarks): MarkdownToken => ({
+ start,
+ end,
+ ...marks,
+});
+
+// Line-start markers (headings, quotes, lists) are dimmed; heading content gets
+// its own style per level. Code fences are handled at the block level.
+const BLOCK_PREFIX_PATTERNS: ReadonlyArray<{ re: RegExp; heading: boolean }> = [
+ { re: /^(#{1,6})\s+/, heading: true },
+ { re: /^>\s/, heading: false },
+ { re: /^[-+*]\s/, heading: false },
+ { re: /^\d+\.\s/, heading: false },
+];
+
+// A `---`/`***`/`___` line is a divider (marked renders it as an
). We
+// never read `text\n---` as a setext h2, so typing a separator can't re-style
+// the line above.
+const THEMATIC_BREAK_RE = /^[ \t]{0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
+
+const matchBlockPrefix = (line: string): { length: number; headingLevel: number } | null => {
+ for (const { re, heading } of BLOCK_PREFIX_PATTERNS) {
+ const match = line.match(re);
+ if (!match) continue;
+ if (heading) {
+ const hashes = match[1] ?? '';
+ return { length: match[0].length, headingLevel: hashes.length || 1 };
+ }
+ return { length: match[0].length, headingLevel: 0 };
+ }
+ return null;
+};
+
+// Inline delimiters. Order matters: longer delimiters before shorter ones so
+// `**` is matched as bold before `*` could match as italic, and `__` before `_`.
+const INLINE_SPANS: ReadonlyArray<{
+ open: string;
+ close: string;
+ inner: MarkdownLeafMarks;
+}> = [
+ { open: '**', close: '**', inner: { markdownBold: true } },
+ { open: '~~', close: '~~', inner: { markdownStrikeThrough: true } },
+ { open: '||', close: '||', inner: { markdownSpoiler: true } },
+ { open: '__', close: '__', inner: { markdownUnderline: true } },
+ { open: '`', close: '`', inner: { markdownCode: true } },
+ { open: '*', close: '*', inner: { markdownItalic: true } },
+ { open: '_', close: '_', inner: { markdownItalic: true } },
+];
+
+type InlineMatch = {
+ contentStart: number;
+ contentEnd: number;
+ inner: MarkdownLeafMarks;
+ recurse: boolean;
+ totalLength: number;
+};
+
+const matchLinkSpan = (text: string): InlineMatch | null => {
+ if (!text.startsWith('[')) return null;
+ let closeBracket = text.indexOf(']');
+ while (closeBracket > 0 && isEscaped(text, closeBracket)) {
+ closeBracket = text.indexOf(']', closeBracket + 1);
+ }
+ if (closeBracket <= 1) return null;
+ if (text[closeBracket + 1] !== '(') return null;
+ const closeParen = text.indexOf(')', closeBracket + 2);
+ if (closeParen < 0) return null;
+ const label = text.slice(1, closeBracket);
+ const url = text.slice(closeBracket + 2, closeParen);
+ if (!label.trim() || !url.trim()) return null;
+ return {
+ contentStart: 1,
+ contentEnd: closeBracket,
+ inner: { markdownLink: true, url },
+ recurse: true,
+ totalLength: closeParen + 1,
+ };
+};
+
+// CommonMark's underscore rule: `_` only opens/closes emphasis when flanked by
+// whitespace or punctuation, so `1_test_1` stays literal while `_bar_`
+// emphasises. `*` has no such restriction (`foo*bar*baz` emphasises `bar`).
+// Line/paragraph edges count as whitespace, like the reference implementations.
+const isWhitespace = (ch: string): boolean => /\s/.test(ch);
+const isPunctuation = (ch: string): boolean => /[\p{P}]/u.test(ch);
+const charBefore = (text: string, idx: number): string => text[idx - 1] ?? '\n';
+const charAfter = (text: string, idx: number, length: number): string => text[idx + length] ?? '\n';
+
+const canOpenUnderscore = (text: string, idx: number, length: number): boolean => {
+ const before = charBefore(text, idx);
+ const after = charAfter(text, idx, length);
+ const leftFlanking =
+ !isWhitespace(after) &&
+ (!isPunctuation(after) || isWhitespace(before) || isPunctuation(before));
+ return leftFlanking && (isWhitespace(before) || isPunctuation(before) || isPunctuation(after));
+};
+
+const canCloseUnderscore = (text: string, idx: number, length: number): boolean => {
+ const before = charBefore(text, idx);
+ const after = charAfter(text, idx, length);
+ const rightFlanking =
+ !isWhitespace(before) &&
+ (!isPunctuation(before) || isWhitespace(after) || isPunctuation(after));
+ return rightFlanking && (isWhitespace(after) || isPunctuation(after) || isPunctuation(before));
+};
+
+const matchInlineSpan = (text: string, from: number, to: number): InlineMatch | null => {
+ const rest = text.slice(from, to);
+ for (const span of INLINE_SPANS) {
+ if (!rest.startsWith(span.open)) continue;
+ const isUnderscoreDelimiter = span.open[0] === '_';
+ if (isUnderscoreDelimiter && !canOpenUnderscore(text, from, span.open.length)) continue;
+ let closeIdx = text.indexOf(span.close, from + span.open.length);
+ const isCloser = (idx: number): boolean =>
+ !isEscaped(text, idx) &&
+ (!isUnderscoreDelimiter || canCloseUnderscore(text, idx, span.close.length));
+ while (closeIdx >= 0 && closeIdx < to && !isCloser(closeIdx)) {
+ closeIdx = text.indexOf(span.close, closeIdx + 1);
+ }
+ if (closeIdx < 0 || closeIdx >= to) continue;
+ const relClose = closeIdx - from;
+ if (relClose <= span.open.length) continue;
+ const content = text.slice(from + span.open.length, closeIdx);
+ if (!content.trim()) continue;
+ return {
+ contentStart: span.open.length,
+ contentEnd: relClose,
+ inner: span.inner,
+ // Code spans are literal: markers inside them are not formatting.
+ recurse: !span.inner.markdownCode,
+ totalLength: relClose + span.close.length,
+ };
+ }
+ return null;
+};
+
+// Bare URLs (schemes Sable linkifies when sending) get the same link styling as
+// [label](url); a non-word boundary keeps `abchttps://x` from matching mid-word.
+// `_` is kept because it's a legal URL path character (the sent renderer links
+// `https://x.com/foo_bar` whole).
+const BARE_URL_RE = /(?`|*~]+/i;
+
+const matchBareUrl = (text: string): InlineMatch | null => {
+ const match = BARE_URL_RE.exec(text);
+ if (!match || match.index !== 0) return null;
+ const url = match[0].replace(/[.,;:!?)]+$/, '');
+ if (!url) return null;
+ return {
+ contentStart: 0,
+ contentEnd: url.length,
+ inner: { markdownLink: true, url },
+ recurse: false,
+ totalLength: url.length,
+ };
+};
+
+const INLINE_OPENERS = ['**', '~~', '||', '__', '`', '*', '_', '['] as const;
+
+const hasMarks = (marks: MarkdownLeafMarks): boolean => Object.values(marks).some(Boolean);
+
+// A backslash escapes the next character (odd run of backslashes), keeping a
+// would-be delimiter literal — `test\*beep\*` renders as plain text.
+const isEscaped = (text: string, idx: number): boolean => {
+ let backslashes = 0;
+ for (let j = idx - 1; j >= 0 && text[j] === '\\'; j -= 1) backslashes += 1;
+ return backslashes % 2 === 1;
+};
+
+// Dims the delimiters of each matched span and recurses into the content with
+// the enclosing marks inherited, so `**||x||**` styles both bold and spoiler.
+// Unstyled runs keep the enclosing marks; at the top level they stay plain.
+const scanInline = (
+ text: string,
+ from: number,
+ to: number,
+ marks: MarkdownLeafMarks,
+ tokens: MarkdownToken[]
+): void => {
+ let i = from;
+ while (i < to) {
+ const rest = text.slice(i, to);
+ // A bare URL inside a [label](url) is not its own link: the label belongs
+ // to the marked link, so its URL is skipped there.
+ const match =
+ matchLinkSpan(rest) ??
+ (marks.markdownLink ? null : matchBareUrl(rest)) ??
+ matchInlineSpan(text, i, to);
+ if (!match) {
+ // A backslash keeps the next character literal.
+ if (text[i] === '\\' && i + 1 < to) {
+ if (hasMarks(marks)) tokens.push(token(i, i + 2, marks));
+ i += 2;
+ continue;
+ }
+ let next = -1;
+ for (const opener of INLINE_OPENERS) {
+ let idx = text.indexOf(opener, i + 1);
+ while (idx >= 0 && idx < to && isEscaped(text, idx)) {
+ idx = text.indexOf(opener, idx + 1);
+ }
+ if (idx >= 0 && idx < to && (next === -1 || idx < next)) next = idx;
+ }
+ if (!marks.markdownLink) {
+ const urlMatch = BARE_URL_RE.exec(text.slice(i + 1, to));
+ if (urlMatch) {
+ const urlStart = i + 1 + urlMatch.index;
+ if (urlStart < to && !isEscaped(text, urlStart) && (next === -1 || urlStart < next)) {
+ next = urlStart;
+ }
+ }
+ }
+ const runEnd = next < 0 ? to : next;
+ if (hasMarks(marks)) tokens.push(token(i, runEnd, marks));
+ i = runEnd;
+ continue;
+ }
+ const contentStart = i + match.contentStart;
+ const contentEnd = i + match.contentEnd;
+ const closeEnd = i + match.totalLength;
+ if (contentStart > i) tokens.push(token(i, contentStart, { markdownToken: true }));
+ const inner: MarkdownLeafMarks = { ...marks, ...match.inner };
+ if (match.recurse) {
+ scanInline(text, contentStart, contentEnd, inner, tokens);
+ } else {
+ tokens.push(token(contentStart, contentEnd, inner));
+ }
+ if (contentEnd < closeEnd) tokens.push(token(contentEnd, closeEnd, { markdownToken: true }));
+ i = closeEnd;
+ }
+};
+
+// Tokenizes a single line. atLineStart controls whether a block marker (which
+// can only begin a line) is recognized — the composer stores one line per
+// paragraph, but a paragraph's later text nodes sit mid-line.
+const tokenizeLine = (text: string, atLineStart: boolean): MarkdownToken[] => {
+ const tokens: MarkdownToken[] = [];
+ const prefix = atLineStart ? matchBlockPrefix(text) : null;
+ if (prefix) {
+ tokens.push(token(0, prefix.length, { markdownToken: true }));
+ if (prefix.headingLevel > 0) {
+ // The heading styles the whole content range; inner formatting spans
+ // render on top and can override the weight (bold stays really bold).
+ tokens.push(token(prefix.length, text.length, { markdownHeading: prefix.headingLevel }));
+ scanInline(text, prefix.length, text.length, {}, tokens);
+ } else {
+ scanInline(text, prefix.length, text.length, {}, tokens);
+ }
+ } else {
+ scanInline(text, 0, text.length, {}, tokens);
+ }
+ return tokens;
+};
+
+/**
+ * Scans a text node and returns the markdown spans that should be rendered
+ * with formatting (the content) or dimmed (the syntax characters). Fenced
+ * code blocks are tracked across lines, matching the composer's layout of
+ * one paragraph per line.
+ */
+export const tokenizeMarkdown = (text: string): MarkdownToken[] => {
+ if (!text) return [];
+ const tokens: MarkdownToken[] = [];
+ let inCode = false;
+ let offset = 0;
+ while (true) {
+ const nl = text.indexOf('\n', offset);
+ const lineEnd = nl < 0 ? text.length : nl;
+ const line = text.slice(offset, lineEnd);
+ if (line.startsWith('```')) {
+ const tag = line.match(/^```\S*/)?.[0].length ?? 3;
+ tokens.push(token(offset, offset + tag, { markdownToken: true }));
+ inCode = !inCode;
+ } else if (inCode) {
+ tokens.push(token(offset, lineEnd, { markdownCodeBlock: true }));
+ } else {
+ for (const t of tokenizeLine(line, true)) {
+ tokens.push({ ...t, start: t.start + offset, end: t.end + offset });
+ }
+ }
+ if (nl < 0) break;
+ offset = nl + 1;
+ }
+ return tokens;
+};
+
+const STYLED_TOKEN_CLASSES: ReadonlyArray<[keyof MarkdownLeafMarks, string]> = [
+ ['markdownBold', css.EditorMarkdownBold],
+ ['markdownItalic', css.EditorMarkdownItalic],
+ ['markdownUnderline', css.EditorMarkdownUnderline],
+ ['markdownStrikeThrough', css.EditorMarkdownStrikeThrough],
+ ['markdownCode', css.EditorMarkdownCode],
+ ['markdownCodeBlock', css.EditorMarkdownCodeBlock],
+ ['markdownSpoiler', css.EditorMarkdownSpoiler],
+ ['markdownLink', css.EditorMarkdownLink],
+];
+
+// Matches the sent renderer, which maps `#`…`######` onto folds' H2/H3/H4
+// heading sizes (h4 shares H4, like the message parser's h1→H2 … h6→H6).
+const HEADING_CLASSES: ReadonlyArray = [
+ css.EditorMarkdownHeading1,
+ css.EditorMarkdownHeading2,
+ css.EditorMarkdownHeading3,
+ css.EditorMarkdownHeading4,
+ css.EditorMarkdownHeading5,
+ css.EditorMarkdownHeading6,
+];
+
+const tokenToDecoration = (nodeStart: number, t: MarkdownToken): Decoration | null => {
+ if (t.markdownToken) {
+ return Decoration.inline(nodeStart + t.start, nodeStart + t.end, {
+ class: css.EditorMarkdownToken,
+ });
+ }
+ const classes = STYLED_TOKEN_CLASSES.filter(([mark]) => t[mark]).map(
+ ([, className]) => className
+ );
+ if (t.markdownHeading) {
+ const level = Math.min(Math.max(t.markdownHeading, 1), 6);
+ classes.push(HEADING_CLASSES[level - 1]!);
+ }
+ if (!classes.length) return null;
+ // The link marker lets the global force-underline-links rule (index.css)
+ // underline preview links only when that setting is on; the href powers
+ // Ctrl+click-to-open in the plugin's click handler.
+ return Decoration.inline(nodeStart + t.start, nodeStart + t.end, {
+ class: classes.join(' '),
+ ...(t.markdownLink
+ ? {
+ 'data-markdown-preview-link': '',
+ 'data-markdown-preview-href': t.url ?? '',
+ }
+ : {}),
+ });
+};
+
+// A paragraph can hold several text nodes (atoms like mentions split them), so
+// tokens from the paragraph's full text are clipped onto each text child. The
+// paragraph sits at pos with its first child at pos + 1.
+type TextChild = { pos: number; start: number; end: number };
+
+const textChildren = (node: ProseMirrorNode, pos: number): TextChild[] => {
+ const children: TextChild[] = [];
+ let start = 0;
+ node.forEach((child, childOffset) => {
+ if (child.isText && child.text) {
+ children.push({ pos: pos + childOffset + 1, start, end: start + child.text.length });
+ start += child.text.length;
+ }
+ });
+ return children;
+};
+
+const lineTokensToDecorations = (
+ children: TextChild[],
+ tokens: MarkdownToken[],
+ decorations: Decoration[]
+): void => {
+ let tokenIdx = 0;
+ for (const child of children) {
+ while (tokenIdx < tokens.length && tokens[tokenIdx]!.end <= child.start) tokenIdx += 1;
+ for (let t = tokenIdx; t < tokens.length && tokens[t]!.start < child.end; t += 1) {
+ const lineToken = tokens[t]!;
+ const start = Math.max(lineToken.start, child.start);
+ const end = Math.min(lineToken.end, child.end);
+ if (end <= start) continue;
+ const decoration = tokenToDecoration(child.pos, {
+ ...lineToken,
+ start: start - child.start,
+ end: end - child.start,
+ });
+ if (decoration) decorations.push(decoration);
+ }
+ }
+};
+
+const highlightCache = new Map<
+ string,
+ ReadonlyArray<{ start: number; end: number; cls: string }> | null
+>();
+const inFlightHighlights = new Set();
+let previewDispatch: (() => void) | null = null;
+
+/** The composer has no view handle inside the decoration pass, so the controller
+ * hands the plugin a callback that dispatches an empty transaction (re-running
+ * decorations) once queued syntax highlighting resolves. */
+export const setMarkdownPreviewDispatch = (dispatch: (() => void) | null): void => {
+ previewDispatch = dispatch;
+};
+
+const highlightKey = (lang: string | null, line: string): string => `${lang ?? ''}\u0000${line}`;
+
+const decodeHtmlText = (text: string): string =>
+ text
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, ' ');
+
+const HIGHLIGHT_SPAN_RE = /|<\/a-[a-z]{1,2}>|([^<]+)/g;
+
+// Arborium returns a line highlighted as flat token elements (e.g.
+// `const`); turn them into line-relative text ranges carrying the
+// token's element name so the decoration pass can render the real elements the
+// CDN arborium CSS styles.
+const parseHighlightHtml = (
+ html: string
+): ReadonlyArray<{ start: number; end: number; cls: string }> => {
+ const segments: { start: number; end: number; cls: string }[] = [];
+ const openTags: string[] = [];
+ let offset = 0;
+ for (const match of html.matchAll(HIGHLIGHT_SPAN_RE)) {
+ const openTag = match[1];
+ const text = match[2];
+ if (openTag !== undefined) {
+ openTags.push(`a-${openTag}`);
+ } else if (text !== undefined) {
+ const decoded = decodeHtmlText(text);
+ const deepest = openTags[openTags.length - 1];
+ if (decoded && deepest)
+ segments.push({ start: offset, end: offset + decoded.length, cls: deepest });
+ offset += decoded.length;
+ } else {
+ openTags.pop();
+ }
+ }
+ return segments;
+};
+
+const MAX_HIGHLIGHT_LINE_LENGTH = 400;
+const MAX_HIGHLIGHT_CACHE_ENTRIES = 200;
+
+// Highlights the fenced lines that the decoration pass has not cached yet, then
+// dispatches an empty transaction so the view re-runs decorations with the new
+// token colours. Plain (unhighlightable) results are cached too, so a lang-less
+// fence is not re-detected on every keystroke.
+const scheduleHighlights = (entries: Array<{ lang: string | null; line: string }>): void => {
+ const toRun = entries.filter(({ lang, line }) => {
+ if (!line || line.length > MAX_HIGHLIGHT_LINE_LENGTH) return false;
+ const key = highlightKey(lang, line);
+ return !highlightCache.has(key) && !inFlightHighlights.has(key);
+ });
+ if (!toRun.length) return;
+ for (const { lang, line } of toRun) inFlightHighlights.add(highlightKey(lang, line));
+ void Promise.all(
+ toRun.map(({ lang, line }) =>
+ highlightCode({ code: line, language: lang, allowDetect: !lang }).then((result) => ({
+ lang,
+ line,
+ result,
+ }))
+ )
+ ).then((results) => {
+ let stored = false;
+ for (const { lang, line, result } of results) {
+ const key = highlightKey(lang, line);
+ inFlightHighlights.delete(key);
+ highlightCache.set(
+ key,
+ result.mode === 'highlighted' ? parseHighlightHtml(result.html) : null
+ );
+ stored = true;
+ }
+ while (highlightCache.size > MAX_HIGHLIGHT_CACHE_ENTRIES) {
+ const oldest = highlightCache.keys().next().value;
+ if (oldest === undefined) break;
+ highlightCache.delete(oldest);
+ }
+ if (stored) previewDispatch?.();
+ });
+};
+
+const decorateCodeLine = (
+ children: TextChild[],
+ lang: string | null,
+ line: string,
+ decorations: Decoration[],
+ pending: Array<{ lang: string | null; line: string }>
+): void => {
+ for (const child of children) {
+ decorations.push(
+ Decoration.inline(child.pos, child.pos + (child.end - child.start), {
+ class: css.EditorMarkdownCodeBlock,
+ })
+ );
+ }
+ const cached = highlightCache.get(highlightKey(lang, line));
+ if (cached) {
+ for (const segment of cached) {
+ for (const child of children) {
+ if (segment.end <= child.start || segment.start >= child.end) continue;
+ const start = Math.max(segment.start, child.start) - child.start;
+ const end = Math.min(segment.end, child.end) - child.start;
+ if (end > start) {
+ decorations.push(
+ Decoration.inline(child.pos + start, child.pos + end, { nodeName: segment.cls })
+ );
+ }
+ }
+ }
+ } else {
+ pending.push({ lang, line });
+ }
+};
+
+/** The composer stores one line per paragraph (insertNewline splits the block),
+ * so fenced code blocks span several paragraphs. Walk them in order and track
+ * whether the current paragraph is inside a fence. */
+export const markdownDecorations = (state: EditorState): DecorationSet => {
+ const decorations: Decoration[] = [];
+ const pending: Array<{ lang: string | null; line: string }> = [];
+ let inCode = false;
+ let currentLang: string | null = null;
+ state.doc.descendants((node, pos) => {
+ if (node.type.name === 'paragraph') {
+ const line = node.textContent;
+ const children = textChildren(node, pos);
+ if (line.startsWith('```')) {
+ const tag = line.match(/^```\S*/)?.[0] ?? '```';
+ inCode = !inCode;
+ currentLang = tag.length > 3 ? tag.slice(3) : null;
+ let remaining = tag.length;
+ for (const child of children) {
+ const length = Math.min(remaining, child.end - child.start);
+ if (length > 0) {
+ decorations.push(
+ Decoration.inline(child.pos, child.pos + length, { class: css.EditorMarkdownToken })
+ );
+ remaining -= length;
+ }
+ if (remaining <= 0) break;
+ }
+ } else if (inCode) {
+ decorateCodeLine(children, currentLang, line, decorations, pending);
+ } else if (THEMATIC_BREAK_RE.test(line)) {
+ decorations.push(
+ Decoration.node(pos, pos + node.nodeSize, { class: css.EditorMarkdownDivider })
+ );
+ } else {
+ lineTokensToDecorations(children, tokenizeLine(line, true), decorations);
+ }
+ return true;
+ }
+ return true;
+ });
+ if (pending.length && previewDispatch) scheduleHighlights(pending);
+ return DecorationSet.create(state.doc, decorations);
+};
+
+/**
+ * Render-time markdown preview: dims syntax characters and styles content via
+ * decorations, so the document and serialized output stay untouched.
+ */
+export const markdownPreviewPlugin = new Plugin({
+ props: {
+ decorations: (state) => markdownDecorations(state),
+ // Preview links open on Ctrl/Cmd+click only, so a stray click never
+ // navigates away while editing.
+ handleDOMEvents: {
+ click: (view, event) => {
+ if (!event.ctrlKey && !event.metaKey) return false;
+ const href = (event.target as HTMLElement | null)
+ ?.closest('[data-markdown-preview-link]')
+ ?.getAttribute('data-markdown-preview-href');
+ if (!href) return false;
+ window.open(href, '_blank', 'noopener,noreferrer');
+ return true;
+ },
+ },
+ },
+});
diff --git a/src/app/components/editor/prosemirrorController.test.tsx b/src/app/components/editor/prosemirrorController.test.tsx
index 0ccedaa2c5..eac085653b 100644
--- a/src/app/components/editor/prosemirrorController.test.tsx
+++ b/src/app/components/editor/prosemirrorController.test.tsx
@@ -3,7 +3,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest';
import { Selection } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';
import type { EditorDocument } from './model';
-import { ProseMirrorEditable } from './ProseMirrorEditable';
+import { markEventConsumedByHost, ProseMirrorEditable } from './ProseMirrorEditable';
import { ProseMirrorEditorController } from './prosemirrorController';
import { BlockType } from './types';
@@ -167,16 +167,19 @@ describe('placeholder', () => {
});
describe('Enter handling', () => {
- it('does not split the paragraph when the host treats Enter as send', () => {
+ it('does not split the paragraph when the host consumes Enter', () => {
const controller = new ProseMirrorEditorController(doc('hello'));
- const onKeyDown = vi.fn<(event: { preventDefault: () => void }) => void>((event) =>
- event.preventDefault()
+ const onKeyDown = vi.fn<(event: { preventDefault: () => void; nativeEvent: Event }) => void>(
+ (event) => {
+ event.preventDefault();
+ markEventConsumedByHost(event.nativeEvent);
+ }
);
const { container } = render(
);
- fireEvent.keyDown(container.querySelector('.ProseMirror')!, { key: 'Enter' });
+ fireEvent.keyDown(container.querySelector('.ProseMirror')!, { key: 'Enter', keyCode: 13 });
expect(onKeyDown).toHaveBeenCalled();
expect(controller.getDocument()).toEqual(doc('hello'));
@@ -203,7 +206,7 @@ describe('Enter handling', () => {
it('leaves an in-flight IME composition alone', () => {
const { controller, editable } = mount(doc('hello'));
- fireEvent.keyDown(editable, { key: 'Enter', isComposing: true });
+ fireEvent.keyDown(editable, { key: 'Enter', keyCode: 13, isComposing: true });
expect(controller.getDocument()).toEqual(doc('hello'));
});
diff --git a/src/app/components/editor/prosemirrorController.ts b/src/app/components/editor/prosemirrorController.ts
index 6c1a92a159..72294cc488 100644
--- a/src/app/components/editor/prosemirrorController.ts
+++ b/src/app/components/editor/prosemirrorController.ts
@@ -18,10 +18,29 @@ import {
toProseMirrorDocument,
toProseMirrorInline,
} from './prosemirrorSchema';
+import { markdownPreviewPlugin, setMarkdownPreviewDispatch } from './markdown';
const isProseMirrorDocumentEmpty = (doc: ProseMirrorNode): boolean =>
doc.childCount === 1 && doc.firstChild?.content.size === 0;
+const paragraphToPreviewText = (paragraph: ProseMirrorNode): string => {
+ let text = '';
+ paragraph.content.forEach((child) => {
+ if (child.isText) {
+ text += child.text ?? '';
+ } else if (child.type.name === 'mention') {
+ text += `@${(child.attrs.name as string | undefined) ?? ''}`;
+ } else if (child.type.name === 'emoticon') {
+ text += (child.attrs.shortcode as string | undefined) ?? '';
+ } else if (child.type.name === 'command') {
+ text += (child.attrs.name as string | undefined) ?? '';
+ } else {
+ text += child.textContent ?? '';
+ }
+ });
+ return text;
+};
+
export type EditorAutocompleteQuery = {
from: number;
prefix: TPrefix;
@@ -100,6 +119,18 @@ export class ProseMirrorEditorController {
.join('\n');
}
+ /** Like getText, but atoms (mentions, emoticons, commands) contribute their
+ * display text instead of the \0 placeholder. */
+ getMarkdownPreviewText(): string {
+ const view = this.view;
+ if (!view) return this.getText();
+ const paragraphs: string[] = [];
+ view.state.doc.content.forEach((paragraph) =>
+ paragraphs.push(paragraphToPreviewText(paragraph))
+ );
+ return paragraphs.join('\n');
+ }
+
setDocument(document: EditorDocument): void {
this.document = structuredClone(document.length ? document : emptyEditorDocument());
if (this.view) {
@@ -130,6 +161,7 @@ export class ProseMirrorEditorController {
doc: toProseMirrorDocument(this.document),
plugins: [
beginCommandPlugin,
+ markdownPreviewPlugin,
history(),
keymap({ 'Mod-z': undo, 'Mod-Shift-z': redo, 'Mod-y': redo }),
// Enter is withheld on purpose: the host decides send vs newline.
@@ -172,7 +204,12 @@ export class ProseMirrorEditorController {
},
}
);
+ setMarkdownPreviewDispatch(() => {
+ const view = this.view;
+ if (view) view.dispatch(view.state.tr);
+ });
return () => {
+ setMarkdownPreviewDispatch(null);
this.view?.destroy();
this.view = undefined;
};
diff --git a/src/app/components/upload-card/UploadDescriptionEditor.tsx b/src/app/components/upload-card/UploadDescriptionEditor.tsx
index 9741a972ad..bd4835917e 100644
--- a/src/app/components/upload-card/UploadDescriptionEditor.tsx
+++ b/src/app/components/upload-card/UploadDescriptionEditor.tsx
@@ -13,6 +13,7 @@ import {
MarkdownFormattingToolbarBottom,
MarkdownFormattingToolbarToggle,
createEmoticonElement,
+ markEventConsumedByHost,
plainToEditorInput,
ProseMirrorEditorSurface,
toMatrixCustomHTML,
@@ -104,6 +105,7 @@ export function DescriptionEditor({
}
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
evt.preventDefault();
+ markEventConsumedByHost(evt.nativeEvent);
handleSave();
}
},
diff --git a/src/app/features/room/RoomInput.test.tsx b/src/app/features/room/RoomInput.test.tsx
index b5af153bce..f8c6e68b35 100644
--- a/src/app/features/room/RoomInput.test.tsx
+++ b/src/app/features/room/RoomInput.test.tsx
@@ -192,6 +192,7 @@ vi.mock('$components/editor', () => {
getMentions: () => ({ users: new Set(), room: undefined }),
getPrevWorldRange: () => undefined,
isEmptyEditor: (editor: any) => textOf(editor.children).trim() === '',
+ markEventConsumedByHost: vi.fn(),
moveCursor: vi.fn(),
plainToEditorInput: (text: string) => [{ type: 'paragraph', children: [{ text }] }],
replaceWithElement: vi.fn(),
@@ -227,6 +228,10 @@ vi.mock('$components/upload-board', async () => {
};
});
+vi.mock('./input/MarkdownPreview', () => ({
+ MarkdownPreview: () => null,
+}));
+
vi.mock('$components/upload-card', () => ({
UploadCardRenderer: ({ fileItem, setMetadata, setDesc }: any) => (
<>
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 2f60d031d3..9c77fd9ab5 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -69,6 +69,7 @@ import {
BEGINNING_AUTOCOMPLETE_PREFIXES,
MarkdownFormattingToolbarBottom,
MarkdownFormattingToolbarToggle,
+ markEventConsumedByHost,
} from '$components/editor';
import { stripMarkdownEscapesForHiddenPreviews } from './message/hiddenLinkPreviews';
import { plainToEditorInput } from '$components/editor/input';
@@ -142,6 +143,7 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice';
+import { MarkdownPreview } from './input/MarkdownPreview';
import { setCurrentlyUsedPerMessageProfileIdForRoom } from '$hooks/usePerMessageProfile';
import type { PerMessageProfileMsc4461 } from '$app/persona';
import { ProfileCatalog } from '$app/persona/catalog';
@@ -334,6 +336,7 @@ export const RoomInput = forwardRef(
const [editorStickerButton] = useSetting(settingsAtom, 'editorStickerButton');
const [editorMicButton] = useSetting(settingsAtom, 'editorMicButton');
const [editorButtonOrder] = useSetting(settingsAtom, 'editorButtonOrder');
+ const [showMarkdownPreview] = useSetting(settingsAtom, 'showMarkdownPreview');
const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@@ -625,6 +628,7 @@ export const RoomInput = forwardRef(
const handlePaste = useFilePasteHandler(handleFiles);
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const [hasText, setHasText] = useState(false);
+ const [markdownPreview, setMarkdownPreview] = useState('');
const lastEncryptionPreparationAt = useRef(0);
const detectAutocomplete = useCallback(() => {
const quickReactPrefix = editor.getText().slice(0, 2);
@@ -644,6 +648,7 @@ export const RoomInput = forwardRef(
const handleEditorChange = useCallback(() => {
setHasText(!editor.isEmpty());
+ setMarkdownPreview(showMarkdownPreview ? editor.getMarkdownPreviewText() : '');
detectAutocomplete();
if (!room.hasEncryptionStateEvent()) return;
@@ -652,7 +657,12 @@ export const RoomInput = forwardRef(
lastEncryptionPreparationAt.current = now;
mx.getCrypto()?.prepareToEncrypt(room);
- }, [editor, detectAutocomplete, mx, room]);
+ }, [editor, showMarkdownPreview, detectAutocomplete, mx, room]);
+ // handleEditorChange only runs on edits, so toggling the preview on with
+ // an existing draft needs a separate sync.
+ useEffect(() => {
+ setMarkdownPreview(showMarkdownPreview ? editor.getMarkdownPreviewText() : '');
+ }, [editor, showMarkdownPreview]);
const hasContent = hasText || selectedFiles.length > 0;
const isComposing = useComposingCheck();
@@ -1721,6 +1731,7 @@ export const RoomInput = forwardRef(
if (selectedItem) {
evt.preventDefault();
+ markEventConsumedByHost(evt.nativeEvent);
selectedItem.click();
return;
}
@@ -1740,6 +1751,7 @@ export const RoomInput = forwardRef(
!isComposing(evt)
) {
evt.preventDefault();
+ markEventConsumedByHost(evt.nativeEvent);
submit().catch((error) => {
log.error('submit failed', { roomId }, error);
});
@@ -2007,6 +2019,9 @@ export const RoomInput = forwardRef(
forceMultilineLayout={showAudioRecorder}
top={
<>
+ {showMarkdownPreview && markdownPreview.trim() !== '' && (
+
+ )}
{selectedFiles.length > 0 && (
{
+const fireResize = (element: Element, height = 1) => {
observers.forEach(({ callback, elements }) => {
if (!elements.has(element)) return;
callback(
- [{ target: element, contentRect: { height: 1 } } as unknown as ResizeObserverEntry],
+ [{ target: element, contentRect: { height } } as unknown as ResizeObserverEntry],
{} as ResizeObserver
);
});
@@ -368,6 +368,12 @@ const getScrollEl = (container: HTMLElement) => {
return scrollEl as Element;
};
+const getViewportEl = (container: HTMLElement) => {
+ const scrollEl = container.querySelector('[data-testid="vlist-scroll"]');
+ expect(scrollEl).toBeTruthy();
+ return scrollEl!.parentElement as Element;
+};
+
const renderTimeline = () => render();
const settleInitialScroll = () =>
act(async () => {
@@ -450,9 +456,15 @@ describe('RoomTimeline content ResizeObserver', () => {
await settleInitialScroll();
vListHandle.scrollToIndex.mockClear();
+ // Pinned: the view ends exactly at the content bottom (1000 - 400 - 600 = 0).
+ vListHandle.scrollOffset = 400;
+ act(() => lastOnScroll?.(400));
+
+ // A growing composer shrinks the timeline viewport (600 -> 578).
const timeline = container.querySelector('[data-testid="timeline"]');
expect(timeline).toBeTruthy();
- act(() => fireResize(timeline!));
+ act(() => fireResize(timeline!, 600));
+ act(() => fireResize(timeline!, 578));
expect(vListHandle.scrollToIndex).toHaveBeenCalledWith(
0,
@@ -800,6 +812,49 @@ describe('RoomTimeline content ResizeObserver', () => {
expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled();
});
+
+ it('re-pins on a viewport shrink while pinned to the bottom', async () => {
+ const { container } = renderTimeline();
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
+ vListHandle.scrollToIndex.mockClear();
+
+ // Pinned: the view ends exactly at the content bottom (1000 - 400 - 600 = 0).
+ vListHandle.scrollOffset = 400;
+ act(() => lastOnScroll?.(400));
+
+ // A growing composer shrinks the timeline viewport (600 -> 578).
+ const viewportEl = getViewportEl(container);
+ act(() => fireResize(viewportEl, 600));
+ act(() => fireResize(viewportEl, 578));
+
+ expect(vListHandle.scrollToIndex).toHaveBeenCalledWith(
+ 0,
+ expect.objectContaining({ align: 'end' })
+ );
+ });
+
+ it('does not re-pin on a viewport shrink while scrolled up but still flagged', async () => {
+ const { container } = renderTimeline();
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
+ vListHandle.scrollToIndex.mockClear();
+
+ // Scrolled up 60px off the bottom (1000 - 340 - 600 = 60): inside the 100px
+ // atBottom tolerance, but the user has clearly moved off the pre-resize bottom.
+ vListHandle.scrollOffset = 340;
+ act(() => lastOnScroll?.(340));
+
+ const viewportEl = getViewportEl(container);
+ act(() => fireResize(viewportEl, 600));
+ act(() => fireResize(viewportEl, 578));
+
+ expect(vListHandle.scrollToIndex).not.toHaveBeenCalled();
+ });
});
describe('remote read receipts', () => {
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 7b37dbcea8..744b437c34 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -503,6 +503,7 @@ export function RoomTimeline({
const scrollElRef = useRef(null);
const [scrollElementVersion, setScrollElementVersion] = useState(0);
+ const prevViewportHeightRef = useRef(0);
const scrollToBottom = useCallback(
(behavior: 'instant' | 'smooth' = 'instant') => {
@@ -954,9 +955,26 @@ export function RoomTimeline({
contentObserver.observe(contentEl);
}
- const observer = new ResizeObserver(() => {
- if (scrollOwnerRef.current === 'live' && atBottomRef.current) scrollToBottom();
- syncAtBottom();
+ const observer = new ResizeObserver((entries) => {
+ const newHeight = entries[0]!.contentRect.height;
+ const prev = prevViewportHeightRef.current;
+ const shrank = newHeight < prev;
+ prevViewportHeightRef.current = newHeight;
+ let rePinned = false;
+ if (scrollOwnerRef.current === 'live' && atBottomRef.current && shrank) {
+ // The atBottom flag lags real scroll position by up to 100px, so a user
+ // who has begun scrolling up is still flagged while a growing composer
+ // shrinks the viewport. Only re-pin when the user was actually pinned to
+ // the pre-resize bottom, measured against the old viewport height.
+ const v = vListRef.current;
+ const wasPinned = Boolean(v && v.scrollSize - v.scrollOffset - prev < 40);
+ if (wasPinned) {
+ // Geometry is still pre-scroll here; the repin's own scroll event resyncs.
+ scrollToBottom();
+ rePinned = true;
+ }
+ }
+ if (!rePinned) syncAtBottom();
});
observer.observe(el);
diff --git a/src/app/features/room/input/MarkdownPreview.tsx b/src/app/features/room/input/MarkdownPreview.tsx
new file mode 100644
index 0000000000..398e280291
--- /dev/null
+++ b/src/app/features/room/input/MarkdownPreview.tsx
@@ -0,0 +1,49 @@
+import { useMemo } from 'react';
+import { Box, Scroll, Text, config } from 'folds';
+import type { Room } from '$types/matrix-sdk';
+import { EventType, MatrixEvent } from '$types/matrix-sdk';
+import { useRoomMessagePreviewRenderer } from '$components/message-preview';
+import { markdownToHtml } from '$plugins/markdown';
+import * as editorCss from '$components/editor/Editor.css';
+
+type MarkdownPreviewProps = {
+ room: Room;
+ markdown: string;
+};
+
+export function MarkdownPreview({ room, markdown }: MarkdownPreviewProps) {
+ const renderContent = useRoomMessagePreviewRenderer(room);
+
+ const event = useMemo(
+ () =>
+ new MatrixEvent({
+ type: EventType.RoomMessage,
+ room_id: room.roomId,
+ content: {
+ body: markdown,
+ msgtype: 'm.text',
+ format: 'org.matrix.custom.html',
+ formatted_body: markdownToHtml(markdown),
+ },
+ }),
+ [room.roomId, markdown]
+ );
+
+ return (
+
+
+ Preview
+
+
+ {renderContent(EventType.RoomMessage, false, event, '', () => event.getContent())}
+
+
+ );
+}
diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx
index af79693675..68517f1022 100644
--- a/src/app/features/room/message/MessageEditor.tsx
+++ b/src/app/features/room/message/MessageEditor.tsx
@@ -36,6 +36,7 @@ import {
ANYWHERE_AUTOCOMPLETE_PREFIXES,
getDocumentLinks,
LINKINPUTREGEX,
+ markEventConsumedByHost,
} from '$components/editor';
import { htmlToMarkdown } from '$plugins/markdown';
import { useSetting } from '$state/hooks/settings';
@@ -309,6 +310,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
if (editor.getAutocompleteQuery(ANYWHERE_AUTOCOMPLETE_PREFIXES)) return;
evt.preventDefault();
+ markEventConsumedByHost(evt.nativeEvent);
handleSave();
}
if (isKeyHotkey('escape', evt)) {
diff --git a/src/app/features/settings/account/BioEditor.tsx b/src/app/features/settings/account/BioEditor.tsx
index 793d6f01a5..6309a7867e 100644
--- a/src/app/features/settings/account/BioEditor.tsx
+++ b/src/app/features/settings/account/BioEditor.tsx
@@ -13,6 +13,7 @@ import {
MarkdownFormattingToolbarBottom,
MarkdownFormattingToolbarToggle,
createEmoticonElement,
+ markEventConsumedByHost,
plainToEditorInput,
ProseMirrorEditorSurface,
toMatrixCustomHTML,
@@ -102,6 +103,7 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor
}
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
evt.preventDefault();
+ markEventConsumedByHost(evt.nativeEvent);
handleSave();
}
},
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 65d21a6263..943323677f 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -454,6 +454,10 @@ function Editor() {
'editorStickerButton'
);
const [editorButtonOrder, setEditorButtonOrder] = useSetting(settingsAtom, 'editorButtonOrder');
+ const [showMarkdownPreview, setShowMarkdownPreview] = useSetting(
+ settingsAtom,
+ 'showMarkdownPreview'
+ );
const [draggingIndex, setDraggingIndex] = useState(null);
const handleReorder = useCallback(
@@ -500,6 +504,13 @@ function Editor() {
value={editorToolbar}
onChange={setEditorToolbar}
/>
+