refactor(web): share markdown element styles between viewer and editor

Extract the Tailwind classes for common markdown elements (paragraph,
blockquote, lists, inline code, link, hr, headings) into a single
markdownStyles.ts consumed by both the read-only MemoContent components
and the WYSIWYG editor, replacing the duplicated per-element strings and
the .memo-wysiwyg CSS block. Heading classes are precomputed per level so
the hot renderHTML path is a lookup, not a cn() merge.

Also require at least one character after `#` before opening the tag
suggestion menu so a bare `#` (or `# ` heading) no longer conflicts with
markdown headings.
pull/6033/head
boojack 3 weeks ago
parent 2c0efeba7c
commit 00225db922

@ -37,6 +37,7 @@
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9",
"@tiptap/core": "3.26.0",
"@tiptap/extension-heading": "3.26.0",
"@tiptap/extension-list": "3.26.0",
"@tiptap/extensions": "3.26.0",
"@tiptap/markdown": "3.26.0",

@ -68,6 +68,9 @@ importers:
'@tiptap/core':
specifier: 3.26.0
version: 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/extension-heading':
specifier: 3.26.0
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
'@tiptap/extension-list':
specifier: 3.26.0
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)

@ -4,6 +4,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo";
import { colorToHex } from "@/lib/color";
import { tagStyles } from "@/lib/markdownStyles";
import { findTagMetadata } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
@ -66,11 +67,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
return (
<span
className={cn(
"inline-flex items-center align-baseline px-1.5 py-0.5 text-[0.9em] leading-none font-normal rounded-full border cursor-pointer transition-opacity hover:opacity-75",
!bgHex && "border-primary text-primary bg-primary/15",
className,
)}
className={cn(tagStyles.base, "cursor-pointer transition-opacity hover:opacity-75", !bgHex && tagStyles.defaultColor, className)}
style={tagStyle}
data-tag={tag}
{...props}

@ -1,3 +1,4 @@
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
import type { ReactMarkdownProps } from "./types";
@ -11,7 +12,7 @@ interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElemen
*/
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
return (
<blockquote className={cn("my-0 mb-2 border-l-4 border-primary/30 pl-3 text-muted-foreground italic", className)} {...props}>
<blockquote className={cn(markdownStyles.blockquote, className)} {...props}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</blockquote>
);

@ -1,3 +1,4 @@
import { headingClass } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
@ -14,17 +15,8 @@ interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement>, ReactMa
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
const Component = `h${level}` as const;
const levelClasses = {
1: "text-3xl font-bold border-b border-border pb-2",
2: "text-2xl font-semibold border-b border-border pb-1.5",
3: "text-xl font-semibold",
4: "text-lg font-semibold",
5: "text-base font-semibold",
6: "text-base font-medium text-muted-foreground",
};
return (
<Component className={cn("mt-3 mb-2 leading-tight", levelClasses[level], className)} {...props}>
<Component className={cn(headingClass(level), className)} {...props}>
{children}
</Component>
);

@ -1,3 +1,4 @@
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
@ -7,5 +8,5 @@ interface HorizontalRuleProps extends React.HTMLAttributes<HTMLHRElement>, React
* Horizontal rule separator
*/
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
return <hr className={cn("my-2 h-0 border-0 border-b border-border", className)} {...props} />;
return <hr className={cn(markdownStyles.horizontalRule, className)} {...props} />;
};

@ -1,3 +1,4 @@
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
@ -10,7 +11,7 @@ interface InlineCodeProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdo
*/
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
return (
<code className={cn("font-mono text-sm bg-muted px-1 py-0.5 rounded-md", className)} {...props}>
<code className={cn(markdownStyles.inlineCode, className)} {...props}>
{children}
</code>
);

@ -1,3 +1,4 @@
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
@ -11,16 +12,7 @@ interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, React
*/
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"text-primary underline decoration-primary/50 underline-offset-2 transition-colors hover:decoration-primary",
className,
)}
{...props}
>
<a href={href} target="_blank" rel="noopener noreferrer" className={cn(markdownStyles.link, className)} {...props}>
{children}
</a>
);

@ -1,4 +1,5 @@
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
@ -72,20 +73,12 @@ interface ListProps extends React.HTMLAttributes<HTMLUListElement | HTMLOListEle
export const List = ({ ordered, children, className, node: _node, ...domProps }: ListProps) => {
const Component = ordered ? "ol" : "ul";
const isTaskList = className?.includes(TASK_LIST_CLASS);
// Task list indentation is handled by task item grid columns; regular lists
// use the shared token (padding + list style).
const listClass = isTaskList ? "my-0 mb-2 list-outside list-none" : ordered ? markdownStyles.orderedList : markdownStyles.bulletList;
return (
<Component
className={cn(
"my-0 mb-2 list-outside",
isTaskList
? // Task list indentation is handled by task item grid columns.
"list-none"
: // Regular list: standard padding and list style
cn("pl-6", ordered ? "list-decimal" : "list-disc"),
className,
)}
{...domProps}
>
<Component className={cn(listClass, className)} {...domProps}>
{children}
</Component>
);
@ -122,7 +115,7 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List
}
return (
<li className={cn("mt-0.5 leading-6", className)} {...domProps}>
<li className={cn(markdownStyles.listItem, className)} {...domProps}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</li>
);

@ -1,4 +1,5 @@
import type { Element } from "hast";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import LinkMetadataCard from "../LinkMetadataCard";
import { useMarkdownRenderContext } from "../MarkdownRenderContext";
@ -47,7 +48,7 @@ export const Paragraph = ({ children, className, node, ...props }: ParagraphProp
const { blockDepth } = useMarkdownRenderContext();
const href = blockDepth === 0 ? getSingleLinkHref(node) : undefined;
const paragraph = (
<p className={cn("my-0 mb-2 leading-6", className)} {...props}>
<p className={cn(markdownStyles.paragraph, className)} {...props}>
{children}
</p>
);

@ -2,6 +2,11 @@ import type { MarkdownToken } from "@tiptap/core";
import { InputRule, Mark, mergeAttributes } from "@tiptap/core";
import type { TokenizerThis, Tokens } from "marked";
import { marked } from "marked";
import { tagStyles } from "@/lib/markdownStyles";
// Default tag pill, shared with the read-only view (MemoContent/Tag.tsx).
// Computed once — renderHTML runs on every view update.
const TAG_CLASS = `${tagStyles.base} ${tagStyles.defaultColor}`;
// Mirrors the renderer's tag lexer (web/src/utils/remark-plugins/remark-tag.ts):
// letters, numbers, symbols, plus _ - / &, max 100 chars. Keep the two in sync.
@ -123,7 +128,7 @@ export const Tag = Mark.create({
return [
"span",
mergeAttributes(HTMLAttributes, {
class: "inline-block rounded bg-accent text-accent-foreground px-1",
class: TAG_CLASS,
}),
0,
];

@ -27,10 +27,16 @@ export const TagSuggestion = Extension.create<TagSuggestionOptions>({
char: "#",
allowSpaces: false,
items: ({ query }) => {
const tags = this.options.getTags();
// Require at least one char after `#` so a bare `#` (or `# ` heading)
// doesn't pop the tag menu and conflict with markdown headings.
if (query.length === 0) {
return [];
}
const q = query.toLowerCase();
const filtered = q ? tags.filter((tag) => tag.toLowerCase().includes(q)) : tags;
return filtered.slice(0, MAX_SUGGESTIONS);
return this.options
.getTags()
.filter((tag) => tag.toLowerCase().includes(q))
.slice(0, MAX_SUGGESTIONS);
},
command: ({ editor, range, props: tag }) => {
editor

@ -1,10 +1,26 @@
import type { AnyExtension } from "@tiptap/core";
import { type AnyExtension, mergeAttributes } from "@tiptap/core";
import { Heading } from "@tiptap/extension-heading";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { Markdown } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
import { type HeadingLevel, headingClass, markdownStyles } from "@/lib/markdownStyles";
import { preservedExtensions } from "./PreservedBlock";
import { Tag } from "./Tag";
/**
* StarterKit's Heading is bundled and cannot vary classes by level via static
* HTMLAttributes, so we disable it (heading: false) and render headings here.
* renderHTML only affects the editable DOM heading markdown serialization is
* unchanged so the round-trip codec is unaffected.
*/
const StyledHeading = Heading.extend({
renderHTML({ node, HTMLAttributes }) {
const { levels } = this.options;
const level = (levels.includes(node.attrs.level) ? node.attrs.level : levels[0]) as HeadingLevel;
return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: headingClass(level) }), 0];
},
});
/**
* The canonical schema-relevant extension set, shared by the live editor and
* the headless markdown codec so that parse/serialize behavior is identical
@ -14,12 +30,20 @@ import { Tag } from "./Tag";
export function buildExtensions(): AnyExtension[] {
return [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
link: { openOnClick: false },
heading: false,
link: { openOnClick: false, HTMLAttributes: { class: markdownStyles.link } },
// Markdown has no underline syntax; keeping the extension would let
// Ctrl+U create marks that cannot serialize. Out of the schema entirely.
underline: false,
paragraph: { HTMLAttributes: { class: markdownStyles.paragraph } },
blockquote: { HTMLAttributes: { class: markdownStyles.blockquote } },
bulletList: { HTMLAttributes: { class: markdownStyles.bulletList } },
orderedList: { HTMLAttributes: { class: markdownStyles.orderedList } },
listItem: { HTMLAttributes: { class: markdownStyles.listItem } },
code: { HTMLAttributes: { class: markdownStyles.inlineCode } },
horizontalRule: { HTMLAttributes: { class: markdownStyles.horizontalRule } },
}),
StyledHeading.configure({ levels: [1, 2, 3, 4, 5, 6] }),
TaskList,
TaskItem.configure({ nested: true }),
Markdown,

@ -41,73 +41,10 @@
pointer-events: none;
}
/* Tailwind's preflight resets h1h6 to inherit; restore the same heading
scale the memo view uses (MemoContent/markdown/Heading.tsx) so live
conversion is visible while editing. */
.memo-wysiwyg h1,
.memo-wysiwyg h2,
.memo-wysiwyg h3,
.memo-wysiwyg h4,
.memo-wysiwyg h5,
.memo-wysiwyg h6 {
margin: 0.75rem 0 0.5rem;
line-height: 1.25;
}
.memo-wysiwyg h1:first-child,
.memo-wysiwyg h2:first-child,
.memo-wysiwyg h3:first-child {
margin-top: 0;
}
.memo-wysiwyg h1 {
font-size: 1.875rem;
font-weight: 700;
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
}
.memo-wysiwyg h2 {
font-size: 1.5rem;
font-weight: 600;
border-bottom: 1px solid var(--border);
padding-bottom: 0.375rem;
}
.memo-wysiwyg h3 {
font-size: 1.25rem;
font-weight: 600;
}
.memo-wysiwyg h4 {
font-size: 1.125rem;
font-weight: 600;
}
.memo-wysiwyg h5 {
font-size: 1rem;
font-weight: 600;
}
.memo-wysiwyg h6 {
font-size: 1rem;
font-weight: 500;
color: var(--muted-foreground);
}
.memo-wysiwyg ul,
.memo-wysiwyg ol {
padding-left: 1.5rem;
}
.memo-wysiwyg ul {
list-style: disc;
}
.memo-wysiwyg ol {
list-style: decimal;
}
/* Editor-only element styles: task lists and code blocks have no shared markup
with the read-only memo view (MemoContent renders those via TaskListItem.tsx
and CodeBlock.tsx), so unlike the elements in markdownStyles.ts they stay
local to the editor here rather than in the shared style token. */
.memo-wysiwyg ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
@ -127,12 +64,6 @@
flex: 1 1 auto;
}
.memo-wysiwyg blockquote {
border-left: 2px solid var(--border);
padding-left: 0.75rem;
opacity: 0.9;
}
.memo-wysiwyg pre {
background: var(--muted);
border-radius: 0.375rem;
@ -142,14 +73,6 @@
white-space: pre-wrap;
}
.memo-wysiwyg code {
background: var(--muted);
border-radius: 0.25rem;
padding: 0 0.25rem;
font-family: var(--font-mono, monospace);
font-size: 0.875em;
}
.memo-wysiwyg pre code {
background: transparent;
padding: 0;

@ -0,0 +1,69 @@
import { cn } from "@/lib/utils";
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
/** Per-level heading classes (size / weight / border), matching MemoContent. */
const headingLevelClasses: Record<HeadingLevel, string> = {
1: "text-3xl font-bold border-b border-border pb-2",
2: "text-2xl font-semibold border-b border-border pb-1.5",
3: "text-xl font-semibold",
4: "text-lg font-semibold",
5: "text-base font-semibold",
6: "text-base font-medium text-muted-foreground",
};
/** Shared base classes applied to every heading level. */
const headingBaseClasses = "mt-3 mb-2 leading-tight";
/**
* Complete heading class per level, precomputed once at module load (base +
* per-level). headingClass is a hot path MemoContent renders it per heading
* and the editor's renderHTML runs it on essentially every keystroke so the
* cn() merge happens here, not per call.
*/
const headingClasses: Record<HeadingLevel, string> = {
1: cn(headingBaseClasses, headingLevelClasses[1]),
2: cn(headingBaseClasses, headingLevelClasses[2]),
3: cn(headingBaseClasses, headingLevelClasses[3]),
4: cn(headingBaseClasses, headingLevelClasses[4]),
5: cn(headingBaseClasses, headingLevelClasses[5]),
6: cn(headingBaseClasses, headingLevelClasses[6]),
};
/**
* Single source of truth for the styling of common markdown elements rendered
* by BOTH the read-only memo view (MemoContent) and the WYSIWYG editor
* (MemoEditor). Each value is a complete, standalone Tailwind class string so it
* can be dropped onto a DOM element as-is (the editor sets these via Tiptap
* `HTMLAttributes`; MemoContent merges them with `cn`).
*
* These are static string literals so Tailwind's JIT scanner detects them.
*/
export const markdownStyles = {
paragraph: "my-0 mb-2 leading-6",
blockquote: "my-0 mb-2 border-l-4 border-primary/30 pl-3 text-muted-foreground italic",
bulletList: "my-0 mb-2 list-outside pl-6 list-disc",
orderedList: "my-0 mb-2 list-outside pl-6 list-decimal",
listItem: "mt-0.5 leading-6",
inlineCode: "font-mono text-sm bg-muted px-1 py-0.5 rounded-md",
link: "text-primary underline decoration-primary/50 underline-offset-2 transition-colors hover:decoration-primary",
horizontalRule: "my-2 h-0 border-0 border-b border-border",
} as const;
/** Complete heading class for a given level (shared base + per-level classes). */
export const headingClass = (level: HeadingLevel): string => headingClasses[level];
/**
* Tag pill styling, shared by the read-only memo view (MemoContent/Tag.tsx) and
* the editor's tag mark (MemoEditor/Editor/Tag.ts) so a `#tag` looks identical
* while typing and after saving. Split into two tokens so the viewer can swap
* `defaultColor` for an inline custom color, and so the editor which is never
* custom-colored and is not a filter button takes the shape + default color
* without the viewer's click/hover affordances.
*/
export const tagStyles = {
/** Shape, padding, and typography — always applied. */
base: "inline-flex items-center align-baseline px-1.5 py-0.5 text-[0.9em] leading-none font-normal rounded-full border",
/** Default theme color, used when no custom tag color is set. */
defaultColor: "border-primary text-primary bg-primary/15",
} as const;
Loading…
Cancel
Save