From 00225db922fb1675e21f0b5181c31f0bd4e3e6d3 Mon Sep 17 00:00:00 2001 From: boojack Date: Sat, 13 Jun 2026 22:17:00 +0800 Subject: [PATCH] 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. --- web/package.json | 1 + web/pnpm-lock.yaml | 3 + web/src/components/MemoContent/Tag.tsx | 7 +- .../MemoContent/markdown/Blockquote.tsx | 3 +- .../MemoContent/markdown/Heading.tsx | 12 +-- .../MemoContent/markdown/HorizontalRule.tsx | 3 +- .../MemoContent/markdown/InlineCode.tsx | 3 +- .../components/MemoContent/markdown/Link.tsx | 12 +-- .../components/MemoContent/markdown/List.tsx | 19 ++--- .../MemoContent/markdown/Paragraph.tsx | 3 +- web/src/components/MemoEditor/Editor/Tag.ts | 7 +- .../MemoEditor/Editor/TagSuggestion.ts | 12 ++- .../MemoEditor/Editor/extensions.ts | 30 ++++++- web/src/index.css | 85 +------------------ web/src/lib/markdownStyles.ts | 69 +++++++++++++++ 15 files changed, 139 insertions(+), 130 deletions(-) create mode 100644 web/src/lib/markdownStyles.ts diff --git a/web/package.json b/web/package.json index b6d636485..4b30df3da 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 40249744d..03a6120c5 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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) diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx index a64efce4d..bcc3e9f35 100644 --- a/web/src/components/MemoContent/Tag.tsx +++ b/web/src/components/MemoContent/Tag.tsx @@ -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 = ({ "data-tag": dataTag, children, classNa return ( { return ( -
+
{children}
); diff --git a/web/src/components/MemoContent/markdown/Heading.tsx b/web/src/components/MemoContent/markdown/Heading.tsx index f000fa5c5..734564503 100644 --- a/web/src/components/MemoContent/markdown/Heading.tsx +++ b/web/src/components/MemoContent/markdown/Heading.tsx @@ -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, 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 ( - + {children} ); diff --git a/web/src/components/MemoContent/markdown/HorizontalRule.tsx b/web/src/components/MemoContent/markdown/HorizontalRule.tsx index dc798b778..5e5dc61df 100644 --- a/web/src/components/MemoContent/markdown/HorizontalRule.tsx +++ b/web/src/components/MemoContent/markdown/HorizontalRule.tsx @@ -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, React * Horizontal rule separator */ export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => { - return
; + return
; }; diff --git a/web/src/components/MemoContent/markdown/InlineCode.tsx b/web/src/components/MemoContent/markdown/InlineCode.tsx index 945dc1c03..87458c739 100644 --- a/web/src/components/MemoContent/markdown/InlineCode.tsx +++ b/web/src/components/MemoContent/markdown/InlineCode.tsx @@ -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, ReactMarkdo */ export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => { return ( - + {children} ); diff --git a/web/src/components/MemoContent/markdown/Link.tsx b/web/src/components/MemoContent/markdown/Link.tsx index d305fdaf7..460caa622 100644 --- a/web/src/components/MemoContent/markdown/Link.tsx +++ b/web/src/components/MemoContent/markdown/Link.tsx @@ -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, React */ export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => { return ( - + {children} ); diff --git a/web/src/components/MemoContent/markdown/List.tsx b/web/src/components/MemoContent/markdown/List.tsx index cae3ea239..790c17e7a 100644 --- a/web/src/components/MemoContent/markdown/List.tsx +++ b/web/src/components/MemoContent/markdown/List.tsx @@ -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 { 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 ( - + {children} ); @@ -122,7 +115,7 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List } return ( -
  • +
  • {children}
  • ); diff --git a/web/src/components/MemoContent/markdown/Paragraph.tsx b/web/src/components/MemoContent/markdown/Paragraph.tsx index 059ce7d71..a64ee3c94 100644 --- a/web/src/components/MemoContent/markdown/Paragraph.tsx +++ b/web/src/components/MemoContent/markdown/Paragraph.tsx @@ -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 = ( -

    +

    {children}

    ); diff --git a/web/src/components/MemoEditor/Editor/Tag.ts b/web/src/components/MemoEditor/Editor/Tag.ts index 9281c4e95..16bdc495e 100644 --- a/web/src/components/MemoEditor/Editor/Tag.ts +++ b/web/src/components/MemoEditor/Editor/Tag.ts @@ -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, ]; diff --git a/web/src/components/MemoEditor/Editor/TagSuggestion.ts b/web/src/components/MemoEditor/Editor/TagSuggestion.ts index 8143991f6..ef584103c 100644 --- a/web/src/components/MemoEditor/Editor/TagSuggestion.ts +++ b/web/src/components/MemoEditor/Editor/TagSuggestion.ts @@ -27,10 +27,16 @@ export const TagSuggestion = Extension.create({ 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 diff --git a/web/src/components/MemoEditor/Editor/extensions.ts b/web/src/components/MemoEditor/Editor/extensions.ts index bfa203b4a..ff8b32e3f 100644 --- a/web/src/components/MemoEditor/Editor/extensions.ts +++ b/web/src/components/MemoEditor/Editor/extensions.ts @@ -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, diff --git a/web/src/index.css b/web/src/index.css index c736c545a..48d895cdc 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -41,73 +41,10 @@ pointer-events: none; } -/* Tailwind's preflight resets h1–h6 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; diff --git a/web/src/lib/markdownStyles.ts b/web/src/lib/markdownStyles.ts new file mode 100644 index 000000000..7e766b2ca --- /dev/null +++ b/web/src/lib/markdownStyles.ts @@ -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 = { + 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 = { + 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;