Merge branch 'release/3.92.1'

This commit is contained in:
purocean
2026-07-12 11:03:29 +08:00
26 changed files with 983 additions and 643 deletions
+10 -6
View File
@@ -76,14 +76,18 @@ For more information on how to use the following functions, please see [characte
## Changelogs
### [v3.90.0](https://github.com/purocean/yn/releases/tag/v3.90.0) 2026-05-16
### [v3.92.1](https://github.com/purocean/yn/releases/tag/v3.92.1) 2026-07-12
[Windows](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-win-x64-3.90.0.exe) | [macOS arm64](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-mac-arm64-3.90.0.dmg) | [macOS x64](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-mac-x64-3.90.0.dmg) | [Linux AppImage](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-linux-x86_64-3.90.0.AppImage) | [Linux deb](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-linux-amd64-3.90.0.deb)
[Windows](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-win-x64-3.92.1.exe) | [macOS arm64](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-mac-arm64-3.92.1.dmg) | [macOS x64](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-mac-x64-3.92.1.dmg) | [Linux AppImage](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-linux-x86_64-3.92.1.AppImage) | [Linux deb](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-linux-amd64-3.92.1.deb)
1. feat: Add MCP setting inspection tools for reading setting schemas, reading current setting values, and opening the settings panel at a specific key or group
2. feat: Add pasted-image filename templates with time and hash placeholders
3. feat: Add a CJ-friendly Markdown rendering option for better Chinese/Japanese text emphasis parsing
4. feat(editor): Focus the floating editor when clicking preview lines and close it automatically when it loses focus
1. feat(markdown): Upgrade Markdown-it to 14.3.0 and add CJK-friendly emphasis parsing for Chinese and Japanese text
2. feat(markdown): Add a raw HTML container that can render HTML blocks mixed with Markdown content
3. feat(plugin): Add range-based text highlighting APIs for extensions and preview interactions
4. feat(editor): Always show the floating editor hint so the feature remains discoverable
5. fix(markdown): Avoid double escaping plain-text code fences with a language marker
6. fix(editor): Restore HTML and other non-Markdown text files correctly when the app starts
7. fix(terminal): Prevent IME layout offsets caused by horizontal terminal overflow
8. chore: Upgrade Electron to 41 and update dependencies and security-related transitive packages
[More release notes](https://github.com/purocean/yn/releases)
+10 -6
View File
@@ -76,14 +76,18 @@
## 更新日志
### [v3.90.0](https://github.com/purocean/yn/releases/tag/v3.90.0) 2026-05-16
### [v3.92.1](https://github.com/purocean/yn/releases/tag/v3.92.1) 2026-07-12
[Windows](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-win-x64-3.90.0.exe) | [macOS arm64](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-mac-arm64-3.90.0.dmg) | [macOS x64](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-mac-x64-3.90.0.dmg) | [Linux AppImage](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-linux-x86_64-3.90.0.AppImage) | [Linux deb](https://github.com/purocean/yn/releases/download/v3.90.0/Yank-Note-linux-amd64-3.90.0.deb)
[Windows](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-win-x64-3.92.1.exe) | [macOS arm64](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-mac-arm64-3.92.1.dmg) | [macOS x64](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-mac-x64-3.92.1.dmg) | [Linux AppImage](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-linux-x86_64-3.92.1.AppImage) | [Linux deb](https://github.com/purocean/yn/releases/download/v3.92.1/Yank-Note-linux-amd64-3.92.1.deb)
1. feat: 增加 MCP 设置排查工具,支持读取设置结构、读取当前设置值,以及打开设置面板并定位到指定字段或分组
2. feat: 增加粘贴图片文件名模板,支持时间和哈希占位符
3. feat: 增加 CJ-friendly Markdown 渲染选项,优化中文、日文文本中的强调解析
4. feat(editor): 点击预览行时聚焦浮动编辑器,并在浮动编辑器失焦后自动关闭
1. feat(markdown): Markdown-it 升级到 14.3.0,并增加 CJK-friendly 强调解析,优化中文、日文文本的渲染
2. feat(markdown): 增加原始 HTML 容器,支持在 Markdown 内容中混合并直接渲染 HTML 区块
3. feat(plugin): 扩展 API 增加基于文本范围的高亮能力,供扩展和预览交互使用
4. feat(editor): 始终显示浮动编辑器提示,方便用户发现和使用该功能
5. fix(markdown): 修复带语言标记的纯文本代码围栏发生重复转义的问题
6. fix(editor): 修复应用启动时无法正确恢复 HTML 及其他非 Markdown 文本文件内容的问题
7. fix(terminal): 避免终端横向溢出导致输入法界面发生位置偏移
8. chore: Electron 升级到 41,并更新依赖和安全相关的间接依赖
[更多发布说明](https://github.com/purocean/yn/releases)
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "yank.note",
"version": "3.90.0",
"version": "3.92.1",
"description": "Yank Note: A highly extensible Markdown editor, designed for productivity.",
"main": "dist/main/app.js",
"license": "AGPL-3.0",
@@ -58,7 +58,7 @@
"socks": "^2.8.3",
"tar-stream": "^2.2.0",
"transliteration": "^2.2.0",
"undici": "^6.24.0",
"undici": "^6.27.0",
"uuid": "^14.0.0",
"yaml": "^2.8.3",
"yargs": "^15.3.1"
@@ -67,7 +67,7 @@
"@commitlint/cli": "^16.2.4",
"@commitlint/config-conventional": "^16.2.4",
"@electron/notarize": "^2.1.0",
"@electron/rebuild": "^4.0.1",
"@electron/rebuild": "^4.0.4",
"@json-editor/json-editor": "^2.5.4",
"@types/adm-zip": "^0.4.34",
"@types/async-lock": "^1.3.0",
@@ -112,7 +112,7 @@
"crypto-js": "^4.2.0",
"dexie": "^4.0.8",
"dom-to-image": "^2.6.0",
"electron": "38.7.1",
"electron": "41.7.0",
"electron-builder": "^26.0.12",
"eslint": "^8.57.0",
"eslint-plugin-import": "^2.29.1",
@@ -128,11 +128,11 @@
"juice": "^8.0.0",
"katex": "^0.16.21",
"lodash-es": "^4.18.1",
"markdown-it": "^14.1.0",
"markdown-it": "^14.3.0",
"markdown-it-abbr": "^2.0.0",
"markdown-it-attributes": "^1.2.0",
"markdown-it-cjk-friendly": "2.0.2",
"markdown-it-container": "^4.0.0",
"markdown-it-cj-friendly": "0.1.1",
"markdown-it-emoji": "^3.0.0",
"markdown-it-github-alerts": "^0.3.0",
"markdown-it-mark": "^4.0.0",
@@ -140,6 +140,7 @@
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"monaco-editor": "^0.49.0",
"node-abi": "^4.31.0",
"normalize.css": "^8.0.1",
"parse-author": "^2.0.0",
"path-browserify": "^1.0.1",
@@ -153,7 +154,7 @@
"typescript": "^5.5.4",
"utility-types": "^3.10.0",
"viewerjs": "^1.11.6",
"vite": "^6.1.6",
"vite": "^6.4.3",
"vitest": "^4.1.5",
"vue": "^3.5.13",
"vue-tsc": "^2.2.0",
+4
View File
@@ -196,4 +196,8 @@ export default defineComponent({
.xterm-dom ::v-deep(textarea) {
transition: none;
}
.xterm-dom ::v-deep(.xterm-screen) {
overflow-x: clip;
}
</style>
@@ -211,7 +211,7 @@ describe('floating-editor plugin', () => {
expect(ctx.editorInstance.focus).toHaveBeenCalledTimes(1)
})
test('watcher shows a limited preview hint when floating editor is available', () => {
test('watcher shows a preview hint whenever floating editor is available', () => {
const ctx = createCtx()
floatingEditor.register(ctx)
const canShow = ctx.lib.vue.watch.mock.calls[0][0]
@@ -222,7 +222,8 @@ describe('floating-editor plugin', () => {
const hint = document.body.querySelector('.floating-editor-hint') as HTMLElement
expect(hint.textContent).toBe('floating-editor.preview-hint:Alt')
expect(ctx.storage.set).toHaveBeenCalledWith('plugin.floating-editor.preview-hint-count', 1)
expect(ctx.storage.get).not.toHaveBeenCalled()
expect(ctx.storage.set).not.toHaveBeenCalled()
})
test('titlebar and resize handles move, maximize, restore, and close the floating frame', async () => {
@@ -1,5 +1,7 @@
const mocks = vi.hoisted(() => ({
highlight: vi.fn((code: string, _grammar: any, lang: string) => `<span class="token ${lang}">${code}</span>`),
highlight: vi.fn((code: string, _grammar: any, lang: string) => lang === 'text'
? code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
: `<span class="token ${lang}">${code}</span>`),
juice: vi.fn((html: string) => `<inlined>${html}</inlined>`),
loggerWarn: vi.fn(),
}))
@@ -10,6 +12,7 @@ vi.mock('prismjs', () => ({
javascript: {},
markup: {},
python: {},
text: {},
typescript: {},
},
highlight: mocks.highlight,
@@ -124,14 +127,22 @@ describe('markdown-code-highlight plugin', () => {
const numbered = ctx.md.options.highlight('const a = 1\nconst b = 2\n', 'ts')
const escaped = ctx.md.options.highlight('<b>x</b>', '')
const fallback = ctx.md.options.highlight('<x>\n', 'unknown')
const plainText = ctx.md.options.highlight('http://<host>:7999?install=1\nline2\n', 'text')
const plainTextContainer = document.createElement('div')
plainTextContainer.innerHTML = plainText
expect(mocks.highlight).toHaveBeenCalledWith('const a = 1\nconst b = 2\n', {}, 'typescript')
expect(mocks.highlight).toHaveBeenCalledWith('http://<host>:7999?install=1\nline2\n', {}, 'text')
expect(numbered).toContain('<table class="hljs-ln" data-lang="ts">')
expect(numbered).toContain('data-line-number="1"')
expect(numbered).toContain('data-line-number="2"')
expect(numbered).toContain('<span class="token typescript">const a = 1</span>')
expect(escaped).toBe('&lt;b&gt;x&lt;/b&gt;')
expect(fallback).toContain('&lt;x&gt;')
expect(plainText).toContain('http://&lt;host&gt;:7999?install=1')
expect(plainText).not.toContain('&amp;lt;host')
expect(plainTextContainer.querySelector('td.hljs-ln-code .hljs-ln-line')?.textContent).toBe('http://<host>:7999?install=1')
expect(mocks.loggerWarn).toHaveBeenCalledWith('Syntax highlight for language "unknown" is not supported.')
})
@@ -4,8 +4,12 @@ vi.mock('@fe/context', () => ({
import MarkdownIt from 'markdown-it'
import markdownContainer from '../markdown-container'
import markdownHtml from '../markdown-html'
import markdownRenderVNode from '../markdown-render-vnode'
function createCtx (md: MarkdownIt) {
const completionItems: any[] = []
return {
view: { addStyles: vi.fn() },
markdown: {
@@ -13,9 +17,10 @@ function createCtx (md: MarkdownIt) {
},
registerHook: vi.fn(),
editor: {
tapSimpleCompletionItems: vi.fn(),
tapSimpleCompletionItems: vi.fn((fn: Function) => fn(completionItems)),
tapMarkdownMonarchLanguage: vi.fn((fn: any) => fn({ tokenizer: { root: [] } })),
},
completionItems,
} as any
}
@@ -32,6 +37,7 @@ describe('markdown-container plugin', () => {
expect(ctx.registerHook).toHaveBeenCalledWith('VIEW_ON_GET_HTML_FILTER_NODE', expect.any(Function))
expect(ctx.editor.tapSimpleCompletionItems).toHaveBeenCalledWith(expect.any(Function))
expect(ctx.editor.tapMarkdownMonarchLanguage).toHaveBeenCalledWith(expect.any(Function))
expect(ctx.completionItems).toContainEqual(expect.objectContaining({ label: '/ ::: Raw HTML' }))
})
test('renders titled containers as vnodes with escaped titles and parsed attrs', () => {
@@ -112,4 +118,70 @@ describe('markdown-container plugin', () => {
expect(vnode.children[2].props.class).toBe('group-item-content')
expect(vnode.children[2].children[0]).toContain('<pre><code class="language-ts">')
})
test('parses raw html containers without parsing their contents as markdown', () => {
const md = new MarkdownIt({ html: true })
markdownHtml.register(createCtx(md))
markdownRenderVNode.register(createCtx(md))
markdownContainer.register(createCtx(md))
const tokens = md.parse('::: html\n<h1>raw *html*</h1>\n<p>line</p>\n:::', { safeMode: false })
const nodes = md.renderer.render(tokens, md.options, { safeMode: false }) as any[]
expect(tokens.map(token => token.type)).toEqual(['html_block'])
expect(tokens[0]).toMatchObject({
content: '<h1>raw *html*</h1>\n<p>line</p>\n',
map: [1, 3],
markup: ':::',
})
expect(nodes[0].children.map((node: any) => node.type)).toEqual(['h1', 'p'])
expect(nodes[0].children[0].props.innerHTML).toBe('raw *html*')
})
test('does not enable raw html containers when html is disabled or safe mode is active', () => {
const htmlDisabled = new MarkdownIt({ html: false })
const safeMode = new MarkdownIt({ html: true })
markdownHtml.register(createCtx(htmlDisabled))
markdownContainer.register(createCtx(htmlDisabled))
markdownHtml.register(createCtx(safeMode))
markdownContainer.register(createCtx(safeMode))
const src = '::: html\n<script>alert(1)</script>\n:::'
const disabledTokens = htmlDisabled.parse(src, { safeMode: false })
const safeTokens = safeMode.parse(src, { safeMode: true })
expect(disabledTokens.some(token => token.type === 'html_block')).toBe(false)
expect(safeTokens.some(token => token.type === 'html_block')).toBe(false)
expect(safeTokens.some(token => token.tag === 'script')).toBe(false)
})
test('uses container fence indentation and marker length rules', () => {
const md = new MarkdownIt({ html: true })
markdownContainer.register(createCtx(md))
const tokens = md.parse(':::: html\n<div>\n :::: not a close\n::: also not a close\n</div>\n::::', { safeMode: false })
expect(tokens).toHaveLength(1)
expect(tokens[0]).toMatchObject({
type: 'html_block',
content: '<div>\n :::: not a close\n::: also not a close\n</div>\n',
map: [1, 5],
markup: '::::',
})
})
test('auto-closes raw html containers at the end of their parent', () => {
const md = new MarkdownIt({ html: true })
markdownContainer.register(createCtx(md))
const tokens = md.parse('::: html\n<div>unclosed</div>', { safeMode: false })
expect(tokens).toHaveLength(1)
expect(tokens[0]).toMatchObject({
type: 'html_block',
content: '<div>unclosed</div>',
map: [1, 2],
})
})
})
@@ -544,7 +544,7 @@ describe('markdown extra branch coverage', () => {
expect(mocks.replaceLine).not.toHaveBeenCalled()
})
test('floating editor noops when unavailable and suppresses hints after the display limit', async () => {
test('floating editor noops when unavailable and shows hints once available', async () => {
vi.useFakeTimers()
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((fn: FrameRequestCallback) => {
fn(0)
@@ -552,7 +552,6 @@ describe('markdown extra branch coverage', () => {
})
const ctx = createFloatingCtx()
ctx.store.state.showEditor = true
ctx.storage.get.mockReturnValue(5)
floatingEditor.register(ctx)
await ctx.actions.get('layout.show-floating-editor').handler({ line: 2, clientY: 80 })
@@ -563,6 +562,6 @@ describe('markdown extra branch coverage', () => {
expect(canShow()).toBe(false)
ctx.store.state.showEditor = false
onChange(true)
expect(document.body.querySelector('.floating-editor-hint')).toBeNull()
expect(document.body.querySelector('.floating-editor-hint')).toBeTruthy()
})
})
@@ -2,6 +2,7 @@ const mocks = vi.hoisted(() => ({
args: new Map<string, string>(),
state: {
currentRepo: { name: 'repo-a', path: '/repos/a' } as any,
currentFile: null as any,
},
switchDoc: vi.fn(),
whenEditorReady: vi.fn(() => Promise.resolve({})),
@@ -80,6 +81,7 @@ describe('status-bar-repository-switch plugin', () => {
beforeEach(() => {
mocks.args.clear()
mocks.state.currentRepo = { name: 'repo-a', path: '/repos/a' }
mocks.state.currentFile = null
mocks.switchDoc.mockReset()
mocks.whenEditorReady.mockReset()
mocks.whenEditorReady.mockResolvedValue({})
@@ -128,6 +130,57 @@ describe('status-bar-repository-switch plugin', () => {
})
})
test.each([
['Markdown', '/docs/restored.md'],
['HTML', '/docs/restored.html'],
])('restores the last opened %s file after editor readiness', async (_type, path) => {
const ctx = createCtx()
mocks.state.currentFile = {
type: 'file',
repo: 'repo-a',
name: path.split('/').pop(),
path,
}
repositorySwitch.register(ctx)
await mocks.whenEditorReady.mock.results[0].value
expect(mocks.switchDoc).toHaveBeenCalledWith(mocks.state.currentFile, { force: true })
})
test('does not restore the persisted file when an init file is provided', async () => {
const ctx = createCtx()
mocks.state.currentFile = {
type: 'file',
repo: 'repo-a',
name: 'restored.html',
path: '/docs/restored.html',
}
mocks.args.set('init-file', '/docs/start.md')
repositorySwitch.register(ctx)
await mocks.whenEditorReady.mock.results[0].value
expect(mocks.switchDoc).toHaveBeenCalledTimes(1)
expect(mocks.switchDoc).toHaveBeenCalledWith(expect.objectContaining({ path: '/docs/start.md' }))
})
test('does not reload a current file that has already finished loading', async () => {
const ctx = createCtx()
mocks.state.currentFile = {
type: 'file',
repo: 'repo-a',
name: 'restored.md',
path: '/docs/restored.md',
status: 'loaded',
}
repositorySwitch.register(ctx)
await mocks.whenEditorReady.mock.results[0].value
expect(mocks.switchDoc).not.toHaveBeenCalled()
})
test('continues startup file switching if init repo selection throws', async () => {
const ctx = createCtx()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+1 -12
View File
@@ -21,8 +21,6 @@ const DEFAULT_HEIGHT = MIN_HEIGHT
const REVEAL_TOP_CONTEXT_LINES = 3
const SIDE_MARGIN = 24
const SCREEN_MARGIN = 8
const HINT_STORAGE_KEY = 'plugin.floating-editor.preview-hint-count'
const HINT_LIMIT = 5
const HINT_DURATION = 5000
const EDITOR_SCROLL_SYNC_PAUSE_TIMEOUT = 350
const CLOSE_SYNC_PAUSE_TIMEOUT = 800
@@ -272,14 +270,6 @@ export default {
editorDom.style.height = `${Math.round(height)}px`
}
function getHintCount () {
return ctx.storage.get(HINT_STORAGE_KEY, 0)
}
function setHintCount (count: number) {
ctx.storage.set(HINT_STORAGE_KEY, count)
}
function hideHint () {
if (hintTimer) {
clearTimeout(hintTimer)
@@ -305,7 +295,7 @@ export default {
}
function showHint () {
if (visible || hint || getHintCount() >= HINT_LIMIT) {
if (visible || hint) {
return
}
@@ -319,7 +309,6 @@ export default {
hint.textContent = getHintText()
document.body.appendChild(hint)
positionHint()
setHintCount(getHintCount() + 1)
hintTimer = window.setTimeout(hideHint, HINT_DURATION)
}
@@ -173,10 +173,6 @@ function duplicateMultilineNodes (element: HTMLElement) {
function wrap (code: string, lang: string, lineNumber: boolean) {
let html = code
if (lang === 'text') {
html = escape(code)
}
if (lineNumber) {
const element = document.createElement('code')
element.innerHTML = html
@@ -229,7 +225,7 @@ function highlight (str: string, lang: string, lineNumber: boolean) {
logger.warn(`Syntax highlight for language "${lang}" is not supported.`)
}
return wrap(str, 'text', lineNumber)
return wrap(escape(str), 'text', lineNumber)
}
export default {
+81 -1
View File
@@ -2,8 +2,83 @@ import MarkdownItContainer from 'markdown-it-container'
import { applyAttrs, getAttrs, parseInfo } from 'markdown-it-attributes'
import { Fragment, h } from 'vue'
import type Token from 'markdown-it/lib/token'
import type StateBlock from 'markdown-it/lib/rules_block/state_block'
import { Plugin } from '@fe/context'
const RAW_HTML_MARKER = 0x3A // ':'
const CONTAINER_NAMES = ['tip', 'warning', 'danger', 'details', 'group-item', 'group', 'row', 'col', 'section', 'div', 'code-group']
function rawHtmlContainer (state: StateBlock, startLine: number, endLine: number, silent: boolean) {
if (!state.md.options.html || (state.env as { safeMode?: boolean })?.safeMode) {
return false
}
let start = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
if (state.src.charCodeAt(start) !== RAW_HTML_MARKER) {
return false
}
let pos = start + 1
while (pos <= max && state.src.charCodeAt(pos) === RAW_HTML_MARKER) {
pos++
}
const markerCount = pos - start
const params = state.src.slice(pos, max).trim()
if (markerCount < 3 || params.split(/\s+/, 1)[0] !== 'html') {
return false
}
if (silent) {
return true
}
let nextLine = startLine
let autoClosed = false
for (;;) {
nextLine++
if (nextLine >= endLine) {
break
}
start = state.bMarks[nextLine] + state.tShift[nextLine]
max = state.eMarks[nextLine]
if (start < max && state.sCount[nextLine] < state.blkIndent) {
break
}
if (state.src.charCodeAt(start) !== RAW_HTML_MARKER || state.sCount[nextLine] - state.blkIndent >= 4) {
continue
}
pos = start + 1
while (pos <= max && state.src.charCodeAt(pos) === RAW_HTML_MARKER) {
pos++
}
if (pos - start < markerCount || state.skipSpaces(pos) < max) {
continue
}
autoClosed = true
break
}
const token = state.push('html_block', '', 0)
token.block = true
token.markup = ':'.repeat(markerCount)
token.info = params
token.content = state.getLines(startLine + 1, nextLine, state.blkIndent, true)
token.map = [startLine + 1, nextLine]
state.line = nextLine + (autoClosed ? 1 : 0)
return true
}
export default {
name: 'markdown-container',
register: ctx => {
@@ -234,7 +309,11 @@ export default {
}
ctx.markdown.registerPlugin(md => {
['tip', 'warning', 'danger', 'details', 'group-item', 'group', 'row', 'col', 'section', 'div', 'code-group'].forEach(name => {
md.block.ruler.before('fence', 'container_html_raw', rawHtmlContainer, {
alt: ['paragraph', 'reference', 'blockquote', 'list']
})
CONTAINER_NAMES.forEach(name => {
const reg = new RegExp(`^${name}\\s*(.*)$`)
md.use(MarkdownItContainer, name, {
@@ -346,6 +425,7 @@ export default {
items.push(
{ language: 'markdown', label: '/ ::: Container', insertText: '${3|:::,::::,:::::|} ${1|tip,warning,danger,details,code-group,group,group-item,row,col,section,div|} ${2:Title}\n${4:Content}\n${3|:::,::::,:::::|}\n', block: true, surroundSelection: '${4:Content}' },
{ language: 'markdown', label: '/ ::: Raw HTML', insertText: '::: html\n${1:<div>Raw HTML</div>}\n:::\n', block: true, surroundSelection: '${1:<div>Raw HTML</div>}' },
{ language: 'markdown', label: '/ ::: Group Container', insertText: ':::: group ${1:Title}\n::: group-item Tab 1\ntest 1\n:::\n::: group-item *Tab 2\ntest 2\n:::\n::: group-item Tab 3\ntest 3\n:::\n::::\n', block: true },
{ language: 'markdown', label: '/ ::: Code Group Container', insertText: '::: code-group ${1:Title}\n${2:```js [test.js]\nlet a = 1\n```\n\n```ts [test.ts]\nlet a: number = 1\n```}\n:::\n', block: true },
{ language: 'markdown', label: '/ ::: Column Container', insertText: ':::: row ${1:Title}\n::: col\ntest 1\n:::\n::: col\ntest 2\n:::\n::::\n', block: true },
@@ -23,7 +23,13 @@ export default {
}
if (initFilePath) {
switchDoc({ type: 'file', repo: initRepoName || currentRepo!.name, name: basename(initFilePath), path: initFilePath })
return switchDoc({ type: 'file', repo: initRepoName || currentRepo!.name, name: basename(initFilePath), path: initFilePath })
}
// The persisted current file only contains metadata. Reload it explicitly
// so non-Markdown plain text files also get their content on startup.
if (store.state.currentFile && store.state.currentFile.status !== 'loaded') {
return switchDoc(store.state.currentFile, { force: true })
}
}
@@ -298,6 +298,32 @@ describe('document service pure helpers', () => {
expect(resolveDocType('/tmp/file.custom')).toBeNull()
})
test('refreshes the current file when runtime document type support changes', async () => {
storeMock.state.currentFile = {
...fileDoc,
path: '/tmp/file.runtime',
name: 'file.runtime',
}
registerDocCategory({
category: 'runtime-docs',
displayName: 'Runtime',
types: [{
id: 'runtime',
displayName: 'Runtime',
extension: ['.runtime'],
plain: true,
}],
})
await vi.waitFor(() => {
expect(apiMocks.readFile).toHaveBeenCalledWith(expect.objectContaining({
path: '/tmp/file.runtime',
}))
})
removeDocCategory('runtime-docs')
})
test('marks and unmarks documents through settings', async () => {
await markDoc(fileDoc)
expect(settingMocks.setSetting).toHaveBeenLastCalledWith('mark', [
+8 -5
View File
@@ -100,16 +100,19 @@ test('tokenize and normalize rules attach source, tokens, and line marks to env'
expect(env.eMarks[0]).toBe(7)
})
test('cj friendly plugin can be toggled at render time', async () => {
const src = '**这应该被识别为强调。**这是后续文本。'
test('cjk friendly plugin can be toggled at render time', async () => {
const cases = [
['**这应该被识别为强调。**这是后续文本。', '<strong>这应该被识别为强调。</strong>'],
['**한국어 괄호 때문에)**후속 문장입니다.', '<strong>한국어 괄호 때문에)</strong>'],
]
const { markdown } = await loadMarkdown()
const env: any = {}
expect(markdown.render(src, env)).toContain(src)
cases.forEach(([src]) => expect(markdown.render(src, env)).toContain(src))
mocks.settings.set('render.md-cj-friendly', true)
expect(markdown.render(src, env)).toContain('<strong>这应该被识别为强调。</strong>')
cases.forEach(([src, strong]) => expect(markdown.render(src, env)).toContain(strong))
mocks.settings.set('render.md-cj-friendly', false)
expect(markdown.render(src, env)).toContain(src)
cases.forEach(([src]) => expect(markdown.render(src, env)).toContain(src))
})
+5 -3
View File
@@ -1004,6 +1004,7 @@ export function hideHistory () {
}
function cacheSupportedExtension () {
const cacheInitialized = supportedExtensionCache.sortedExtensions.length > 0
const currentFileSupported = !!(store.state.currentFile && supported(store.state.currentFile))
supportedExtensionCache.types.clear()
@@ -1020,11 +1021,12 @@ function cacheSupportedExtension () {
supportedExtensionCache.sortedExtensions.sort((a, b) => b.length - a.length)
// refresh current file if change supported status
if (store.state.currentFile) {
// Refresh the current file when document types change at runtime. Startup
// restoration is handled separately after the editor is ready.
if (cacheInitialized && store.state.currentFile) {
const currentFileSupportedNow = supported(store.state.currentFile)
if (currentFileSupported !== currentFileSupportedNow) {
switchDoc(store.state.currentFile || null, { force: true })
switchDoc(store.state.currentFile, { force: true })
}
}
}
+4 -4
View File
@@ -4,7 +4,7 @@ import MarkdownItSup from 'markdown-it-sup'
import MarkdownItMark from 'markdown-it-mark'
import MarkdownItAbbr from 'markdown-it-abbr'
import MarkdownItAttributes from 'markdown-it-attributes'
import MarkdownItCjFriendly from 'markdown-it-cj-friendly'
import MarkdownItCjkFriendly from 'markdown-it-cjk-friendly'
import MarkdownItMultimdTable from 'markdown-it-multimd-table'
import { triggerHook } from '@fe/core/hook'
import { HELP_REPO_NAME } from '@fe/support/args'
@@ -18,8 +18,8 @@ import { RULE_NAME as HASHTAG_RULE_NAME } from '@fe/plugins/markdown-hashtags/li
*/
export const markdown = Markdown({ linkify: true, breaks: true, html: true })
const defaultInlineState = markdown.inline.State
MarkdownItCjFriendly(markdown)
const cjFriendlyInlineState = markdown.inline.State
MarkdownItCjkFriendly(markdown)
const cjkFriendlyInlineState = markdown.inline.State
markdown.inline.State = defaultInlineState
/**
@@ -39,7 +39,7 @@ markdown.render = (src: string, env: RenderEnv) => {
markdown.options.breaks = getSetting('render.md-breaks', true)
markdown.options.linkify = getSetting('render.md-linkify', true)
markdown.options.typographer = getSetting('render.md-typographer', false)
markdown.inline.State = getSetting('render.md-cj-friendly', false) ? cjFriendlyInlineState : defaultInlineState
markdown.inline.State = getSetting('render.md-cj-friendly', false) ? cjkFriendlyInlineState : defaultInlineState
const enabledRules: string[] = []
const disabledRules: string[] = []
+50 -6
View File
@@ -108,9 +108,11 @@ describe('utils index utilities', () => {
describe('createTextHighlighter', () => {
const highlightStore = new Map<string, unknown>()
beforeEach(() => {
highlightStore.clear()
Object.defineProperty(globalThis, 'Highlight', {
const installHighlightApi = (target: typeof globalThis, store: Map<string, unknown>) => {
const deleteHighlight = vi.fn((name: string) => store.delete(name))
const setHighlight = vi.fn((name: string, value: unknown) => store.set(name, value))
Object.defineProperty(target, 'Highlight', {
configurable: true,
value: class {
ranges: Range[]
@@ -120,15 +122,22 @@ describe('utils index utilities', () => {
}
},
})
Object.defineProperty(globalThis, 'CSS', {
Object.defineProperty(target, 'CSS', {
configurable: true,
value: {
highlights: {
delete: vi.fn((name: string) => highlightStore.delete(name)),
set: vi.fn((name: string, value: unknown) => highlightStore.set(name, value)),
delete: deleteHighlight,
set: setHighlight,
},
},
})
return { deleteHighlight, setHighlight }
}
beforeEach(() => {
highlightStore.clear()
installHighlightApi(globalThis, highlightStore)
})
test('installs styles, highlights text matches, and disposes cleanly', () => {
@@ -173,5 +182,40 @@ describe('utils index utilities', () => {
expect((highlightStore.get('words') as { ranges: Range[] }).ranges).toHaveLength(2)
})
test('moves styles and range highlights to the container document', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const iframe = document.createElement('iframe')
document.body.appendChild(iframe)
const iframeWindow = iframe.contentWindow!
const iframeDocument = iframe.contentDocument!
const iframeHighlightStore = new Map<string, unknown>()
const iframeHighlightApi = installHighlightApi(iframeWindow as unknown as typeof globalThis, iframeHighlightStore)
const iframeContainer = iframeDocument.createElement('div')
iframeContainer.textContent = 'review text'
iframeDocument.body.appendChild(iframeContainer)
let currentContainer = container
const highlighter = createTextHighlighter(() => currentContainer, 'review', 'background: yellow')
expect(document.querySelector('style[data-highlight-name="review"]')).not.toBeNull()
const range = iframeDocument.createRange()
range.selectNodeContents(iframeContainer)
currentContainer = iframeContainer
highlighter.highlightRanges([range])
expect(document.querySelector('style[data-highlight-name="review"]')).toBeNull()
expect(iframeDocument.querySelector('style[data-highlight-name="review"]')).not.toBeNull()
expect(iframeHighlightApi.setHighlight).toHaveBeenCalledWith('review', expect.any((iframeWindow as any).Highlight))
expect((iframeHighlightStore.get('review') as { ranges: Range[] }).ranges).toEqual([range])
highlighter.dispose()
expect(iframeHighlightApi.deleteHighlight).toHaveBeenLastCalledWith('review')
expect(iframeDocument.querySelector('style[data-highlight-name="review"]')).toBeNull()
iframe.remove()
container.remove()
})
})
})
+61 -15
View File
@@ -82,14 +82,22 @@ export function createTextHighlighter (
highlightName: string,
css: string | undefined | null | ((colorScheme: 'light' | 'dark') => string) = color => `color: ${color === 'dark' ? '#ffec99' : '#bd7f02'}`
) {
let style: HTMLStyleElement | null = null
const resolveContainer = () => typeof container === 'function' ? container() : container
let style: HTMLStyleElement | null = null
let contextDocument = resolveContainer()?.ownerDocument || document
let contextWindow = contextDocument.defaultView || window
const installStyle = () => {
if (!css) {
return
}
if (css) {
// remove existing styles
const existingStyle = document.querySelectorAll(`style[data-highlight-name="${highlightName}"]`)
const existingStyle = contextDocument.querySelectorAll(`style[data-highlight-name="${highlightName}"]`)
existingStyle.forEach(style => style.remove())
style = document.createElement('style')
style = contextDocument.createElement('style')
style.dataset.highlightName = highlightName
style.textContent = `
@media screen {
@@ -108,47 +116,84 @@ export function createTextHighlighter (
}
`
document.head.appendChild(style)
contextDocument.head.appendChild(style)
}
const switchContext = (nextDocument: Document) => {
if (nextDocument === contextDocument) {
return
}
contextWindow.CSS.highlights.delete(highlightName)
style?.remove()
style = null
contextDocument = nextDocument
contextWindow = nextDocument.defaultView || window
installStyle()
}
const remove = () => {
CSS.highlights.delete(highlightName)
contextWindow.CSS.highlights.delete(highlightName)
}
const dispose = () => {
remove()
style?.remove()
style = null
}
const applyRanges = (ranges: Range[], targetDocument: Document) => {
switchContext(targetDocument)
remove()
if (ranges.length > 0) {
const HighlightConstructor = (contextWindow as any).Highlight
contextWindow.CSS.highlights.set(highlightName, new HighlightConstructor(...ranges))
}
return remove
}
/** Highlight precomputed DOM ranges, such as ranges restored from review annotations. */
const highlightRanges = (ranges: Range[]) => {
const targetDocument = ranges[0]?.startContainer.ownerDocument ||
resolveContainer()?.ownerDocument ||
contextDocument
return applyRanges(ranges, targetDocument)
}
const highlight = (keyword: string | RegExp) => {
remove()
keyword = typeof keyword === 'string' ? keyword.trim() : keyword
if (!keyword) {
remove()
return
}
const ranges: Range[] = []
const containerElement = typeof container === 'function' ? container() : container
const containerElement = resolveContainer()
if (!containerElement) {
remove()
return
}
const treeWalker = document.createTreeWalker(containerElement, NodeFilter.SHOW_TEXT)
switchContext(containerElement.ownerDocument)
const treeWalker = contextDocument.createTreeWalker(containerElement, contextWindow.NodeFilter.SHOW_TEXT)
let node: Node | null = null
do {
node = treeWalker.nextNode()
if (node && node.nodeType === Node.TEXT_NODE) {
if (node && node.nodeType === contextWindow.Node.TEXT_NODE) {
const textContent = (node as Text).textContent || ''
const regex = typeof keyword === 'string' ? new RegExp(`(${keyword})`, 'gi') : keyword
let match: RegExpExecArray | null
while ((match = regex.exec(textContent)) !== null) {
const range = document.createRange()
const range = contextDocument.createRange()
range.setStart(node, match.index)
range.setEnd(node, match.index + match[0].length)
ranges.push(range)
@@ -156,14 +201,15 @@ export function createTextHighlighter (
}
} while (node)
CSS.highlights.set(highlightName, new Highlight(...ranges))
return remove
return applyRanges(ranges, containerElement.ownerDocument)
}
installStyle()
return {
dispose,
remove,
highlight,
highlightRanges,
}
}
+1 -1
View File
@@ -443,7 +443,7 @@ const data = {
'md-typographer': 'Enable some language-neutral replacement + quotes beautification',
'md-sup': 'Enable sup syntax: 29^th^',
'md-sub': 'Enable sub syntax: H~2~O',
'md-cj-friendly': 'Enable CJ-friendly emphasis parsing',
'md-cj-friendly': 'Enable CJK-friendly emphasis parsing',
'md-emoji': 'Enable emoji syntax: :smile:',
'list-collapsible': 'Enable collapsible lists - Click to collapse/expand nested lists',
'multimd-multiline': '[markdown-it-multimd-table]: Enable multiline syntax',
+1 -1
View File
@@ -444,7 +444,7 @@ const data: BaseLanguage = {
'md-typographer': 'Включите некоторые языковые замены + улучшение кавычек',
'md-sup': 'Включить надстрочный синтаксис: 29^th^',
'md-sub': 'Включить подстрочный синтаксис: H~2~O',
'md-cj-friendly': 'Включить CJ-совместимый разбор выделения',
'md-cj-friendly': 'Включить CJK-совместимый разбор выделения',
'md-emoji': 'Включите синтаксис эмодзи: :smile:',
'list-collapsible': 'Включить складывание списков - Нажмите, чтобы свернуть/развернуть вложенные списки',
'multimd-multiline': 'Включить многострочный синтаксис [multimd]:',
+1 -1
View File
@@ -434,7 +434,7 @@ const data: BaseLanguage = {
'md-typographer': '启用排版美化,如 (c) -> ©',
'md-sup': '启用上标语法: 29^th^',
'md-sub': '启用下标语法: H~2~O',
'md-cj-friendly': '启用 CJ 友好的强调解析',
'md-cj-friendly': '启用 CJK 友好的强调解析',
'md-emoji': '启用 Emoji 语法: :smile:',
'list-collapsible': '启用列表折叠 - 点击折叠/展开嵌套列表',
'multimd-multiline': '[markdown-it-multimd-table]: 启用 multiline 语法',
+1 -1
View File
@@ -434,7 +434,7 @@ const data: BaseLanguage = {
'md-typographer': '啟用排版美化,如 (c) -> ©',
'md-sup': '啟用上標語法: 29^th^',
'md-sub': '啟用下標語法: H~2~O',
'md-cj-friendly': '啟用 CJ 友好的強調解析',
'md-cj-friendly': '啟用 CJK 友好的強調解析',
'md-emoji': '啟用 Emoji 語法: :smile:',
'list-collapsible': '啟用列表折疊 - 點擊折疊/展開嵌套列表',
'multimd-multiline': '[markdown-it-multimd-table]: 啟用 multiline 語法',
+12
View File
@@ -192,3 +192,15 @@ Code blocks automatically display line numbers with a sticky left panel:
// Line 14
// Line 15
```
## Plain Text
```text
http://<host>:7999?install=1
line2
```
```
http://<host>:7999?install=1
line2
```
+11
View File
@@ -110,6 +110,17 @@ Press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> to open command palette.
</div>
## Raw HTML Container
The `html` container renders its contents as HTML without parsing Markdown inside it:
::: html
<div style="border: 2px dashed #888; padding: 1em;">
<strong>Raw HTML</strong>
<span>*This stays literal instead of becoming Markdown emphasis.*</span>
</div>
:::
## Notes
- HTML rendering is controlled by `mdOptions.html` (default: `true`)
+537 -561
View File
File diff suppressed because it is too large Load Diff