mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(claude-design-import): restore notched wheel zoom + warn on regex drift (#1814)
This commit is contained in:
parent
e40399d39a
commit
7c1d9e9634
2 changed files with 173 additions and 12 deletions
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue