Skip to content
This repository was archived by the owner on Sep 21, 2021. It is now read-only.

Commit e923826

Browse files
committed
Fix linkification for cropped URLs
Fixes #616. Add many tests to make sure we cover various cases.
1 parent 7fda78a commit e923826

File tree

3 files changed

+270
-36
lines changed

3 files changed

+270
-36
lines changed

packages/devtools-reps/src/reps/rep-utils.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Dependencies
66
const validProtocols = /^(http|https|ftp|data|javascript|resource|chrome):/i;
77
const tokenSplitRegex = /(\s|\'|\"|\\)+/;
8+
const ELLIPSIS = "\u2026";
89
const dom = require("react-dom-factories");
910
const { span } = dom;
1011

@@ -138,11 +139,7 @@ function cropMultipleLines(text, limit) {
138139
return escapeNewLines(cropString(text, limit));
139140
}
140141

141-
function rawCropString(text, limit, alternativeText) {
142-
if (!alternativeText) {
143-
alternativeText = "\u2026";
144-
}
145-
142+
function rawCropString(text, limit, alternativeText = ELLIPSIS) {
146143
// Crop the string only if a limit is actually specified.
147144
if (!limit || limit <= 0) {
148145
return text;
@@ -413,4 +410,5 @@ module.exports = {
413410
getGripPreviewItems,
414411
getGripType,
415412
tokenSplitRegex,
413+
ELLIPSIS,
416414
};

packages/devtools-reps/src/reps/string.js

Lines changed: 110 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
sanitizeString,
1515
wrapRender,
1616
tokenSplitRegex,
17+
ELLIPSIS,
1718
} = require("./rep-utils");
1819

1920
const dom = require("react-dom-factories");
@@ -62,47 +63,125 @@ function StringRep(props) {
6263
text = sanitizeString(text);
6364
}
6465

65-
if ((!member || !member.open) && cropLimit) {
66-
text = rawCropString(text, cropLimit);
67-
}
68-
66+
const shouldCrop = (!member || !member.open) && cropLimit;
6967
if (!containsURL(text)) {
68+
if (shouldCrop) {
69+
text = rawCropString(text, cropLimit);
70+
}
7071
return span(config, text);
7172
}
7273

73-
const items = [];
74+
return span(config,
75+
...getLinkifiedElements(text, shouldCrop && cropLimit, omitLinkHref, openLink));
76+
}
77+
78+
/**
79+
* Get an array of the elements representing the string, cropped if needed,
80+
* with actual links.
81+
*
82+
* @param {String} text: The actual string to linkify.
83+
* @param {Integer | null} cropLimit
84+
* @param {Boolean} omitLinkHref: Do not create an href attribute if true.
85+
* @param {Function} openLink: Function handling the link opening.
86+
* @returns {Array<String|ReactElement>}
87+
*/
88+
function getLinkifiedElements(text, cropLimit, omitLinkHref, openLink) {
89+
const halfLimit = Math.ceil((cropLimit - ELLIPSIS.length) / 2);
90+
const startCropIndex = halfLimit;
91+
const endCropIndex = text.length - halfLimit;
7492

7593
// As we walk through the tokens of the source string, we make sure to preserve
7694
// the original whitespace that separated the tokens.
77-
let tokens = text.split(tokenSplitRegex);
78-
let textIndex = 0;
79-
let tokenStart;
80-
tokens.forEach((token, i) => {
81-
tokenStart = text.indexOf(token, textIndex);
95+
let currentIndex = 0;
96+
const items = [];
97+
for (let token of text.split(tokenSplitRegex)) {
8298
if (isURL(token)) {
83-
items.push(text.slice(textIndex, tokenStart));
84-
textIndex = tokenStart + token.length;
85-
86-
items.push(a({
87-
className: "url",
88-
title: token,
89-
href: omitLinkHref === true
90-
? null
91-
: token,
92-
draggable: false,
93-
onClick: openLink
94-
? e => {
95-
e.preventDefault();
96-
openLink(token);
97-
}
98-
: null
99-
}, token));
99+
// Let's grab all the non-url strings before the link.
100+
const tokenStart = text.indexOf(token, currentIndex);
101+
let nonUrlText = text.slice(currentIndex, tokenStart);
102+
nonUrlText = getCroppedString(
103+
nonUrlText, currentIndex, startCropIndex, endCropIndex);
104+
if (nonUrlText) {
105+
items.push(nonUrlText);
106+
}
107+
108+
// Update the index to match the beginning of the token.
109+
currentIndex = tokenStart;
110+
111+
let linkText = getCroppedString(token, currentIndex, startCropIndex, endCropIndex);
112+
if (linkText) {
113+
items.push(a({
114+
className: "url",
115+
title: token,
116+
href: omitLinkHref === true
117+
? null
118+
: token,
119+
draggable: false,
120+
onClick: openLink
121+
? e => {
122+
e.preventDefault();
123+
openLink(token);
124+
}
125+
: null
126+
}, linkText));
127+
}
128+
129+
currentIndex = tokenStart + token.length;
130+
}
131+
}
132+
133+
// Clean up any non-URL text at the end of the source string,
134+
// i.e. not handled in the loop.
135+
if (currentIndex !== text.length) {
136+
let nonUrlText = text.slice(currentIndex, text.length);
137+
if (currentIndex < endCropIndex) {
138+
const cutIndex = endCropIndex - currentIndex;
139+
nonUrlText = nonUrlText.substring(cutIndex);
100140
}
101-
});
141+
items.push(nonUrlText);
142+
}
143+
144+
return items;
145+
}
146+
147+
/**
148+
* Returns a cropped substring given an offset, start and end crop indices in a parent
149+
* string.
150+
*
151+
* @param {String} text: The substring to crop.
152+
* @param {Integer} offset: The offset corresponding to the index at which the substring
153+
* is in the parent string.
154+
* @param {Integer} startCropIndex: the index where the start of the crop should happen
155+
* in the parent string
156+
* @param {Integer} endCropIndex: the index where the end of the crop should happen
157+
* in the parent string
158+
* @returns {String|null} The cropped substring, or null if the text is completly cropped.
159+
*/
160+
function getCroppedString(text, offset = 0, startCropIndex, endCropIndex) {
161+
const start = offset;
162+
const end = offset + text.length;
163+
164+
const shouldBeVisible = !(start >= startCropIndex && end <= endCropIndex);
165+
if (!shouldBeVisible) {
166+
return null;
167+
}
168+
169+
const shouldCropEnd = start < startCropIndex && end > startCropIndex;
170+
const shouldCropStart = start < endCropIndex && end > endCropIndex;
171+
if (shouldCropEnd) {
172+
const cutIndex = startCropIndex - start;
173+
return text.substring(0, cutIndex) +
174+
ELLIPSIS +
175+
(shouldCropStart ? text.substring(endCropIndex - start) : "");
176+
}
177+
178+
if (shouldCropStart) {
179+
// The string should be cropped at the beginning.
180+
const cutIndex = endCropIndex - start;
181+
return text.substring(cutIndex);
182+
}
102183

103-
// Clean up any non-URL text at the end of the source string.
104-
items.push(text.slice(textIndex, text.length));
105-
return span(config, ...items);
184+
return text;
106185
}
107186

108187
function supportsObject(object, noGrip = false) {

packages/devtools-reps/src/reps/tests/string-with-url.js

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,163 @@ describe("test String with URL", () => {
174174
expect(openLink).toBeCalledWith(url2);
175175
});
176176

177+
it("renders multiple URLs with various spacing", () => {
178+
const url1 = "http://example.com";
179+
const url2 = "https://example.com/foo";
180+
const string = ` ${url1} ${url2} ${url2} ${url1} `;
181+
const element = renderRep(string, {useQuotes: false});
182+
expect(element.text()).toEqual(string);
183+
const links = element.find("a");
184+
expect(links.length).toBe(4);
185+
});
186+
187+
it("renders a cropped URL", () => {
188+
const url = "http://example.com";
189+
const openLink = jest.fn();
190+
const element = renderRep(url, {
191+
openLink,
192+
useQuotes: false,
193+
cropLimit: 15
194+
});
195+
196+
expect(element.text()).toEqual("http://…ple.com");
197+
const link = element.find("a");
198+
expect(link.prop("href")).toBe(url);
199+
expect(link.prop("title")).toBe(url);
200+
201+
link.simulate("click");
202+
expect(openLink).toBeCalledWith(url);
203+
});
204+
205+
it("renders URLs with a stripped string between", () => {
206+
const text = "- http://example.fr --- http://example.us -";
207+
const openLink = jest.fn();
208+
const element = renderRep(text, {
209+
openLink,
210+
useQuotes: false,
211+
cropLimit: 41
212+
});
213+
214+
expect(element.text()).toEqual("- http://example.fr … http://example.us -");
215+
const linkFr = element.find("a").at(0);
216+
expect(linkFr.prop("href")).toBe("http://example.fr");
217+
expect(linkFr.prop("title")).toBe("http://example.fr");
218+
219+
const linkUs = element.find("a").at(1);
220+
expect(linkUs.prop("href")).toBe("http://example.us");
221+
expect(linkUs.prop("title")).toBe("http://example.us");
222+
});
223+
224+
it("renders URLs with a cropped string between", () => {
225+
const text = "- http://example.fr ---- http://example.us -";
226+
const openLink = jest.fn();
227+
const element = renderRep(text, {
228+
openLink,
229+
useQuotes: false,
230+
cropLimit: 42
231+
});
232+
233+
expect(element.text()).toEqual("- http://example.fr -…- http://example.us -");
234+
const linkFr = element.find("a").at(0);
235+
expect(linkFr.prop("href")).toBe("http://example.fr");
236+
expect(linkFr.prop("title")).toBe("http://example.fr");
237+
238+
const linkUs = element.find("a").at(1);
239+
expect(linkUs.prop("href")).toBe("http://example.us");
240+
expect(linkUs.prop("title")).toBe("http://example.us");
241+
});
242+
243+
it("renders successive cropped URLs, one at the start, one at the end", () => {
244+
const text = "- http://example-long.fr http://example.us -";
245+
const openLink = jest.fn();
246+
const element = renderRep(text, {
247+
openLink,
248+
useQuotes: false,
249+
cropLimit: 20
250+
});
251+
252+
expect(element.text()).toEqual("- http://e…ample.us -");
253+
const linkFr = element.find("a").at(0);
254+
expect(linkFr.prop("href")).toBe("http://example-long.fr");
255+
expect(linkFr.prop("title")).toBe("http://example-long.fr");
256+
257+
const linkUs = element.find("a").at(1);
258+
expect(linkUs.prop("href")).toBe("http://example.us");
259+
expect(linkUs.prop("title")).toBe("http://example.us");
260+
});
261+
262+
it("renders successive URLs, one cropped in the middle", () => {
263+
const text = "- http://example-long.fr http://example.com http://example.us -";
264+
const openLink = jest.fn();
265+
const element = renderRep(text, {
266+
openLink,
267+
useQuotes: false,
268+
cropLimit: 60
269+
});
270+
271+
expect(element.text()).toEqual("- http://example-long.fr http:…xample.com http://example.us -");
272+
const linkFr = element.find("a").at(0);
273+
expect(linkFr.prop("href")).toBe("http://example-long.fr");
274+
expect(linkFr.prop("title")).toBe("http://example-long.fr");
275+
276+
const linkCom = element.find("a").at(1);
277+
expect(linkCom.prop("href")).toBe("http://example.com");
278+
expect(linkCom.prop("title")).toBe("http://example.com");
279+
280+
const linkUs = element.find("a").at(2);
281+
expect(linkUs.prop("href")).toBe("http://example.us");
282+
expect(linkUs.prop("title")).toBe("http://example.us");
283+
});
284+
285+
it("renders successive cropped URLs with cropped elements between", () => {
286+
const text = "- http://example.fr test http://example.fr test http://example.us -";
287+
const openLink = jest.fn();
288+
const element = renderRep(text, {
289+
openLink,
290+
useQuotes: false,
291+
cropLimit: 20
292+
});
293+
294+
expect(element.text()).toEqual("- http://e…ample.us -");
295+
const linkFr = element.find("a").at(0);
296+
expect(linkFr.prop("href")).toBe("http://example.fr");
297+
expect(linkFr.prop("title")).toBe("http://example.fr");
298+
299+
const linkUs = element.find("a").at(1);
300+
expect(linkUs.prop("href")).toBe("http://example.us");
301+
expect(linkUs.prop("title")).toBe("http://example.us");
302+
});
303+
304+
it("renders a cropped URL followed by a cropped string", () => {
305+
const text = "http://example.fr abcdefghijkl";
306+
const openLink = jest.fn();
307+
const element = renderRep(text, {
308+
openLink,
309+
useQuotes: false,
310+
cropLimit: 20
311+
});
312+
313+
expect(element.text()).toEqual("http://exa…cdefghijkl");
314+
const linkFr = element.find("a").at(0);
315+
expect(linkFr.prop("href")).toBe("http://example.fr");
316+
expect(linkFr.prop("title")).toBe("http://example.fr");
317+
});
318+
319+
it("renders a cropped string followed by a cropped URL", () => {
320+
const text = "abcdefghijkl stripped http://example.fr ";
321+
const openLink = jest.fn();
322+
const element = renderRep(text, {
323+
openLink,
324+
useQuotes: false,
325+
cropLimit: 20
326+
});
327+
328+
expect(element.text()).toEqual("abcdefghij…xample.fr ");
329+
const linkFr = element.find("a").at(0);
330+
expect(linkFr.prop("href")).toBe("http://example.fr");
331+
expect(linkFr.prop("title")).toBe("http://example.fr");
332+
});
333+
177334
it("does not render a link if the URL has no scheme", () => {
178335
const url = "example.com";
179336
const element = renderRep(url, {useQuotes: false});

0 commit comments

Comments
 (0)