Skip to content
Closed
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
26 changes: 26 additions & 0 deletions apps/geolibre-desktop/src/components/panels/AttributeTable.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import {
attributeLinkUrl,
coerceAttributeFormValue,
isDuckDBQueryLayer,
useAppStore,
Expand Down Expand Up @@ -125,6 +126,7 @@ import {
type VectorExportFormat,
} from "../../lib/vector-export";
import { PANEL_RESIZE_END_EVENT, PANEL_RESIZE_START_EVENT } from "../../lib/panel-resize";
import { openExternalLink } from "../../lib/open-external";

type SortDirection = "asc" | "desc";
type SortKey = "__featureId" | string;
Expand Down Expand Up @@ -1835,6 +1837,7 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
: "h-7 min-w-0 px-2 text-xs";
const config = formFields.get(col);
const current = draft ?? formatAttributeValue(value);
const linkUrl = attributeLinkUrl(value);
const invalidTitle = invalid
? formError
? formErrorText(formError)
Expand Down Expand Up @@ -1918,6 +1921,29 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
onChange={(event) => commitDraft(event.target.value)}
/>
)
) : linkUrl ? (
// A cell holding nothing but a web address is
// worth clicking (GeoLibre#1655). stopPropagation
// so following the link doesn't also reselect the
// row, same as the edit widgets above.
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
title={linkUrl}
// A URL has no break opportunity, so let it
// ellipsize inside the cell rather than run
// over the neighbouring column and steal its
// clicks.
className="inline-block max-w-full truncate align-bottom text-primary underline underline-offset-2"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void openExternalLink(linkUrl);
}}
>
{linkUrl}
</a>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) : (
formatAttributeValue(value)
)}
Expand Down
11 changes: 11 additions & 0 deletions apps/geolibre-desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1947,6 +1947,17 @@ body,
text-decoration: underline;
}

/* URL-valued attributes rendered as links by the Identify popup, styled to
match the anchors a KML description carries. `anywhere` rather than the
cell's `break-words`: a long query string has no break opportunity, so
without it the URL widens the popup instead of wrapping. */
.geolibre-attribute-link {
color: hsl(var(--primary));
text-decoration: underline;
text-underline-offset: 2px;
overflow-wrap: anywhere;
}

.geolibre-identify-popup .maplibregl-popup-close-button {
color: hsl(var(--muted-foreground));
}
Expand Down
51 changes: 51 additions & 0 deletions apps/geolibre-desktop/src/lib/external-link-interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { attributeLinkUrl } from "@geolibre/core";
import { isTauri } from "./is-tauri";
import { openExternalLink } from "./open-external";

/**
* Route outbound http(s) anchor clicks to the system browser on the desktop
* build.
*
* The Tauri webview ignores `target="_blank"`, so a plain anchor either does
* nothing or, worse, navigates the single app webview away from GeoLibre with
* no way back. Plenty of anchors are rendered outside React and outside this
* repo — Identify popups, KML `<description>` markup, plugin panels — so
* catching them one call site at a time is a losing game. One delegated
* listener covers all of them.
*
* Left plain clicks only: a modified click (new tab/window, download) and the
* middle button already mean "not here", and the webview handles those itself.
*/
export function installExternalLinkInterceptor(
target: Pick<Document, "addEventListener"> = document,
): void {
if (!isTauri()) return;
target.addEventListener(
"click",
(event) => {
const mouseEvent = event as MouseEvent;
if (mouseEvent.defaultPrevented || mouseEvent.button !== 0) return;
if (mouseEvent.metaKey || mouseEvent.ctrlKey || mouseEvent.shiftKey || mouseEvent.altKey)
return;
const anchor = (mouseEvent.target as Element | null)?.closest?.("a[href]");
const url = attributeLinkUrl(anchor?.getAttribute("href"));
if (!url) return;
// Windows serves the app itself over http://tauri.localhost, so a
// same-origin link is in-app navigation, not something to hand off.
if (sameOrigin(url)) return;
mouseEvent.preventDefault();
void openExternalLink(url);
},
// Bubble, not capture: a component that handles its own link click and
// calls preventDefault (the attribute table does) still wins.
false,
);
}

function sameOrigin(url: string): boolean {
try {
return new URL(url).origin === window.location.origin;
} catch {
return false;
}
}
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import "./lib/lidar-style";
// created. See https://github.com/hyperknot/openfreemap/issues/118.
import "./lib/rtl-text";
import "./lib/swipe-style";
import { installExternalLinkInterceptor } from "./lib/external-link-interceptor";
import { registerSW } from "virtual:pwa-register";
import { TooltipProvider } from "@geolibre/ui";
import { I18nextProvider } from "react-i18next";
Expand Down Expand Up @@ -107,6 +108,10 @@ installStaleChunkReload();
// stale lazy chunk 404s (cooldown-guarded; if sessionStorage is blocked it
// skips the reload and lets the preload error surface instead). That keeps
// the user's session/map state intact and removes the self-refresh loop.
// Hand outbound links to the system browser on the desktop build, where the
// webview would otherwise swallow them or navigate away from the app.
installExternalLinkInterceptor();

registerSW({
immediate: true,
onNeedReload() {
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/hyperlink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Attribute values routinely carry a web address: the `url` on a USGS
* earthquake feature, a photo page on a survey point, a "Link" field someone
* typed onto a marker they drew. Identify and the attribute table render every
* value as text, so those arrive as dead strings the user has to select and
* paste. Detect the case where the whole value *is* one http(s) URL so they can
* be rendered as real links instead.
*
* Deliberately strict, matching a whole value rather than linkifying substrings
* of prose: guessing where a URL ends inside a sentence gets trailing
* punctuation wrong, and a permissive scheme test would let `javascript:` or
* `file:` reach an opener.
*/
export function attributeLinkUrl(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
// A URL cannot carry unescaped whitespace, so an inner space means this is
// prose that mentions a link, not a link.
if (!trimmed || /\s/.test(trimmed)) return null;
// Require a scheme plus a non-empty authority up front: `new URL` alone
// accepts shapes such as "https:" or "http://" that are not openable.
if (!/^https?:\/\/[^/?#]/i.test(trimmed)) return null;
try {
const { protocol } = new URL(trimmed);
return protocol === "http:" || protocol === "https:" ? trimmed : null;
} catch {
return null;
}
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./marker-shape";
export * from "./photo";
export * from "./ellipsoids";
export * from "./geojson-z";
export * from "./hyperlink";
export * from "./color-ramp";
export * from "./paths";
export * from "./routing";
Expand Down
12 changes: 12 additions & 0 deletions packages/map/src/MapCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
applyGroupEffects,
attributeLinkUrl,
isDuckDBQueryLayer,
PHOTO_FULL_PROPERTY,
PHOTO_PROPERTY,
Expand Down Expand Up @@ -125,6 +126,7 @@ function createIdentifyPopupElement(

const valueCell = document.createElement("div");
valueCell.className = "break-words text-foreground";
const linkUrl = attributeLinkUrl(value);
// Render known KML description structures as sanitized markup. Requiring a
// supported tag keeps ordinary text such as "Elevation <500m>" intact.
if (
Expand All @@ -144,6 +146,16 @@ function createIdentifyPopupElement(
image.loading = "lazy";
image.className = "max-h-40 max-w-full rounded";
valueCell.appendChild(image);
} else if (linkUrl) {
// A value that is entirely a web address is worth clicking; rendering it
// as text leaves the user copying it out by hand (GeoLibre#1655).
const link = document.createElement("a");
link.href = linkUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.className = "geolibre-attribute-link";
link.textContent = linkUrl;
valueCell.appendChild(link);
} else {
valueCell.textContent = stringifyIdentifyValue(value);
}
Expand Down
56 changes: 56 additions & 0 deletions tests/attribute-hyperlink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { attributeLinkUrl } from "@geolibre/core";

describe("attributeLinkUrl", () => {
it("accepts a whole http(s) URL", () => {
assert.equal(attributeLinkUrl("https://www.bbc.co.uk/"), "https://www.bbc.co.uk/");
assert.equal(attributeLinkUrl("http://example.com"), "http://example.com");
assert.equal(
attributeLinkUrl("https://earthquake.usgs.gov/earthquakes/eventpage/us7000szf3"),
"https://earthquake.usgs.gov/earthquakes/eventpage/us7000szf3",
);
});

it("trims surrounding whitespace", () => {
assert.equal(attributeLinkUrl(" https://example.com/a?b=1#c "), "https://example.com/a?b=1#c");
});

it("is case-insensitive about the scheme", () => {
assert.equal(attributeLinkUrl("HTTPS://Example.com/x"), "HTTPS://Example.com/x");
});

it("rejects prose that merely mentions a link", () => {
assert.equal(attributeLinkUrl("see https://example.com for details"), null);
assert.equal(attributeLinkUrl("https://example.com https://other.com"), null);
});

it("rejects schemes that must never reach an opener", () => {
assert.equal(attributeLinkUrl("javascript:alert(1)"), null);
assert.equal(attributeLinkUrl("file:///etc/passwd"), null);
assert.equal(attributeLinkUrl("data:text/html,<script>alert(1)</script>"), null);
// mailto: is a real link but not one openExternalLink can open, so the
// popup leaves it as text rather than rendering a dead anchor.
assert.equal(attributeLinkUrl("mailto:someone@example.com"), null);
});

it("rejects shapes with no authority to open", () => {
assert.equal(attributeLinkUrl("https:"), null);
assert.equal(attributeLinkUrl("https://"), null);
assert.equal(attributeLinkUrl("https:///path"), null);
assert.equal(attributeLinkUrl("www.example.com"), null);
});

it("rejects non-string and empty values", () => {
assert.equal(attributeLinkUrl(null), null);
assert.equal(attributeLinkUrl(undefined), null);
assert.equal(attributeLinkUrl(42), null);
assert.equal(attributeLinkUrl({ href: "https://example.com" }), null);
assert.equal(attributeLinkUrl(""), null);
assert.equal(attributeLinkUrl(" "), null);
});

it("leaves an inline image data URL alone so it still renders as a thumbnail", () => {
assert.equal(attributeLinkUrl("data:image/png;base64,iVBORw0KGgo="), null);
});
});
129 changes: 129 additions & 0 deletions tests/external-link-interceptor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import { before, beforeEach, describe, it } from "node:test";

// The module reads `window` for the Tauri check and the same-origin guard, so
// the stub has to exist before it is imported; hence the dynamic import below.
const win = ((globalThis as { window?: Record<string, unknown> }).window ??= {});
win.location = { origin: "http://tauri.localhost" };

type Module = typeof import("../apps/geolibre-desktop/src/lib/external-link-interceptor");
let installExternalLinkInterceptor: Module["installExternalLinkInterceptor"];

before(async () => {
({ installExternalLinkInterceptor } =
await import("../apps/geolibre-desktop/src/lib/external-link-interceptor"));
});

interface FakeEvent {
type: string;
button: number;
defaultPrevented: boolean;
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
target: {
closest: (selector: string) => { getAttribute: (name: string) => string | null } | null;
};
preventDefault: () => void;
}

function anchorEvent(href: string | null, overrides: Partial<FakeEvent> = {}): FakeEvent {
const event: FakeEvent = {
type: "click",
button: 0,
defaultPrevented: false,
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
target: {
closest: () => (href === null ? null : { getAttribute: () => href }),
},
preventDefault: () => {
event.defaultPrevented = true;
},
...overrides,
};
return event;
}

describe("installExternalLinkInterceptor", () => {
let handler: ((event: unknown) => void) | null = null;
const target = {
addEventListener: (_type: string, listener: unknown) => {
handler = listener as (event: unknown) => void;
},
};

beforeEach(() => {
handler = null;
delete win.__TAURI_INTERNALS__;
});

it("stays out of the way on the web build", () => {
installExternalLinkInterceptor(target as never);
assert.equal(handler, null);
});

describe("under Tauri", () => {
beforeEach(() => {
// Stub `invoke` too: the interceptor hands the URL to the opener plugin,
// which would otherwise log a failure against the bare marker object.
win.__TAURI_INTERNALS__ = { invoke: () => Promise.resolve() };
installExternalLinkInterceptor(target as never);
assert.notEqual(handler, null);
});

it("takes over an outbound http(s) link", () => {
const event = anchorEvent("https://www.bbc.co.uk/");
handler?.(event);
assert.equal(event.defaultPrevented, true);
});

it("leaves a same-origin link to the app itself", () => {
const event = anchorEvent("http://tauri.localhost/index.html");
handler?.(event);
assert.equal(event.defaultPrevented, false);
});

it("leaves non-http(s) schemes to the webview", () => {
for (const href of ["mailto:someone@example.com", "blob:abc", "#section", "/relative"]) {
const event = anchorEvent(href);
handler?.(event);
assert.equal(event.defaultPrevented, false, href);
}
});

it("ignores a click that did not land on a link", () => {
const event = anchorEvent(null);
handler?.(event);
assert.equal(event.defaultPrevented, false);
});

it("leaves modified and non-left clicks alone", () => {
const variants: Partial<FakeEvent>[] = [
{ metaKey: true },
{ ctrlKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
];
for (const overrides of variants) {
const event = anchorEvent("https://www.bbc.co.uk/", overrides);
handler?.(event);
assert.equal(event.defaultPrevented, false, JSON.stringify(overrides));
}
});

it("defers to a handler that already claimed the click", () => {
const event = anchorEvent("https://www.bbc.co.uk/", { defaultPrevented: true });
let prevented = false;
event.preventDefault = () => {
prevented = true;
};
handler?.(event);
assert.equal(prevented, false);
});
});
});
Loading