diff --git a/apps/web/features/editor/content-editor.css b/apps/web/features/editor/content-editor.css index 697771a7..ca0a3aba 100644 --- a/apps/web/features/editor/content-editor.css +++ b/apps/web/features/editor/content-editor.css @@ -1,4 +1,29 @@ -/* Rich text editor: ProseMirror styles using shadcn design tokens */ +/* + * ContentEditor typography — ProseMirror styles using shadcn design tokens. + * + * Design tier: "Compact" (same tier as Linear, Slack). Optimized for short-form + * content (issue descriptions, comments) that users scan, not long-form reading. + * + * Typography values benchmarked against (April 2026): + * - github-markdown-css (GitHub's markdown renderer) + * - @tailwindcss/typography prose-sm preset + * - Linear's editor (Tiptap-based, 14px body) + * + * Key decisions: + * Body: 14px (text-sm), line-height 1.625 (between GitHub 1.5 and Tailwind 1.714) + * Headings: h1=22px (1.57x), h2=18px (1.29x), h3=15px (1.07x) — compact but + * with clear hierarchy. Previous h3 was 14px (same as body = no differentiation). + * Paragraph spacing: 10px (was 8px; GitHub uses 10px, Tailwind prose-sm uses 16px) + * List indent: 20px for ul (was 16px; standard is 22-32px) + * Code block margin: 12px (was 8px; gives breathing room between code and prose) + * Blockquote border: 3px (was 2px; GitHub/Tailwind both use 4px) + * Links: var(--brand) blue with 40% opacity underline (was var(--primary) near-black) + * + * Inline elements (mention cards, inline code) that exceed line-height: + * The browser auto-expands the line box for lines containing taller inline + * elements. Controlled via vertical-align on [data-node-view-wrapper] and + * box-decoration-break: clone on inline code. + */ .rich-text-editor.ProseMirror { color: var(--foreground); diff --git a/apps/web/features/editor/content-editor.tsx b/apps/web/features/editor/content-editor.tsx index 1517bcbf..85e040d2 100644 --- a/apps/web/features/editor/content-editor.tsx +++ b/apps/web/features/editor/content-editor.tsx @@ -1,5 +1,30 @@ "use client"; +/** + * ContentEditor — the single rich-text editor for the entire application. + * + * Architecture decisions (April 2026 refactor): + * + * 1. ONE COMPONENT for both editing and readonly display. The `editable` prop + * controls the mode. Previously we had RichTextEditor + ReadonlyEditor as + * separate components with duplicated extension configs — this caused + * visual inconsistency between edit and display modes. + * + * 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with + * `contentType: 'markdown'` and saved with `editor.getMarkdown()`. + * Previously we had a custom `markdownToHtml()` pipeline (Marked library) + * for loading and regex post-processing for saving — two asymmetric paths + * that caused roundtrip inconsistencies. The @tiptap/markdown extension + * (v3.21.0+) handles table cell

wrapping and custom mention tokenizers + * natively, eliminating the need for the HTML detour. + * + * 3. PREPROCESSING is minimal: only legacy mention shortcode migration and + * URL linkification (preprocessMarkdown). No HTML conversion. + * + * Tech: Tiptap v3.22.1 (ProseMirror wrapper), @tiptap/markdown for + * bidirectional Markdown ↔ ProseMirror JSON conversion. + */ + import { forwardRef, useEffect, diff --git a/apps/web/features/editor/extensions/index.ts b/apps/web/features/editor/extensions/index.ts index b901b452..c182dd8a 100644 --- a/apps/web/features/editor/extensions/index.ts +++ b/apps/web/features/editor/extensions/index.ts @@ -1,3 +1,24 @@ +/** + * Shared extension factory for ContentEditor. + * + * One function builds the extension array for BOTH edit and readonly modes. + * This ensures visual consistency — the same extensions parse and render + * content identically regardless of mode. + * + * Split: + * - Both modes: StarterKit, CodeBlock, Link, Image, Table, Markdown, Mention + * - Edit only: Typography, Placeholder, markdownPaste, submitShortcut, + * fileUpload, Mention suggestion popup + * + * Link config differs: edit mode has autolink (detects URLs while typing), + * readonly does not (prevents false positives on display). + * + * Mention suggestion is only attached in edit mode — readonly doesn't need + * the autocomplete popup. + * + * All link styling is controlled by content-editor.css (var(--brand) color), + * not Tailwind HTMLAttributes, to keep a single source of truth. + */ import type { RefObject } from "react"; import StarterKit from "@tiptap/starter-kit"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; diff --git a/apps/web/features/editor/extensions/markdown-paste.ts b/apps/web/features/editor/extensions/markdown-paste.ts index 7dde1fb9..00132ce2 100644 --- a/apps/web/features/editor/extensions/markdown-paste.ts +++ b/apps/web/features/editor/extensions/markdown-paste.ts @@ -1,3 +1,27 @@ +/** + * Markdown paste extension — ensures pasted text is parsed as Markdown. + * + * Problem: The browser clipboard can contain BOTH text/plain and text/html. + * ProseMirror always prefers text/html when present (hardcoded in + * parseFromClipboard: `let asText = !html`). When copying from VS Code, + * text editors, or .md files, the OS wraps text in

/
HTML tags. + * ProseMirror parses these as code blocks — wrong. + * + * Solution: Use `handlePaste` (the only ProseMirror prop that runs for ALL + * paste events and has access to raw ClipboardEvent). We check for + * `data-pm-slice` in the HTML — this attribute is added by ProseMirror's + * own clipboard serializer. If present, the source is another ProseMirror + * editor and its HTML is structurally correct — let ProseMirror handle it. + * Otherwise, ignore the HTML and parse text/plain as Markdown. + * + * Why not clipboardTextParser? It only runs when there's NO text/html on + * the clipboard (ProseMirror source: `let asText = !!text && !html`). + * + * Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable. + * VS Code's HTML contains tags that fool rich-content detectors. + * Markdown pattern matching has too many edge cases. The data-pm-slice + * check is deterministic — no false positives. + */ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Slice } from "@tiptap/pm/model"; diff --git a/apps/web/features/editor/extensions/mention-view.tsx b/apps/web/features/editor/extensions/mention-view.tsx index b721140a..0f62158d 100644 --- a/apps/web/features/editor/extensions/mention-view.tsx +++ b/apps/web/features/editor/extensions/mention-view.tsx @@ -1,5 +1,23 @@ "use client"; +/** + * MentionView — NodeView for rendering @mentions inline in the editor. + * + * Member/agent mentions: plain "@Name" text with .mention class styling. + * Issue mentions: inline card with StatusIcon + identifier + title. + * + * Issue card sizing: must fit within the paragraph line box (14px * 1.625 + * = 22.75px). Card uses text-xs (12px) + py-0.5 + border ≈ 22px total. + * vertical-align: middle is set on the [data-node-view-wrapper] in CSS + * (not on the tag) because the wrapper is the outermost inline element + * that participates in line box calculation. Setting it on the inner + * had no effect since the wrapper was already positioned. + * + * Fallback: when issue is not in the Zustand store (deleted or other + * workspace), the same card style is used with just the identifier from + * fallbackLabel — no visual degradation to a plain text link. + */ + import { NodeViewWrapper } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react"; import { useIssueStore } from "@/features/issues/store"; diff --git a/apps/web/features/editor/utils/preprocess.ts b/apps/web/features/editor/utils/preprocess.ts index d820b79a..ffb4d259 100644 --- a/apps/web/features/editor/utils/preprocess.ts +++ b/apps/web/features/editor/utils/preprocess.ts @@ -4,9 +4,17 @@ import { preprocessMentionShortcodes } from "@/components/markdown/mentions"; /** * Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'. * - * Two string→string transforms: + * This is the ONLY transform applied before @tiptap/markdown parses the content. + * It does NOT convert to HTML — that was the old markdownToHtml.ts pipeline which + * was deleted in the April 2026 refactor. + * + * Two string→string transforms on raw Markdown: * 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id) - * 2. Raw URLs → markdown links (so they render as clickable Link nodes) + * (old serialization format in database, migrated on read) + * 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes) + * + * After this, @tiptap/markdown's parse() handles everything else: headings, lists, + * tables, code blocks, and our custom mention tokenizer ([@Name](mention://type/id)). */ export function preprocessMarkdown(markdown: string): string { if (!markdown) return "";