Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 107 additions & 15 deletions apps/web/e2e/file-comments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { expect, test } from '@playwright/test';

import { openNewCanvas } from './helpers';
import { openNewCanvas, readScreenshotPixels } from './helpers';

import type * as QuestionModule from '../src/components/Nodes/question/questionCompose';
import type * as CanvasModule from '../src/store/canvasStore';
Expand All @@ -13,7 +13,7 @@ import type * as PreviewActions from '../src/store/previewWorkspace/actions';
import type * as PreviewModule from '../src/store/previewWorkspace/store';
import type * as ToolModule from '../src/store/toolStore';

const sourceText = 'Before selected passage after.\n\nSecond line.';
const sourceText = `Before selected passage after. ${'Wrapped source text. '.repeat(16)}\n\nSecond line.`;

for (const mode of ['mouse', 'touch'] as const) {
test(`file preview ${mode} stages a frozen partial-file Comment without a read, send or navigation`, async ({
Expand Down Expand Up @@ -119,6 +119,9 @@ for (const mode of ['mouse', 'touch'] as const) {
);
const editor = page.locator('.cm-content');
await expect(editor).toBeVisible();
await page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write']);
const initialReads = reads;
const before = await page.evaluate(async () => {
const previewPath = '/src/store/previewWorkspace/store.ts';
Expand All @@ -142,25 +145,114 @@ for (const mode of ['mouse', 'touch'] as const) {
)
turns++;
});
if (mode === 'mouse') {
await editor.locator('.cm-line').first().click({ clickCount: 3 });
await page.getByRole('button', { name: 'Comment', exact: true }).click();
} else {
await editor.locator('.cm-line').first().tap();
await editor
.locator('.cm-line')
.first()
.evaluate((line) => {
const selectPassage = async () => {
const line = editor.locator('.cm-line').first();
if (mode === 'touch') {
await line.tap();
await line.evaluate((element) => {
const text = element.firstChild;
if (!text) throw new Error('Missing source text');
const range = document.createRange();
range.selectNodeContents(line);
range.setStart(text, 7);
range.setEnd(text, 23);
const selection = window.getSelection();
if (!selection) throw new Error('Missing document selection');
selection.removeAllRanges();
selection.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
});
await page.getByRole('button', { name: 'Comment', exact: true }).tap();
}
} else {
const points = await line.evaluate((element) => {
const text = element.firstChild;
if (!text) throw new Error('Missing source text');
const point = (offset: number) => {
const range = document.createRange();
range.setStart(text, offset);
range.collapse(true);
const rect = range.getBoundingClientRect();
return { x: rect.x, y: rect.y + rect.height / 2 };
};
return { from: point(7), to: point(23) };
});
await page.mouse.move(points.from.x, points.from.y);
await page.mouse.down();
await page.mouse.move(points.to.x, points.to.y, { steps: 8 });
await page.mouse.up();
}
await expect(
page.getByRole('button', { name: 'Comment', exact: true }),
).toBeVisible();
};
await selectPassage();
await expect(
page.getByRole('button', { name: 'Copy', exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Add as note', exact: true }),
).toBeVisible();
const paint = await editor.evaluate((content) => {
const line = content.querySelector('.cm-activeLine');
const shell = content.closest('.cm-editor');
const selection = shell?.querySelector('.cm-selectionBackground');
if (!line || !selection)
throw new Error('Missing active line or drawn selection');
const rect = selection.getBoundingClientRect();
const style = getComputedStyle(line);
return {
lineBackground: style.backgroundColor,
lineHeight: line.getBoundingClientRect().height,
textLineHeight: Number.parseFloat(style.lineHeight),
backgroundLayer: getComputedStyle(line, '::before').zIndex,
point: { x: rect.x + rect.width / 2, y: rect.bottom - 2 },
};
});
expect(paint.lineHeight).toBeGreaterThan(paint.textLineHeight * 2);
expect(paint.lineBackground).toBe('rgba(0, 0, 0, 0)');
expect(paint.backgroundLayer).toBe('-2');
const [pixel] = await readScreenshotPixels(page, [paint.point]);
expect(pixel[2] - pixel[0]).toBeGreaterThan(5);
await page.screenshot({
path: test.info().outputPath(`file-selection-${mode}.png`),
});
await editor.evaluate((content) => content.blur());
await expect(page.locator('.cm-editor')).not.toHaveClass(/cm-focused/);
const [unfocusedPixel] = await readScreenshotPixels(page, [paint.point]);
expect(unfocusedPixel[2] - unfocusedPixel[0]).toBeGreaterThan(5);
const copy = page.getByRole('button', { name: 'Copy', exact: true });
if (mode === 'touch') await copy.tap();
else await copy.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe('selected passage');
await expect(copy).toBeHidden();
await selectPassage();
const save = page.getByRole('button', { name: 'Add as note', exact: true });
if (mode === 'touch') await save.tap();
else await save.click();
await expect
.poll(() =>
page.evaluate(async () => {
const canvasPath = '/src/store/canvasStore.ts';
const { default: canvas } = (await import(
canvasPath
)) as typeof CanvasModule;
return canvas
.getState()
.nodes.filter(
(node) =>
node.type === 'note' &&
node.data.content === 'selected passage',
)
.map((node) => node.data.origin);
}),
)
.toEqual([
{ type: 'user-excerpt', excerptFromNodeId: 'node-file-source' },
]);
await selectPassage();
const comment = page.getByRole('button', { name: 'Comment', exact: true });
if (mode === 'touch') await comment.tap();
else await comment.click();
await expect(
page.getByText('Captured file snapshot', { exact: false }),
).toContainText('/canonical/source.tex');
Expand Down Expand Up @@ -199,7 +291,7 @@ for (const mode of ['mouse', 'touch'] as const) {
expect(after.thread.draft).toBe('Keep instructions');
expect(after.thread.pendingQuotedComments).toMatchObject([
{
quote: 'Before selected passage after.',
quote: 'selected passage',
comment: 'Explain the file',
source: {
kind: 'file-reference',
Expand Down
74 changes: 74 additions & 0 deletions apps/web/e2e/latex-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ However, execution traces alone do not establish whether a proposed
change improves efficiency without compromising task performance.`;

async function mount(page: Page, theme: string, input: 'mouse' | 'finger') {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.addInitScript(() => performance.setResourceTimingBufferSize(2000));
await page.route(
(url) => url.pathname.startsWith('/api/'),
Expand Down Expand Up @@ -177,6 +178,79 @@ for (const theme of ['light', 'dark']) {
path: info.outputPath(`${theme}-${input}.png`),
});
});

test('code source soft-wraps on container resize without changing copied text', async ({
page,
}, info) => {
await mount(page, theme, input);
const host = page.locator('#latex-fixture');
const body = ` ${'unbroken_token'.repeat(28)}\n\tindented source`;
for (const language of ['text', 'latex', 'tex']) {
const markdown = `\`\`\`${language}\n${body}\n\`\`\``;
await page.evaluate(
(content) => window.latexFixture.render(content, true),
markdown,
);
const block = host.locator('.milkdown-code-block').first();
await expect(block.locator('.cm-line')).toHaveText(body.split('\n'));
const heights: number[] = [];
for (const width of [640, 320, 520]) {
await host.evaluate((element, width) => {
element.style.width = `${width}px`;
}, width);
await expect
.poll(() =>
block
.locator('.cm-scroller')
.evaluate(
(element) => element.scrollWidth - element.clientWidth,
),
)
.toBeLessThanOrEqual(1);
const line = block.locator('.cm-line').first();
await expect
.poll(() =>
line.evaluate(
(element) =>
element.getBoundingClientRect().height /
Number.parseFloat(getComputedStyle(element).lineHeight),
),
)
.toBeGreaterThan(1.5);
heights.push(
await line.evaluate(
(element) => element.getBoundingClientRect().height,
),
);
await expect(block.locator('.cm-content')).toHaveClass(
/cm-lineWrapping/,
);
await expect(block.locator('.cm-line')).toHaveText(
body.split('\n'),
);
}
expect(heights[1]).toBeGreaterThan(heights[0]);
expect(heights[2]).toBeLessThan(heights[1]);
await page.evaluate(
(content) => window.latexFixture.render(content, false),
markdown,
);
await block.hover();
await block.locator('.copy-button').click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(body);
await host
.getByRole('button', { name: 'Copy message', exact: true })
.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(markdown);
}
await host.screenshot({
path: info.outputPath(`wrapped-${theme}-${input}.png`),
});
});
});
}
}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/CodeMirror/huabuLightTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,17 @@ const chrome = EditorView.theme(

// Active line: very subtle to avoid competing with selection.
'.cm-activeLine': {
backgroundColor: 'transparent',
position: 'relative',
},
// CodeMirror draws selections behind content at z-index -1.
'.cm-activeLine::before': {
content: '""',
position: 'absolute',
inset: '0',
backgroundColor: BG_HOVER,
zIndex: '-2',
pointerEvents: 'none',
},
'.cm-activeLineGutter': {
backgroundColor: BG_HOVER,
Expand Down
26 changes: 25 additions & 1 deletion apps/web/src/components/Milkdown/__tests__/latexPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ change improves efficiency without compromising task performance.`;
const instances: MilkdownInstance[] = [];
const roots: HTMLElement[] = [];

async function mount(markdown: string, previewMode = true) {
async function mount(markdown: string, previewMode = true, editable = true) {
const root = document.createElement('div');
document.body.append(root);
roots.push(root);
const instance = await createMilkdown({
root,
initialMarkdown: markdown,
previewMode,
editable,
toolbarMode: 'none',
});
instances.push(instance);
Expand All @@ -37,6 +38,29 @@ afterEach(async () => {
});

describe('LaTeX source fences versus explicit math', () => {
it.each([
{ previewMode: true, editable: true },
{ previewMode: true, editable: false },
{ previewMode: false, editable: true },
{ previewMode: false, editable: false },
])(
'preserves long source lines and indentation with wrapping (preview=$previewMode, editable=$editable)',
async ({ previewMode, editable }) => {
const body = ` ${'unbroken_token'.repeat(40)}\n\tindented source`;
const instance = await mount('', previewMode, editable);
for (const language of ['text', 'latex', 'tex']) {
const source = `\`\`\`${language}\n${body}\n\`\`\``;
instance.setMarkdown(source);
const serialized = instance.getMarkdown();
expect(serialized).toContain(source);
instance.setMarkdown(serialized);
expect(instance.getMarkdown()).toBe(serialized);
instance.__selectCurrentBlockForTest?.();
expect(instance.getDragPayload()?.markdown).toContain(body);
}
},
);

it.each(['latex', 'LaTeX', 'LATEX', 'tex'])(
'retains %s document source and its fence language through serialization',
async (language) => {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/Milkdown/createMilkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* - Hides the async lifecycle: callers always receive a ready instance.
*/

import { EditorView as CodeMirrorView } from '@codemirror/view';
import {
editorViewCtx,
editorViewOptionsCtx,
Expand Down Expand Up @@ -1948,6 +1949,9 @@ export async function createMilkdown(
: {}),
},
featureConfigs: {
[Crepe.Feature.CodeMirror]: {
extensions: [CodeMirrorView.lineWrapping],
},
...(placeholder
? { [Crepe.Feature.Placeholder]: { text: placeholder } }
: {}),
Expand Down
Loading
Loading