mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-07 00:12:50 +00:00
Fix link preview size (#8294)
This commit is contained in:
parent
def2a411c5
commit
f0dd4403e0
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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))
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
@ -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 }
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user