-
-
Notifications
You must be signed in to change notification settings - Fork 570
feat(attributes): make URL attribute values clickable #1660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
51 changes: 51 additions & 0 deletions
51
apps/geolibre-desktop/src/lib/external-link-interceptor.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.