mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
fix(app): preserve native HTML fragment navigation in previews
Give each HTML preview its own document URL so section links resolve within the file. Browser default navigation preserves cancellation by page handlers without an injected click interceptor. Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
This commit is contained in:
@@ -63,6 +63,24 @@ function htmlPreview(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
async function followHtmlPreviewLink(page: Page, name: string): Promise<void> {
|
||||
await htmlPreview(page).document.getByRole("link", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPageHandlesFragment(
|
||||
page: Page,
|
||||
handler: "Document" | "Window",
|
||||
): Promise<void> {
|
||||
const preview = htmlPreview(page);
|
||||
await followHtmlPreviewLink(page, `${handler} handled jump`);
|
||||
await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`);
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
}
|
||||
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
@@ -851,9 +869,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.href)).toBe(
|
||||
"about:srcdoc#section",
|
||||
);
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
@@ -880,12 +896,59 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.href)).toBe(
|
||||
"about:srcdoc",
|
||||
);
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("respects delegated HTML fragment handlers and normal URL parsing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "defaults.html"),
|
||||
`<!doctype html><html><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section" id="document-handler">Document handled jump</a>
|
||||
<a href="#section" id="window-handler">Window handled jump</a>
|
||||
<a href=" \t#section\n">Whitespace jump</a>
|
||||
<a href="#100%">Percent jump</a>
|
||||
<output>Waiting for click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><h2 id="100%">Percent target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
document.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'document-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Document handled click';
|
||||
}
|
||||
});
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'window-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Window handled click';
|
||||
}
|
||||
});
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "defaults.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expectPageHandlesFragment(page, "Document");
|
||||
await expectPageHandlesFragment(page, "Window");
|
||||
|
||||
await followHtmlPreviewLink(page, "Whitespace jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await followHtmlPreviewLink(page, "Percent jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Percent target" }),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -950,7 +1013,7 @@ window.addEventListener('contextmenu', modifiedClick);
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section");
|
||||
const scrollY = await body.evaluate(() => window.scrollY);
|
||||
await preview.document.getByRole("link", { name: "Missing jump" }).click();
|
||||
expect(await body.evaluate(() => location.href)).toBe("about:srcdoc#encoded%20section");
|
||||
expect(await body.evaluate(() => location.hash)).toBe("#missing");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(scrollY);
|
||||
|
||||
await preview.document.getByRole("link", { name: "Empty jump" }).click();
|
||||
@@ -963,7 +1026,7 @@ window.addEventListener('contextmenu', modifiedClick);
|
||||
}
|
||||
await nested.click({ button: "middle" });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
expect(await body.evaluate(() => location.href)).toBe("about:srcdoc#");
|
||||
expect(await body.evaluate(() => location.hash)).toBe("");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -28,29 +28,6 @@ const POLICY = [
|
||||
|
||||
const META = `<meta http-equiv="Content-Security-Policy" content="${POLICY}">`;
|
||||
|
||||
// Native WebViews already handle fragments; only srcdoc inherits the app's base URL.
|
||||
const FRAGMENT_NAVIGATION = `<script>
|
||||
if (location.href === "about:srcdoc") {
|
||||
document.addEventListener("click", function (event) {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
const anchor = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
||||
const fragment = anchor && anchor.getAttribute("href");
|
||||
if (!fragment || !fragment.startsWith("#")) return;
|
||||
event.preventDefault();
|
||||
let id = fragment.slice(1);
|
||||
try { id = decodeURIComponent(id); } catch { return; }
|
||||
const target = document.getElementById(id) || Array.from(document.getElementsByName(id)).find(function (element) { return element.tagName === "A"; });
|
||||
if (id && !target) return;
|
||||
const previousHash = location.hash;
|
||||
location.hash = fragment;
|
||||
if (location.hash === previousHash) {
|
||||
if (target) target.scrollIntoView();
|
||||
else window.scrollTo(0, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>`;
|
||||
|
||||
// The policy must reach the parser before any markup the document declares, and it
|
||||
// only counts if it lands in `<head>` — once the parser has moved on to `<body>`, a
|
||||
// meta http-equiv CSP is ignored outright.
|
||||
@@ -68,7 +45,7 @@ if (location.href === "about:srcdoc") {
|
||||
// the parser ignores wherever it appears. Standards mode is guaranteed, the policy
|
||||
// is always the first element and therefore always in the head, and no part of the
|
||||
// document has to be parsed to place it.
|
||||
const PROLOGUE = `<!doctype html>${META}${FRAGMENT_NAVIGATION}`;
|
||||
const PROLOGUE = `<!doctype html>${META}`;
|
||||
|
||||
// Left where it is, a BOM would sit mid-document and render as a zero-width space.
|
||||
const BOM = "\uFEFF";
|
||||
|
||||
@@ -25,12 +25,17 @@ const iframeStyle = {
|
||||
|
||||
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const document = useMemo(() => withPreviewCsp(html), [html]);
|
||||
// A document URL lets the browser resolve fragment links within the preview.
|
||||
// srcDoc would inherit the app URL and navigate the frame back into Paseo.
|
||||
const source = useMemo(
|
||||
() => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`,
|
||||
[html],
|
||||
);
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testID}
|
||||
title={t("panels.file.filePanels.builtin.html")}
|
||||
srcDoc={document}
|
||||
src={source}
|
||||
sandbox={SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
style={iframeStyle}
|
||||
|
||||
Reference in New Issue
Block a user