fix(claude-design-import): restore notched wheel zoom + warn on regex drift (#1814)

This commit is contained in:
lefarcen 2026-05-15 21:44:57 +08:00 committed by GitHub
parent e40399d39a
commit 7c1d9e9634
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 173 additions and 12 deletions

View file

@ -83,13 +83,42 @@ export async function importClaudeDesignZip(zipPath: string, projectDir: string)
function normalizeImportedClaudeDesignFile(relPath: string, body: Buffer): Buffer {
if (path.basename(relPath) !== 'design-canvas.jsx') return body;
const source = body.toString('utf8');
const normalized = normalizeDesignCanvasWheelHandling(source);
return normalized === source ? body : Buffer.from(normalized, 'utf8');
const { result, wheelMatched, gestureMatched } = normalizeDesignCanvasWheelHandling(source);
// Warn whenever any rewrite regex missed. Either one drifting silently
// is enough to ship a half-rewritten canvas: a missed `wheelBlock`
// reproduces the original zoom-on-scroll bug, and a missed
// `gestureBlock` lets Safari's native gesture* handlers re-introduce
// their own pinch zoom on top of the normalized wheel path. Operators
// grep for `[claude-design-import]` to find these before the bug
// report comes back in.
if (!wheelMatched || !gestureMatched) {
const missing: string[] = [];
if (!wheelMatched) missing.push('wheel-handler');
if (!gestureMatched) missing.push('gesture-handler');
console.warn(
`[claude-design-import] design-canvas.jsx found but ${missing.join(' + ')} rewrite regex(es) did not match; imported canvas may zoom on scroll or behave unexpectedly. Update normalizeDesignCanvasWheelHandling to match the new template.`,
);
}
return result === source ? body : Buffer.from(result, 'utf8');
}
function normalizeDesignCanvasWheelHandling(source: string): string {
function normalizeDesignCanvasWheelHandling(source: string): {
result: string;
wheelMatched: boolean;
gestureMatched: boolean;
} {
const wheelBlock = / \/\/ Mouse-wheel vs trackpad-scroll heuristic\.[\s\S]*? const onWheel = \(e\) => \{\n[\s\S]*? \};\n/;
if (!wheelBlock.test(source)) return source;
const gestureBlock = / \/\/ Safari sends native gesture\* events for trackpad pinch with a smooth\n[\s\S]*? const onGestureEnd = \(e\) => \{ e\.preventDefault\(\); isGesturing = false; \};/;
// Check both regexes against the original source so callers can tell
// wheel-only drift from gesture-only drift. If `wheelBlock` does not
// match we leave the source untouched and skip the gesture rewrite —
// a partial rewrite that swapped the gesture handler against an
// unchanged wheel handler would be worse than no rewrite at all.
const wheelMatched = wheelBlock.test(source);
const gestureMatched = gestureBlock.test(source);
if (!wheelMatched) {
return { result: source, wheelMatched, gestureMatched };
}
const normalizedWheel = source.replace(wheelBlock, ` // Plain wheel input should pan the infinite canvas. Claude Design exports
// previously guessed that large integer vertical deltas were mouse-wheel
// zoom clicks, but macOS trackpads can emit the same shape during ordinary
@ -108,19 +137,37 @@ function normalizeDesignCanvasWheelHandling(source: string): string {
apply();
};
// Cmd+wheel still zooms, but we have to split notched mouse wheels from
// smooth trackpad pinch deltas inside the Cmd branch: a single mouse
// notch arrives as deltaY≈100, and Math.exp(-100*0.01)≈0.367 would shrink
// the canvas by ~63% per click. The notched ratio Math.exp(-sign*0.18)
// gives ~17% per click — the same feel the original Claude export had
// before this normalizer collapsed both paths. We also accept ctrlKey
// here because Chromium/Firefox synthesize wheel events with
// \`ctrlKey: true\` during a trackpad pinch — without that, smooth pinch
// would silently fall through to panByWheel(e) and the canvas would
// pan instead of zoom on those browsers.
const isNotchedWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
e.stopPropagation();
if (isGesturing) return;
if (e.metaKey) {
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
if (e.ctrlKey || e.metaKey) {
const factor = isNotchedWheel(e)
? Math.exp(-Math.sign(e.deltaY) * 0.18)
: Math.exp(-e.deltaY * 0.01);
zoomAt(e.clientX, e.clientY, factor);
return;
}
panByWheel(e);
};
`);
const gestureBlock = / \/\/ Safari sends native gesture\* events for trackpad pinch with a smooth\n[\s\S]*? const onGestureEnd = \(e\) => \{ e\.preventDefault\(\); isGesturing = false; \};/;
return normalizedWheel.replace(gestureBlock, ` // Safari can emit native gesture* events while a user scrolls on a
if (!gestureMatched) {
return { result: normalizedWheel, wheelMatched, gestureMatched };
}
const result = normalizedWheel.replace(gestureBlock, ` // Safari can emit native gesture* events while a user scrolls on a
// trackpad. Ignore those here; explicit zoom is Cmd+wheel or the host
// toolbar.
let isGesturing = false;
@ -130,6 +177,7 @@ function normalizeDesignCanvasWheelHandling(source: string): string {
e.stopPropagation();
};
const onGestureEnd = (e) => { e.preventDefault(); e.stopPropagation(); isGesturing = false; };`);
return { result, wheelMatched, gestureMatched };
}
function readCentralDirectory(zip: Buffer): ZipEntry[] {

View file

@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'n
import path from 'node:path';
import os from 'node:os';
import { deflateRawSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { importClaudeDesignZip } from '../src/claude-design-import.js';
function buildZip(
@ -237,14 +237,127 @@ function DCViewport() {
expect(result.files).toContain('design-canvas.jsx');
const written = readFileSync(path.join(projectDir, 'design-canvas.jsx'), 'utf8');
expect(written).not.toContain('const isMouseWheel');
expect(written).not.toContain('Math.exp(-Math.sign(e.deltaY) * 0.18)');
expect(written).not.toContain('(gsBase * e.scale) / tf.current.scale');
expect(written).toContain('const panByWheel = (e) =>');
expect(written).toContain('if (e.metaKey)');
expect(written).toContain('zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));');
// The Cmd-zoom gate accepts ctrlKey too because Chromium/Firefox
// synthesize wheel events with `ctrlKey: true` during a trackpad
// pinch; without that, smooth pinch would fall through to
// `panByWheel(e)` instead of zooming.
expect(written).toContain('if (e.ctrlKey || e.metaKey)');
// The rewritten Cmd-zoom path now keeps both ratios so a physical mouse
// wheel does not shrink the canvas by ~63% per notch: the notched
// detector matches deltaMode!==0 or large integer pixel deltas, and the
// notched factor (Math.sign * 0.18) gives ~17% per click while trackpad
// smooth scrolls keep the original deltaY * 0.01 ratio.
expect(written).toContain('const isNotchedWheel = (e) =>');
expect(written).toContain('Math.exp(-Math.sign(e.deltaY) * 0.18)');
expect(written).toContain('Math.exp(-e.deltaY * 0.01)');
expect(written).toContain("const limit = axis === 'y' ? 72 : 160;");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('warns and preserves source when the design-canvas wheel-handler shape drifts', async () => {
// Same general layout as a real Claude Design canvas export, but with
// tab indentation and a rephrased comment so neither rewrite regex
// matches. The importer should leave the source untouched and emit a
// console.warn that operators can grep when the zoom-on-scroll bug
// reappears with a future canvas template.
const driftedCanvas = `
function DCViewport() {
\tReact.useEffect(() => {
\t\t// Wheel routing: distinguish trackpad pan from notched mouse wheel zoom.
\t\tconst onWheel = (e) => {
\t\t\te.preventDefault();
\t\t};
\t});
}
`;
const zip = buildZip([
{ name: 'index.html', body: Buffer.from('<html><script src="design-canvas.jsx"></script></html>') },
{ name: 'design-canvas.jsx', body: Buffer.from(driftedCanvas) },
]);
const tmp = mkdtempSync(path.join(os.tmpdir(), 'cd-import-drift-'));
const zipPath = path.join(tmp, 'in.zip');
const projectDir = path.join(tmp, 'proj');
writeFileSync(zipPath, zip);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = await importClaudeDesignZip(zipPath, projectDir);
expect(result.files).toContain('design-canvas.jsx');
const written = readFileSync(path.join(projectDir, 'design-canvas.jsx'), 'utf8');
// Source must be preserved verbatim — no partial rewrite, no crash.
expect(written).toBe(driftedCanvas);
// And the importer must have logged so the regression is greppable.
expect(warn).toHaveBeenCalledTimes(1);
const firstCall = warn.mock.calls[0]?.[0];
expect(typeof firstCall).toBe('string');
expect(firstCall).toContain('[claude-design-import]');
expect(firstCall).toContain('design-canvas.jsx');
} finally {
warn.mockRestore();
rmSync(tmp, { recursive: true, force: true });
}
});
it('warns when only the gesture-handler regex drifts', async () => {
// Real-world drift case: Anthropic ships a fresh wheel-handler block
// (so `wheelBlock` still matches and gets normalized) but rewords the
// Safari `gesture*` comment so `gestureBlock` misses. Without per-regex
// tracking the previous warn() only fired when neither block matched,
// which let this half-rewrite ship silently with the old Safari pinch
// handlers still active over a normalized wheel path. Verify the warn
// still fires and identifies the gesture handler as the missing one.
const partialDriftCanvas = `
function DCViewport() {
React.useEffect(() => {
// Mouse-wheel vs trackpad-scroll heuristic. Keep this on the host so
// an embedded export still routes Cmd+wheel through host zoom.
const onWheel = (e) => {
e.preventDefault();
};
// (Reworded) Safari trackpad pinch via native gesture* events.
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; };
const onGestureChange = (e) => { e.preventDefault(); };
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
});
}
`;
const zip = buildZip([
{ name: 'index.html', body: Buffer.from('<html><script src="design-canvas.jsx"></script></html>') },
{ name: 'design-canvas.jsx', body: Buffer.from(partialDriftCanvas) },
]);
const tmp = mkdtempSync(path.join(os.tmpdir(), 'cd-import-gesture-drift-'));
const zipPath = path.join(tmp, 'in.zip');
const projectDir = path.join(tmp, 'proj');
writeFileSync(zipPath, zip);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = await importClaudeDesignZip(zipPath, projectDir);
expect(result.files).toContain('design-canvas.jsx');
const written = readFileSync(path.join(projectDir, 'design-canvas.jsx'), 'utf8');
// Wheel block still matched, so the new pan/zoom handler is in place.
expect(written).toContain('const panByWheel = (e) =>');
expect(written).toContain('if (e.ctrlKey || e.metaKey)');
// Gesture block missed, so the original (reworded) gesture handlers
// are still present verbatim.
expect(written).toContain('(Reworded) Safari trackpad pinch');
// And the importer logged a warning naming the gesture handler as
// the missing one, so future regex tweaks have to confront the drift.
expect(warn).toHaveBeenCalled();
const warnedLines = warn.mock.calls
.map((args) => args[0])
.filter((s): s is string => typeof s === 'string');
const gestureWarn = warnedLines.find((line) =>
line.includes('[claude-design-import]') && line.includes('gesture-handler'),
);
expect(gestureWarn).toBeDefined();
} finally {
warn.mockRestore();
rmSync(tmp, { recursive: true, force: true });
}
});
});