fix(muya): keep the code block when a cut ends in its language line (… · marktext/marktext@2bbca37 · GitHub
Skip to content

Commit 2bbca37

Browse files
Jocsclaude
andcommitted
fix(muya): keep the code block when a cut ends in its language line (#5371)
A cross-block cut merges the end leaf's text after the caret into the start block and removes the end leaf's branch. When the selection ends inside a later code block's language line (Shift+Down from a paragraph above lands there), that merged the rest of the language into the start paragraph while the code block kept its full language, and `pruneAfterBranch` removed the language input node itself (a content leaf, so no json op), leaving a code block whose language could not be edited until the document was reopened. Inside a list item, the blocks between the start and the code block were not removed either. Stop such a cut before that code block: nothing from the language joins the start block, the blocks before the code block are removed as usual, and the code block keeps its code and language input and loses only the selected start of its language. The #918 path (a cut that starts in a language line) resolves its end the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEPLCvFTgbYf35z85XUJop
1 parent 555f3a7 commit 2bbca37

3 files changed

Lines changed: 234 additions & 9 deletions

File tree

Lines changed: 54 additions & 0 deletions
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// @vitest-environment happy-dom
2+
3+
import type Content from '../../block/base/content';
4+
import type Parent from '../../block/base/parent';
5+
import type { Muya } from '../../muya';
6+
import type { TState } from '../../state/types';
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8+
import { Muya as MuyaClass } from '../../muya';
9+
import { SelectionCaretType, SelectionDirection } from '../../selection/types';
10+
11+
// #5371: a cross-block cut that ends inside a code block's language line
12+
// keeps that code block and its language input; only the selected start of
13+
// the language goes. The rest of the language used to be merged into the
14+
// start block (duplicating it) and the language input node was dropped.
15+
16+
vi.mock('../../utils/prism/index', () => ({
17+
default: {},
18+
walkTokens: () => null,
19+
loadedLanguages: new Set(),
20+
transformAliasToOrigin: (s: string) => s,
21+
loadLanguage: () => Promise.resolve([]),
22+
search: () => [],
23+
}));
24+
25+
const bootedHosts: HTMLElement[] = [];
26+
let hadVersion = false;
27+
let originalVersion: string | undefined;
28+
29+
beforeEach(() => {
30+
hadVersion = 'MUYA_VERSION' in window;
31+
originalVersion = window.MUYA_VERSION;
32+
window.MUYA_VERSION = 'test';
33+
});
34+
35+
afterEach(() => {
36+
while (bootedHosts.length)
37+
bootedHosts.pop()!.remove();
38+
if (hadVersion)
39+
window.MUYA_VERSION = originalVersion as string;
40+
else
41+
delete (window as Partial<Window>).MUYA_VERSION;
42+
});
43+
44+
function bootMuya(markdown: string): Muya {
45+
const host = document.createElement('div');
46+
document.body.appendChild(host);
47+
const muya = new MuyaClass(host, { markdown } as ConstructorParameters<typeof MuyaClass>[1]);
48+
muya.init();
49+
bootedHosts.push(muya.domNode);
50+
return muya;
51+
}
52+
53+
function contentBlocks(muya: Muya): Content[] {
54+
const blocks: Content[] = [];
55+
let block: Content | null = muya.editor.scrollPage!.firstContentInDescendant();
56+
while (block) {
57+
blocks.push(block);
58+
block = block.nextContentInContext() ?? null;
59+
}
60+
return blocks;
61+
}
62+
63+
function findContent(muya: Muya, blockName: string, text: string, nth = 0): Content {
64+
const block = contentBlocks(muya).filter(b => b.blockName === blockName && b.text.includes(text))[nth];
65+
if (!block)
66+
throw new Error(`no ${blockName} containing ${JSON.stringify(text)}`);
67+
return block;
68+
}
69+
70+
function cut(muya: Muya, anchor: Content, anchorOffset: number, focus: Content, focusOffset: number): void {
71+
const anchorPath = anchor.path;
72+
const focusPath = focus.path;
73+
muya.editor.selection.getSelection = () => ({
74+
anchor: { offset: anchorOffset, block: anchor, path: anchorPath },
75+
focus: { offset: focusOffset, block: focus, path: focusPath },
76+
isCollapsed: false,
77+
isSelectionInSameBlock: false,
78+
direction: SelectionDirection.FORWARD,
79+
type: SelectionCaretType.RANGE,
80+
});
81+
muya.editor.clipboard.cutHandler();
82+
}
83+
84+
function settledState(muya: Muya): TState[] {
85+
muya.editor.jsonState.flush();
86+
const state = muya.editor.jsonState.getState();
87+
const tree: TState[] = [];
88+
muya.editor.scrollPage!.forEach(block => tree.push((block as Parent).getState()));
89+
expect(state).toEqual(tree);
90+
return state;
91+
}
92+
93+
// Every code block still starts with its language input, holding its language.
94+
function expectLanguageInputs(muya: Muya, languages: string[]): void {
95+
const inputs = contentBlocks(muya).filter(b => b.blockName === 'language-input');
96+
expect(inputs.map(input => input.text)).toEqual(languages);
97+
const codeBlocks = contentBlocks(muya).filter(b => b.blockName === 'codeblock.content').map(code => code.parent!.parent!);
98+
expect(codeBlocks.map(codeBlock => (codeBlock as Parent).firstContentInDescendant()?.blockName)).toEqual(languages.map(() => 'language-input'));
99+
}
100+
101+
const paragraph = (text: string) => ({ name: 'paragraph', text });
102+
const codeBlock = (lang: string, text: string) => ({ name: 'code-block', meta: { type: 'fenced', lang }, text });
103+
104+
describe('cross-block cut ending in a code block\'s language line (#5371)', () => {
105+
it('trims the selected start of the language and keeps the code block', () => {
106+
const muya = bootMuya('Intro paragraph\n\n```js\ncode\n```\n\nOutro\n');
107+
108+
cut(muya, findContent(muya, 'paragraph.content', 'Intro'), 3, findContent(muya, 'language-input', 'js'), 1);
109+
110+
expect(settledState(muya)).toEqual([paragraph('Int'), codeBlock('s', 'code'), paragraph('Outro')]);
111+
expectLanguageInputs(muya, ['s']);
112+
});
113+
114+
it('removes the blocks before a nested code block and keeps its language', () => {
115+
const muya = bootMuya('intro\n\n- item\n\n ```js\n code\n ```\n\n tail\n');
116+
117+
cut(muya, findContent(muya, 'paragraph.content', 'intro'), 2, findContent(muya, 'language-input', 'js'), 0);
118+
119+
const [first, list] = settledState(muya) as [unknown, { name: string; children: Array<{ children: unknown[] }> }];
120+
expect(first).toEqual(paragraph('in'));
121+
expect(list.name).toBe('bullet-list');
122+
expect(list.children.map(item => item.children)).toEqual([[codeBlock('js', 'code'), paragraph('tail')]]);
123+
expectLanguageInputs(muya, ['js']);
124+
});
125+
126+
it('clears the language when the whole language line is selected', () => {
127+
const muya = bootMuya('Intro\n\n```js\ncode\n```\n');
128+
129+
cut(muya, findContent(muya, 'paragraph.content', 'Intro'), 5, findContent(muya, 'language-input', 'js'), 2);
130+
131+
expect(settledState(muya)).toEqual([paragraph('Intro'), codeBlock('', 'code')]);
132+
expectLanguageInputs(muya, ['']);
133+
});
134+
135+
it('from one language line into another: collapses the first code block and keeps the second', () => {
136+
const muya = bootMuya('```js\na\n```\n\nmid\n\n```py\nb\n```\n');
137+
138+
cut(muya, findContent(muya, 'language-input', 'js'), 1, findContent(muya, 'language-input', 'py'), 1);
139+
140+
expect(settledState(muya)).toEqual([paragraph('j'), codeBlock('y', 'b')]);
141+
expectLanguageInputs(muya, ['y']);
142+
});
143+
});

‎packages/muya/src/clipboard/cut.ts‎

Lines changed: 37 additions & 9 deletions

0 commit comments

Comments
 (0)