Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e5daa46
feat(editor): add live markdown preview while typing
Th-Underscore Aug 12, 2026
62c600b
fix(editor): refine the live markdown preview
Th-Underscore Aug 12, 2026
a3528c6
fix(timeline): stop repinning when the user scrolls up during compose…
Th-Underscore Aug 12, 2026
96b94a6
refactor(editor): align markdown preview comments and helpers with re…
Th-Underscore Aug 12, 2026
ab3096b
feat(editor): render the markdown preview like sent messages
Th-Underscore Aug 13, 2026
b3a4a31
docs: update changeset for the final markdown preview feature set
Th-Underscore Aug 13, 2026
29250a8
fix(editor): keep intraword underscores literal in the markdown preview
Th-Underscore Aug 14, 2026
a9ec1cb
fix(timeline): satisfy consistent-return in the viewport observer
Th-Underscore Aug 14, 2026
4f9af65
test(timeline): model a genuinely pinned viewport in the shrink re-pi…
Th-Underscore Aug 14, 2026
d3780aa
refactor(editor): align markdown preview JSDoc with repo conventions
Th-Underscore Aug 14, 2026
b6e010d
fix(settings): register the markdown preview toggle's focusId
Th-Underscore Aug 14, 2026
5d8d023
fix(editor): style the markdown preview box scrollbar
Th-Underscore Aug 14, 2026
cab152d
feat(editor): preview markdown thematic breaks
Th-Underscore Aug 16, 2026
fda6352
fix(editor): keep underscores inside bare URLs in the markdown preview
Th-Underscore Aug 16, 2026
ccd0f14
fix(editor): match markdown preview inline code to sent-message code …
Th-Underscore Aug 16, 2026
f2c61e8
Merge branch 'dev' into feat/markdown-preview-prosemirror
Th-Underscore Aug 17, 2026
cf5a853
feat(editor): support double-backtick inline code in the markdown pre…
Th-Underscore Aug 21, 2026
d0e3fc8
Merge branch 'dev' into feat/markdown-preview-prosemirror
Th-Underscore Aug 24, 2026
70c8e86
fix(editor): parse highlight html with the platform parser
Th-Underscore Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-composer-markdown-preview-prosemirror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add a live markdown preview to the composer: markdown syntax characters stay visible but dimmed, with their content styled inline while typing (bold, italic, underline, strikethrough, spoilers, links, per-level headings, and fenced code blocks syntax-highlighted like sent messages), plus an optional rendered preview box that can be enabled above the composer in Settings. Also lets Enter insert a newline in the composer and stops the timeline from snapping back to the bottom when the composer grows while scrolling up.
83 changes: 83 additions & 0 deletions src/app/components/editor/Editor.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,86 @@ export const EditorToolbarBase = style({
export const EditorToolbar = style({
padding: config.space.S100,
});

export const EditorMarkdownToken = style({
opacity: 0.4,
});

// Matches the dimmed block markers and the sent renderer's <hr> border.
export const EditorMarkdownDivider = style({
opacity: 0.4,
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
});

export const EditorMarkdownItalic = style({
fontStyle: 'italic',
});

export const EditorMarkdownUnderline = style({
textDecoration: 'underline',
});

export const EditorMarkdownStrikeThrough = style({
textDecoration: 'line-through',
});

export const EditorMarkdownLink = style({
color: color.Primary.OnContainer,
});

export const EditorMarkdownCode = style([
DefaultReset,
{
fontFamily: 'var(--font-monospace)',
fontSize: '0.9em',
color: color.SurfaceVariant.OnContainer,
background: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `0 ${config.space.S100}`,
},
]);

export const EditorMarkdownCodeBlock = style({
fontFamily: 'var(--font-monospace)',
fontSize: '0.9em',
lineHeight: 1.3,
letterSpacing: '-0.01em',
fontVariantLigatures: 'contextual',
background: color.SurfaceVariant.Container,
});

// Heading levels reuse folds' own heading sizes/weights so the preview matches
// the sent renderer exactly (the message parser maps `#`…`######` onto
// Text sizes H2…H6, all at weight 600).
const heading = (size: 'H2' | 'H3' | 'H4' | 'H5' | 'H6') =>
style({
fontSize: config.fontSize[size],
lineHeight: config.lineHeight[size],
letterSpacing: config.letterSpacing[size],
fontWeight: config.fontWeight.W600,
});

export const EditorMarkdownHeading1 = heading('H2');
export const EditorMarkdownHeading2 = heading('H3');
export const EditorMarkdownHeading3 = heading('H4');
export const EditorMarkdownHeading4 = heading('H4');
export const EditorMarkdownHeading5 = heading('H5');
export const EditorMarkdownHeading6 = heading('H6');

// Declared after the heading classes so its 700 weight beats a heading's 600
// when `# **bold**` stacks both classes onto one span (as the sent renderer's
// `<strong>` inside the heading does).
export const EditorMarkdownBold = style({
fontWeight: 700,
});

export const EditorMarkdownSpoiler = style({
backgroundColor: `color-mix(in srgb, ${color.SurfaceVariant.ContainerLine} 30%, transparent)`,
borderRadius: config.radii.R300,
});

export const EditorMarkdownPreviewContent = style({
maxHeight: toRem(220),
overscrollBehavior: 'contain',
});
20 changes: 16 additions & 4 deletions src/app/components/editor/ProseMirrorEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event>();

/** 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;
Expand Down Expand Up @@ -37,12 +49,12 @@ export const ProseMirrorEditable = forwardRef<ProseMirrorEditableHandle, ProseMi
) => {
const rootRef = useRef<HTMLDivElement | null>(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<HTMLDivElement> = (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();
};
Expand Down
Loading
Loading