From 67ec52365a94b6e710918f36864401ff596b656c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Wed, 13 Oct 2021 19:36:55 +0200 Subject: [PATCH] (core) Showing links in text cells Summary: When there is a link in a text cell (and formula cells), it will be rendered with a little clickable icon wrapped in the anchor tag with a proper link. Only links that starts with https? will be rendered as links. Links are shown in a Text and Formula fields, inside a GridView, CardView and in the Import preview dialog. Test Plan: Browser tests Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: dsagal, alexmojaki Differential Revision: https://phab.getgrist.com/D3070 --- app/client/lib/textUtils.ts | 47 ++++++++++++++ app/client/ui2018/icons.ts | 9 +++ app/client/ui2018/links.ts | 36 ++++++++++- app/client/widgets/HyperLinkTextBox.ts | 58 ++++++------------ app/client/widgets/NTextBox.ts | 84 +++++++++++++++++++++++--- 5 files changed, 182 insertions(+), 52 deletions(-) create mode 100644 app/client/lib/textUtils.ts diff --git a/app/client/lib/textUtils.ts b/app/client/lib/textUtils.ts new file mode 100644 index 00000000..0dd8b5d2 --- /dev/null +++ b/app/client/lib/textUtils.ts @@ -0,0 +1,47 @@ +// There are many regex for matching URL, but non seem to be the correct solution. +// Here we will use very fast and simple one. +// Tested most of the regex solutions mentioned in this post +// https://stackoverflow.com/questions/37684/how-to-replace-plain-urls-with-links. +// The best one was http://alexcorvi.github.io/anchorme.js/, which still wasn't perfect. +// The best non regex solution was https://github.com/Hypercontext/linkifyjs, but it feels a little too heavy. +// Some examples why this is better or worse from other solution: +/** + +For 'http://www.uk,http://www.uk' +'OurRegex' [ 'http://www.uk', 'http://www.uk' ] +'Anchrome' [ 'http://www.uk,http://www.uk' ] +'linkify' [ 'http://www.uk,http://www.uk' ] +'url-regex' [ 'http://www.uk', 'http://www.uk' ] + +For 'might.it be a link' +'OurRegex' [] +'Anchrome' [ 'might.it' ] +'linkify' [ 'http://might.it' ] +'url-regex' [] + +For 'Is this correct.No it is not' +'OurRegex' [] +'Anchrome' [ 'correct.No' ] +'linkify' [ 'http://correct.No' ] +'url-regex' [] + +For 'Link (in http://www.uk?)' +'OurRegex' [ 'http://www.uk' ] +'Anchrome' [ 'http://www.uk' ] +'linkify' [ 'http://www.uk' ] +'url-regex' [ 'http://www.uk?)' ] +*/ + +// Match http or https then domain name (with optional port) then any text that ends with letter or number. +export const urlRegex = /(https?:\/\/[A-Za-z\d][A-Za-z\d-.]*(?!\.)(?::\d+)?(?:\/[^\s]*)?[\w\d/])/; + +/** + * Detects URLs in a text and returns list of tokens { value, isLink } + */ +export function findLinks(text: string): Array<{value: string, isLink: boolean}> { + if (!text) { + return [{ value: text, isLink: false }]; + } + // urls will be at odd-number indices + return text.split(urlRegex).map((value, i) => ({ value, isLink : (i % 2) === 1})); +} diff --git a/app/client/ui2018/icons.ts b/app/client/ui2018/icons.ts index 2521b2b3..361eb8d8 100644 --- a/app/client/ui2018/icons.ts +++ b/app/client/ui2018/icons.ts @@ -68,6 +68,15 @@ const iconDiv = styled('div', ` background-color: var(--icon-color, black); `); +export const cssIconBackground = styled(iconDiv, ` + background-color: var(--icon-background, inherit); + -webkit-mask: none; + & .${iconDiv.className} { + transition: inherit; + display: block; + } +`); + export function icon(name: IconName, ...domArgs: DomElementArg[]): HTMLElement { return iconDiv( dom.style('-webkit-mask-image', `var(--icon-${name})`), diff --git a/app/client/ui2018/links.ts b/app/client/ui2018/links.ts index bc8421f4..e0b8f57c 100644 --- a/app/client/ui2018/links.ts +++ b/app/client/ui2018/links.ts @@ -1,10 +1,12 @@ +import { sameDocumentUrlState, urlState } from 'app/client/models/gristUrlState'; +import { colors } from 'app/client/ui2018/cssVars'; +import { CellValue } from 'app/plugin/GristData'; +import { dom, IDomArgs, Observable, styled } from 'grainjs'; + /** * Styling for a simple green link. */ -import { colors } from 'app/client/ui2018/cssVars'; -import { styled } from 'grainjs'; - // Match the font-weight of buttons. export const cssLink = styled('a', ` color: ${colors.lightGreen}; @@ -16,3 +18,31 @@ export const cssLink = styled('a', ` text-decoration: underline; } `); + +export function gristLink(href: string|Observable, ...args: IDomArgs) { + return dom("a", + dom.attr("href", href), + dom.attr("target", "_blank"), + dom.on("click", ev => onClickHyperLink(ev, typeof href === 'string' ? href : href.get())), + // As per Google and Mozilla recommendations to prevent opened links + // from running on the same process as Grist: + // https://developers.google.com/web/tools/lighthouse/audits/noopener + dom.attr("rel", "noopener noreferrer"), + args + ); +} + +/** + * If possible (i.e. if `url` points to somewhere in the current document) + * use pushUrl to navigate without reloading or opening a new tab + */ +export async function onClickHyperLink(ev: MouseEvent, url: CellValue) { + // Only override plain-vanilla clicks. + if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; } + + const newUrlState = sameDocumentUrlState(url); + if (!newUrlState) { return; } + + ev.preventDefault(); + await urlState().pushUrl(newUrlState); +} diff --git a/app/client/widgets/HyperLinkTextBox.ts b/app/client/widgets/HyperLinkTextBox.ts index 21d0ca29..5f07822c 100644 --- a/app/client/widgets/HyperLinkTextBox.ts +++ b/app/client/widgets/HyperLinkTextBox.ts @@ -1,11 +1,12 @@ -import {CellValue} from 'app/common/DocActions'; -import {DataRowModel} from 'app/client/models/DataRowModel'; -import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; -import {colors, testId} from 'app/client/ui2018/cssVars'; -import {icon} from 'app/client/ui2018/icons'; -import {NTextBox} from 'app/client/widgets/NTextBox'; -import {dom, styled} from 'grainjs'; -import {constructUrl, sameDocumentUrlState, urlState} from "app/client/models/gristUrlState"; +import { DataRowModel } from 'app/client/models/DataRowModel'; +import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; +import { constructUrl } from 'app/client/models/gristUrlState'; +import { colors, testId } from 'app/client/ui2018/cssVars'; +import { cssIconBackground, icon } from 'app/client/ui2018/icons'; +import { gristLink } from 'app/client/ui2018/links'; +import { cssHoverIn, NTextBox } from 'app/client/widgets/NTextBox'; +import { CellValue } from 'app/common/DocActions'; +import { Computed, dom, styled } from 'grainjs'; /** * Creates a widget for displaying links. Links can entered directly or following a title. @@ -19,22 +20,19 @@ export class HyperLinkTextBox extends NTextBox { public buildDom(row: DataRowModel) { const value = row.cells[this.field.colId()]; + const url = Computed.create(null, (use) => constructUrl(use(value))); return cssFieldClip( + dom.autoDispose(url), dom.style('text-align', this.alignment), dom.cls('text_wrapping', this.wrapping), dom.maybe((use) => Boolean(use(value)), () => - dom('a', - dom.attr('href', (use) => constructUrl(use(value))), - dom.attr('target', '_blank'), - dom.on('click', (ev) => - _onClickHyperlink(ev, value.peek())), - // As per Google and Mozilla recommendations to prevent opened links - // from running on the same process as Grist: - // https://developers.google.com/web/tools/lighthouse/audits/noopener - dom.attr('rel', 'noopener noreferrer'), - cssLinkIcon('FieldLink', testId('tb-link-icon')), - testId('tb-link') - ) + gristLink(url, + cssIconBackground( + icon("FieldLink", testId('tb-link-icon')), + dom.cls(cssHoverOnField.className), + ), + testId('tb-link'), + ), ), dom.text((use) => _formatValue(use(value))), ); @@ -50,26 +48,8 @@ function _formatValue(value: CellValue): string { return index >= 0 ? value.slice(0, index) : value; } -/** - * If possible (i.e. if `url` points to somewhere in the current document) - * use pushUrl to navigate without reloading or opening a new tab - */ -async function _onClickHyperlink(ev: MouseEvent, url: CellValue) { - // Only override plain-vanilla clicks. - if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; } - - const newUrlState = sameDocumentUrlState(url); - if (!newUrlState) { return; } - - ev.preventDefault(); - await urlState().pushUrl(newUrlState); -} - const cssFieldClip = styled('div.field_clip', ` color: var(--grist-actual-cell-color, ${colors.lightGreen}); `); -const cssLinkIcon = styled(icon, ` - background-color: var(--grist-actual-cell-color, ${colors.lightGreen}); - margin: -1px 2px 2px 0; -`); +const cssHoverOnField = cssHoverIn(cssFieldClip.className); diff --git a/app/client/widgets/NTextBox.ts b/app/client/widgets/NTextBox.ts index 27dd1870..eb98172d 100644 --- a/app/client/widgets/NTextBox.ts +++ b/app/client/widgets/NTextBox.ts @@ -1,11 +1,14 @@ -import {fromKoSave} from 'app/client/lib/fromKoSave'; -import {DataRowModel} from 'app/client/models/DataRowModel'; -import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; -import {cssRow} from 'app/client/ui/RightPanel'; -import {alignmentSelect, makeButtonSelect} from 'app/client/ui2018/buttonSelect'; -import {testId} from 'app/client/ui2018/cssVars'; -import {NewAbstractWidget, Options} from 'app/client/widgets/NewAbstractWidget'; -import {dom, DomContents, fromKo, Observable} from 'grainjs'; +import { fromKoSave } from 'app/client/lib/fromKoSave'; +import { findLinks } from 'app/client/lib/textUtils'; +import { DataRowModel } from 'app/client/models/DataRowModel'; +import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; +import { cssRow } from 'app/client/ui/RightPanel'; +import { alignmentSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect'; +import { colors, testId } from 'app/client/ui2018/cssVars'; +import { cssIconBackground, icon } from 'app/client/ui2018/icons'; +import { gristLink } from 'app/client/ui2018/links'; +import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; +import { dom, DomArg, DomContents, fromKo, Observable, styled } from 'grainjs'; /** * TextBox - The most basic widget for displaying text information. @@ -42,13 +45,74 @@ export class NTextBox extends NewAbstractWidget { return dom('div.field_clip', dom.style('text-align', this.alignment), dom.cls('text_wrapping', this.wrapping), - dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))), + dom.domComputed((use) => use(row._isAddRow) ? null : makeLinks(use(this.valueFormatter).format(use(value)))) ); } - private _toggleWrap(value: boolean) { + private _toggleWrap() { const newValue = !this.wrapping.get(); this.options.update({wrap: newValue}); (this.options as any).save(); } } + +function makeLinks(text: string) { + try { + const domElements: DomArg[] = []; + for (const {value, isLink} of findLinks(text)) { + if (isLink) { + // Wrap link with a span to provide hover on and to override wrapping. + domElements.push(cssMaybeWrap( + gristLink(value, + cssIconBackground( + icon("FieldLink", testId('tb-link-icon')), + dom.cls(cssHoverInText.className), + ), + ), + linkColor(value), + testId("text-link") + )); + } else { + domElements.push(value); + } + } + return domElements; + } catch(ex) { + // In case when something went wrong, simply log and return original text, as showing + // links is not that important. + console.warn("makeLinks failed", ex); + return text; + } +} + +// For links we want to break all the parts, not only words. +const cssMaybeWrap = styled('span', ` + white-space: inherit; + .text_wrapping & { + word-break: break-all; + white-space: pre-wrap; + } +`); + +// A gentle transition effect on hover in, and the same effect on hover out with a little delay. +export const cssHoverIn = (parentClass: string) => styled('span', ` + --icon-color: var(--grist-actual-cell-color, ${colors.lightGreen}); + margin: -1px 2px 2px 0; + border-radius: 3px; + transition-property: background-color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + transition-delay: 90ms; + .${parentClass}:hover & { + --icon-background: ${colors.lightGreen}; + --icon-color: white; + transition-duration: 80ms; + transition-delay: 0ms; + } +`); + +const cssHoverInText = cssHoverIn(cssMaybeWrap.className); + +const linkColor = styled('span', ` + color: var(--grist-actual-cell-color, ${colors.lightGreen});; +`);