From 8ffd4062fa27cf6f497bf0eb71809af7034de9dd Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 4 Aug 2026 12:30:40 +0800 Subject: [PATCH] Unify editor text spacing across themes --- .../src/components/LocalTiptapEditor.tsx | 5 ++- .../src/screens/WorkspaceMemoDetail.tsx | 2 +- apps/web/src/components/EditorPane.tsx | 1 + apps/web/src/lib/wechat-copy.ts | 31 ++++++++++---- apps/web/src/styles/editor-themes/base.css | 7 +++- .../styles/editor-themes/minimal-emerald.css | 16 ------- .../src/styles/editor-themes/modern-mint.css | 16 ------- .../styles/editor-themes/outline-emerald.css | 16 ------- .../src/styles/editor-themes/wechat-green.css | 16 ------- .../web/src/styles/editor-typography.test.mjs | 42 +++++++++++++++++++ apps/web/src/styles/globals.css | 3 +- .../web/src/styles/mobile-markdown-editor.css | 6 ++- .../shared/src/memo-content-style.test.mjs | 2 +- packages/shared/src/memo-content-style.ts | 1 + 14 files changed, 83 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/styles/editor-typography.test.mjs diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index 0cb0f59d..c0a8f88e 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -9,7 +9,7 @@ import { TableKit } from "@tiptap/extension-table"; import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import * as Clipboard from "expo-clipboard"; -import { getImageReferrerPolicy, getResourceIdFromUrl, type TiptapDoc } from "@edgeever/shared"; +import { MEMO_CONTENT_STYLE, getImageReferrerPolicy, getResourceIdFromUrl, type TiptapDoc } from "@edgeever/shared"; import { DEFAULT_IMAGE_WIDTH_PERCENT, IMAGE_WIDTH_PRESETS, @@ -1374,8 +1374,9 @@ const getEditorStyles = (theme: "light" | "dark") => ` .edgeever-editor-toolbar button:disabled { opacity: 0.38; } .tiptap { min-height: 100%; outline: none; } .edgeever-editor-shell > div:last-child { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } - .edgeever-editor-content { min-height: 100%; padding: 18px 12px 32px; font-size: 17px; line-height: 1.7; word-break: break-word; caret-color: #0f766e; } + .edgeever-editor-content { min-height: 100%; padding: 18px 12px 32px; font-size: 17px; line-height: ${MEMO_CONTENT_STYLE.body.lineHeight / MEMO_CONTENT_STYLE.body.fontSize}; word-break: break-word; caret-color: #0f766e; } .edgeever-editor-content > :first-child { margin-top: 0; } + .edgeever-editor-content p { margin: 0 0 ${MEMO_CONTENT_STYLE.body.paragraphSpacing}px; padding: 0; line-height: ${MEMO_CONTENT_STYLE.body.lineHeight / MEMO_CONTENT_STYLE.body.fontSize}; } .edgeever-editor-content p.is-editor-empty:first-child::before { float: left; height: 0; color: #94a3b8; content: attr(data-placeholder); pointer-events: none; } .edgeever-editor-content h1, .edgeever-editor-content h2, .edgeever-editor-content h3 { line-height: 1.3; } .edgeever-editor-content blockquote { margin-left: 0; padding-left: 14px; border-left: 3px solid #5eead4; color: ${theme === "dark" ? "#cbd5e1" : "#475569"}; } diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index 7d5e1f2b..6fab2168 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -705,7 +705,7 @@ const detailMarkdownStyles = StyleSheet.create({ marginVertical: 8, }, paragraph: { - marginBottom: 10, + marginBottom: MEMO_CONTENT_STYLE.body.paragraphSpacing, marginTop: 0, }, strong: { diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index d61e2038..11c14b17 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -3393,6 +3393,7 @@ const RichEditorPane = ({ style={{ "--editor-body-font-size": `${MEMO_CONTENT_STYLE.body.fontSize}px`, "--editor-body-line-height": String(MEMO_CONTENT_STYLE.body.lineHeight / MEMO_CONTENT_STYLE.body.fontSize), + "--editor-paragraph-spacing": `${MEMO_CONTENT_STYLE.body.paragraphSpacing}px`, "--memo-content-divider-color": MEMO_CONTENT_STYLE.divider.color[resolvedTheme], "--memo-content-divider-spacing": `${MEMO_CONTENT_STYLE.divider.marginVertical}px`, ...(editorTheme !== "default" && diff --git a/apps/web/src/lib/wechat-copy.ts b/apps/web/src/lib/wechat-copy.ts index 4e0c34ab..493f1414 100644 --- a/apps/web/src/lib/wechat-copy.ts +++ b/apps/web/src/lib/wechat-copy.ts @@ -1,16 +1,21 @@ import type { Editor } from "@tiptap/react"; +import { MEMO_CONTENT_STYLE } from "@edgeever/shared"; import { marked } from "marked"; import { parseCustomCssToStyles } from "@/lib/css-sandbox"; +const BODY_LINE_HEIGHT = MEMO_CONTENT_STYLE.body.lineHeight / MEMO_CONTENT_STYLE.body.fontSize; +const BODY_FONT_SIZE = `${MEMO_CONTENT_STYLE.body.fontSize}px`; +const PARAGRAPH_SPACING = `${MEMO_CONTENT_STYLE.body.paragraphSpacing}px`; + const WECHAT_STYLES: Record = { - p: "margin: 0 0 1em; line-height: 1.75; font-size: 16px; color: #333;", + p: `margin: 0 0 ${PARAGRAPH_SPACING}; padding: 0; line-height: ${BODY_LINE_HEIGHT}; font-size: ${BODY_FONT_SIZE}; color: #333;`, h1: "margin: 1.2em 0 0.6em; font-size: 24px; line-height: 1.35; font-weight: 700; color: #1f2937;", h2: "margin: 1.1em 0 0.55em; font-size: 21px; line-height: 1.4; font-weight: 700; color: #1f2937;", h3: "margin: 1em 0 0.5em; font-size: 18px; line-height: 1.45; font-weight: 700; color: #1f2937;", - blockquote: "margin: 1em 0; padding: 0.6em 1em; border-left: 4px solid #10b981; background: #f0fdf4; color: #4b5563; line-height: 1.75;", - ul: "margin: 0 0 1em; padding-left: 1.6em; line-height: 1.75;", - ol: "margin: 0 0 1em; padding-left: 1.6em; line-height: 1.75;", - li: "margin: 0.25em 0; line-height: 1.75;", + blockquote: `margin: 1em 0; padding: 0.6em 1em; border-left: 4px solid #10b981; background: #f0fdf4; color: #4b5563; line-height: ${BODY_LINE_HEIGHT};`, + ul: `margin: 0 0 1em; padding-left: 1.6em; line-height: ${BODY_LINE_HEIGHT};`, + ol: `margin: 0 0 1em; padding-left: 1.6em; line-height: ${BODY_LINE_HEIGHT};`, + li: `margin: 0.25em 0; line-height: ${BODY_LINE_HEIGHT};`, a: "color: #059669; text-decoration: underline;", strong: "font-weight: 700;", em: "font-style: italic;", @@ -64,7 +69,7 @@ const applyInlineStyles = ( const customStyles = customCss ? parseCustomCssToStyles(customCss) : null; - root.style.cssText = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 16px; line-height: 1.75; color: ${textColor}; background-color: ${bgColors}; word-break: break-word;`; + root.style.cssText = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: ${BODY_FONT_SIZE}; line-height: ${BODY_LINE_HEIGHT}; color: ${textColor}; background-color: ${bgColors}; word-break: break-word;`; root.querySelectorAll("*").forEach((element) => { const tagName = element.tagName.toLowerCase(); @@ -72,11 +77,11 @@ const applyInlineStyles = ( if (customColors) { if (tagName === "p") { - style = `margin: 0 0 1em; line-height: 1.75; font-size: 16px; color: ${textColor};`; + style = `margin: 0 0 ${PARAGRAPH_SPACING}; padding: 0; line-height: ${BODY_LINE_HEIGHT}; font-size: ${BODY_FONT_SIZE}; color: ${textColor};`; } else if (tagName === "h1" || tagName === "h2" || tagName === "h3") { style = style.replace("color: #1f2937;", `color: ${textColor};`); } else if (tagName === "blockquote") { - style = `margin: 1em 0; padding: 0.6em 1em; border-left: 4px solid ${accent}; background: ${soft}; color: ${textColor}; line-height: 1.75;`; + style = `margin: 1em 0; padding: 0.6em 1em; border-left: 4px solid ${accent}; background: ${soft}; color: ${textColor}; line-height: ${BODY_LINE_HEIGHT};`; } else if (tagName === "a") { style = `color: ${accent}; text-decoration: underline;`; } else if (tagName === "code") { @@ -99,6 +104,16 @@ const applyInlineStyles = ( if (style) element.style.cssText = `${style}${element.style.cssText}`; }); + root.querySelectorAll("p").forEach((paragraph) => { + paragraph.style.margin = `0 0 ${PARAGRAPH_SPACING}`; + paragraph.style.padding = "0"; + paragraph.style.lineHeight = String(BODY_LINE_HEIGHT); + }); + + root.querySelectorAll("ul, ol, li, blockquote").forEach((bodyBlock) => { + bodyBlock.style.lineHeight = String(BODY_LINE_HEIGHT); + }); + root.querySelectorAll("pre code").forEach((element) => { element.style.cssText = "padding: 0; background: transparent; color: inherit; font-family: Menlo, Consolas, monospace; font-size: 13px; white-space: pre-wrap;"; }); diff --git a/apps/web/src/styles/editor-themes/base.css b/apps/web/src/styles/editor-themes/base.css index e65c3c9f..2ae0b6f7 100644 --- a/apps/web/src/styles/editor-themes/base.css +++ b/apps/web/src/styles/editor-themes/base.css @@ -20,6 +20,7 @@ background: var(--editor-theme-bg); color: var(--editor-theme-text); font-family: var(--editor-theme-font, inherit); + line-height: var(--editor-body-line-height); } .edgeever-editor[data-editor-theme]:not([data-editor-theme="default"]) .ProseMirror h1, @@ -82,7 +83,9 @@ .edgeever-editor[data-editor-theme]:not([data-editor-theme="default"]) .ProseMirror p { - line-height: 1.8; + margin: 0 0 var(--editor-paragraph-spacing); + padding: 0; + line-height: var(--editor-body-line-height); } .edgeever-editor[data-editor-theme]:not([data-editor-theme="default"]) .ProseMirror strong { @@ -107,7 +110,7 @@ .edgeever-editor[data-editor-theme]:not([data-editor-theme="default"]) .ProseMirror ul, .edgeever-editor[data-editor-theme]:not([data-editor-theme="default"]) .ProseMirror ol { - line-height: 1.75; + line-height: var(--editor-body-line-height); } .edgeever-editor[data-editor-theme]:not([data-editor-theme="default"]) .ProseMirror li::marker { diff --git a/apps/web/src/styles/editor-themes/minimal-emerald.css b/apps/web/src/styles/editor-themes/minimal-emerald.css index 7be160e2..b3e23d87 100644 --- a/apps/web/src/styles/editor-themes/minimal-emerald.css +++ b/apps/web/src/styles/editor-themes/minimal-emerald.css @@ -27,15 +27,10 @@ } .edgeever-editor[data-editor-theme="minimal-emerald"]:not([data-editor-theme="default"]) .ProseMirror { - font-size: 1rem; - line-height: 1.75; letter-spacing: -0.003em; } .edgeever-editor[data-editor-theme="minimal-emerald"]:not([data-editor-theme="default"]) .ProseMirror p { - margin: 0; - padding: 0.4rem 0; - line-height: 1.75; color: var(--editor-theme-text); } @@ -94,17 +89,6 @@ padding: 0.6rem 1rem; } -.edgeever-editor[data-editor-theme="minimal-emerald"]:not([data-editor-theme="default"]) .ProseMirror ul, -.edgeever-editor[data-editor-theme="minimal-emerald"]:not([data-editor-theme="default"]) .ProseMirror ol { - margin: 0.5rem 0; - padding-left: 1.4rem; -} - -.edgeever-editor[data-editor-theme="minimal-emerald"]:not([data-editor-theme="default"]) .ProseMirror li { - margin: 0.2rem 0; - line-height: 1.7; -} - .edgeever-editor[data-editor-theme="minimal-emerald"]:not([data-editor-theme="default"]) .ProseMirror li::marker { color: var(--editor-theme-accent); font-weight: 600; diff --git a/apps/web/src/styles/editor-themes/modern-mint.css b/apps/web/src/styles/editor-themes/modern-mint.css index a9f715b8..8fcc56a6 100644 --- a/apps/web/src/styles/editor-themes/modern-mint.css +++ b/apps/web/src/styles/editor-themes/modern-mint.css @@ -27,15 +27,10 @@ } .edgeever-editor[data-editor-theme="modern-mint"]:not([data-editor-theme="default"]) .ProseMirror { - font-size: 1rem; - line-height: 1.75; letter-spacing: -0.003em; } .edgeever-editor[data-editor-theme="modern-mint"]:not([data-editor-theme="default"]) .ProseMirror p { - margin: 0; - padding: 0.45rem 0; - line-height: 1.75; color: var(--editor-theme-text); } @@ -99,17 +94,6 @@ padding: 1rem 1.25rem; } -.edgeever-editor[data-editor-theme="modern-mint"]:not([data-editor-theme="default"]) .ProseMirror ul, -.edgeever-editor[data-editor-theme="modern-mint"]:not([data-editor-theme="default"]) .ProseMirror ol { - margin: 1.0rem 0; - padding-left: 1.75rem; -} - -.edgeever-editor[data-editor-theme="modern-mint"]:not([data-editor-theme="default"]) .ProseMirror li { - margin: 0.5rem 0; - line-height: 1.75; -} - .edgeever-editor[data-editor-theme="modern-mint"]:not([data-editor-theme="default"]) .ProseMirror li::marker { color: var(--editor-theme-accent); font-weight: 600; diff --git a/apps/web/src/styles/editor-themes/outline-emerald.css b/apps/web/src/styles/editor-themes/outline-emerald.css index 43b4a252..4fac0b55 100644 --- a/apps/web/src/styles/editor-themes/outline-emerald.css +++ b/apps/web/src/styles/editor-themes/outline-emerald.css @@ -27,15 +27,10 @@ } .edgeever-editor[data-editor-theme="outline-emerald"]:not([data-editor-theme="default"]) .ProseMirror { - font-size: 1rem; - line-height: 1.7; letter-spacing: -0.005em; } .edgeever-editor[data-editor-theme="outline-emerald"]:not([data-editor-theme="default"]) .ProseMirror p { - margin: 0; - padding: 0.45rem 0; - line-height: 1.7; color: var(--editor-theme-text); } @@ -96,17 +91,6 @@ padding: 0.6rem 1rem; } -.edgeever-editor[data-editor-theme="outline-emerald"]:not([data-editor-theme="default"]) .ProseMirror ul, -.edgeever-editor[data-editor-theme="outline-emerald"]:not([data-editor-theme="default"]) .ProseMirror ol { - margin: 0.5rem 0; - padding-left: 1.4rem; -} - -.edgeever-editor[data-editor-theme="outline-emerald"]:not([data-editor-theme="default"]) .ProseMirror li { - margin: 0.2rem 0; - line-height: 1.65; -} - .edgeever-editor[data-editor-theme="outline-emerald"]:not([data-editor-theme="default"]) .ProseMirror li::marker { color: var(--editor-theme-accent); font-weight: 600; diff --git a/apps/web/src/styles/editor-themes/wechat-green.css b/apps/web/src/styles/editor-themes/wechat-green.css index d01d746e..8742017b 100644 --- a/apps/web/src/styles/editor-themes/wechat-green.css +++ b/apps/web/src/styles/editor-themes/wechat-green.css @@ -27,15 +27,10 @@ } .edgeever-editor[data-editor-theme="wechat-green"]:not([data-editor-theme="default"]) .ProseMirror { - font-size: 1rem; - line-height: 1.75; letter-spacing: -0.003em; } .edgeever-editor[data-editor-theme="wechat-green"]:not([data-editor-theme="default"]) .ProseMirror p { - margin: 0; - padding: 0.45rem 0; - line-height: 1.75; color: var(--editor-theme-text); } @@ -107,17 +102,6 @@ padding: 1rem 1.25rem; } -.edgeever-editor[data-editor-theme="wechat-green"]:not([data-editor-theme="default"]) .ProseMirror ul, -.edgeever-editor[data-editor-theme="wechat-green"]:not([data-editor-theme="default"]) .ProseMirror ol { - margin: 1rem 0; - padding-left: 1.75rem; -} - -.edgeever-editor[data-editor-theme="wechat-green"]:not([data-editor-theme="default"]) .ProseMirror li { - margin: 0.5rem 0; - line-height: 1.75; -} - .edgeever-editor[data-editor-theme="wechat-green"]:not([data-editor-theme="default"]) .ProseMirror li::marker { color: var(--editor-theme-accent); font-weight: 600; diff --git a/apps/web/src/styles/editor-typography.test.mjs b/apps/web/src/styles/editor-typography.test.mjs new file mode 100644 index 00000000..9d0eae54 --- /dev/null +++ b/apps/web/src/styles/editor-typography.test.mjs @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; +import { MEMO_CONTENT_STYLE } from "@edgeever/shared"; + +const PRESET_THEME_FILES = [ + "minimal-emerald.css", + "outline-emerald.css", + "wechat-green.css", + "modern-mint.css", +]; + +const readStyle = (relativePath) => readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +const declarationsForSelector = (source, selectorSuffix) => + [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)] + .filter(([, selectors]) => selectors.split(",").some((selector) => selector.trim().endsWith(selectorSuffix))) + .map(([, , declarations]) => declarations) + .join("\n"); + +describe("editor typography contract", () => { + test("keeps the shared body rhythm compact", () => { + expect(MEMO_CONTENT_STYLE.body.lineHeight / MEMO_CONTENT_STYLE.body.fontSize).toBe(1.6); + expect(MEMO_CONTENT_STYLE.body.paragraphSpacing).toBe(6); + }); + + test("does not let preset themes override body rhythm", () => { + for (const filename of PRESET_THEME_FILES) { + const source = readStyle(`./editor-themes/${filename}`); + const editorRules = declarationsForSelector(source, ".ProseMirror"); + const paragraphRules = declarationsForSelector(source, ".ProseMirror p"); + const listRules = [ + declarationsForSelector(source, ".ProseMirror ul"), + declarationsForSelector(source, ".ProseMirror ol"), + declarationsForSelector(source, ".ProseMirror li"), + ].join("\n"); + + expect(editorRules).not.toMatch(/(?:font-size|line-height)\s*:/); + expect(paragraphRules).not.toMatch(/(?:line-height|margin|padding)(?:-[a-z]+)?\s*:/); + expect(listRules).not.toMatch(/(?:line-height|margin)(?:-[a-z]+)?\s*:/); + } + }); +}); diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 97ceb85b..19c93369 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -41,7 +41,7 @@ --dark-surface-rgb: 255 255 255; --editor-body-font-size: 0.9375rem; --editor-body-line-height: 1.6; - --editor-paragraph-spacing: 0.45rem; + --editor-paragraph-spacing: 0.35rem; --memo-content-divider-color: rgb(var(--brand-green-300-rgb)); --memo-content-divider-spacing: 1.5rem; --editor-outline-width: 300px; @@ -286,6 +286,7 @@ textarea { .ProseMirror p { line-height: var(--editor-body-line-height); margin: 0 0 var(--editor-paragraph-spacing); + padding: 0; } .ProseMirror a.edgeever-note-link, diff --git a/apps/web/src/styles/mobile-markdown-editor.css b/apps/web/src/styles/mobile-markdown-editor.css index 300853df..569b67f4 100644 --- a/apps/web/src/styles/mobile-markdown-editor.css +++ b/apps/web/src/styles/mobile-markdown-editor.css @@ -397,7 +397,7 @@ input { padding: 18px 12px 32px; color: #020617; font-size: 17px; - line-height: 1.7; + line-height: 1.6; -webkit-user-select: text; user-select: text; caret-color: auto; @@ -405,7 +405,9 @@ input { } .edgeever-mobile-tiptap-content p { - margin: 0 0 0.85em; + margin: 0 0 6px; + padding: 0; + line-height: 1.6; } .edgeever-mobile-tiptap-content h1, diff --git a/packages/shared/src/memo-content-style.test.mjs b/packages/shared/src/memo-content-style.test.mjs index f3069724..40c05ed8 100644 --- a/packages/shared/src/memo-content-style.test.mjs +++ b/packages/shared/src/memo-content-style.test.mjs @@ -3,7 +3,7 @@ import { MEMO_CONTENT_STYLE } from "./memo-content-style.ts"; describe("shared memo content style", () => { test("matches the established mobile PWA reading scale", () => { - expect(MEMO_CONTENT_STYLE.body).toEqual({ fontSize: 15, lineHeight: 24 }); + expect(MEMO_CONTENT_STYLE.body).toEqual({ fontSize: 15, lineHeight: 24, paragraphSpacing: 6 }); expect(MEMO_CONTENT_STYLE.divider).toEqual({ color: { dark: "#4ade80", light: "#66ca80" }, marginVertical: 24, diff --git a/packages/shared/src/memo-content-style.ts b/packages/shared/src/memo-content-style.ts index e8dd14fd..df10bff8 100644 --- a/packages/shared/src/memo-content-style.ts +++ b/packages/shared/src/memo-content-style.ts @@ -2,6 +2,7 @@ export const MEMO_CONTENT_STYLE = { body: { fontSize: 15, lineHeight: 24, + paragraphSpacing: 6, }, divider: { color: {