From e5daa46f1cfcf2748ebf497751d0976b94c210c1 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Tue, 11 Aug 2026 22:21:03 -0400 Subject: [PATCH 01/15] feat(editor): add live markdown preview while typing --- ...d-composer-markdown-preview-prosemirror.md | 5 + src/app/components/editor/Editor.css.ts | 44 ++++ .../components/editor/ProseMirrorEditable.tsx | 20 +- src/app/components/editor/markdown.test.ts | 199 ++++++++++++++++++ src/app/components/editor/markdown.ts | 175 +++++++++++++++ .../editor/prosemirrorController.test.tsx | 15 +- .../editor/prosemirrorController.ts | 2 + .../upload-card/UploadDescriptionEditor.tsx | 2 + src/app/features/room/RoomInput.test.tsx | 5 + src/app/features/room/RoomInput.tsx | 12 +- .../features/room/input/MarkdownPreview.tsx | 41 ++++ .../features/room/message/MessageEditor.tsx | 2 + .../features/settings/account/BioEditor.tsx | 2 + src/app/features/settings/general/General.tsx | 11 + src/app/state/settings.ts | 2 + 15 files changed, 526 insertions(+), 11 deletions(-) create mode 100644 .changeset/add-composer-markdown-preview-prosemirror.md create mode 100644 src/app/components/editor/markdown.test.ts create mode 100644 src/app/components/editor/markdown.ts create mode 100644 src/app/features/room/input/MarkdownPreview.tsx diff --git a/.changeset/add-composer-markdown-preview-prosemirror.md b/.changeset/add-composer-markdown-preview-prosemirror.md new file mode 100644 index 0000000000..5574692e23 --- /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 are dimmed and their content rendered (bold, italic, underline, strikethrough, code, spoilers, and links) while typing, Discord-style, and an optional rendered preview box can be enabled above the composer in Settings. diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 79bf483fdc..a2c0348b47 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -120,3 +120,47 @@ export const EditorToolbarBase = style({ export const EditorToolbar = style({ padding: config.space.S100, }); + +export const EditorMarkdownToken = style({ + opacity: 0.4, +}); + +export const EditorMarkdownBold = style({ + fontWeight: 700, +}); + +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, + textDecoration: 'underline', + textUnderlineOffset: toRem(2), +}); + +export const EditorMarkdownCode = style([ + DefaultReset, + { + fontFamily: 'var(--font-monospace)', + 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 EditorMarkdownSpoiler = style({ + backgroundColor: color.SurfaceVariant.ContainerLine, + borderRadius: config.radii.R300, + color: 'transparent', +}); diff --git a/src/app/components/editor/ProseMirrorEditable.tsx b/src/app/components/editor/ProseMirrorEditable.tsx index 93af51a0e1..5a6bf921f5 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..bbf623c82f --- /dev/null +++ b/src/app/components/editor/markdown.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, beforeAll } from 'vitest'; +import { EditorState } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { + tokenizeMarkdown, + markdownDecorations, + markdownPreviewPlugin, + type MarkdownToken, +} from './markdown'; +import { toProseMirrorDocument } from './prosemirrorSchema'; +import type { EditorDocument } from './model'; +import { BlockType } from './types'; +import * as editorCss from './Editor.css'; + +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('returns no tokens for plain text', () => { + expect(tokenizeMarkdown('just plain text')).toHaveLength(0); + }); + + it('returns no tokens for empty text', () => { + expect(tokenizeMarkdown('')).toHaveLength(0); + }); +}); + +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 = (text: string) => { + const editorDocument: EditorDocument = [{ 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 === 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('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('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(); + }); +}); diff --git a/src/app/components/editor/markdown.ts b/src/app/components/editor/markdown.ts new file mode 100644 index 0000000000..cf2701eeb6 --- /dev/null +++ b/src/app/components/editor/markdown.ts @@ -0,0 +1,175 @@ +import { Plugin } from 'prosemirror-state'; +import type { EditorState } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import * as css from './Editor.css'; + +export type MarkdownLeafMarks = { + markdownToken?: boolean; + markdownBold?: boolean; + markdownItalic?: boolean; + markdownUnderline?: boolean; + markdownStrikeThrough?: boolean; + markdownCode?: boolean; + markdownSpoiler?: boolean; + markdownLink?: boolean; +}; + +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, code fences) are dimmed, not styled. +const BLOCK_PREFIX_PATTERNS: ReadonlyArray<{ re: RegExp; marks: MarkdownLeafMarks }> = [ + { re: /^#{1,6}\s+/, marks: { markdownToken: true } }, + { re: /^>\s/, marks: { markdownToken: true } }, + { re: /^[-+*]\s/, marks: { markdownToken: true } }, + { re: /^\d+\.\s/, marks: { markdownToken: true } }, + { re: /^```/, marks: { markdownToken: true } }, +]; + +const collectBlockPrefixTokens = (text: string, tokens: MarkdownToken[]): void => { + let lineStart = 0; + while (lineStart < text.length) { + const nl = text.indexOf('\n', lineStart); + const lineEnd = nl < 0 ? text.length : nl; + const line = text.slice(lineStart, lineEnd); + for (const { re, marks } of BLOCK_PREFIX_PATTERNS) { + const match = line.match(re); + if (match) { + tokens.push(token(lineStart, lineStart + match[0].length, marks)); + break; + } + } + if (nl < 0) break; + lineStart = nl + 1; + } +}; + +// Inline delimiters. Order matters: longer delimiters before shorter ones so +// `**` is matched as bold before `*` could match as italic. +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 } }, +]; + +const matchLinkSpan = (text: string): MarkdownToken[] | null => { + if (!text.startsWith('[')) return null; + const closeBracket = text.indexOf(']'); + 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 [ + token(0, 1, { markdownToken: true }), + token(1, closeBracket, { markdownLink: true }), + token(closeBracket, closeParen + 1, { markdownToken: true }), + ]; +}; + +const matchInlineSpan = (text: string): MarkdownToken[] | null => { + const linkTokens = matchLinkSpan(text); + if (linkTokens) return linkTokens; + + for (const span of INLINE_SPANS) { + if (!text.startsWith(span.open)) continue; + const closeIdx = text.indexOf(span.close, span.open.length); + if (closeIdx <= span.open.length) continue; + const content = text.slice(span.open.length, closeIdx); + if (!content.trim()) continue; + const contentStart = span.open.length; + const contentEnd = closeIdx; + return [ + token(0, contentStart, { markdownToken: true }), + token(contentStart, contentEnd, span.inner), + token(contentEnd, contentEnd + span.close.length, { markdownToken: true }), + ]; + } + return null; +}; + +/** + * Scans a text node and returns the markdown spans that should be rendered + * with formatting (the content) or dimmed (the syntax characters). + */ +export const tokenizeMarkdown = (text: string): MarkdownToken[] => { + if (!text) return []; + const tokens: MarkdownToken[] = []; + collectBlockPrefixTokens(text, tokens); + + let i = 0; + while (i < text.length) { + const rest = text.slice(i); + const spanTokens = matchInlineSpan(rest); + if (spanTokens) { + const consumed = spanTokens[spanTokens.length - 1]!.end; + spanTokens.forEach((t) => tokens.push({ ...t, start: t.start + i, end: t.end + i })); + i += consumed; + } else { + i += 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], + ['markdownSpoiler', css.EditorMarkdownSpoiler], + ['markdownLink', css.EditorMarkdownLink], +]; + +const tokenToDecoration = (nodeStart: number, t: MarkdownToken): Decoration | null => { + if (t.markdownToken) { + return Decoration.inline(nodeStart + t.start, nodeStart + t.end, { + class: css.EditorMarkdownToken, + }); + } + for (const [mark, className] of STYLED_TOKEN_CLASSES) { + if (t[mark]) { + return Decoration.inline(nodeStart + t.start, nodeStart + t.end, { class: className }); + } + } + return null; +}; + +export const markdownDecorations = (state: EditorState): DecorationSet => { + const decorations: Decoration[] = []; + state.doc.descendants((node, pos) => { + if (!node.isText || !node.text) return true; + for (const t of tokenizeMarkdown(node.text)) { + const decoration = tokenToDecoration(pos, t); + if (decoration) decorations.push(decoration); + } + return true; + }); + 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), + }, +}); 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..39636701ff 100644 --- a/src/app/components/editor/prosemirrorController.ts +++ b/src/app/components/editor/prosemirrorController.ts @@ -18,6 +18,7 @@ import { toProseMirrorDocument, toProseMirrorInline, } from './prosemirrorSchema'; +import { markdownPreviewPlugin } from './markdown'; const isProseMirrorDocumentEmpty = (doc: ProseMirrorNode): boolean => doc.childCount === 1 && doc.firstChild?.content.size === 0; @@ -130,6 +131,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. 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..638b27d84b 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.getText() : ''); detectAutocomplete(); if (!room.hasEncryptionStateEvent()) return; @@ -652,7 +657,7 @@ export const RoomInput = forwardRef( lastEncryptionPreparationAt.current = now; mx.getCrypto()?.prepareToEncrypt(room); - }, [editor, detectAutocomplete, mx, room]); + }, [editor, showMarkdownPreview, detectAutocomplete, mx, room]); const hasContent = hasText || selectedFiles.length > 0; const isComposing = useComposingCheck(); @@ -1721,6 +1726,7 @@ export const RoomInput = forwardRef( if (selectedItem) { evt.preventDefault(); + markEventConsumedByHost(evt.nativeEvent); selectedItem.click(); return; } @@ -1740,6 +1746,7 @@ export const RoomInput = forwardRef( !isComposing(evt) ) { evt.preventDefault(); + markEventConsumedByHost(evt.nativeEvent); submit().catch((error) => { log.error('submit failed', { roomId }, error); }); @@ -2007,6 +2014,9 @@ export const RoomInput = forwardRef( forceMultilineLayout={showAudioRecorder} top={ <> + {showMarkdownPreview && markdownPreview.trim() !== '' && ( + + )} {selectedFiles.length > 0 && ( + 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} /> + Date: Wed, 12 Aug 2026 12:37:12 -0400 Subject: [PATCH 02/15] fix(editor): refine the live markdown preview Stack overlapping inline formatting so inner spans keep the outer marks, render spoilers as a translucent highlight, show atoms in the preview text, and cap the preview box height with its own scrollbar. --- src/app/components/editor/Editor.css.ts | 9 +- src/app/components/editor/markdown.test.ts | 50 +++++++- src/app/components/editor/markdown.ts | 119 ++++++++++++------ .../editor/prosemirrorController.ts | 28 +++++ src/app/features/room/RoomInput.tsx | 7 +- .../features/room/input/MarkdownPreview.tsx | 7 +- 6 files changed, 178 insertions(+), 42 deletions(-) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index a2c0348b47..3ece340727 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -160,7 +160,12 @@ export const EditorMarkdownCode = style([ ]); export const EditorMarkdownSpoiler = style({ - backgroundColor: color.SurfaceVariant.ContainerLine, + backgroundColor: `color-mix(in srgb, ${color.SurfaceVariant.ContainerLine} 30%, transparent)`, borderRadius: config.radii.R300, - color: 'transparent', +}); + +export const EditorMarkdownPreviewContent = style({ + maxHeight: toRem(220), + overflowY: 'auto', + overscrollBehavior: 'contain', }); diff --git a/src/app/components/editor/markdown.test.ts b/src/app/components/editor/markdown.test.ts index bbf623c82f..9f423c114a 100644 --- a/src/app/components/editor/markdown.test.ts +++ b/src/app/components/editor/markdown.test.ts @@ -126,6 +126,46 @@ describe('tokenizeMarkdown', () => { 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('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); }); @@ -155,7 +195,7 @@ const renderEditor = (text: string) => { }; const decorationSpan = (container: HTMLElement, cls: string) => - Array.from(container.querySelectorAll('span')).find((span) => span.className === cls); + Array.from(container.querySelectorAll('span')).find((span) => span.className.includes(cls)); describe('markdownPreviewPlugin decorations', () => { it('maps bold tokens onto doc positions', () => { @@ -190,6 +230,14 @@ describe('markdownPreviewPlugin decorations', () => { 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('produces no decorations for plain text', () => { const { container, view } = renderEditor('just plain text'); expect(decorationSpan(container, editorCss.EditorMarkdownBold)).toBeUndefined(); diff --git a/src/app/components/editor/markdown.ts b/src/app/components/editor/markdown.ts index cf2701eeb6..e75147ae08 100644 --- a/src/app/components/editor/markdown.ts +++ b/src/app/components/editor/markdown.ts @@ -53,7 +53,7 @@ const collectBlockPrefixTokens = (text: string, tokens: MarkdownToken[]): void = }; // Inline delimiters. Order matters: longer delimiters before shorter ones so -// `**` is matched as bold before `*` could match as italic. +// `**` is matched as bold before `*` could match as italic, and `__` before `_`. const INLINE_SPANS: ReadonlyArray<{ open: string; close: string; @@ -65,9 +65,18 @@ const INLINE_SPANS: ReadonlyArray<{ { open: '__', close: '__', inner: { markdownUnderline: true } }, { open: '`', close: '`', inner: { markdownCode: true } }, { open: '*', close: '*', inner: { markdownItalic: true } }, + { open: '_', close: '_', inner: { markdownItalic: true } }, ]; -const matchLinkSpan = (text: string): MarkdownToken[] | null => { +type InlineMatch = { + contentStart: number; + contentEnd: number; + inner: MarkdownLeafMarks; + recurse: boolean; + totalLength: number; +}; + +const matchLinkSpan = (text: string): InlineMatch | null => { if (!text.startsWith('[')) return null; const closeBracket = text.indexOf(']'); if (closeBracket <= 1) return null; @@ -77,34 +86,81 @@ const matchLinkSpan = (text: string): MarkdownToken[] | null => { const label = text.slice(1, closeBracket); const url = text.slice(closeBracket + 2, closeParen); if (!label.trim() || !url.trim()) return null; - return [ - token(0, 1, { markdownToken: true }), - token(1, closeBracket, { markdownLink: true }), - token(closeBracket, closeParen + 1, { markdownToken: true }), - ]; + return { + contentStart: 1, + contentEnd: closeBracket, + inner: { markdownLink: true }, + recurse: true, + totalLength: closeParen + 1, + }; }; -const matchInlineSpan = (text: string): MarkdownToken[] | null => { - const linkTokens = matchLinkSpan(text); - if (linkTokens) return linkTokens; - +const matchInlineSpan = (text: string): InlineMatch | null => { for (const span of INLINE_SPANS) { if (!text.startsWith(span.open)) continue; const closeIdx = text.indexOf(span.close, span.open.length); if (closeIdx <= span.open.length) continue; const content = text.slice(span.open.length, closeIdx); if (!content.trim()) continue; - const contentStart = span.open.length; - const contentEnd = closeIdx; - return [ - token(0, contentStart, { markdownToken: true }), - token(contentStart, contentEnd, span.inner), - token(contentEnd, contentEnd + span.close.length, { markdownToken: true }), - ]; + return { + contentStart: span.open.length, + contentEnd: closeIdx, + inner: span.inner, + // Code spans are literal: markers inside them are not formatting. + recurse: !span.inner.markdownCode, + totalLength: closeIdx + span.close.length, + }; } return null; }; +const INLINE_OPENERS = ['**', '~~', '||', '__', '`', '*', '_', '['] as const; + +const hasMarks = (marks: MarkdownLeafMarks): boolean => + Object.values(marks).some((value) => value === true); + +// Scans a range and, for each matched span, dims the delimiters and recurses +// into the content with the enclosing marks inherited, so `**||x||**` styles +// the text as both bold and spoiler instead of stopping at the outer span. +// Unstyled runs inside a styled span keep the enclosing marks; at the top +// level (no marks) they stay plain and produce no decoration. +const scanInlineRange = ( + text: string, + from: number, + to: number, + marks: MarkdownLeafMarks, + tokens: MarkdownToken[] +): void => { + let i = from; + while (i < to) { + const rest = text.slice(i, to); + const match = matchLinkSpan(rest) ?? matchInlineSpan(rest); + if (!match) { + let next = -1; + for (const opener of INLINE_OPENERS) { + const idx = text.indexOf(opener, i + 1); + if (idx >= 0 && idx < to && (next === -1 || idx < next)) next = idx; + } + 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; + tokens.push(token(i, contentStart, { markdownToken: true })); + const inner: MarkdownLeafMarks = { ...marks, ...match.inner }; + if (match.recurse) { + scanInlineRange(text, contentStart, contentEnd, inner, tokens); + } else { + tokens.push(token(contentStart, contentEnd, inner)); + } + tokens.push(token(contentEnd, closeEnd, { markdownToken: true })); + i = closeEnd; + } +}; + /** * Scans a text node and returns the markdown spans that should be rendered * with formatting (the content) or dimmed (the syntax characters). @@ -113,19 +169,7 @@ export const tokenizeMarkdown = (text: string): MarkdownToken[] => { if (!text) return []; const tokens: MarkdownToken[] = []; collectBlockPrefixTokens(text, tokens); - - let i = 0; - while (i < text.length) { - const rest = text.slice(i); - const spanTokens = matchInlineSpan(rest); - if (spanTokens) { - const consumed = spanTokens[spanTokens.length - 1]!.end; - spanTokens.forEach((t) => tokens.push({ ...t, start: t.start + i, end: t.end + i })); - i += consumed; - } else { - i += 1; - } - } + scanInlineRange(text, 0, text.length, {}, tokens); return tokens; }; @@ -145,12 +189,13 @@ const tokenToDecoration = (nodeStart: number, t: MarkdownToken): Decoration | nu class: css.EditorMarkdownToken, }); } - for (const [mark, className] of STYLED_TOKEN_CLASSES) { - if (t[mark]) { - return Decoration.inline(nodeStart + t.start, nodeStart + t.end, { class: className }); - } - } - return null; + const classes = STYLED_TOKEN_CLASSES.filter(([mark]) => t[mark]).map( + ([, className]) => className + ); + if (!classes.length) return null; + return Decoration.inline(nodeStart + t.start, nodeStart + t.end, { + class: classes.join(' '), + }); }; export const markdownDecorations = (state: EditorState): DecorationSet => { diff --git a/src/app/components/editor/prosemirrorController.ts b/src/app/components/editor/prosemirrorController.ts index 39636701ff..a475b72098 100644 --- a/src/app/components/editor/prosemirrorController.ts +++ b/src/app/components/editor/prosemirrorController.ts @@ -101,6 +101,34 @@ export class ProseMirrorEditorController { .join('\n'); } + // Like getText, but atom nodes (mentions, emoticons, commands) contribute + // their display text instead of the \0 placeholder textBetween emits, so + // the markdown preview shows them. + getMarkdownPreviewText(): string { + const view = this.view; + if (!view) return this.getText(); + const paragraphText = (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; + }; + const paragraphs: string[] = []; + view.state.doc.content.forEach((paragraph) => paragraphs.push(paragraphText(paragraph))); + return paragraphs.join('\n'); + } + setDocument(document: EditorDocument): void { this.document = structuredClone(document.length ? document : emptyEditorDocument()); if (this.view) { diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 638b27d84b..9c77fd9ab5 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -648,7 +648,7 @@ export const RoomInput = forwardRef( const handleEditorChange = useCallback(() => { setHasText(!editor.isEmpty()); - setMarkdownPreview(showMarkdownPreview ? editor.getText() : ''); + setMarkdownPreview(showMarkdownPreview ? editor.getMarkdownPreviewText() : ''); detectAutocomplete(); if (!room.hasEncryptionStateEvent()) return; @@ -658,6 +658,11 @@ export const RoomInput = forwardRef( lastEncryptionPreparationAt.current = now; mx.getCrypto()?.prepareToEncrypt(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(); diff --git a/src/app/features/room/input/MarkdownPreview.tsx b/src/app/features/room/input/MarkdownPreview.tsx index 369ac0af3b..5b8c740b5c 100644 --- a/src/app/features/room/input/MarkdownPreview.tsx +++ b/src/app/features/room/input/MarkdownPreview.tsx @@ -4,6 +4,7 @@ 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; @@ -33,7 +34,11 @@ export function MarkdownPreview({ room, markdown }: MarkdownPreviewProps) { Preview - + {renderContent(EventType.RoomMessage, false, event, '', () => event.getContent())} From a3528c6922b48009f8fe64c4bd70bc6c84664424 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Wed, 12 Aug 2026 12:37:22 -0400 Subject: [PATCH 03/15] fix(timeline): stop repinning when the user scrolls up during composer growth The atBottom flag lags real scroll position by up to 100px, so a user who has started scrolling up is still flagged while a growing composer shrinks the timeline viewport. Gate the viewport ResizeObserver repin on the actual distance to the pre-resize bottom instead of the lagging flag. --- src/app/features/room/RoomTimeline.test.tsx | 53 ++++++++++++++++++++- src/app/features/room/RoomTimeline.tsx | 21 +++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index 7186ed9d11..383d478442 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -330,11 +330,11 @@ function ResizeObserverStub(this: unknown, callback: ResizeObserverCallback) { const nativeResizeObserver = globalThis.ResizeObserver; -const fireResize = (element: Element) => { +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 () => { @@ -800,6 +806,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..26c1c6d7f7 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,8 +955,24 @@ export function RoomTimeline({ contentObserver.observe(contentEl); } - const observer = new ResizeObserver(() => { - if (scrollOwnerRef.current === 'live' && atBottomRef.current) scrollToBottom(); + const observer = new ResizeObserver((entries) => { + const newHeight = entries[0]!.contentRect.height; + const prev = prevViewportHeightRef.current; + const shrank = newHeight < prev; + prevViewportHeightRef.current = newHeight; + 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(); + return; + } + } syncAtBottom(); }); From 96b94a621a8195e49014f9774306acf1fc20aba5 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Wed, 12 Aug 2026 16:15:27 -0400 Subject: [PATCH 04/15] refactor(editor): align markdown preview comments and helpers with repo conventions --- src/app/components/editor/markdown.ts | 19 ++++---- .../editor/prosemirrorController.ts | 44 ++++++++++--------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/src/app/components/editor/markdown.ts b/src/app/components/editor/markdown.ts index e75147ae08..0cabc3871d 100644 --- a/src/app/components/editor/markdown.ts +++ b/src/app/components/editor/markdown.ts @@ -116,14 +116,11 @@ const matchInlineSpan = (text: string): InlineMatch | null => { const INLINE_OPENERS = ['**', '~~', '||', '__', '`', '*', '_', '['] as const; -const hasMarks = (marks: MarkdownLeafMarks): boolean => - Object.values(marks).some((value) => value === true); - -// Scans a range and, for each matched span, dims the delimiters and recurses -// into the content with the enclosing marks inherited, so `**||x||**` styles -// the text as both bold and spoiler instead of stopping at the outer span. -// Unstyled runs inside a styled span keep the enclosing marks; at the top -// level (no marks) they stay plain and produce no decoration. +const hasMarks = (marks: MarkdownLeafMarks): boolean => Object.values(marks).some(Boolean); + +// 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 scanInlineRange = ( text: string, from: number, @@ -211,8 +208,10 @@ export const markdownDecorations = (state: EditorState): DecorationSet => { 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. +/** + * 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), diff --git a/src/app/components/editor/prosemirrorController.ts b/src/app/components/editor/prosemirrorController.ts index a475b72098..7727756277 100644 --- a/src/app/components/editor/prosemirrorController.ts +++ b/src/app/components/editor/prosemirrorController.ts @@ -23,6 +23,24 @@ import { markdownPreviewPlugin } 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; @@ -101,31 +119,15 @@ export class ProseMirrorEditorController { .join('\n'); } - // Like getText, but atom nodes (mentions, emoticons, commands) contribute - // their display text instead of the \0 placeholder textBetween emits, so - // the markdown preview shows them. + /** 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 paragraphText = (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; - }; const paragraphs: string[] = []; - view.state.doc.content.forEach((paragraph) => paragraphs.push(paragraphText(paragraph))); + view.state.doc.content.forEach((paragraph) => + paragraphs.push(paragraphToPreviewText(paragraph)) + ); return paragraphs.join('\n'); } From ab3096b44c79a2e5e6ba0a8ff05470aec1a953b8 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Thu, 13 Aug 2026 02:07:48 -0400 Subject: [PATCH 05/15] feat(editor): render the markdown preview like sent messages Style fenced code with arborium's real token elements, keep backslash escaped delimiters literal, scale heading levels (# to ###### onto folds' H2 to H6) at weight 600 with real bold inside, and underline preview links only when the underline-links setting is on. --- src/app/components/editor/Editor.css.ts | 40 +- src/app/components/editor/markdown.test.ts | 329 +++++++++++++- src/app/components/editor/markdown.ts | 407 ++++++++++++++++-- .../editor/prosemirrorController.ts | 7 +- src/index.css | 5 + 5 files changed, 737 insertions(+), 51 deletions(-) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 3ece340727..8bd473b2f5 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -125,10 +125,6 @@ export const EditorMarkdownToken = style({ opacity: 0.4, }); -export const EditorMarkdownBold = style({ - fontWeight: 700, -}); - export const EditorMarkdownItalic = style({ fontStyle: 'italic', }); @@ -143,8 +139,6 @@ export const EditorMarkdownStrikeThrough = style({ export const EditorMarkdownLink = style({ color: color.Primary.OnContainer, - textDecoration: 'underline', - textUnderlineOffset: toRem(2), }); export const EditorMarkdownCode = style([ @@ -159,6 +153,40 @@ export const EditorMarkdownCode = style([ }, ]); +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, diff --git a/src/app/components/editor/markdown.test.ts b/src/app/components/editor/markdown.test.ts index 9f423c114a..52d6d6f949 100644 --- a/src/app/components/editor/markdown.test.ts +++ b/src/app/components/editor/markdown.test.ts @@ -1,17 +1,24 @@ -import { describe, expect, it, beforeAll } from 'vitest'; +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; @@ -170,9 +177,126 @@ describe('tokenizeMarkdown', () => { 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('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[]) => { @@ -185,8 +309,11 @@ const decorationFinder = (texts: string[]) => { return markdownDecorations(state).find(1, doc.content.size); }; -const renderEditor = (text: string) => { - const editorDocument: EditorDocument = [{ type: BlockType.Paragraph, children: [{ text }] }]; +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'); @@ -216,32 +343,220 @@ describe('markdownPreviewPlugin decorations', () => { ]); }); + 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**'); + 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||'); + 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 { 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'); + 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); + }); }); diff --git a/src/app/components/editor/markdown.ts b/src/app/components/editor/markdown.ts index 0cabc3871d..caff4cca1b 100644 --- a/src/app/components/editor/markdown.ts +++ b/src/app/components/editor/markdown.ts @@ -1,6 +1,8 @@ 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 = { @@ -10,8 +12,11 @@ export type MarkdownLeafMarks = { markdownUnderline?: boolean; markdownStrikeThrough?: boolean; markdownCode?: boolean; + markdownCodeBlock?: boolean; + markdownHeading?: number; markdownSpoiler?: boolean; markdownLink?: boolean; + url?: string; }; export type MarkdownToken = { @@ -25,31 +30,26 @@ const token = (start: number, end: number, marks: MarkdownLeafMarks): MarkdownTo ...marks, }); -// Line-start markers (headings, quotes, lists, code fences) are dimmed, not styled. -const BLOCK_PREFIX_PATTERNS: ReadonlyArray<{ re: RegExp; marks: MarkdownLeafMarks }> = [ - { re: /^#{1,6}\s+/, marks: { markdownToken: true } }, - { re: /^>\s/, marks: { markdownToken: true } }, - { re: /^[-+*]\s/, marks: { markdownToken: true } }, - { re: /^\d+\.\s/, marks: { markdownToken: true } }, - { re: /^```/, marks: { markdownToken: true } }, +// 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 }, ]; -const collectBlockPrefixTokens = (text: string, tokens: MarkdownToken[]): void => { - let lineStart = 0; - while (lineStart < text.length) { - const nl = text.indexOf('\n', lineStart); - const lineEnd = nl < 0 ? text.length : nl; - const line = text.slice(lineStart, lineEnd); - for (const { re, marks } of BLOCK_PREFIX_PATTERNS) { - const match = line.match(re); - if (match) { - tokens.push(token(lineStart, lineStart + match[0].length, marks)); - break; - } +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 }; } - if (nl < 0) break; - lineStart = nl + 1; + return { length: match[0].length, headingLevel: 0 }; } + return null; }; // Inline delimiters. Order matters: longer delimiters before shorter ones so @@ -78,7 +78,10 @@ type InlineMatch = { const matchLinkSpan = (text: string): InlineMatch | null => { if (!text.startsWith('[')) return null; - const closeBracket = text.indexOf(']'); + 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); @@ -89,7 +92,7 @@ const matchLinkSpan = (text: string): InlineMatch | null => { return { contentStart: 1, contentEnd: closeBracket, - inner: { markdownLink: true }, + inner: { markdownLink: true, url }, recurse: true, totalLength: closeParen + 1, }; @@ -98,7 +101,10 @@ const matchLinkSpan = (text: string): InlineMatch | null => { const matchInlineSpan = (text: string): InlineMatch | null => { for (const span of INLINE_SPANS) { if (!text.startsWith(span.open)) continue; - const closeIdx = text.indexOf(span.close, span.open.length); + let closeIdx = text.indexOf(span.close, span.open.length); + while (closeIdx >= 0 && isEscaped(text, closeIdx)) { + closeIdx = text.indexOf(span.close, closeIdx + 1); + } if (closeIdx <= span.open.length) continue; const content = text.slice(span.open.length, closeIdx); if (!content.trim()) continue; @@ -114,14 +120,40 @@ const matchInlineSpan = (text: string): InlineMatch | null => { 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. +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 scanInlineRange = ( +const scanInline = ( text: string, from: number, to: number, @@ -131,13 +163,36 @@ const scanInlineRange = ( let i = from; while (i < to) { const rest = text.slice(i, to); - const match = matchLinkSpan(rest) ?? matchInlineSpan(rest); + // 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(rest); 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) { - const idx = text.indexOf(opener, i + 1); + 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; @@ -146,27 +201,69 @@ const scanInlineRange = ( const contentStart = i + match.contentStart; const contentEnd = i + match.contentEnd; const closeEnd = i + match.totalLength; - tokens.push(token(i, contentStart, { markdownToken: true })); + if (contentStart > i) tokens.push(token(i, contentStart, { markdownToken: true })); const inner: MarkdownLeafMarks = { ...marks, ...match.inner }; if (match.recurse) { - scanInlineRange(text, contentStart, contentEnd, inner, tokens); + scanInline(text, contentStart, contentEnd, inner, tokens); } else { tokens.push(token(contentStart, contentEnd, inner)); } - tokens.push(token(contentEnd, closeEnd, { markdownToken: true })); + 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). + * 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[] = []; - collectBlockPrefixTokens(text, tokens); - scanInlineRange(text, 0, text.length, {}, tokens); + 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; }; @@ -176,10 +273,22 @@ const STYLED_TOKEN_CLASSES: ReadonlyArray<[keyof MarkdownLeafMarks, string]> = [ ['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, { @@ -189,22 +298,233 @@ const tokenToDecoration = (nodeStart: number, t: MarkdownToken): Decoration | nu 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.isText || !node.text) return true; - for (const t of tokenizeMarkdown(node.text)) { - const decoration = tokenToDecoration(pos, t); - if (decoration) decorations.push(decoration); + 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 { + lineTokensToDecorations(children, tokenizeLine(line, true), decorations); + } + return true; } return true; }); + if (pending.length && previewDispatch) scheduleHighlights(pending); return DecorationSet.create(state.doc, decorations); }; @@ -215,5 +535,18 @@ export const markdownDecorations = (state: EditorState): DecorationSet => { 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.ts b/src/app/components/editor/prosemirrorController.ts index 7727756277..72294cc488 100644 --- a/src/app/components/editor/prosemirrorController.ts +++ b/src/app/components/editor/prosemirrorController.ts @@ -18,7 +18,7 @@ import { toProseMirrorDocument, toProseMirrorInline, } from './prosemirrorSchema'; -import { markdownPreviewPlugin } from './markdown'; +import { markdownPreviewPlugin, setMarkdownPreviewDispatch } from './markdown'; const isProseMirrorDocumentEmpty = (doc: ProseMirrorNode): boolean => doc.childCount === 1 && doc.firstChild?.content.size === 0; @@ -204,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/index.css b/src/index.css index a80a37d106..ad0ed9b193 100755 --- a/src/index.css +++ b/src/index.css @@ -158,6 +158,11 @@ pre { text-underline-offset: 2px; } +.force-underline-links [data-markdown-preview-link] { + text-decoration: underline !important; + text-underline-offset: 2px; +} + body.reduced-motion * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; From b3a4a31914726e5d09f71b11889e31f5708e2847 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Thu, 13 Aug 2026 02:18:33 -0400 Subject: [PATCH 06/15] docs: update changeset for the final markdown preview feature set --- .changeset/add-composer-markdown-preview-prosemirror.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/add-composer-markdown-preview-prosemirror.md b/.changeset/add-composer-markdown-preview-prosemirror.md index 5574692e23..01c3ff9ac1 100644 --- a/.changeset/add-composer-markdown-preview-prosemirror.md +++ b/.changeset/add-composer-markdown-preview-prosemirror.md @@ -2,4 +2,4 @@ default: minor --- -Add a live markdown preview to the composer. Markdown syntax characters are dimmed and their content rendered (bold, italic, underline, strikethrough, code, spoilers, and links) while typing, Discord-style, and an optional rendered preview box can be enabled above the composer in Settings. +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. From 29250a831d216894a1942923da4be666c63de649 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Fri, 14 Aug 2026 07:30:50 -0400 Subject: [PATCH 07/15] fix(editor): keep intraword underscores literal in the markdown preview --- src/app/components/editor/markdown.test.ts | 37 +++++++++++++++ src/app/components/editor/markdown.ts | 53 ++++++++++++++++++---- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/app/components/editor/markdown.test.ts b/src/app/components/editor/markdown.test.ts index 52d6d6f949..60da38b326 100644 --- a/src/app/components/editor/markdown.test.ts +++ b/src/app/components/editor/markdown.test.ts @@ -158,6 +158,43 @@ describe('tokenizeMarkdown', () => { 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); diff --git a/src/app/components/editor/markdown.ts b/src/app/components/editor/markdown.ts index caff4cca1b..6600cc432f 100644 --- a/src/app/components/editor/markdown.ts +++ b/src/app/components/editor/markdown.ts @@ -98,23 +98,58 @@ const matchLinkSpan = (text: string): InlineMatch | null => { }; }; -const matchInlineSpan = (text: string): InlineMatch | null => { +// 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 (!text.startsWith(span.open)) continue; - let closeIdx = text.indexOf(span.close, span.open.length); - while (closeIdx >= 0 && isEscaped(text, closeIdx)) { + 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 <= span.open.length) continue; - const content = text.slice(span.open.length, closeIdx); + 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: closeIdx, + contentEnd: relClose, inner: span.inner, // Code spans are literal: markers inside them are not formatting. recurse: !span.inner.markdownCode, - totalLength: closeIdx + span.close.length, + totalLength: relClose + span.close.length, }; } return null; @@ -168,7 +203,7 @@ const scanInline = ( const match = matchLinkSpan(rest) ?? (marks.markdownLink ? null : matchBareUrl(rest)) ?? - matchInlineSpan(rest); + matchInlineSpan(text, i, to); if (!match) { // A backslash keeps the next character literal. if (text[i] === '\\' && i + 1 < to) { From a9ec1cbc5af2db3112515389deb5704b976b58ac Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Fri, 14 Aug 2026 07:46:10 -0400 Subject: [PATCH 08/15] fix(timeline): satisfy consistent-return in the viewport observer --- src/app/features/room/RoomTimeline.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 26c1c6d7f7..744b437c34 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -960,6 +960,7 @@ export function RoomTimeline({ 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 @@ -970,10 +971,10 @@ export function RoomTimeline({ if (wasPinned) { // Geometry is still pre-scroll here; the repin's own scroll event resyncs. scrollToBottom(); - return; + rePinned = true; } } - syncAtBottom(); + if (!rePinned) syncAtBottom(); }); observer.observe(el); From 4f9af65cca44962177bb8b839935415fb6e7abf0 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Fri, 14 Aug 2026 07:50:31 -0400 Subject: [PATCH 09/15] test(timeline): model a genuinely pinned viewport in the shrink re-pin test --- src/app/features/room/RoomTimeline.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index 383d478442..8b04f70695 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -456,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, From d3780aafe59ce1860fc5b21f09e07c6bbec5cb2b Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Fri, 14 Aug 2026 07:57:55 -0400 Subject: [PATCH 10/15] refactor(editor): align markdown preview JSDoc with repo conventions --- src/app/components/editor/ProseMirrorEditable.tsx | 6 +++--- src/app/components/editor/markdown.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/components/editor/ProseMirrorEditable.tsx b/src/app/components/editor/ProseMirrorEditable.tsx index 5a6bf921f5..d93d7b6666 100644 --- a/src/app/components/editor/ProseMirrorEditable.tsx +++ b/src/app/components/editor/ProseMirrorEditable.tsx @@ -6,9 +6,9 @@ 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. */ + * 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); }; diff --git a/src/app/components/editor/markdown.ts b/src/app/components/editor/markdown.ts index 6600cc432f..af850d77e3 100644 --- a/src/app/components/editor/markdown.ts +++ b/src/app/components/editor/markdown.ts @@ -400,8 +400,8 @@ 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. */ + * 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; }; @@ -524,8 +524,8 @@ const decorateCodeLine = ( }; /** 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. */ + * 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 }> = []; From b6e010d92d48c9ba0ff01c2e61d0c64e118c18b7 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Fri, 14 Aug 2026 15:07:16 -0400 Subject: [PATCH 11/15] fix(settings): register the markdown preview toggle's focusId --- src/app/features/settings/settingsLink.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index d8cda46fe8..573719087e 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -76,6 +76,7 @@ export const settingsLinkFocusIdsBySection: Record Date: Fri, 14 Aug 2026 16:18:27 -0400 Subject: [PATCH 12/15] fix(editor): style the markdown preview box scrollbar --- src/app/components/editor/Editor.css.ts | 1 - src/app/features/room/input/MarkdownPreview.tsx | 11 +++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 8bd473b2f5..5271942797 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -194,6 +194,5 @@ export const EditorMarkdownSpoiler = style({ export const EditorMarkdownPreviewContent = style({ maxHeight: toRem(220), - overflowY: 'auto', overscrollBehavior: 'contain', }); diff --git a/src/app/features/room/input/MarkdownPreview.tsx b/src/app/features/room/input/MarkdownPreview.tsx index 5b8c740b5c..398e280291 100644 --- a/src/app/features/room/input/MarkdownPreview.tsx +++ b/src/app/features/room/input/MarkdownPreview.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Box, Text, config } from 'folds'; +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'; @@ -34,13 +34,16 @@ export function MarkdownPreview({ room, markdown }: MarkdownPreviewProps) { Preview - {renderContent(EventType.RoomMessage, false, event, '', () => event.getContent())} - + ); } From cab152d575fb2f450e9cfd0cb019947804f10f6b Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Sat, 15 Aug 2026 22:56:06 -0400 Subject: [PATCH 13/15] feat(editor): preview markdown thematic breaks --- src/app/components/editor/Editor.css.ts | 6 ++++ src/app/components/editor/markdown.test.ts | 37 ++++++++++++++++++++++ src/app/components/editor/markdown.ts | 9 ++++++ 3 files changed, 52 insertions(+) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 5271942797..caa24f056c 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -125,6 +125,12 @@ 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', }); diff --git a/src/app/components/editor/markdown.test.ts b/src/app/components/editor/markdown.test.ts index 60da38b326..2c53afdbfc 100644 --- a/src/app/components/editor/markdown.test.ts +++ b/src/app/components/editor/markdown.test.ts @@ -596,4 +596,41 @@ describe('markdownPreviewPlugin decorations', () => { 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 index af850d77e3..ec0e893029 100644 --- a/src/app/components/editor/markdown.ts +++ b/src/app/components/editor/markdown.ts @@ -39,6 +39,11 @@ const BLOCK_PREFIX_PATTERNS: ReadonlyArray<{ re: RegExp; heading: boolean }> = [ { 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); @@ -552,6 +557,10 @@ export const markdownDecorations = (state: EditorState): DecorationSet => { } } 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); } From fda6352276866d8bff7a370b6c1f779678d8978a Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Sat, 15 Aug 2026 22:56:11 -0400 Subject: [PATCH 14/15] fix(editor): keep underscores inside bare URLs in the markdown preview --- src/app/components/editor/markdown.test.ts | 13 +++++++++++++ src/app/components/editor/markdown.ts | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/components/editor/markdown.test.ts b/src/app/components/editor/markdown.test.ts index 2c53afdbfc..26b0a4ad6f 100644 --- a/src/app/components/editor/markdown.test.ts +++ b/src/app/components/editor/markdown.test.ts @@ -276,6 +276,19 @@ describe('tokenizeMarkdown', () => { 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); diff --git a/src/app/components/editor/markdown.ts b/src/app/components/editor/markdown.ts index ec0e893029..5d08d1da81 100644 --- a/src/app/components/editor/markdown.ts +++ b/src/app/components/editor/markdown.ts @@ -161,8 +161,10 @@ const matchInlineSpan = (text: string, from: number, to: number): InlineMatch | }; // 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. -const BARE_URL_RE = /(?`|*_~]+/i; +// [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); From ccd0f149366e4bd99c0ba1074a40b18e5485fc0e Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Sun, 16 Aug 2026 01:38:50 -0400 Subject: [PATCH 15/15] fix(editor): match markdown preview inline code to sent-message code size --- src/app/components/editor/Editor.css.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index caa24f056c..291cbc0373 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -151,6 +151,7 @@ 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}`,