diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 5abab2a16..52967633e 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -229,6 +229,42 @@ const allCommands = [ advanced: true, }, + { + name: "LinkHints.activateModeToCopyCodeBlock", + desc: "Copy a code block's text to the clipboard", + group: "navigation", + advanced: true, + noRepeat: true, + }, + { + name: "LinkHints.activateModeToCopyParagraph", + desc: "Copy a paragraph's text to the clipboard", + group: "navigation", + advanced: true, + noRepeat: true, + }, + { + name: "LinkHints.activateModeToCopySpan", + desc: "Copy a span's text to the clipboard", + group: "navigation", + advanced: true, + noRepeat: true, + }, + { + name: "LinkHints.activateModeToCopyAnchor", + desc: "Copy an anchor's text to the clipboard", + group: "navigation", + advanced: true, + noRepeat: true, + }, + { + name: "LinkHints.activateModeToCopyAny", + desc: "Copy text from various element types (a, p, span, code, pre)", + group: "navigation", + advanced: true, + noRepeat: true, + }, + { name: "goPrevious", desc: "Follow the link labeled previous or <", diff --git a/background_scripts/commands.js b/background_scripts/commands.js index 11673f22a..0fb681422 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -432,6 +432,13 @@ const defaultKeyMappings = { "v": "enterVisualMode", "V": "enterVisualLineMode", + // Copy element text (prefix c): cc (code), cp (paragraph), cs (span), ca (anchor), c* (any) + "cc": "LinkHints.activateModeToCopyCodeBlock", + "cp": "LinkHints.activateModeToCopyParagraph", + "cs": "LinkHints.activateModeToCopySpan", + "ca": "LinkHints.activateModeToCopyAnchor", + "c*": "LinkHints.activateModeToCopyAny", + // Link hints "f": "LinkHints.activateMode", "F": "LinkHints.activateModeToOpenInNewTab", diff --git a/content_scripts/link_hints.js b/content_scripts/link_hints.js index 2b8f979f1..19523744c 100644 --- a/content_scripts/link_hints.js +++ b/content_scripts/link_hints.js @@ -146,6 +146,70 @@ const FOCUS_LINK = { }, }; +// Generic copy modes (selector-based). Each provides customSelectors array for hint gathering. +function copyElementTextActivator(label) { + return function (el) { + if (!el) return; + let text = el.innerText || el.textContent || ""; + if (!text.trim()) { + HUD.show(`No ${label} text to yank.`, 1500); + return; + } + HUD.copyToClipboard(text); + const preview = text.replace(/\s+/g, " ").trim().slice(0, 40); + HUD.show(`Yanked ${label}: ${preview}${text.length > 40 ? "..." : ""}`, 2000); + }; +} + +const COPY_CODE_BLOCK = { + name: "code", + indicator: "Copy code block", + customSelectors: [ + "pre", + "code", + ".hljs", + "div.highlight pre", + "div.highlight code", + "[data-code-block]", + ], + linkActivator: copyElementTextActivator("code"), +}; +const COPY_PARAGRAPH_BLOCK = { + name: "para", + indicator: "Copy paragraph", + customSelectors: ["p"], + linkActivator: copyElementTextActivator("paragraph"), +}; +const COPY_SPAN_BLOCK = { + name: "span", + indicator: "Copy span", + customSelectors: ["span"], + linkActivator: copyElementTextActivator("span"), +}; +const COPY_ANCHOR_BLOCK = { + name: "alink", + indicator: "Copy link text", + customSelectors: ["a"], + linkActivator: copyElementTextActivator("link"), +}; +const COPY_ANY_BLOCK = { + name: "any", + indicator: "Copy element", + // Union of existing selector groups (anchor, span, paragraph, code family). + customSelectors: [ + "a", + "span", + "p", + "pre", + "code", + ".hljs", + "div.highlight pre", + "div.highlight code", + "[data-code-block]", + ], + linkActivator: copyElementTextActivator("element"), +}; + const availableModes = [ OPEN_IN_CURRENT_TAB, OPEN_IN_NEW_BG_TAB, @@ -157,6 +221,11 @@ const availableModes = [ COPY_LINK_TEXT, HOVER_LINK, FOCUS_LINK, + COPY_CODE_BLOCK, + COPY_PARAGRAPH_BLOCK, + COPY_SPAN_BLOCK, + COPY_ANCHOR_BLOCK, + COPY_ANY_BLOCK, ]; const HintCoordinator = { @@ -222,14 +291,19 @@ const HintCoordinator = { getHintDescriptors({ modeIndex, requestedByHelpDialog }, _sender) { if (!DomUtils.isReady() || DomUtils.windowIsTooSmall()) return []; - const requireHref = [COPY_LINK_URL, OPEN_INCOGNITO].includes(availableModes[modeIndex]); + const mode = availableModes[modeIndex]; + const requireHref = [COPY_LINK_URL, OPEN_INCOGNITO].includes(mode); // If link hints is launched within the help dialog, then we only offer hints from that frame. // This improves the usability of the help dialog on the options page (particularly for // selecting command names). if (requestedByHelpDialog && !globalThis.isVimiumHelpDialog) { this.localHints = []; } else { - this.localHints = LocalHints.getLocalHints(requireHref); + if (mode.customSelectors) { + this.localHints = LocalHints.getHintsForSelectors(mode.customSelectors); + } else { + this.localHints = LocalHints.getLocalHints(requireHref); + } } this.localHintDescriptors = this.localHints.map(({ linkText }, localIndex) => ( new HintDescriptor({ @@ -484,6 +558,52 @@ class LinkHintsMode { // Note that Vimium's CSS is user-customizable. We're adding the "vimiumHintMarker" class here // for users to customize. See further comments about this in vimium.css. el.className = "vimium-reset internal-vimium-hint-marker vimiumHintMarker"; + + // Classify the element so we can style different hint types differently (buttons, links, + // external links). We only attach these classes for local markers actually rendered here. + try { + const target = localHint.element; + if (target) { + const tag = target.tagName?.toLowerCase?.() || ""; + const role = target.getAttribute?.("role")?.toLowerCase?.(); + let typeClass = null; + + // Helper to decide if an is external (different domain). + const classifyAnchor = () => { + if (!target.href) return "vimium-hint-type-link"; // No href => treat as normal link. + let linkHostname; + try { + linkHostname = new URL(target.href, document.baseURI).hostname || ""; + } catch (_) { + linkHostname = ""; // Malformed; treat as same-domain link. + } + const norm = (h) => h.replace(/^www\./, ""); + if (linkHostname && norm(linkHostname) && norm(linkHostname) !== norm(location.hostname || "")) { + return "vimium-hint-type-external"; + } + return "vimium-hint-type-link"; + }; + + if ( + tag === "button" || + (tag === "input" && ["button", "submit", "reset"].includes((target.getAttribute("type") || "").toLowerCase())) || + role === "button" + ) { + typeClass = "vimium-hint-type-button"; + } else if (tag === "a" || role === "link") { + typeClass = classifyAnchor(); + } else if (target.href && tag !== "area") { // Generic clickable with href (e.g.
+ typeClass = classifyAnchor(); + } + + // Apply class if determined. + if (typeClass) { + el.classList.add(typeClass); + } + } + } catch (_) { + // Swallow any classification errors; hint rendering must not break. + } Object.assign(marker, { element: el, localHint, @@ -1548,4 +1668,36 @@ Object.assign(globalThis, { AlphabetHints, FilterHints, WaitForEnter, + COPY_CODE_BLOCK, + COPY_PARAGRAPH_BLOCK, + COPY_SPAN_BLOCK, + COPY_ANCHOR_BLOCK, + COPY_ANY_BLOCK, }); +// Generic selector-based helper for custom copy modes. +LocalHints.getHintsForSelectors = function (selectors) { + if (!document.documentElement) return []; + let elements = []; + try { + elements = selectors.flatMap((sel) => Array.from(document.querySelectorAll(sel))); + } catch (_) { + return []; + } + elements = elements.filter((el) => !elements.some((other) => (other !== el) && other.contains(el))); + const hints = []; + const { top: viewportTop, left: viewportLeft } = DomUtils.getViewportTopLeft(); + for (const el of elements) { + const rect = DomUtils.getVisibleClientRect(el, true); + if (!rect) continue; + if (rect.width < 10 || rect.height < 10) continue; + let text = (el.innerText || el.textContent || "").trim(); + if (!text) continue; + let caption = text.split(/\n/)[0]; + if (caption.length > 80) caption = caption.slice(0, 77) + "..."; + // Adjust to document coordinates (LinkHints expects rect relative to full document, not viewport). + rect.top += viewportTop; + rect.left += viewportLeft; + hints.push(new LocalHint({ element: el, rect, linkText: caption, showLinkText: true })); + } + return hints; +}; diff --git a/content_scripts/mode_normal.js b/content_scripts/mode_normal.js index eaa649c3f..a955d8804 100644 --- a/content_scripts/mode_normal.js +++ b/content_scripts/mode_normal.js @@ -353,6 +353,12 @@ const NormalModeCommands = { "LinkHints.activateModeToOpenIncognito": LinkHints.activateModeToOpenIncognito.bind(LinkHints), "LinkHints.activateModeToDownloadLink": LinkHints.activateModeToDownloadLink.bind(LinkHints), "LinkHints.activateModeToCopyLinkUrl": LinkHints.activateModeToCopyLinkUrl.bind(LinkHints), + // Custom: copy element text via hinting (prefix 'c'). + "LinkHints.activateModeToCopyCodeBlock": function () { HintCoordinator.prepareToActivateMode(COPY_CODE_BLOCK, function () {}); }, + "LinkHints.activateModeToCopyParagraph": function () { HintCoordinator.prepareToActivateMode(COPY_PARAGRAPH_BLOCK, function () {}); }, + "LinkHints.activateModeToCopySpan": function () { HintCoordinator.prepareToActivateMode(COPY_SPAN_BLOCK, function () {}); }, + "LinkHints.activateModeToCopyAnchor": function () { HintCoordinator.prepareToActivateMode(COPY_ANCHOR_BLOCK, function () {}); }, + "LinkHints.activateModeToCopyAny": function () { HintCoordinator.prepareToActivateMode(COPY_ANY_BLOCK, function () {}); }, "Vomnibar.activate": Vomnibar.activate.bind(Vomnibar), "Vomnibar.activateInNewTab": Vomnibar.activateInNewTab.bind(Vomnibar), diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index 9182d7255..8e1c0bb86 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -118,6 +118,28 @@ div.internal-vimium-hint-marker { z-index: 2147483647; } +/* Hint type variants: these classes are added dynamically in link_hints.js. + * We keep them additive so user-defined CSS can override them easily. */ +.internal-vimium-hint-marker.vimium-hint-type-button { + background: linear-gradient(to bottom, #d0ebff 0%, #74c0fc 100%); + border-color: #339af0; +} +.internal-vimium-hint-marker.vimium-hint-type-link { + background: linear-gradient(to bottom, #e6ffe6 0%, #9bde83 100%); + border-color: #4caf50; +} +.internal-vimium-hint-marker.vimium-hint-type-external { + background: linear-gradient(to bottom, #ffe6f1 0%, #ff87b5 100%); + border-color: #ff3d7f; +} + +/* Active state tweaks keep foreground legible */ +.internal-vimium-hint-marker.vimium-hint-type-external span, +.internal-vimium-hint-marker.vimium-hint-type-button span, +.internal-vimium-hint-marker.vimium-hint-type-link span { + color: #302505; +} + div.internal-vimium-hint-marker span { color: #302505; font-family: Helvetica, Arial, sans-serif;