Merge remote-tracking branch 'origin/main' into release/v0.9.0

This commit is contained in:
lefarcen 2026-05-29 20:59:20 +08:00
commit 7b00e1de87
3 changed files with 79 additions and 2 deletions

View file

@ -259,6 +259,36 @@ const pageHtml = renderToStaticMarkup(
);
}
// Hamburger menu toggle. Active only at narrow viewports (CSS
// hides the toggle button at ≥1080px). Click toggles `.is-open`
// on the header; outside-click, Escape, and clicking any link
// inside the menu close it again. Keeps `aria-expanded` in sync.
// This mirrors the handler in `header-enhancer.astro` — the
// homepage runs its own inline enhancer instead of importing
// that component, so the toggle has to be wired up here too.
const navToggle = document.querySelector('[data-nav-toggle]');
const primaryNav = document.querySelector('[data-nav-primary]');
const navEl = navToggle ? navToggle.closest('header.nav') : null;
if (navToggle && primaryNav && navEl) {
const setNavOpen = (open) => {
navEl.classList.toggle('is-open', open);
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
};
navToggle.addEventListener('click', (ev) => {
ev.stopPropagation();
setNavOpen(!navEl.classList.contains('is-open'));
});
primaryNav.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => setNavOpen(false));
});
document.addEventListener('click', (ev) => {
if (!navEl.contains(ev.target)) setNavOpen(false);
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') setNavOpen(false);
});
}
const stars = document.querySelector('[data-github-stars]');
if (stars) {
fetch('https://api.github.com/repos/nexu-io/open-design', {

View file

@ -172,6 +172,18 @@ export function PreviewDrawOverlay({
) ?? null;
}
// The snapshot bridge only lives in the srcDoc transport iframe. For URL-load
// previews (e.g. decks) that iframe is mounted but hidden (data-od-active is on
// the bridgeless URL iframe), so snapshotting the *active* frame times out and
// capture fails. Prefer the srcDoc-render-mode frame; capture mode keeps it on
// full content, so it carries the bridge.
function snapshotHostIframe(): HTMLIFrameElement | null {
return (
wrapRef.current?.querySelector<HTMLIFrameElement>('iframe[data-od-render-mode="srcdoc"]') ??
activePreviewIframe()
);
}
function onPointerDown(e: PointerEvent) {
if (!active || sending) return;
(e.target as Element).setPointerCapture?.(e.pointerId);
@ -335,9 +347,16 @@ export function PreviewDrawOverlay({
}
async function requestSnapshot(): Promise<{ dataUrl: string; w: number; h: number } | null> {
const iframe = activePreviewIframe();
const iframe = snapshotHostIframe();
if (!iframe) return null;
return requestPreviewSnapshot(iframe);
// Capture mode may still be swapping the srcDoc frame to full content when
// the user submits, so retry with growing timeouts before giving up.
const timeouts = [1500, 3000, 6000];
for (const timeout of timeouts) {
const snapshot = await requestPreviewSnapshot(iframe, timeout);
if (snapshot) return snapshot;
}
return null;
}
function drawCaptureTarget(

View file

@ -4,9 +4,19 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewDrawOverlay } from '../../src/components/PreviewDrawOverlay';
import { requestPreviewSnapshot } from '../../src/runtime/exports';
vi.mock('../../src/runtime/exports', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/runtime/exports')>();
return {
...actual,
requestPreviewSnapshot: vi.fn(async () => ({ dataUrl: 'data:image/png;base64,AAAA', w: 10, h: 10 })),
};
});
afterEach(() => {
cleanup();
vi.mocked(requestPreviewSnapshot).mockClear();
});
describe('PreviewDrawOverlay', () => {
@ -160,4 +170,22 @@ describe('PreviewDrawOverlay', () => {
expect(onActiveChange).toHaveBeenCalledWith(false);
});
it('snapshots the srcDoc bridge iframe, not the visible URL-load frame', async () => {
const snapshot = vi.mocked(requestPreviewSnapshot);
const { getByRole } = render(
<PreviewDrawOverlay active captureViewport>
{/* URL-load frame is the visible/active one (e.g. a deck) but has no bridge */}
<iframe title="url" data-od-active="true" />
{/* srcDoc frame is mounted but hidden; it hosts the snapshot bridge */}
<iframe title="srcdoc" data-od-render-mode="srcdoc" data-od-active="false" />
</PreviewDrawOverlay>,
);
fireEvent.click(getByRole('button', { name: 'Send' }));
await waitFor(() => expect(snapshot).toHaveBeenCalled());
const usedIframe = snapshot.mock.calls[0]?.[0] as HTMLIFrameElement;
expect(usedIframe.getAttribute('data-od-render-mode')).toBe('srcdoc');
});
});