Skip to content

Commit 0726ee1

Browse files
authored
Fix non selectable comment link (#186)
* Fix non selectable comment link * Fix url match * Fix lint
1 parent ec9a13b commit 0726ee1

File tree

4 files changed

+52
-53
lines changed

4 files changed

+52
-53
lines changed

editor/commentLink.js

Lines changed: 46 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,17 @@
11
import {syntaxTree} from "@codemirror/language";
2-
import {Decoration, ViewPlugin, WidgetType} from "@codemirror/view";
2+
import {Decoration, ViewPlugin, EditorView} from "@codemirror/view";
33

44
const isWindows = navigator.userAgent.includes("Windows");
55

6-
class LinkWidget extends WidgetType {
7-
constructor(url) {
8-
super();
9-
this.url = url;
10-
}
11-
12-
toDOM() {
13-
const link = document.createElement("a");
14-
link.href = this.url;
15-
link.textContent = this.url;
16-
link.target = "_blank";
17-
link.rel = "noopener noreferrer";
18-
link.className = "cm-comment-link";
19-
link.title = `Open in a new tab (${isWindows ? "Ctrl" : "Cmd"} + Click)`;
20-
21-
// Prevent default click behavior - only open on Cmd+Click
22-
const preventDefault = (e) => !e.metaKey && !e.ctrlKey && e.preventDefault();
23-
link.addEventListener("click", preventDefault);
24-
25-
// Change cursor to pointer when Ctrl/Cmd is held
26-
const add = (e) => (e.metaKey || e.ctrlKey) && link.classList.add("cmd-clickable");
27-
const remove = (e) => !e.metaKey && !e.ctrlKey && link.classList.remove("cmd-clickable");
28-
link.addEventListener("mouseenter", add);
29-
link.addEventListener("mousemove", add);
30-
link.addEventListener("mouseleave", remove);
31-
return link;
32-
}
33-
34-
ignoreEvent(event) {
35-
// Ignore click events when Cmd/Ctrl is held (for opening links)
36-
if (event.type === "mousedown" || event.type === "click") {
37-
return event.metaKey || event.ctrlKey;
38-
}
39-
return false;
40-
}
6+
// Create a decoration that marks links but keeps text selectable.
7+
function createLinkDecoration(url) {
8+
return Decoration.mark({
9+
class: "cm-comment-link",
10+
attributes: {
11+
"data-url": url,
12+
title: `Open in a new tab (${isWindows ? "Ctrl" : "Cmd"} + Click)`,
13+
},
14+
});
4115
}
4216

4317
export const commentLink = ViewPlugin.fromClass(
@@ -53,7 +27,7 @@ export const commentLink = ViewPlugin.fromClass(
5327
}
5428

5529
buildDeco(view) {
56-
let widgets = [];
30+
let decorations = [];
5731
let tree = syntaxTree(view.state);
5832

5933
// Only iterate tree nodes that intersect with the viewport.
@@ -64,31 +38,55 @@ export const commentLink = ViewPlugin.fromClass(
6438
enter: ({type, from, to}) => {
6539
if (type.name === "BlockComment" || type.name === "LineComment") {
6640
const text = view.state.doc.sliceString(from, to);
67-
let httpRegex = /https?:\/\/[^\s\)]+/g;
41+
// Match URLs, allowing common URL characters, then trim trailing punctuation.
42+
let httpRegex = /https?:\/\/[^\s\)\]\}]+/g;
6843
let m;
6944
while ((m = httpRegex.exec(text))) {
45+
let url = m[0];
46+
// Trim trailing punctuation that shouldn't be part of URL.
47+
// These are common sentence/clause-ending punctuation.
48+
url = url.replace(/[.,;:!?)\]}>]+$/, "");
49+
if (!url || url.length < 11) continue; // Minimum valid URL length.
7050
const tagStart = from + m.index;
71-
const tagEnd = tagStart + m[0].length;
51+
const tagEnd = tagStart + url.length;
7252
if (tagEnd >= from && tagStart <= to) {
73-
const url = m[0];
74-
const widget = new LinkWidget(url);
75-
widgets.push(
76-
Decoration.replace({
77-
widget,
78-
inclusive: true,
79-
}).range(tagStart, tagEnd),
80-
);
53+
decorations.push(createLinkDecoration(url).range(tagStart, tagEnd));
8154
}
8255
}
8356
}
8457
},
8558
});
8659
}
8760

88-
return Decoration.set(widgets, true);
61+
return Decoration.set(decorations, true);
8962
}
9063
},
9164
{
9265
decorations: (v) => v.decorations,
9366
},
9467
);
68+
69+
// Handle clicks and cursor updates on comment links.
70+
export const commentLinkClickHandler = EditorView.domEventHandlers({
71+
mousedown(event) {
72+
const target = event.target.closest?.(".cm-comment-link");
73+
if (!target) return false;
74+
const url = target.getAttribute("data-url");
75+
if (!url) return false;
76+
if (event.metaKey || event.ctrlKey) {
77+
event.preventDefault();
78+
window.open(url, "_blank", "noopener,noreferrer");
79+
return true;
80+
}
81+
return false;
82+
},
83+
mousemove(event) {
84+
const element = event.target;
85+
const isCmdHeld = event.metaKey || event.ctrlKey;
86+
const target = element?.closest?.(".cm-comment-link");
87+
if (!target) return false;
88+
if (isCmdHeld) target.classList.add("cmd-clickable");
89+
else target.classList.remove("cmd-clickable");
90+
return false;
91+
},
92+
});

editor/index.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@
168168
cursor: pointer;
169169
}
170170

171-
.cm-comment-link.cmd-clickable:hover {
172-
color: #0550ae;
171+
.cm-comment-link.cmd-clickable:hover > span {
172+
color: #0550ae !important;
173173
}
174174
}

editor/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {dispatch as d3Dispatch} from "d3-dispatch";
1616
import {controls} from "./controls/index.js";
1717
import {rechoCompletion} from "./completion.js";
1818
import {docStringTag} from "./docStringTag.js";
19-
import {commentLink} from "./commentLink.js";
19+
import {commentLink, commentLinkClickHandler} from "./commentLink.js";
2020

2121
// @see https://github.com/UziTech/eslint-linter-browserify/blob/master/example/script.js
2222
// @see https://codemirror.net/examples/lint/
@@ -74,6 +74,7 @@ export function createEditor(container, options) {
7474
// outputProtection(),
7575
docStringTag,
7676
commentLink,
77+
commentLinkClickHandler,
7778
linter(esLint(new eslint.Linter(), eslintConfig)),
7879
],
7980
});

test/js/comment-link.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
export const commentLink = `// https://recho.dev
2-
// http://recho.dev
1+
export const commentLink = `// https://recho.dev!
2+
// http://recho.dev,
33
44
/**
55
* [Recho](https://recho.dev)

0 commit comments

Comments
 (0)