From 081d69db4526b8a90b46fbf40b25bae069477511 Mon Sep 17 00:00:00 2001 From: Furkan Bora Murat <64507603+FurkaanBoraa@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:54:04 +0300 Subject: [PATCH 1/3] fix(renderer): make the custom dictionary table readable on dark themes (#4721) The spell-checker custom dictionary table (shown on Windows/Linux) is an Element Plus el-table whose colours followed Element Plus's light defaults instead of the active theme, so on dark themes: - the cells used EP's grey --el-text-color-regular -> now the theme's --editorColor. - the hovered row used EP's light --el-table-row-hover-bg-color, a near-white bar that hid the text -> now the theme's --selectionColor. - the fixed "Options" column header used EP's white --el-table-header-bg-color, leaving a white block -> now the theme's --editorBgColor. CSS-only, scoped to .pref-spellchecker. --- .../src/prefComponents/spellchecker/index.vue | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/desktop/src/renderer/src/prefComponents/spellchecker/index.vue b/packages/desktop/src/renderer/src/prefComponents/spellchecker/index.vue index 7b35a22ab8..6198e2ff92 100644 --- a/packages/desktop/src/renderer/src/prefComponents/spellchecker/index.vue +++ b/packages/desktop/src/renderer/src/prefComponents/spellchecker/index.vue @@ -223,6 +223,14 @@ const handleDeleteClick = (selectedItem: CustomDictionaryWord): void => { .pref-spellchecker .el-table tr { background: var(--editorBgColor); } +/* Element Plus colours table cells with its own grey --el-text-color-regular, + which the app never themes, so the custom-dictionary words rendered as + low-contrast grey on every theme. Use the theme's editor text colour. */ +.pref-spellchecker .el-table, +.pref-spellchecker .el-table th.el-table__cell, +.pref-spellchecker .el-table td.el-table__cell { + color: var(--editorColor); +} .pref-spellchecker .el-table th.el-table__cell.is-leaf, .pref-spellchecker .el-table th, .pref-spellchecker .el-table td { @@ -240,11 +248,14 @@ const handleDeleteClick = (selectedItem: CustomDictionaryWord): void => { .pref-spellchecker .el-table__fixed::before { background: var(--tableBorderColor); } -.pref-spellchecker .el-table__body tr.hover-row.current-row > td, -.pref-spellchecker .el-table__body tr.hover-row.el-table__row--striped.current-row > td, -.pref-spellchecker .el-table__body tr.hover-row.el-table__row--striped > td, -.pref-spellchecker .el-table__body tr.hover-row > td { - background: var(--selectionColor); +/* Theme Element Plus's table colour variables so the active theme is honoured + instead of EP's light defaults: the hovered row (--el-fill-color-light, a + near-white bar that hides the text) and the header background + (--el-fill-color-blank / white, which left the fixed "Options" column header + a white block on dark themes). */ +.pref-spellchecker .el-table { + --el-table-row-hover-bg-color: var(--selectionColor); + --el-table-header-bg-color: var(--editorBgColor); } .pref-spellchecker .el-table .el-table__cell { padding: 2px 0; From d84dd6de5fed57eeb2627687cd14f62907c99cca Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Fri, 26 Jun 2026 09:50:34 +0800 Subject: [PATCH 2/3] feat(muya): self-contained editor typography via --mu-* options (#4722) * feat(muya): apply typography options as --mu-* vars on the editor root Co-Authored-By: Claude Sonnet 4.6 * feat(muya): consume --mu-* typography vars in editor styles * docs(muya): document the --mu-* typography contract * style(muya): wrap long --mu-code-font-family value to satisfy eslint prettier rule * fix(muya): apply code font to the element so the family change reaches the text * fix(muya): re-measure code-block line numbers on code font/size/wrap change * test(muya): e2e regression for code-block font + line-number relayout * fix(muya): re-measure code-block line numbers on editor font/line-height change too * test(muya): cover editor-font line-number relayout and wrap effectiveness * fix(muya): relayout code-block line numbers via ResizeObserver instead of option triggers The gutter's per-line tops are measured; a one-shot rAF fired after an option change can run before a heavy document's reflow lands, leaving the numbers overlapping (spans sized for the new font but positioned at stale tops). A ResizeObserver on the code element re-measures after layout settles, and also covers window-resize-induced rewraps. * refactor(muya): trim verbose comments and drop an orphaned one in typography/line-number code * refactor(muya): remove unused setFont/setTabSize (callers use setOptions) * refactor(muya): line-number gutter relies solely on ResizeObserver (drop redundant rAF, self-disconnect) * test(muya): dedup thirdLineTop helper in code-font e2e --------- Co-authored-by: Claude Sonnet 4.6 --- packages/muya/CLAUDE.md | 23 +++++ .../muya/e2e/tests/options/code-font.spec.ts | 97 +++++++++++++++++++ .../muya/src/__tests__/setOptions.spec.ts | 51 ++++++++-- .../muya/src/assets/styles/blockSyntax.css | 28 ++++++ .../block/content/codeBlockContent/index.ts | 20 +++- packages/muya/src/config/index.ts | 2 + packages/muya/src/muya.ts | 34 ++++--- packages/muya/src/types.ts | 4 + 8 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 packages/muya/e2e/tests/options/code-font.spec.ts diff --git a/packages/muya/CLAUDE.md b/packages/muya/CLAUDE.md index 4bcb060e9a..9728c84cde 100644 --- a/packages/muya/CLAUDE.md +++ b/packages/muya/CLAUDE.md @@ -72,6 +72,29 @@ Each subfolder is a floating tool/menu (inline format toolbar, image tools, para `src/index.ts` is the published entrypoint. The `exports` map in `package.json` points `.` at `./src/index.ts` during development and `./lib/es/index.js` after publish — keep this file the single export hub. +### Appearance contract (typography) + +muya renders its own content's typography from two equivalent inputs — pass +options, or override the CSS custom properties directly (pure-CSS theming). +The variables are set on the editor root (`.mu-editor`) and consumed by the +bundled stylesheets; each has a default baked into the CSS, so passing nothing +renders the standalone defaults. + +| Option (`IMuyaOptions`) | CSS variable | Default | Applies to | +|---|---|---|---| +| `fontSize` (number, px) | `--mu-font-size` | `16px` | `.mu-editor` base text | +| `lineHeight` (number) | `--mu-line-height` | `1.6` | `.mu-editor` base text | +| `editorFontFamily` (string) | `--mu-font-family` | Open Sans stack | `.mu-editor` base text | +| `codeFontSize` (number, px) | `--mu-code-font-size` | `90%` | `.mu-code-block` only | +| `codeFontFamily` (string) | `--mu-code-font-family` | DejaVu Sans Mono stack | `.mu-code-block` only | +| `wrapCodeBlocks` (boolean) | — (`.mu-code-wrap` root class) | off (`pre`) | code-block line wrapping | + +Inline code (`code.mu-inline-rule`) is deliberately NOT driven by these — it +keeps its relative `0.8em` / mono sizing. Editor column width +(`--editor-area-width`) and the colour palette (`--editor-color-*`) are +separate, pre-existing contracts owned by the host. All runtime changes go +through `muya.setOptions({...})`. + ## Conventions enforced by tooling - **ESLint** (`eslint.config.mjs`, antfu base) adds: diff --git a/packages/muya/e2e/tests/options/code-font.spec.ts b/packages/muya/e2e/tests/options/code-font.spec.ts new file mode 100644 index 0000000000..4d13e99b64 --- /dev/null +++ b/packages/muya/e2e/tests/options/code-font.spec.ts @@ -0,0 +1,97 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures/muya'; + +/** + * Code-block font options reaching the rendered code, and the line-number + * gutter re-measuring when those options change. + * + * The code text is rendered as `…`. The `` element's user-agent + * `font-family: monospace` overrides the inherited block font, so + * `--mu-code-font-family` (set on the editor root) only reached `.mu-code-block` + * (the `
`) and never the text — changing the code font had no visible
+ * effect. `.mu-code-block .mu-code { font-family: inherit }` re-opens the
+ * cascade. `font-size` was unaffected because the UA `` rule sets no
+ * size, so it kept inheriting.
+ *
+ * Separately, the line-number gutter is positioned by measuring each line's
+ * pixel top (`repositionLineNumberSpans`), which only re-ran on text edits — a
+ * code-font / size / wrap change left the numbers misaligned until `setOptions`
+ * re-measured them.
+ */
+
+// The host boots with `codeBlockLineNumbers: true`, so the gutter renders.
+const CODE_MD = '```js\nconst a = 1\nconst b = 2\nconst c = 3\n```\n';
+
+// Measured `top` of the 3rd line number (the gutter is positioned, not derived).
+const thirdLineTop = (page: Page) => page.evaluate(() => {
+    const spans = document.querySelectorAll('.mu-line-numbers-rows span');
+    return Number.parseFloat(spans[2]?.style.top || '0');
+});
+
+test.describe('code-block font options', () => {
+    test('codeFontFamily reaches the code text element', async ({ page }) => {
+        await page.evaluate(md => window.muya!.setContent(md), CODE_MD);
+        await page.waitForSelector('.mu-codeblock-content');
+
+        const family = () => page.evaluate(() =>
+            getComputedStyle(document.querySelector('.mu-codeblock-content')!).fontFamily);
+
+        // Default: the bundled DejaVu stack inherited from the block (not the
+        // browser's bare `monospace`).
+        await expect.poll(family).toContain('DejaVu Sans Mono');
+
+        await page.evaluate(() =>
+            window.muya!.setOptions({ codeFontFamily: 'Courier New, monospace' }));
+
+        await expect.poll(family).toContain('Courier New');
+    });
+
+    test('changing code font size re-measures the line-number gutter', async ({ page }) => {
+        await page.evaluate(md => window.muya!.setContent(md), CODE_MD);
+        await page.waitForSelector('.mu-line-numbers-rows span');
+
+        // Wait for the initial line-number positioning.
+        await expect.poll(() => thirdLineTop(page)).toBeGreaterThan(0);
+        const before = await thirdLineTop(page);
+
+        await page.evaluate(() => window.muya!.setOptions({ codeFontSize: 30 }));
+
+        // The 3rd line number must move down to track the taller lines.
+        await expect.poll(() => thirdLineTop(page)).toBeGreaterThan(before + 5);
+    });
+
+    test('editor font size also re-measures the line-number gutter', async ({ page }) => {
+        await page.evaluate(md => window.muya!.setContent(md), CODE_MD);
+        await page.waitForSelector('.mu-line-numbers-rows span');
+
+        await expect.poll(() => thirdLineTop(page)).toBeGreaterThan(0);
+        const before = await thirdLineTop(page);
+
+        // The code block font is relative (`90%`), so the editor base font
+        // change enlarges the code lines — the gutter must track them.
+        await page.evaluate(() => window.muya!.setOptions({ fontSize: 30 }));
+
+        await expect.poll(() => thirdLineTop(page)).toBeGreaterThan(before + 5);
+    });
+
+    test('wrapCodeBlocks wraps long lines', async ({ page }) => {
+        const longLine = `\`\`\`js\nconst x = "${'a'.repeat(160)}"\n\`\`\`\n`;
+        await page.evaluate(md => window.muya!.setContent(md), longLine);
+        await page.waitForSelector('.mu-code-block .mu-code');
+
+        const overflowing = () => page.evaluate(() => {
+            const code = document.querySelector('.mu-code-block .mu-code') as HTMLElement;
+            return code.scrollWidth > code.clientWidth + 2;
+        });
+
+        // Off: the long line overflows horizontally.
+        await expect.poll(overflowing).toBe(true);
+
+        await page.evaluate(() => window.muya!.setOptions({ wrapCodeBlocks: true }));
+        await expect.poll(overflowing).toBe(false);
+
+        await page.evaluate(() => window.muya!.setOptions({ wrapCodeBlocks: false }));
+        await expect.poll(overflowing).toBe(true);
+    });
+});
diff --git a/packages/muya/src/__tests__/setOptions.spec.ts b/packages/muya/src/__tests__/setOptions.spec.ts
index 8c6f5fd29e..c327c47d42 100644
--- a/packages/muya/src/__tests__/setOptions.spec.ts
+++ b/packages/muya/src/__tests__/setOptions.spec.ts
@@ -14,7 +14,7 @@ vi.mock('../utils/diagram', () => ({
 }));
 
 // Coverage for the runtime option API added for the muyajs -> @muyajs/core
-// migration: setOptions / setFont / setTabSize / setListIndentation. Every
+// migration: setOptions / setListIndentation. Every
 // desktop Preferences toggle depends on options updating live. setOptions with
 // forceRender re-renders from current state (so render-affecting options take
 // effect) WITHOUT clearing undo history, and preserves the document content.
@@ -94,15 +94,6 @@ describe('muya runtime options', () => {
         expect(muya.domNode.getAttribute('spellcheck')).toBe('false');
     });
 
-    it('setFont and setTabSize update options', () => {
-        const muya = bootMuya('x\n');
-        muya.setFont({ fontSize: 18, lineHeight: 1.8 });
-        expect(muya.options.fontSize).toBe(18);
-        expect(muya.options.lineHeight).toBe(1.8);
-        muya.setTabSize(2);
-        expect(muya.options.tabSize).toBe(2);
-    });
-
     it('setListIndentation updates options and preserves content', () => {
         const muya = bootMuya('- a\n- b\n');
         const before = muya.getMarkdown();
@@ -128,6 +119,46 @@ describe('muya runtime options', () => {
         muya.setListIndentation(4);
         expect(muya.getMarkdown()).toBe('- a\n     - b\n');
     });
+
+    it('setOptions writes typography as --mu-* custom properties on the root', () => {
+        const muya = bootMuya('x\n');
+        muya.setOptions({
+            fontSize: 18,
+            lineHeight: 1.8,
+            editorFontFamily: 'Inter',
+            codeFontSize: 13,
+            codeFontFamily: 'Fira Code',
+        });
+        const { style } = muya.domNode;
+        expect(style.getPropertyValue('--mu-font-size')).toBe('18px');
+        expect(style.getPropertyValue('--mu-line-height')).toBe('1.8');
+        expect(style.getPropertyValue('--mu-font-family')).toBe('Inter');
+        expect(style.getPropertyValue('--mu-code-font-size')).toBe('13px');
+        expect(style.getPropertyValue('--mu-code-font-family')).toBe('Fira Code');
+    });
+
+    it('setOptions toggles the .mu-code-wrap class', () => {
+        const muya = bootMuya('x\n');
+        muya.setOptions({ wrapCodeBlocks: true });
+        expect(muya.domNode.classList.contains('mu-code-wrap')).toBe(true);
+        muya.setOptions({ wrapCodeBlocks: false });
+        expect(muya.domNode.classList.contains('mu-code-wrap')).toBe(false);
+    });
+
+    it('construction applies typography options onto the root', () => {
+        const host = document.createElement('div');
+        document.body.appendChild(host);
+        const muya = new Muya(host, {
+            fontSize: 20,
+            codeFontSize: 12,
+            wrapCodeBlocks: true,
+        } as ConstructorParameters[1]);
+        muya.init();
+        bootedHosts.push(muya.domNode);
+        expect(muya.domNode.style.getPropertyValue('--mu-font-size')).toBe('20px');
+        expect(muya.domNode.style.getPropertyValue('--mu-code-font-size')).toBe('12px');
+        expect(muya.domNode.classList.contains('mu-code-wrap')).toBe(true);
+    });
 });
 
 // Render-affecting options: the inline renderer (`InlineRenderer.tokenizer`)
diff --git a/packages/muya/src/assets/styles/blockSyntax.css b/packages/muya/src/assets/styles/blockSyntax.css
index a399d943d5..83a0b392b5 100644
--- a/packages/muya/src/assets/styles/blockSyntax.css
+++ b/packages/muya/src/assets/styles/blockSyntax.css
@@ -1,4 +1,8 @@
 .mu-editor {
+    font-size: var(--mu-font-size, 16px);
+    font-family: var(--mu-font-family, 'Open Sans', 'Clear Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif);
+    line-height: var(--mu-line-height, 1.6);
+
     outline: none;
 }
 
@@ -210,9 +214,33 @@
     border-radius: 3px;
 }
 
+/* Code-block font via `--mu-code-*`; code blocks only (inline code untouched). */
+.mu-code-block {
+    font-size: var(--mu-code-font-size, 90%);
+    font-family: var(
+        --mu-code-font-family,
+        'DejaVu Sans Mono',
+        'Source Code Pro',
+        'Droid Sans Mono',
+        Consolas,
+        monospace
+    );
+}
+
 .mu-code-block .mu-code {
     display: block;
     overflow: auto;
+
+    /* Override the  UA monospace so the block's code font reaches the text. */
+    font-family: inherit;
+}
+
+/* wrapCodeBlocks → `.mu-code-wrap`; wraps long lines (overrides prism's `pre`). */
+.mu-code-wrap .mu-code-block .mu-code {
+    overflow: hidden;
+
+    white-space: pre-wrap;
+    overflow-wrap: break-word;
 }
 
 .mu-code-block .mu-code::-webkit-scrollbar {
diff --git a/packages/muya/src/block/content/codeBlockContent/index.ts b/packages/muya/src/block/content/codeBlockContent/index.ts
index 6cd704d3e2..8ebf9fd767 100644
--- a/packages/muya/src/block/content/codeBlockContent/index.ts
+++ b/packages/muya/src/block/content/codeBlockContent/index.ts
@@ -169,6 +169,7 @@ class CodeBlockContent extends Content {
     }
 
     private _lastLineCount = -1;
+    private _lineNumberResizeObserver: ResizeObserver | null = null;
 
     private _updateLineNumbers(text: string) {
         if (!this.muya.options.codeBlockLineNumbers)
@@ -181,12 +182,23 @@ class CodeBlockContent extends Content {
             syncLineNumbersSpans(wrapper, count);
             this._lastLineCount = count;
         }
-        // Reposition on every update so wrap-mode line breaks are reflected.
-        const codeEl = this.domNode;
-        requestAnimationFrame(() => {
-            if (codeEl && wrapper.isConnected)
+        this._observeLineNumberResize(wrapper);
+    }
+
+    // Re-measure the gutter after any code-block reflow (initial render, font /
+    // wrap change, content edit, viewport resize). Fires post-layout, so it
+    // can't read stale positions; owns all repositioning.
+    private _observeLineNumberResize(wrapper: HTMLElement) {
+        if (this._lineNumberResizeObserver != null || typeof ResizeObserver === 'undefined')
+            return;
+        const codeEl = this.domNode!;
+        this._lineNumberResizeObserver = new ResizeObserver(() => {
+            if (codeEl.isConnected && wrapper.isConnected)
                 repositionLineNumberSpans(wrapper, codeEl);
+            else
+                this._lineNumberResizeObserver?.disconnect();
         });
+        this._lineNumberResizeObserver.observe(codeEl);
     }
 
     override inputHandler(event: Event): void {
diff --git a/packages/muya/src/config/index.ts b/packages/muya/src/config/index.ts
index 8242a1a1ab..d9f88fac59 100644
--- a/packages/muya/src/config/index.ts
+++ b/packages/muya/src/config/index.ts
@@ -113,6 +113,7 @@ export const CLASS_NAMES = genUpper2LowerKeyHash([
     'MU_EMPTY',
     'MU_FENCE_CODE',
     'MU_FOCUS_MODE',
+    'MU_CODE_WRAP',
     'MU_FRONT_MATTER',
     'MU_FRONT_ICON',
     'MU_GRAY',
@@ -320,6 +321,7 @@ export const MUYA_DEFAULT_OPTIONS = {
     orderListDelimiter: '.',
     tabSize: 4,
     codeBlockLineNumbers: false,
+    wrapCodeBlocks: false,
     // bullet/list marker width + listIndentation, tab or Daring Fireball Markdown (4 spaces) --> list indentation
     listIndentation: 1,
     frontmatterType: '-',
diff --git a/packages/muya/src/muya.ts b/packages/muya/src/muya.ts
index ddc7243089..a6e8125636 100644
--- a/packages/muya/src/muya.ts
+++ b/packages/muya/src/muya.ts
@@ -333,6 +333,8 @@ export class Muya {
             );
         }
 
+        applyAppearance(this.domNode, options);
+
         if (!forceRender)
             return;
 
@@ -357,19 +359,6 @@ export class Muya {
         }
     }
 
-    /** Update the editor font size / line height. */
-    setFont({ fontSize, lineHeight }: { fontSize?: IMuyaOptions['fontSize']; lineHeight?: IMuyaOptions['lineHeight'] }) {
-        if (typeof fontSize === 'number')
-            this.options.fontSize = fontSize;
-        if (typeof lineHeight === 'number')
-            this.options.lineHeight = lineHeight;
-    }
-
-    /** Update the tab size used for indentation. */
-    setTabSize(tabSize: IMuyaOptions['tabSize']) {
-        this.options.tabSize = tabSize;
-    }
-
     /** Update list indentation and re-render so it takes effect. */
     setListIndentation(listIndentation: IMuyaOptions['listIndentation']) {
         this.setOptions({ listIndentation }, true);
@@ -1668,6 +1657,23 @@ export class Muya {
     }
 }
 
+// Write provided appearance options as `--mu-*` vars / a wrap class on the root.
+function applyAppearance(domNode: HTMLElement, options: Partial) {
+    const { style } = domNode;
+    if (typeof options.fontSize === 'number')
+        style.setProperty('--mu-font-size', `${options.fontSize}px`);
+    if (typeof options.lineHeight === 'number')
+        style.setProperty('--mu-line-height', `${options.lineHeight}`);
+    if (options.editorFontFamily)
+        style.setProperty('--mu-font-family', options.editorFontFamily);
+    if (typeof options.codeFontSize === 'number')
+        style.setProperty('--mu-code-font-size', `${options.codeFontSize}px`);
+    if (options.codeFontFamily)
+        style.setProperty('--mu-code-font-family', options.codeFontFamily);
+    if ('wrapCodeBlocks' in options)
+        domNode.classList.toggle(CLASS_NAMES.MU_CODE_WRAP, !!options.wrapCodeBlocks);
+}
+
 /**
  * [ensureContainerDiv ensure container element is div]
  */
@@ -1699,5 +1705,7 @@ function getContainer(originContainer: HTMLElement, options: IMuyaOptions) {
     newContainer.setAttribute('spellcheck', spellcheckEnabled ? 'true' : 'false');
     originContainer.replaceWith(newContainer);
 
+    applyAppearance(newContainer, options);
+
     return newContainer;
 }
diff --git a/packages/muya/src/types.ts b/packages/muya/src/types.ts
index e7a2e4c9bb..4291764d00 100644
--- a/packages/muya/src/types.ts
+++ b/packages/muya/src/types.ts
@@ -3,6 +3,10 @@ import type { TState } from './state/types';
 export interface IMuyaOptions {
     fontSize: number;
     lineHeight: number;
+    editorFontFamily?: string;
+    codeFontSize?: number;
+    codeFontFamily?: string;
+    wrapCodeBlocks?: boolean;
     focusMode: boolean;
     trimUnnecessaryCodeBlockEmptyLines: boolean;
     preferLooseListItem: boolean;

From af9665b9e8dbc9ab89ecbbd363d344a9b3c1a959 Mon Sep 17 00:00:00 2001
From: Ran Luo 
Date: Fri, 26 Jun 2026 09:53:28 +0800
Subject: [PATCH 3/3] refactor(desktop): consume muya self-contained
 typography, remove shell style injection (#4726)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat(desktop): pass editor typography to muya as options, drop wrapper inline font

Co-Authored-By: Claude Sonnet 4.6 

* feat(desktop): route editor typography prefs through muya.setOptions

Co-Authored-By: Claude Sonnet 4.6 

* refactor(desktop): scope addCommonStyle to source-mode .CodeMirror, drop setWrapCodeBlocks

Muya now owns code-block font styling via --mu-code-* CSS vars (set at
construction and updated via setOptions), so the desktop's injected
common