mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
Closes #90. Code blocks and inline code inherited the typography plugin's defaults, which shrank them to ~86% of an already-monospace body and painted them --bg-surface — the same colour as the default note card — so a snippet was only recognisable by being smaller. Long lines also grew a horizontal scrollbar inside card previews. - New --code-bg surface (one step below the page in light mode, page-level ink in dark) with a subtle border, and code keeps the surrounding text size. Decorative backtick pseudo-elements dropped now that the chip carries the signal. - Card previews clip wide code instead of scrolling; editor and share page keep horizontal scroll. - Syntax highlighting via one shared lowlight instance feeding both the TipTap editor (extension-code-block-lowlight) and the markdown-it renderer, so a fence looks identical in the editor, on cards and on the share page. Only highlight.js "common" grammars are bundled. - No auto-detection: highlight.js misreads note-sized snippets too often (JavaScript as CSS, SQL as VB.NET). Unlabelled fences stay plain; the language comes from the fence info string or a toolbar picker shown while the cursor is in a code block. - Six --hl-* token colours per theme; a unit test parses app.css and asserts AA contrast against --code-bg and mutual distinguishability. - Sanitizer now allows only language-* / hljs-* classes on code and span. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
165 lines
7.0 KiB
TypeScript
165 lines
7.0 KiB
TypeScript
import { test, expect, noteCard } from './helpers/fixtures.js';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
async function waitForTiptapEditor(page: Page) {
|
|
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector('[data-testid="tiptap-editor"]');
|
|
return el && (el as any).__tiptapEditor != null;
|
|
});
|
|
}
|
|
|
|
async function runTiptapCommand(page: Page, commandFn: string) {
|
|
await page.getByTestId('tiptap-editor').evaluate(
|
|
(el, fn) => {
|
|
const editor = (el as any).__tiptapEditor;
|
|
if (!editor) throw new Error('TipTap editor not found on element');
|
|
new Function('editor', fn)(editor);
|
|
},
|
|
commandFn
|
|
);
|
|
}
|
|
|
|
async function toggleMarkdownMode(page: Page) {
|
|
await page.getByTestId('overflow-menu-btn').click();
|
|
await page.getByTestId('markdown-toggle').click();
|
|
}
|
|
|
|
/** Write note content as markdown, then return to the rich editor. */
|
|
async function typeViaMarkdown(page: Page, content: string) {
|
|
await toggleMarkdownMode(page);
|
|
await page.getByTestId('note-content-input').fill(content);
|
|
await toggleMarkdownMode(page);
|
|
}
|
|
|
|
test.describe('Code snippets', () => {
|
|
test('Scenario: A fenced block with a language is syntax highlighted in the editor and on the card', async ({
|
|
authenticatedPage: page
|
|
}) => {
|
|
// Given a note whose content is a JavaScript code block
|
|
await page.getByTestId('new-note-btn').click();
|
|
await page.getByTestId('note-title-input').fill('JS Snippet');
|
|
await typeViaMarkdown(page, '```js\nconst answer = 42;\n```');
|
|
await waitForTiptapEditor(page);
|
|
|
|
// Then the editor colours the keyword and number as separate tokens
|
|
const editor = page.getByTestId('tiptap-editor');
|
|
await expect(editor.locator('pre .hljs-keyword')).toHaveText('const');
|
|
await expect(editor.locator('pre .hljs-number')).toHaveText('42');
|
|
|
|
// When the note is closed
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// Then the card preview shows the same highlighting
|
|
const preview = noteCard(page, 'JS Snippet').getByTestId('note-content-preview');
|
|
await expect(preview.locator('pre .hljs-keyword')).toHaveText('const');
|
|
await expect(preview.locator('pre .hljs-number')).toHaveText('42');
|
|
});
|
|
|
|
test('Scenario: An unlabelled code block stays plain instead of guessing a language', async ({
|
|
authenticatedPage: page
|
|
}) => {
|
|
// Given a note whose content is a code block with no language
|
|
await page.getByTestId('new-note-btn').click();
|
|
await page.getByTestId('note-title-input').fill('Plain Snippet');
|
|
await typeViaMarkdown(page, '```\nSELECT * FROM users;\n```');
|
|
await waitForTiptapEditor(page);
|
|
|
|
// Then the editor shows the block without any token colouring
|
|
const editor = page.getByTestId('tiptap-editor');
|
|
await expect(editor.locator('pre code')).toHaveText('SELECT * FROM users;');
|
|
await expect(editor.locator('pre [class*="hljs-"]')).toHaveCount(0);
|
|
|
|
// And so does the card preview
|
|
await page.getByTestId('close-editor-btn').click();
|
|
const preview = noteCard(page, 'Plain Snippet').getByTestId('note-content-preview');
|
|
await expect(preview.locator('pre code')).toHaveText('SELECT * FROM users;');
|
|
await expect(preview.locator('pre [class*="hljs-"]')).toHaveCount(0);
|
|
});
|
|
|
|
test('Scenario: Choosing a language for a code block is saved in the markdown fence', async ({
|
|
authenticatedPage: page
|
|
}) => {
|
|
// Given the user is editing a note with a plain code block
|
|
await page.getByTestId('new-note-btn').click();
|
|
await typeViaMarkdown(page, '```\ndef greet():\n return "hi"\n```');
|
|
await waitForTiptapEditor(page);
|
|
|
|
// And the language picker is offered only while the cursor is in the block
|
|
await runTiptapCommand(page, 'editor.chain().focus("start").run()');
|
|
const picker = page.getByTestId('format-code-language');
|
|
await expect(picker).toBeVisible();
|
|
await expect(picker).toHaveValue('');
|
|
|
|
// When the user picks Python
|
|
await picker.selectOption('python');
|
|
|
|
// Then the block is highlighted as Python
|
|
await expect(page.getByTestId('tiptap-editor').locator('pre .hljs-keyword').first()).toHaveText('def');
|
|
await expect(picker).toHaveValue('python');
|
|
|
|
// And the markdown fence carries the language
|
|
await toggleMarkdownMode(page);
|
|
await expect(page.getByTestId('note-content-input')).toHaveValue('```python\ndef greet():\n return "hi"\n```');
|
|
});
|
|
|
|
test('Scenario: The language picker is hidden outside a code block', async ({ authenticatedPage: page }) => {
|
|
// Given the user is editing a note with a paragraph
|
|
await page.getByTestId('new-note-btn').click();
|
|
await typeViaMarkdown(page, 'just prose');
|
|
await waitForTiptapEditor(page);
|
|
await runTiptapCommand(page, 'editor.chain().focus("end").run()');
|
|
|
|
// Then no language picker is shown
|
|
await expect(page.getByTestId('format-code-language')).toHaveCount(0);
|
|
});
|
|
|
|
test('Scenario: Code keeps the body text size instead of shrinking', async ({ authenticatedPage: page }) => {
|
|
// Given a note with a code block and a paragraph
|
|
await page.getByTestId('new-note-btn').click();
|
|
await page.getByTestId('note-title-input').fill('Sized Snippet');
|
|
await typeViaMarkdown(page, 'Some prose\n\n```js\nconst a = 1;\n```');
|
|
await waitForTiptapEditor(page);
|
|
|
|
// Then the code block is set at the same size as the prose around it
|
|
const editor = page.getByTestId('tiptap-editor');
|
|
const fontSize = (locator: ReturnType<Page['locator']>) =>
|
|
locator.evaluate((el) => getComputedStyle(el).fontSize);
|
|
expect(await fontSize(editor.locator('pre code'))).toBe(await fontSize(editor.locator('p').first()));
|
|
|
|
// And the same holds on the card preview
|
|
await page.getByTestId('close-editor-btn').click();
|
|
const preview = noteCard(page, 'Sized Snippet').getByTestId('note-content-preview');
|
|
expect(await fontSize(preview.locator('pre code'))).toBe(await fontSize(preview.locator('p').first()));
|
|
});
|
|
|
|
test('Scenario: A long code line is clipped on the card instead of adding a scrollbar', async ({
|
|
authenticatedPage: page
|
|
}) => {
|
|
// Given a note with a code line far wider than a card
|
|
const longLine = 'const url = "https://example.com/' + 'segment/'.repeat(30) + '";';
|
|
await page.getByTestId('new-note-btn').click();
|
|
await page.getByTestId('note-title-input').fill('Wide Snippet');
|
|
await typeViaMarkdown(page, '```js\n' + longLine + '\n```');
|
|
await page.getByTestId('close-editor-btn').click();
|
|
|
|
// Then the card's code block stays inside the card and does not scroll horizontally
|
|
const preview = noteCard(page, 'Wide Snippet').getByTestId('note-content-preview');
|
|
const pre = preview.locator('pre');
|
|
await expect(pre).toBeVisible();
|
|
const overflow = await pre.evaluate((el) => {
|
|
const style = getComputedStyle(el);
|
|
const box = el.getBoundingClientRect();
|
|
const container = el.parentElement!.getBoundingClientRect();
|
|
return {
|
|
x: style.overflowX,
|
|
wider: el.scrollWidth > el.clientWidth,
|
|
spill: box.right - container.right
|
|
};
|
|
});
|
|
expect(overflow.wider, 'the line must actually be wider than the card for this test to mean anything').toBe(true);
|
|
expect(overflow.x).toBe('hidden');
|
|
expect(overflow.spill).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|