diff --git a/packages/desktop/src/renderer/src/components/recent/index.vue b/packages/desktop/src/renderer/src/components/recent/index.vue index af52c0a3bd..9d87c53ee6 100644 --- a/packages/desktop/src/renderer/src/components/recent/index.vue +++ b/packages/desktop/src/renderer/src/components/recent/index.vue @@ -41,14 +41,14 @@ const newFile = () => { margin-top: 20px; } & .el-button.is-text.is-has-bg { - background-color: var(--itemBgColor); - color: var(--themeColor); + background-color: var(--buttonPrimaryBgColor); + color: var(--buttonPrimaryFontColor); border-color: transparent; } & .el-button.is-text.is-has-bg:hover, & .el-button.is-text.is-has-bg:focus { - background-color: var(--floatHoverColor); - color: var(--themeColor); + background-color: var(--buttonPrimaryBgColorHover); + color: var(--buttonPrimaryFontColorHover); } } } diff --git a/packages/desktop/src/renderer/src/components/sideBar/search.vue b/packages/desktop/src/renderer/src/components/sideBar/search.vue index d1a808d0ab..725e746387 100644 --- a/packages/desktop/src/renderer/src/components/sideBar/search.vue +++ b/packages/desktop/src/renderer/src/components/sideBar/search.vue @@ -439,14 +439,14 @@ onMounted(() => { margin-top: 20px; } & .no-data .el-button.is-text.is-has-bg { - background-color: var(--itemBgColor); - color: var(--themeColor); + background-color: var(--buttonPrimaryBgColor); + color: var(--buttonPrimaryFontColor); border-color: transparent; } & .no-data .el-button.is-text.is-has-bg:hover, & .no-data .el-button.is-text.is-has-bg:focus { - background-color: var(--floatHoverColor); - color: var(--themeColor); + background-color: var(--buttonPrimaryBgColorHover); + color: var(--buttonPrimaryFontColorHover); } } diff --git a/packages/desktop/src/renderer/src/components/sideBar/tree.vue b/packages/desktop/src/renderer/src/components/sideBar/tree.vue index fa7de93ef6..a7d435f0d9 100644 --- a/packages/desktop/src/renderer/src/components/sideBar/tree.vue +++ b/packages/desktop/src/renderer/src/components/sideBar/tree.vue @@ -427,16 +427,16 @@ onMounted(() => { } .open-project .el-button.is-text.is-has-bg, .empty-project .el-button.is-text.is-has-bg { - background-color: var(--itemBgColor); - color: var(--themeColor); + background-color: var(--buttonPrimaryBgColor); + color: var(--buttonPrimaryFontColor); border-color: transparent; } .open-project .el-button.is-text.is-has-bg:hover, .open-project .el-button.is-text.is-has-bg:focus, .empty-project .el-button.is-text.is-has-bg:hover, .empty-project .el-button.is-text.is-has-bg:focus { - background-color: var(--floatHoverColor); - color: var(--themeColor); + background-color: var(--buttonPrimaryBgColorHover); + color: var(--buttonPrimaryFontColorHover); } .new-input { outline: none; diff --git a/packages/desktop/test/unit/specs/empty-state-button-contrast.spec.ts b/packages/desktop/test/unit/specs/empty-state-button-contrast.spec.ts new file mode 100644 index 0000000000..b8347e5abb --- /dev/null +++ b/packages/desktop/test/unit/specs/empty-state-button-contrast.spec.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +// #4774: the sidebar "Open Folder" and editor "New File" empty-state buttons +// styled their label as `color: var(--themeColor)` on +// `background-color: var(--itemBgColor)`. Neither is a contrast-controlled +// pairing, so the label was unreadable in several themes (e.g. ayu-light, +// everforest-light). Every theme already defines a contrast-tuned primary +// pairing (--buttonPrimaryFontColor / --buttonPrimaryBgColor) that drives the +// app-wide `.button-primary`. These buttons must be at least as readable as +// that standard primary button in EVERY built-in theme. + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const RENDERER = resolve(__dirname, '../../../src/renderer/src') +const THEME_DIR = resolve(RENDERER, 'assets/themes') +const BASE_CSS = resolve(RENDERER, 'assets/styles/index.css') + +type Rgb = [number, number, number] +type Rgba = [number, number, number, number] + +const parseVars = (css: string): Record => { + const vars: Record = {} + const re = /(--[\w-]+)\s*:\s*([^;]+);/g + let m: RegExpExecArray | null + while ((m = re.exec(css))) vars[m[1]] = m[2].trim() + return vars +} + +const resolveVar = (value: string, vars: Record): string => { + let v = value + let guard = 0 + while (/var\(/.test(v) && guard++ < 20) { + v = v + .replace(/var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)/g, (_, name) => vars[name] ?? '') + .trim() + } + return v +} + +const toRgba = (raw: string): Rgba | null => { + const str = raw.trim() + let m: RegExpMatchArray | null + if ((m = str.match(/^#([0-9a-fA-F]{3})$/))) { + const h = m[1] + return [ + parseInt(h[0] + h[0], 16), + parseInt(h[1] + h[1], 16), + parseInt(h[2] + h[2], 16), + 1 + ] + } + if ((m = str.match(/^#([0-9a-fA-F]{6})$/))) { + const h = m[1] + return [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + 1 + ] + } + if ((m = str.match(/^rgba?\(([^)]+)\)/))) { + const p = m[1].split(',').map((s) => parseFloat(s.trim())) + return [p[0], p[1], p[2], p[3] === undefined ? 1 : p[3]] + } + // Some themes use a gradient for the primary button background; the solid + // stop is representative for a contrast estimate. + if (/^linear-gradient/.test(str)) { + const h = str.match(/#([0-9a-fA-F]{6})/) + if (h) return toRgba('#' + h[1]) + } + return null +} + +// Composite a translucent colour over an opaque backdrop. +const over = (fg: Rgba, bg: Rgb): Rgb => { + const a = fg[3] + return [ + fg[0] * a + bg[0] * (1 - a), + fg[1] * a + bg[1] * (1 - a), + fg[2] * a + bg[2] * (1 - a) + ] +} + +const relLuminance = ([r, g, b]: Rgb): number => { + const f = (c: number): number => { + const s = c / 255 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) + } + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) +} + +const contrast = (a: Rgb, b: Rgb): number => { + const l1 = relLuminance(a) + const l2 = relLuminance(b) + const hi = Math.max(l1, l2) + const lo = Math.min(l1, l2) + return (hi + 0.05) / (lo + 0.05) +} + +// Resolve a (foreground var, background var) pairing to its rendered WCAG +// contrast for a given theme, compositing any translucent colours over the +// surface the button sits on. +const pairContrast = ( + fgVar: string, + bgVar: string, + surfaceVar: string, + vars: Record +): number => { + const surface = toRgba(resolveVar(`var(${surfaceVar})`, vars)) + const surfaceRgb: Rgb = surface ? [surface[0], surface[1], surface[2]] : [255, 255, 255] + const bgRaw = toRgba(resolveVar(`var(${bgVar})`, vars)) + const fgRaw = toRgba(resolveVar(`var(${fgVar})`, vars)) + if (!bgRaw || !fgRaw) throw new Error(`unresolved colour ${fgVar}/${bgVar}`) + const bg = bgRaw[3] < 1 ? over(bgRaw, surfaceRgb) : [bgRaw[0], bgRaw[1], bgRaw[2]] as Rgb + const fg = fgRaw[3] < 1 ? over(fgRaw, bg) : [fgRaw[0], fgRaw[1], fgRaw[2]] as Rgb + return contrast(fg, bg) +} + +// Pull the foreground (color) and background-color custom-property names the +// empty-state button rule assigns, straight from the component's scoped CSS. +const extractButtonVars = ( + componentPath: string +): { fgVar: string; bgVar: string } => { + const css = readFileSync(componentPath, 'utf8') + const ruleRe = /([^{}]+)\{([^{}]*)\}/g + let m: RegExpExecArray | null + while ((m = ruleRe.exec(css))) { + const selector = m[1] + const body = m[2] + if (!selector.includes('.is-text.is-has-bg')) continue + if (/:hover|:focus/.test(selector)) continue + const bg = body.match(/(? f.endsWith('.theme.css')) + +const COMPONENTS = [ + { + name: 'Open Folder (sidebar/tree.vue)', + path: resolve(RENDERER, 'components/sideBar/tree.vue'), + surfaceVar: '--sideBarBgColor' + }, + { + name: 'New File (recent/index.vue)', + path: resolve(RENDERER, 'components/recent/index.vue'), + surfaceVar: '--editorBgColor' + }, + { + name: 'Open Folder (sideBar/search.vue no-data)', + path: resolve(RENDERER, 'components/sideBar/search.vue'), + surfaceVar: '--sideBarBgColor' + } +] + +describe('empty-state button readability (#4774)', () => { + it('found theme files and base variables to test against', () => { + expect(themeFiles.length).toBeGreaterThan(20) + expect(baseVars['--buttonPrimaryFontColor']).toBeTruthy() + expect(baseVars['--buttonPrimaryBgColor']).toBeTruthy() + }) + + for (const component of COMPONENTS) { + it(`${component.name} label is at least as readable as the standard primary button, in every theme`, () => { + const { fgVar, bgVar } = extractButtonVars(component.path) + const failures: string[] = [] + + for (const file of themeFiles) { + const vars = { + ...baseVars, + ...parseVars(readFileSync(resolve(THEME_DIR, file), 'utf8')) + } + const buttonContrast = pairContrast(fgVar, bgVar, component.surfaceVar, vars) + const primaryContrast = pairContrast( + '--buttonPrimaryFontColor', + '--buttonPrimaryBgColor', + component.surfaceVar, + vars + ) + // Equal is fine (the button reuses the primary pairing); only a + // strictly-worse contrast than the standard primary button fails. + if (buttonContrast < primaryContrast - 0.01) { + failures.push( + `${file.replace('.theme.css', '')}: button ${buttonContrast.toFixed(2)} < primary ${primaryContrast.toFixed(2)}` + ) + } + } + + expect(failures, `\n ${failures.join('\n ')}\n`).toEqual([]) + }) + } +}) diff --git a/packages/muya/src/block/base/__tests__/formatToggle.spec.ts b/packages/muya/src/block/base/__tests__/formatToggle.spec.ts index df755fb493..c096bd7655 100644 --- a/packages/muya/src/block/base/__tests__/formatToggle.spec.ts +++ b/packages/muya/src/block/base/__tests__/formatToggle.spec.ts @@ -228,6 +228,33 @@ describe('format.format() apply-ON over a non-collapsed selection', () => { }); }); +// #2166 — double-clicking a word selects the word PLUS the trailing whitespace. +// Wrapping that whitespace inside the markers (`**foo **`) is invalid emphasis +// per CommonMark's flanking rules, so it renders as literal text. The markers +// must hug the non-whitespace content, leaving the whitespace outside. +describe('format.format() trims selection whitespace before wrapping (#2166)', () => { + it('strong: selecting `foo ` (with trailing space) wraps only `foo`', () => { + // `foo bar`: offsets 0..4 cover `foo ` including the trailing space. + const content = selectInFirstBlock(bootMuya('foo bar\n'), 0, 4); + content.format('strong'); + expect(content.text).toBe('**foo** bar'); + }); + + it('em: selecting ` bar` (with leading space) wraps only `bar`', () => { + // `foo bar`: offsets 3..7 cover ` bar` including the leading space. + const content = selectInFirstBlock(bootMuya('foo bar\n'), 3, 7); + content.format('em'); + expect(content.text).toBe('foo *bar*'); + }); + + it('strong: selecting `foo ` then ` bar` style both-side padding wraps only the words', () => { + // ` foo ` at offsets 0..5 of ` foo bar` → only `foo` gets wrapped. + const content = selectInFirstBlock(bootMuya(' foo bar\n'), 0, 5); + content.format('strong'); + expect(content.text).toBe(' **foo** bar'); + }); +}); + describe('format.format(\'clear\') with the caret inside the run', () => { it('strips a strong run to plain text', () => { const content = caretInFirstBlock(bootMuya('**word**\n'), 2); diff --git a/packages/muya/src/block/base/__tests__/imageSelfClose.spec.ts b/packages/muya/src/block/base/__tests__/imageSelfClose.spec.ts new file mode 100644 index 0000000000..60ce8c6049 --- /dev/null +++ b/packages/muya/src/block/base/__tests__/imageSelfClose.spec.ts @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom + +import type Format from '../format'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Muya } from '../../../muya'; +import { getImageInfo } from '../../../utils/image'; + +// #2505 — aligning an image rewrites `![alt](src)` into an inline HTML `` +// so it can carry the alignment attribute. The generated tag was left OPEN +// (``), which is valid Markdown/HTML but breaks JSX/MDX (Docusaurus: +// "Unterminated JSX contents"). It must be self-closed (``). This +// drives the real alignment path the image toolbar uses +// (block.updateImage(info, 'data-align', …)) and the edit path +// (block.replaceImage) on a booted engine. + +const bootedHosts: HTMLElement[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + document.getSelection()?.removeAllRanges(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +function firstBlock(muya: Muya): Format { + return muya.editor.scrollPage!.firstContentInDescendant() as unknown as Format; +} + +describe('#2505 — aligned/edited images emit a self-closing (JSX-safe)', () => { + it('aligning a markdown image produces a self-closed ', () => { + const muya = bootMuya('![cat](https://example.com/cat.png)\n'); + const block = firstBlock(muya); + const imageEl = muya.domNode.querySelector('[data-raw]'); + expect(imageEl).not.toBeNull(); + + const imageInfo = getImageInfo(imageEl!); + block.updateImage(imageInfo, 'data-align', 'center'); + + expect(block.text).toMatch(/]*\/>/); + // and never an unterminated open tag + expect(block.text).not.toMatch(/]*[^/]>/); + }); + + it('editing an existing inline HTML keeps it self-closed', () => { + const muya = bootMuya('a\n'); + const block = firstBlock(muya); + const imageEl = muya.domNode.querySelector('[data-raw]'); + expect(imageEl).not.toBeNull(); + + const imageInfo = getImageInfo(imageEl!); + block.replaceImage(imageInfo, { alt: 'b', src: 'https://example.com/b.png', title: '' }); + + expect(block.text).toMatch(/]*\/>/); + expect(block.text).not.toMatch(/]*[^/]>/); + }); +}); diff --git a/packages/muya/src/block/base/format.ts b/packages/muya/src/block/base/format.ts index 1926475377..2b2175dadd 100644 --- a/packages/muya/src/block/base/format.ts +++ b/packages/muya/src/block/base/format.ts @@ -393,7 +393,7 @@ class Format extends Content { imageText += `${attr}="${value}" `; } imageText = imageText.trim(); - imageText += '>'; + imageText += ' />'; } this.text @@ -424,7 +424,7 @@ class Format extends Content { imageText += `${attr}="${value}" `; } imageText = imageText.trim(); - imageText += '>'; + imageText += ' />'; this.text = oldText.substring(0, start) + imageText + oldText.substring(end); @@ -1648,6 +1648,15 @@ class Format extends Content { end.offset += end.delta; this.text = generator(tokens, true); + // Whitespace wrapped inside emphasis markers is invalid CommonMark + // (`**foo **` is not right-flanking, so it renders literally), so + // trim the selection to its non-whitespace span before wrapping. + const selected = this.text.substring(start.offset, end.offset); + if (selected.trim().length > 0) { + start.offset += selected.length - selected.trimStart().length; + end.offset -= selected.length - selected.trimEnd().length; + } + this._addFormat(type, { start, end }); if (type === 'image') { diff --git a/packages/muya/src/block/content/codeBlockContent/__tests__/enterBackspaceHandler.spec.ts b/packages/muya/src/block/content/codeBlockContent/__tests__/enterBackspaceHandler.spec.ts index f31a0c255a..60ebded2d4 100644 --- a/packages/muya/src/block/content/codeBlockContent/__tests__/enterBackspaceHandler.spec.ts +++ b/packages/muya/src/block/content/codeBlockContent/__tests__/enterBackspaceHandler.spec.ts @@ -157,6 +157,23 @@ describe('codeBlockContent.enterHandler — plain Enter inserts newline + indent expect(event.preventDefault).toHaveBeenCalledTimes(1); }); + it('inherits the CURRENT line indent when Enter is pressed on a later line, not line 0', () => { + const muya = bootMuya('```js\nfoo\n```\n'); + const content = codeContent(muya); + // Two lines: line 0 has no indent, line 1 is indented 4 spaces. + content.text = 'def foo():\n bar()'; + muya.editor.activeContentBlock = content; + const offset = content.text.length; // caret at end of the indented 2nd line + content.setCursor(offset, offset, true); + + content.enterHandler(keyEvent({ key: 'Enter' })); + + // The new line must inherit line 1's 4-space indent, not line 0's empty indent. + expect(content.text).toBe('def foo():\n bar()\n '); + const cursor = content.getCursor()!; + expect(cursor.start.offset).toBe(offset + 1 + 4); + }); + it('adds an extra tabSize block when the caret sits inside an auto-indent pair', () => { const muya = bootMuya('```js\nfoo\n```\n'); const content = codeContent(muya); diff --git a/packages/muya/src/block/content/codeBlockContent/index.ts b/packages/muya/src/block/content/codeBlockContent/index.ts index b40cbb189f..c4c05a2452 100644 --- a/packages/muya/src/block/content/codeBlockContent/index.ts +++ b/packages/muya/src/block/content/codeBlockContent/index.ts @@ -22,8 +22,12 @@ function checkAutoIndent(text: string, offset: number) { return /^(?:\{\}|\[\]|\(\)|><)$/.test(pairStr); } -function getIndentSpace(text: string) { - const match = /^(\s*)\S/.exec(text); +function getIndentSpace(text: string, offset: number) { + const lineStart = text.lastIndexOf('\n', offset - 1) + 1; + let lineEnd = text.indexOf('\n', lineStart); + if (lineEnd === -1) + lineEnd = text.length; + const match = /^(\s*)\S/.exec(text.slice(lineStart, lineEnd)); return match ? match[1] : ''; } @@ -281,7 +285,7 @@ class CodeBlockContent extends Content { const { start } = this.getCursor()!; const { text } = this; const autoIndent = checkAutoIndent(text, start.offset); - const indent = getIndentSpace(text); + const indent = getIndentSpace(text, start.offset); this.text = `${text.substring(0, start.offset) diff --git a/packages/muya/src/block/content/paragraphContent/__tests__/listMathEnterConvert.spec.ts b/packages/muya/src/block/content/paragraphContent/__tests__/listMathEnterConvert.spec.ts new file mode 100644 index 0000000000..20203e0246 --- /dev/null +++ b/packages/muya/src/block/content/paragraphContent/__tests__/listMathEnterConvert.spec.ts @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom + +import type Content from '../../../base/content'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../../../muya'; + +// #2276 — inside a list item, typing `$$` then Enter should convert the item's +// paragraph into a math block IN PLACE (like a code fence already does), not +// split the item and leave behind an extra empty list entry. The enterHandler +// early-converts code fences even inside a list, but `$$` fell through to the +// list-split path, producing the math input AND an unwanted next list item. + +const bootedHosts: HTMLElement[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + document.getSelection()?.removeAllRanges(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +function keyEvent(over: Partial = {}): KeyboardEvent { + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + key: 'Enter', + shiftKey: false, + ...over, + } as unknown as KeyboardEvent; +} + +function flush(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +// Drive `$$` + Enter on the list item's paragraph content and return the +// resulting top-level document state (the order-list). +async function enterDollarsInList(token: string): Promise<{ listChildren: number; itemBlockNames: string[] }> { + const muya = bootMuya('1. x\n'); + const content = muya.editor.scrollPage!.firstContentInDescendant() as unknown as Content; + content.text = token; + muya.editor.activeContentBlock = content as never; + content.setCursor(token.length, token.length, true); + + content.enterHandler(keyEvent({ key: 'Enter' })); + await flush(); + + const state = muya.getState() as Array<{ name: string; children?: Array<{ name: string; children?: Array<{ name: string }> }> }>; + const list = state[0]; + const items = list.children ?? []; + return { + listChildren: items.length, + itemBlockNames: (items[0]?.children ?? []).map(c => c.name), + }; +} + +describe('#2276 — `$$`/code-fence + Enter inside a list converts in place, no extra item', () => { + it('`$$` converts the list item to a math block without adding a new list item', async () => { + const { listChildren, itemBlockNames } = await enterDollarsInList('$$'); + expect(listChildren).toBe(1); // no unwanted second list item + expect(itemBlockNames).toContain('math-block'); + }); + + it('a code fence ```` ``` ```` converts in place without adding a new list item', async () => { + const { listChildren, itemBlockNames } = await enterDollarsInList('```js'); + expect(listChildren).toBe(1); + expect(itemBlockNames).toContain('code-block'); + }); + + it('a table `|a|b|` converts in place without adding a new list item', async () => { + const { listChildren, itemBlockNames } = await enterDollarsInList('|a|b|'); + expect(listChildren).toBe(1); + expect(itemBlockNames).toContain('table'); + }); + + it('an HTML block `
` converts in place without adding a new list item', async () => { + const { listChildren, itemBlockNames } = await enterDollarsInList('
'); + expect(listChildren).toBe(1); + expect(itemBlockNames).toContain('html-block'); + }); +}); diff --git a/packages/muya/src/block/content/paragraphContent/index.ts b/packages/muya/src/block/content/paragraphContent/index.ts index 8aa0b162d1..c42f696984 100644 --- a/packages/muya/src/block/content/paragraphContent/index.ts +++ b/packages/muya/src/block/content/paragraphContent/index.ts @@ -41,6 +41,39 @@ const debug = logger('paragraph:content'); const HTML_BLOCK_REG = /^<([a-z\d-]+)(?=\s|>)[^<>]*>$/i; const CODE_BLOCK_REG = /(^ {0,3}`{3,})([^` ]*)/; +const MATH_BLOCK_REG = /^\$\$/; +// eslint-disable-next-line regexp/no-super-linear-backtracking +const TABLE_BLOCK_REG = /^\|.*?(\\*)\|.*?(\\*)\|/; + +type BlockConversion + = | { kind: 'math' } + | { kind: 'code'; lang: string } + | { kind: 'table' } + | { kind: 'html'; tagName: string }; + +// Single source of truth for "what block, if any, does this paragraph text +// convert into on Enter". Shared by the enterHandler guard (to decide whether +// to convert in place) and `_enterConvert` (to perform it), so the match rules +// can never drift between the two. +function matchBlockConversion(text: string): BlockConversion | null { + if (MATH_BLOCK_REG.test(text)) + return { kind: 'math' }; + + const codeBlockToken = text.match(CODE_BLOCK_REG); + if (codeBlockToken) + return { kind: 'code', lang: codeBlockToken[2] }; + + const tableMatch = TABLE_BLOCK_REG.exec(text); + if (tableMatch && isLengthEven(tableMatch[1]) && isLengthEven(tableMatch[2])) + return { kind: 'table' }; + + const htmlMatch = HTML_BLOCK_REG.exec(text); + const tagName = htmlMatch && htmlMatch[1] && HTML_TAGS.find(t => t === htmlMatch[1]); + if (tagName && VOID_HTML_TAGS.every(tag => tag !== tagName)) + return { kind: 'html', tagName }; + + return null; +} const BOTH_SIDES_FORMATS = [ 'strong', @@ -224,118 +257,114 @@ class ParagraphContent extends Format { event.preventDefault(); event.stopPropagation(); - // eslint-disable-next-line regexp/no-super-linear-backtracking - const TABLE_BLOCK_REG = /^\|.*?(\\*)\|.*?(\\*)\|/; - const MATH_BLOCK_REG = /^\$\$/; - const { text } = this; - const codeBlockToken = text.match(CODE_BLOCK_REG); - const tableMatch = TABLE_BLOCK_REG.exec(text); - const htmlMatch = HTML_BLOCK_REG.exec(text); - const mathMath = MATH_BLOCK_REG.exec(text); - const tagName - = htmlMatch && htmlMatch[1] && HTML_TAGS.find(t => t === htmlMatch[1]); - - if (mathMath) { - const state = { - name: 'math-block', - text: '', - meta: { - mathStyle: '', - }, - }; - const mathBlock = ScrollPage.loadBlock('math-block').create( - this.muya, - state, - ); - this.parent!.replaceWith(mathBlock); - mathBlock.firstContentInDescendant().setCursor(0, 0); - } - else if (codeBlockToken) { - const lang = codeBlockToken[2]; - // Diagram fences (```mermaid etc.) become diagram blocks, mirroring - // the file-load path in markdownToState; everything else is a fenced - // code block. - const diagramMatch = /^(?:mermaid|vega-lite|plantuml|flowchart|sequence)$/.exec(lang); - if (diagramMatch) { - const type = lang as IDiagramMeta['type']; + const match = matchBlockConversion(this.text); + if (!match) + return super.enterHandler(event); + + switch (match.kind) { + case 'math': { const state = { - name: 'diagram', + name: 'math-block', text: '', meta: { - type, - lang: type === 'vega-lite' ? 'json' : 'yaml', + mathStyle: '', }, }; - const diagramBlock = ScrollPage.loadBlock(state.name).create( + const mathBlock = ScrollPage.loadBlock('math-block').create( this.muya, state, ); + this.parent!.replaceWith(mathBlock); + mathBlock.firstContentInDescendant().setCursor(0, 0); + break; + } - this.parent!.replaceWith(diagramBlock); + case 'code': { + const { lang } = match; + // Diagram fences (```mermaid etc.) become diagram blocks, + // mirroring the file-load path in markdownToState; everything + // else is a fenced code block. + const diagramMatch = /^(?:mermaid|vega-lite|plantuml|flowchart|sequence)$/.exec(lang); + if (diagramMatch) { + const type = lang as IDiagramMeta['type']; + const state = { + name: 'diagram', + text: '', + meta: { + type, + lang: type === 'vega-lite' ? 'json' : 'yaml', + }, + }; + const diagramBlock = ScrollPage.loadBlock(state.name).create( + this.muya, + state, + ); + + this.parent!.replaceWith(diagramBlock); + + diagramBlock.firstContentInDescendant().setCursor(0, 0, true); + } + else { + const state = { + name: 'code-block', + meta: { + lang, + type: 'fenced', + }, + text: '', + }; + const codeBlock = ScrollPage.loadBlock(state.name).create( + this.muya, + state, + ); + + this.parent!.replaceWith(codeBlock); + + codeBlock.lastContentInDescendant().setCursor(0, 0); + } + break; + } - diagramBlock.firstContentInDescendant().setCursor(0, 0, true); + case 'table': { + const tableHeader = parseTableHeader(this.text); + // Table extends the base `create` shape with a static + // `createWithHeader(muya, header)` factory; the registry-level + // IConstructor doesn't surface it. Cast to a structural view + // that names only the static slot we read. + const tableCtor = ScrollPage.loadBlock('table') as { + createWithHeader?: (muya: Muya, header: string[]) => Parent; + }; + const tableBlock = tableCtor.createWithHeader!(this.muya, tableHeader); + + this.parent!.replaceWith(tableBlock); + + // Set cursor at the first cell of second row. The runtime chain + // is: table → table-body (Parent.firstChild) → row (find(1)) → + // first cell content (firstContentInDescendant). Asserted as + // Parent at each container hop since createWithHeader guarantees + // a populated structure. + const tableBody = tableBlock.firstChild as Parent; + const secondRow = tableBody.find(1) as Parent; + secondRow.firstContentInDescendant()?.setCursor(0, 0, true); + break; } - else { + + case 'html': { + const { tagName } = match; const state = { - name: 'code-block', - meta: { - lang, - type: 'fenced', - }, - text: '', + name: 'html-block', + text: `<${tagName}>\n\n`, }; - const codeBlock = ScrollPage.loadBlock(state.name).create( + const htmlBlock = ScrollPage.loadBlock('html-block').create( this.muya, state, ); - - this.parent!.replaceWith(codeBlock); - - codeBlock.lastContentInDescendant().setCursor(0, 0); + this.parent!.replaceWith(htmlBlock); + const offset = tagName.length + 3; + htmlBlock.firstContentInDescendant().setCursor(offset, offset); + break; } } - else if ( - tableMatch - && isLengthEven(tableMatch[1]) - && isLengthEven(tableMatch[2]) - ) { - const tableHeader = parseTableHeader(this.text); - // Table extends the base `create` shape with a static - // `createWithHeader(muya, header)` factory; the registry-level - // IConstructor doesn't surface it. Cast to a structural view that - // names only the static slot we read. - const tableCtor = ScrollPage.loadBlock('table') as { - createWithHeader?: (muya: Muya, header: string[]) => Parent; - }; - const tableBlock = tableCtor.createWithHeader!(this.muya, tableHeader); - - this.parent!.replaceWith(tableBlock); - - // Set cursor at the first cell of second row. The runtime chain - // is: table → table-body (Parent.firstChild) → row (find(1)) → - // first cell content (firstContentInDescendant). Asserted as - // Parent at each container hop since createWithHeader guarantees - // a populated structure. - const tableBody = tableBlock.firstChild as Parent; - const secondRow = tableBody.find(1) as Parent; - secondRow.firstContentInDescendant()?.setCursor(0, 0, true); - } - else if (tagName && VOID_HTML_TAGS.every(tag => tag !== tagName)) { - const state = { - name: 'html-block', - text: `<${tagName}>\n\n`, - }; - const htmlBlock = ScrollPage.loadBlock('html-block').create( - this.muya, - state, - ); - this.parent!.replaceWith(htmlBlock); - const offset = tagName.length + 3; - htmlBlock.firstContentInDescendant().setCursor(offset, offset); - } - else { - return super.enterHandler(event); - } } private _enterInBlockQuote(event: KeyboardEvent) { @@ -524,10 +553,13 @@ class ParagraphContent extends Format { if (event.shiftKey) return this.shiftEnterHandler(event); - // A code fence (```` ```lang ````) always converts the paragraph in - // place, even inside a block-quote or list item — the resulting - // code-block stays nested in its container (matches muyajs). - if (CODE_BLOCK_REG.test(this.text)) + // Any paragraph that would convert to a block (code fence, math block, + // table, HTML block) converts in place, even inside a block-quote or + // list item — the resulting block stays nested in its container + // (matches muyajs). Otherwise typing the block syntax in a list would + // split the item and strand an empty list entry (#2276, plus table / + // HTML block). + if (matchBlockConversion(this.text)) return this._enterConvert(event); const type = this._paragraphParentType(); diff --git a/packages/muya/src/editor/__tests__/clickableAutolink.spec.ts b/packages/muya/src/editor/__tests__/clickableAutolink.spec.ts new file mode 100644 index 0000000000..88e7459e4b --- /dev/null +++ b/packages/muya/src/editor/__tests__/clickableAutolink.spec.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom + +import type { Muya as MuyaType } from '../../muya'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Muya } from '../../muya'; + +// #2165 — "make it easy to follow a link". CommonMark autolinks +// (``) and GFM bare-URL autolinks (`https://x.com`) render as +// `a.mu-auto-link` / `a.mu-auto-link-extension`, but those classes were absent +// from linkMouseEvents' LINK_SELECTOR and the renderers never set `data-raw`, +// so `getLinkInfo` returned null and a Cmd/Ctrl-click never emitted +// `format-click` — the link could not be followed. These boot a real engine, +// render each autolink variant, and assert a modifier-click asks the host to +// open it. + +const bootedHosts: HTMLElement[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + document.getSelection()?.removeAllRanges(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): MuyaType { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +interface IFormatClick { formatType: string; data: { href: string | null; raw: string } } + +function captureFormatClick(muya: MuyaType): IFormatClick[] { + const emits: IFormatClick[] = []; + muya.eventCenter.subscribe('format-click', (payload: IFormatClick) => emits.push(payload)); + return emits; +} + +function modifierClick(el: Element) { + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true })); +} + +describe('#2165 — autolinks are followable via Cmd/Ctrl-click', () => { + it('renders a.mu-auto-link with data-raw + href for a CommonMark autolink ``', () => { + const muya = bootMuya('\n'); + const anchor = muya.domNode.querySelector('a.mu-auto-link'); + expect(anchor).not.toBeNull(); + expect(anchor!.dataset.raw).toBeTruthy(); + expect(anchor!.getAttribute('href')).toContain('https://example.com'); + }); + + it('a Ctrl-click on a CommonMark autolink emits format-click with the href', () => { + const muya = bootMuya('\n'); + const emits = captureFormatClick(muya); + const anchor = muya.domNode.querySelector('a.mu-auto-link')!; + + modifierClick(anchor); + + expect(emits).toHaveLength(1); + expect(emits[0].formatType).toBe('link'); + expect(emits[0].data.href).toContain('https://example.com'); + }); + + it('renders a.mu-auto-link-extension for a bare GFM URL `https://example.com` and follows it', () => { + const muya = bootMuya('see https://example.com here\n'); + const emits = captureFormatClick(muya); + const anchor = muya.domNode.querySelector('a.mu-auto-link-extension'); + expect(anchor).not.toBeNull(); + expect(anchor!.dataset.raw).toBeTruthy(); + + modifierClick(anchor!); + + expect(emits).toHaveLength(1); + expect(emits[0].data.href).toContain('https://example.com'); + }); + + it('a plain (non-modifier) click on an autolink does NOT emit format-click', () => { + const muya = bootMuya('\n'); + const emits = captureFormatClick(muya); + const anchor = muya.domNode.querySelector('a.mu-auto-link')!; + + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(emits).toHaveLength(0); + }); + + it('hovering an autolink does NOT open the edit/unlink popover (follow-only)', () => { + const muya = bootMuya('\n'); + const popoverEmits: unknown[] = []; + muya.eventCenter.subscribe('muya-link-tools', (p: { reference: unknown }) => { + if (p.reference) + popoverEmits.push(p); + }); + const anchor = muya.domNode.querySelector('a.mu-auto-link')!; + + anchor.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + expect(popoverEmits).toHaveLength(0); + }); +}); diff --git a/packages/muya/src/editor/linkMouseEvents.ts b/packages/muya/src/editor/linkMouseEvents.ts index 6c359cac37..34f9b02b87 100644 --- a/packages/muya/src/editor/linkMouseEvents.ts +++ b/packages/muya/src/editor/linkMouseEvents.ts @@ -40,6 +40,8 @@ const LINK_SELECTOR = [ `span.${CLASS_NAMES.MU_LINK}`, `a.${CLASS_NAMES.MU_REFERENCE_LINK}`, `a.${CLASS_NAMES.MU_RAW_HTML}`, + `a.${CLASS_NAMES.MU_AUTO_LINK}`, + `a.${CLASS_NAMES.MU_AUTO_LINK_EXTENSION}`, ].join(', '); // Click suppression covers all real anchor variants whether or not they @@ -62,6 +64,16 @@ function isModifierClick(event: Event): boolean { } function isPopoverTarget(wrapper: HTMLElement): boolean { + // Auto-detected links are follow-only (Cmd/Ctrl-click). The edit/unlink + // popover doesn't apply — there is no `[text](url)` source to rewrite, and + // the URL re-autolinks on the next render anyway. + if ( + wrapper.classList.contains(CLASS_NAMES.MU_AUTO_LINK) + || wrapper.classList.contains(CLASS_NAMES.MU_AUTO_LINK_EXTENSION) + ) { + return false; + } + // HTML `` is always a popover target — no source markers to hide. if (wrapper.classList.contains(CLASS_NAMES.MU_RAW_HTML)) return true; diff --git a/packages/muya/src/inlineRenderer/renderer/autoLink.ts b/packages/muya/src/inlineRenderer/renderer/autoLink.ts index af5911a313..cabaf87b3b 100644 --- a/packages/muya/src/inlineRenderer/renderer/autoLink.ts +++ b/packages/muya/src/inlineRenderer/renderer/autoLink.ts @@ -42,6 +42,11 @@ export default function autoLink( href: sanitizeHyperlink(hyperlink), target: '_blank', }, + dataset: { + start: String(start), + end: String(end), + raw: token.raw, + }, }, content, ), diff --git a/packages/muya/src/inlineRenderer/renderer/autoLinkExtension.ts b/packages/muya/src/inlineRenderer/renderer/autoLinkExtension.ts index fae3d937d7..4b1490baab 100644 --- a/packages/muya/src/inlineRenderer/renderer/autoLinkExtension.ts +++ b/packages/muya/src/inlineRenderer/renderer/autoLinkExtension.ts @@ -30,6 +30,11 @@ export default function autoLinkExtension( href: sanitizeHyperlink(hyperlink), target: '_blank', }, + dataset: { + start: String(start), + end: String(end), + raw: token.raw, + }, }, content, ),