Fix link preview size (#8294)

This commit is contained in:
Kristina 2025-03-20 15:44:09 +04:00 committed by GitHub
parent def2a411c5
commit f0dd4403e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 311 additions and 172 deletions

View File

@ -24,11 +24,18 @@ export interface LinkPreviewDetails {
url?: string
icon?: string
image?: string
imageWidth?: number
imageHeight?: number
charset?: string
hostname?: string
host?: string
}
export type LinkPreviewAttachmentMetadata = Pick<
LinkPreviewDetails,
'title' | 'description' | 'image' | 'imageWidth' | 'imageHeight'
>
export function canDisplayLinkPreview (val: LinkPreviewDetails): boolean {
if (isEmpty(val.host) && isEmpty(val.title)) {
return false

View File

@ -20,6 +20,7 @@
import BrokenImage from './icons/BrokenImage.svelte'
import { AttachmentImageSize } from '../types'
import { getImageDimensions } from '../utils'
export let value: WithLookup<Attachment> | BlobType
export let size: AttachmentImageSize = 'auto'
@ -33,64 +34,24 @@
const minSizeRem = 4
const maxSizeRem = 20
const preferredWidthMap = {
'x-large': 300
} as const
let dimensions: Dimensions
$: dimensions = getDimensions(value, size)
function getDimensions (value: Attachment | BlobType, size: AttachmentImageSize): Dimensions {
if (size === 'auto' || size == null) {
return {
width: 300,
height: 300,
fit: 'contain'
}
}
const byDefault = { width: 300, height: 300, fit: 'contain' } as const
if (size === 'auto' || size == null) return byDefault
const preferredWidth = preferredWidthMap[size]
const { metadata } = value
if (metadata === undefined) return byDefault
if (metadata === undefined) {
return {
width: preferredWidth,
height: preferredWidth,
fit: 'contain'
}
}
const { originalWidth, originalHeight } = metadata
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
const maxSize = maxSizeRem * fontSize
const minSize = minSizeRem * fontSize
const width = Math.min(originalWidth, preferredWidth)
const ratio = originalHeight / originalWidth
const height = Math.ceil(width * ratio)
const fit = width < minSize || height < minSize ? 'cover' : 'contain'
if (height > maxSize) {
return {
width: maxSize / ratio,
height: maxSize,
fit
}
} else if (height < minSize) {
return {
width,
height: minSize,
fit
}
} else {
return {
width,
height,
fit
}
}
return getImageDimensions(
{
width: metadata.originalWidth,
height: metadata.originalHeight
},
{ maxWidth: maxSizeRem, minWidth: minSizeRem, maxHeight: maxSizeRem, minHeight: minSizeRem }
)
}
function toStyle (size: 'auto' | number): string {
@ -131,8 +92,8 @@
blob={value.file}
alt={value.name}
fit={dimensions.fit}
width={Math.ceil(dimensions.width)}
height={Math.ceil(dimensions.height)}
width={dimensions.width}
height={dimensions.height}
on:load={handleLoad}
on:error={handleError}
on:loadstart={handleLoadStart}

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Attachment } from '@hcengineering/attachment'
import { Attachment, AttachmentMetadata } from '@hcengineering/attachment'
import {
Class,
Doc,
@ -37,7 +37,8 @@
getClient,
getFileMetadata,
isLinkPreviewEnabled,
uploadFile
uploadFile,
LinkPreviewAttachmentMetadata
} from '@hcengineering/presentation'
import { EmptyMarkup } from '@hcengineering/text'
import textEditor, { type RefAction } from '@hcengineering/text-editor'
@ -176,10 +177,10 @@
}
}
async function createAttachment (file: File): Promise<void> {
async function createAttachment (file: File, meta?: AttachmentMetadata): Promise<void> {
try {
const uuid = await uploadFile(file)
const metadata = await getFileMetadata(file, uuid)
const metadata = meta ?? (await getFileMetadata(file, uuid))
const _id: Ref<Attachment> = generateId()
attachments.set(_id, {
@ -360,7 +361,14 @@
if (canDisplayLinkPreview(meta) && meta.url !== undefined) {
const blob = new Blob([JSON.stringify(meta)])
const file = new File([blob], meta.url, { type: 'application/link-preview' })
await createAttachment(file)
const metadata: LinkPreviewAttachmentMetadata = {
title: meta.title,
image: meta.image,
description: meta.description,
imageWidth: meta.imageWidth,
imageHeight: meta.imageHeight
}
await createAttachment(file, metadata)
}
} catch (err: any) {
void setPlatformStatus(unknownError(err))

View File

@ -0,0 +1,49 @@
<!--
// Copyright © 2025 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">
import WebIcon from './icons/Web.svelte'
export let src: string | undefined = undefined
export let size: 'small' | 'large' = 'small'
let useDefaultIcon = false
</script>
{#if src && !useDefaultIcon}
<img
{src}
class="link-preview__icon {size}"
alt="link-preview-icon"
on:error={() => {
useDefaultIcon = true
}}
/>
{:else}
<WebIcon {size} />
{/if}
<style lang="scss">
.link-preview__icon {
&.small {
width: 1rem;
height: 1rem;
}
&.large {
width: 2.5rem;
height: 2.5rem;
}
}
</style>

View File

@ -0,0 +1,83 @@
<!--
// Copyright © 2025 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">
import { onMount } from 'svelte'
export let url: string | undefined = undefined
export let src: string
export let width: number
export let height: number | undefined
export let fit: string
let previewSrc: string | undefined
let retryCount = 0
function refreshPreviewSrc (): void {
if (src === undefined) {
previewSrc = undefined
return
}
if (retryCount > 3) {
previewSrc = undefined
return
}
retryCount++
previewSrc = `${src}#${Date.now()}`
}
onMount(() => {
refreshPreviewSrc()
})
</script>
{#if previewSrc}
{#if url}
<a target="_blank" href={url}>
<img
src={previewSrc}
class="link-preview__image"
alt="link-preview"
width={`${width}px`}
height={`${height}px`}
style:fit
on:error={() => {
refreshPreviewSrc()
}}
/>
</a>
{:else}
<img
src={previewSrc}
class="link-preview__image"
alt="link-preview"
width={`${width}px`}
height={`${height}px`}
style:fit
on:error={() => {
refreshPreviewSrc()
}}
/>
{/if}
{/if}
<style lang="scss">
.link-preview__image {
margin-top: 0.5rem;
border-radius: 0.375rem;
max-width: 24.5rem;
max-height: 15rem;
}
</style>

View File

@ -13,33 +13,29 @@
// limitations under the License.
// -->
<script lang="ts">
import { getJsonOrEmpty, getClient, type LinkPreviewDetails } from '@hcengineering/presentation'
import {
getJsonOrEmpty,
getClient,
type LinkPreviewDetails,
LinkPreviewAttachmentMetadata
} from '@hcengineering/presentation'
import { type Attachment } from '@hcengineering/attachment'
import { type WithLookup } from '@hcengineering/core'
import { Spinner } from '@hcengineering/ui'
import WebIcon from './icons/Web.svelte'
import { onMount } from 'svelte'
import TrashIcon from './icons/Trash.svelte'
import { getImageDimensions } from '../utils'
import LinkPreviewIcon from './LinkPreviewIcon.svelte'
import LinkPreviewImage from './LinkPreviewImage.svelte'
export let attachment: WithLookup<Attachment>
export let isOwn = false
let useDefaultIcon = false
let retryCount = 0
let viewModel: LinkPreviewDetails | undefined
let previewImageSrc: string | undefined
function refreshPreviewImage (): void {
if (viewModel?.image === undefined) {
return
}
if (retryCount > 3) {
previewImageSrc = undefined
return
}
retryCount++
previewImageSrc = `${viewModel.image}#${Date.now()}`
}
let metadata: LinkPreviewAttachmentMetadata | undefined
$: metadata = attachment.metadata
const client = getClient()
async function onDelete (): Promise<void> {
@ -52,129 +48,129 @@
'attachments'
)
}
onMount(() => {
void getJsonOrEmpty<LinkPreviewDetails>(attachment.file, attachment.name)
.then((res) => {
viewModel = res
refreshPreviewImage()
})
.catch((err) => {
viewModel = undefined
console.error(err)
})
})
$: description = viewModel?.description ?? metadata?.description
$: title = viewModel?.title ?? metadata?.title
$: icon = viewModel?.icon
$: image = viewModel?.image ?? metadata?.image
$: host = viewModel?.host
$: hostname = viewModel?.hostname
$: url = viewModel?.url
$: imageDimensions =
metadata?.imageWidth && metadata.imageHeight
? getImageDimensions(
{ width: metadata.imageWidth, height: metadata.imageHeight },
{
maxWidth: 24.5,
minWidth: 4,
maxHeight: 15,
minHeight: 4
}
)
: undefined
</script>
<div class="content">
{#if viewModel}
<div class="title">
{#if viewModel.icon !== undefined && !useDefaultIcon}
<img
src={viewModel.icon}
class="preview-icon"
alt="link-preview-icon"
on:error={() => {
useDefaultIcon = true
}}
/>
{:else}
<WebIcon size="small" />
{/if}
<b><a class="link" target="_blank" href={viewModel.host}>{viewModel.hostname}</a></b>
{#if isOwn}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="delete-button"
tabindex="0"
role="button"
on:click={() => {
void onDelete()
}}><TrashIcon size="small" /></span
>
{/if}
</div>
<div class="description">
{#if viewModel.title?.toLowerCase() !== viewModel.hostname?.toLowerCase()}
<div>
<b><a class="link" target="_blank" href={viewModel.url}>{viewModel.title}</a></b>
</div>
{/if}
{#if viewModel.description}
<div>
{viewModel.description}
</div>
{/if}
</div>
{#if previewImageSrc}
<div>
<a target="_blank" href={viewModel.url}>
<img
src={previewImageSrc}
class="round-image"
alt="link-preview"
on:error={() => {
refreshPreviewImage()
}}
/>
</a>
<div class="link-preview">
<div class="link-preview__header">
<LinkPreviewIcon src={icon} />
{#if host}
<b class="overflow-label"><a class="link" target="_blank" href={host}>{hostname}</a></b>
{/if}
{#if isOwn}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="link-preview__delete-button" tabindex="0" role="button" on:click={onDelete}>
<TrashIcon size="small" />
</div>
{/if}
{:else}
<div class="centered">
<Spinner size="medium" />
</div>
</div>
<div class="link-preview__body">
{#if title && title.toLowerCase() !== (hostname ?? '').toLowerCase()}
{#if url}
<b><a class="link" target="_blank" href={url}>{title}</a></b>
{:else}
<b>{title}</b>
{/if}
{/if}
{#if description && description !== ''}
<span class="link-preview__description lines-limit-4">
{description}
</span>
{/if}
</div>
{#if image}
<LinkPreviewImage
{url}
src={image}
width={imageDimensions?.width ?? 300}
height={imageDimensions?.height ?? 170}
fit={imageDimensions?.fit ?? 'contain'}
/>
{/if}
</div>
<style lang="scss">
.delete-button {
margin-left: auto;
cursor: pointer;
}
.delete-button:not(:hover) {
color: var(--theme-link-preview-description-color);
}
.round-image {
margin-top: 0.5rem;
border-radius: 0.375rem;
max-width: 24.5rem;
max-height: 15rem;
}
.preview-icon {
width: 16px;
height: 16px;
}
.link {
color: var(--theme-link-preview-text-color);
}
.title {
gap: 0.375rem;
.link-preview {
display: flex;
flex-direction: row;
align-items: center;
}
.description {
color: var(--theme-link-preview-description-color);
overflow: hidden;
}
.content span {
display: none;
}
.content:hover span {
display: block;
}
.content {
flex-direction: column;
display: flex;
gap: 0.25rem;
line-height: 150%;
gap: 0.188rem;
padding: 0.75rem;
background-color: var(--theme-link-preview-bg-color);
border-radius: 0.75rem;
scroll-snap-align: start;
max-width: 26rem;
max-height: 28rem;
font-family: var(--font-family);
min-width: 16rem;
&:hover {
.link-preview__delete-button {
visibility: visible;
}
}
}
.link-preview__header {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.375rem;
height: 1.375rem;
}
.link-preview__delete-button {
margin-left: auto;
cursor: pointer;
visibility: hidden;
&:not(:hover) {
color: var(--theme-link-preview-description-color);
}
}
.link-preview__body {
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow: hidden;
}
.link-preview__description {
color: var(--theme-link-preview-description-color);
overflow: hidden;
}
.link {
color: var(--theme-link-preview-text-color);
}
</style>

View File

@ -213,3 +213,38 @@ export function showAttachmentPreviewPopup (value: WithLookup<Attachment> | Blob
getPreviewAlignment(value.type ?? '')
)
}
interface ImageDimensions {
width: number
height: number
fit: 'cover' | 'contain'
}
export function getImageDimensions (
size: { width: number, height: number },
maxRem: { maxWidth: number, minWidth: number, maxHeight: number, minHeight: number }
): ImageDimensions {
const originalWidth = size.width
const originalHeight = size.height
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
const maxWidthPx = maxRem.maxWidth * fontSize
const minWidthPx = maxRem.minWidth * fontSize
const maxHeightPx = maxRem.maxHeight * fontSize
const minHeightPx = maxRem.minHeight * fontSize
const ratio = originalHeight / originalWidth
let width = Math.min(originalWidth, maxWidthPx)
let height = Math.ceil(width * ratio)
const fit = width < minWidthPx || height < minHeightPx ? 'cover' : 'contain'
if (height > maxHeightPx) {
width = maxHeightPx / ratio
height = maxHeightPx
} else if (height < minHeightPx) {
height = minHeightPx
}
return { width: Math.round(width), height: Math.round(height), fit }
}