fix(ui): stabilize Markdown code block line layout (#2444)
* fix(ui): stabilize markdown code block layout * fix(ui): respect text range boundary affinity * test(ui): cover trailing text range boundaries
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { copyMarkdownToClipboard } from './clipboard';
|
||||
|
||||
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
const originalClipboardItem = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem');
|
||||
|
||||
const restoreGlobal = (name: 'navigator' | 'ClipboardItem', descriptor?: PropertyDescriptor): void => {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobal('navigator', originalNavigator);
|
||||
restoreGlobal('ClipboardItem', originalClipboardItem);
|
||||
});
|
||||
|
||||
describe('copyMarkdownToClipboard', () => {
|
||||
test('offers rich HTML and Markdown source in one clipboard item', async () => {
|
||||
let writtenItem: { data: Record<string, Blob> } | undefined;
|
||||
class FakeClipboardItem {
|
||||
static supports(type: string): boolean {
|
||||
return type === 'text/markdown';
|
||||
}
|
||||
|
||||
readonly data: Record<string, Blob>;
|
||||
|
||||
constructor(data: Record<string, Blob>) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: FakeClipboardItem });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
write: async (items: Array<{ data: Record<string, Blob> }>) => {
|
||||
writtenItem = items[0];
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await copyMarkdownToClipboard('**bold**', '<p><strong>bold</strong></p>');
|
||||
|
||||
expect(result).toEqual({ ok: true, method: 'clipboard' });
|
||||
expect(Object.keys(writtenItem?.data ?? {}).sort()).toEqual(['text/html', 'text/markdown', 'text/plain']);
|
||||
expect(await writtenItem?.data['text/plain']?.text()).toBe('**bold**');
|
||||
expect(await writtenItem?.data['text/markdown']?.text()).toBe('**bold**');
|
||||
expect(await writtenItem?.data['text/html']?.text()).toBe('<p><strong>bold</strong></p>');
|
||||
});
|
||||
|
||||
test('falls back to plain Markdown when a rich clipboard write fails', async () => {
|
||||
let fallbackText = '';
|
||||
class FakeClipboardItem {
|
||||
static supports(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: FakeClipboardItem });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
write: async () => {
|
||||
throw new Error('unsupported');
|
||||
},
|
||||
writeText: async (text: string) => {
|
||||
fallbackText = text;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await copyMarkdownToClipboard('# title', '<h1>title</h1>');
|
||||
|
||||
expect(result).toEqual({ ok: true, method: 'clipboard' });
|
||||
expect(fallbackText).toBe('# title');
|
||||
});
|
||||
});
|
||||
@@ -37,3 +37,27 @@ export async function copyTextToClipboard(text: string): Promise<ClipboardCopyRe
|
||||
error: clipboardError ?? 'Clipboard access denied in current context',
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyMarkdownToClipboard(markdown: string, html: string): Promise<ClipboardCopyResult> {
|
||||
if (
|
||||
typeof navigator !== 'undefined'
|
||||
&& navigator.clipboard?.write
|
||||
&& typeof ClipboardItem !== 'undefined'
|
||||
) {
|
||||
try {
|
||||
const payload: Record<string, Blob> = {
|
||||
'text/plain': new Blob([markdown], { type: 'text/plain' }),
|
||||
'text/html': new Blob([html], { type: 'text/html' }),
|
||||
};
|
||||
if (typeof ClipboardItem.supports === 'function' && ClipboardItem.supports('text/markdown')) {
|
||||
payload['text/markdown'] = new Blob([markdown], { type: 'text/markdown' });
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem(payload)]);
|
||||
return { ok: true, method: 'clipboard' };
|
||||
} catch {
|
||||
// Fall back to plain Markdown when rich clipboard writes are unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return copyTextToClipboard(markdown);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user