[TSK-1327] Clickable links for string presenters and rich text editor (#3088)

Signed-off-by: Sergei Ogorelkov <sergei.ogorelkov@icloud.com>
This commit is contained in:
Sergei Ogorelkov 2023-04-27 16:28:12 +04:00 committed by GitHub
parent 324ff317cb
commit 073e55b15c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 94 additions and 37 deletions

View File

@ -269,6 +269,7 @@ specifiers:
'@types/xml2js': ~0.4.9
'@typescript-eslint/eslint-plugin': ^5.41.0
'@typescript-eslint/parser': ^5.41.0
autolinker: 4.0.0
autoprefixer: ^10.4.14
body-parser: ~1.19.1
browserslist: 4.21.5
@ -641,6 +642,7 @@ dependencies:
'@types/xml2js': 0.4.11
'@typescript-eslint/eslint-plugin': 5.42.1_d506b9be61cb4ac2646ecbc6e0680464
'@typescript-eslint/parser': 5.42.1_eslint@8.27.0+typescript@4.8.4
autolinker: 4.0.0
autoprefixer: 10.4.14_postcss@8.4.20
body-parser: 1.19.2
browserslist: 4.21.5
@ -4267,6 +4269,12 @@ packages:
engines: {node: '>=8.0.0'}
dev: false
/autolinker/4.0.0:
resolution: {integrity: sha512-fl5Kh6BmEEZx+IWBfEirnRUU5+cOiV0OK7PEt0RBKvJMJ8GaRseIOeDU3FKf4j3CE5HVefcjHmhYPOcaVt0bZw==}
dependencies:
tslib: 2.4.1
dev: false
/autoprefixer/10.4.14_postcss@8.4.20:
resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==}
engines: {node: ^10 || ^12 || >=14}
@ -16711,7 +16719,7 @@ packages:
dev: false
file:projects/ui.tgz_a1d864769aaf53d09b76fe134ab55e60:
resolution: {integrity: sha512-hn+I2VojXSb6t3PYHND/JyhwX8tZ8Jr7D+rzs1XjmVxhNMHnO+HA8QHzXyqZBjggTsBc8kUqWlnm44W5tt3m8Q==, tarball: file:projects/ui.tgz}
resolution: {integrity: sha512-TvtqATO5uYXW+jS7m2OEAikTIUwJ5JSDwTdxk0OqD62FO4nkED8FA63goDvEoBPUZtoFn7yTzy2cWiXepZNiXA==, tarball: file:projects/ui.tgz}
id: file:projects/ui.tgz
name: '@rush-temp/ui'
version: 0.0.0
@ -16719,6 +16727,7 @@ packages:
'@types/jest': 28.1.8
'@typescript-eslint/eslint-plugin': 5.42.1_d506b9be61cb4ac2646ecbc6e0680464
'@typescript-eslint/parser': 5.42.1_eslint@8.27.0+typescript@4.8.4
autolinker: 4.0.0
eslint: 8.27.0
eslint-config-standard-with-typescript: 23.0.0_c9fe9619f50f4e82337a86c3af25e566
eslint-plugin-import: 2.26.0_eslint@8.27.0

View File

@ -56,7 +56,10 @@ export const defaultExtensions = [
Heading.configure({
levels: headingLevels
}),
Link.configure({ openOnClick: false }),
Link.configure({
openOnClick: true,
HTMLAttributes: { class: 'cursor-pointer', rel: 'noopener noreferrer', target: '_blank' }
}),
...tableExtensions,
...taskListExtensions
]

View File

@ -38,7 +38,8 @@
"@hcengineering/core": "^0.6.23",
"just-clone": "~6.2.0",
"svelte": "3.55.1",
"fast-equals": "^2.0.3"
"fast-equals": "^2.0.3",
"autolinker": "4.0.0"
},
"repository": "https://github.com/hcenginneing/anticrm",
"publishConfig": {

View File

@ -0,0 +1,31 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
// This component converts all URLs from the provided string or IntlString to Links.
import { IntlString, translate } from '@hcengineering/platform'
import { replaceURLs } from '../utils'
export let text: string | undefined = undefined
export let label: IntlString | undefined = undefined
export let params: Readonly<Record<string, any>> = {}
$: label && translate(label, params).then((result) => (text = result))
</script>
{#if text}
<!-- "replaceURLs" produces sanitazed string -->
{@html replaceURLs(text)}
{/if}

View File

@ -86,6 +86,7 @@ export { default as Spinner } from './components/Spinner.svelte'
export { default as Popup } from './components/Popup.svelte'
export { default as CircleButton } from './components/CircleButton.svelte'
export { default as Link } from './components/Link.svelte'
export { default as LinkWrapper } from './components/LinkWrapper.svelte'
export { default as Like } from './components/Like.svelte'
export { default as TimeSince } from './components/TimeSince.svelte'
export { default as Dropdown } from './components/Dropdown.svelte'

View File

@ -17,6 +17,7 @@ import { generateId } from '@hcengineering/core'
import type { Metadata } from '@hcengineering/platform'
import { setMetadata } from '@hcengineering/platform'
import { writable } from 'svelte/store'
import autolinker from 'autolinker'
import { Notification, NotificationPosition, NotificationSeverity, notificationsStore } from '.'
import { AnyComponent, AnySvelteComponent } from './types'
@ -128,3 +129,24 @@ export function mouseAttractor (op: () => void, diff = 2): (evt: MouseEvent) =>
}
}
}
/**
* Replaces URLs with Links in a given block of text/HTML
*
* @example
* replaceURLs("Check out google.com")
* returns: "Check out <a href='http://google.com' target='_blank' rel='noopener noreferrer'>google.com</a>"
*
* @export
* @param {string} text
* @returns {string} string with replaced URLs
*/
export function replaceURLs (text: string): string {
return autolinker.link(text, {
urls: true,
phone: false,
email: false,
sanitizeHtml: true,
stripPrefix: false
})
}

View File

@ -21,7 +21,7 @@
import { Vacancy } from '@hcengineering/recruit'
import { FullDescriptionBox } from '@hcengineering/text-editor'
import tracker from '@hcengineering/tracker'
import { Button, Component, EditBox, Grid, IconMixin, IconMoreH, showPopup } from '@hcengineering/ui'
import { Button, Component, EditBox, Grid, IconMixin, IconMoreH, LinkWrapper, showPopup } from '@hcengineering/ui'
import { ContextMenu, DocAttributeBar } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import recruit from '../plugin'
@ -113,13 +113,11 @@
>
<svelte:fragment slot="subtitle">
{#if object.description}
{#if object.description.trim().startsWith('http://') || object.description.trim().startsWith('https://')}
<a href={object.description} class="whitespace-nowrap" target="_blank" rel="noreferrer noopener">
{object.description}
</a>
{:else}
{object.description}
{/if}
<div class="flex">
<span class="overflow-label" title={object.description}>
<LinkWrapper text={object.description} />
</span>
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="attributes" let:direction={dir}>

View File

@ -14,13 +14,10 @@
// limitations under the License.
-->
<script lang="ts">
import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import { LabelAndProps, tooltip } from '@hcengineering/ui'
import StringEditor from './StringEditor.svelte'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { LabelAndProps, LinkWrapper, tooltip } from '@hcengineering/ui'
export let value: string | string[] | undefined
export let onChange: ((value: string) => void) | undefined = undefined
export let placeholder: IntlString = getEmbeddedLabel('')
$: tooltipParams = getTooltip(value)
@ -41,11 +38,9 @@
<span class="lines-limit-2 select-text" use:tooltip={tooltipParams}>
{#if Array.isArray(value)}
{#each value as str, i}
<span class:ml-1={i !== 0}>{str}</span>
<span class:ml-1={i !== 0}><LinkWrapper text={str} /></span>
{/each}
{:else if onChange === undefined}
{value ?? ''}
{:else}
<StringEditor {onChange} value={value ?? ''} {placeholder} />
{:else if value}
<LinkWrapper text={value} />
{/if}
</span>

View File

@ -15,7 +15,7 @@
<script lang="ts">
import type { Class, Doc, Ref, Space } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { AnyComponent } from '@hcengineering/ui'
import { AnyComponent, LinkWrapper } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { DocNavLink } from '@hcengineering/view-resources'
import plugin from '../plugin'
@ -52,13 +52,11 @@
</div>
{#if description}
<span class="ac-header__description">
{#if description.trim().startsWith('http://') || description.trim().startsWith('https://')}
<a href={description} class="whitespace-nowrap" target="_blank" rel="noreferrer noopener">
{description}
</a>
{:else}
{description}
{/if}
<div class="flex">
<span class="overflow-label" title={description}>
<LinkWrapper text={description} />
</span>
</div>
</span>{/if}
</div>

View File

@ -49,12 +49,11 @@ test.describe('recruit tests', () => {
await page.click(`text="${last} ${first}"`)
await expect(page.locator(`text=${first}`).first()).toBeVisible()
await expect(page.locator(`text=${last}`).first()).toBeVisible()
await expect(page.locator(`text=${loc}`).first()).toBeVisible()
const panel = page.locator('.popupPanel')
expect(await panel.locator('[placeholder="First name"]').inputValue()).toEqual(first)
expect(await panel.locator('[placeholder="Last name"]').inputValue()).toEqual(last)
expect(await panel.locator('[placeholder="Location"]').inputValue()).toEqual(loc)
await panel.locator('[id="gmail\\:string\\:Email"]').scrollIntoViewIfNeeded()
await panel.locator('[id="gmail\\:string\\:Email"]').click()
expect(await page.locator('.cover-channel >> input').inputValue()).toEqual(email)