diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index c65515cd402..374e91ff78d 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -20609,12 +20609,13 @@ packages: dev: false file:projects/presentation.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2): - resolution: {integrity: sha512-aIs1NWqMRjF8+bpEPUJW3nkQFkxnv0GvllOQk4DWbDdfxuAlGkvBKiKaDwlx3cku9hulZqpwr6EyIbkotn1Rsw==, tarball: file:projects/presentation.tgz} + resolution: {integrity: sha512-afwb+Kuc6Gu/8xgzxYSMNmMewCvsAX46bAkIMVDBO71momZ1zBKAhLM1kFXIp1UnaOKH2vnq3myx4ZpOrjBAZw==, tarball: file:projects/presentation.tgz} id: file:projects/presentation.tgz name: '@rush-temp/presentation' version: 0.0.0 dependencies: '@types/jest': 29.5.12 + '@types/png-chunks-extract': 1.0.2 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) eslint: 8.56.0 @@ -20625,6 +20626,7 @@ packages: eslint-plugin-svelte: 2.35.1(eslint@8.56.0)(svelte@4.2.11)(ts-node@10.9.2) fast-equals: 2.0.4 jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + png-chunks-extract: 1.0.0 prettier: 3.2.5 prettier-plugin-svelte: 3.2.1(prettier@3.2.5)(svelte@4.2.11) sass: 1.71.1 @@ -23328,7 +23330,7 @@ packages: dev: false file:projects/text-editor.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(prosemirror-model@1.19.4)(ts-node@10.9.2): - resolution: {integrity: sha512-/TK8U02uYlCsPKiYVNS7t+UMmDxEPWLwY05soaV+Yuu3nXRGt2m2CkEFnv8I5IAqsEzgogKhqFXAgcQHcjfG+A==, tarball: file:projects/text-editor.tgz} + resolution: {integrity: sha512-obPE9MHV6S63InShtdrvDbNa5/A9BgF49k6zgO3T4C+niVTKsmsKz4/Pt+aew2X5eAAxehSq69bWIjBZidydNA==, tarball: file:projects/text-editor.tgz} id: file:projects/text-editor.tgz name: '@rush-temp/text-editor' version: 0.0.0 diff --git a/models/attachment/src/index.ts b/models/attachment/src/index.ts index 72a1f6e430f..66807a0b57b 100644 --- a/models/attachment/src/index.ts +++ b/models/attachment/src/index.ts @@ -14,7 +14,7 @@ // import activity from '@hcengineering/activity' -import type { Attachment, Photo, SavedAttachments } from '@hcengineering/attachment' +import type { Attachment, AttachmentMetadata, Photo, SavedAttachments } from '@hcengineering/attachment' import { type Domain, IndexKind, type Ref } from '@hcengineering/core' import { type Builder, @@ -64,6 +64,8 @@ export class TAttachment extends TAttachedDoc implements Attachment { @Prop(TypeBoolean(), attachment.string.Pinned) pinned!: boolean + + metadata?: AttachmentMetadata } @Model(attachment.class.Photo, attachment.class.Attachment) diff --git a/packages/presentation/package.json b/packages/presentation/package.json index 2800e9e96a7..79f7a7e40d9 100644 --- a/packages/presentation/package.json +++ b/packages/presentation/package.json @@ -35,7 +35,8 @@ "jest": "^29.7.0", "ts-jest": "^29.1.1", "@types/jest": "^29.5.5", - "svelte-eslint-parser": "^0.33.1" + "svelte-eslint-parser": "^0.33.1", + "@types/png-chunks-extract": "^1.0.2" }, "dependencies": { "@hcengineering/platform": "^0.6.9", @@ -46,6 +47,7 @@ "svelte": "^4.2.5", "@hcengineering/client": "^0.6.14", "@hcengineering/collaborator-client": "^0.6.0", - "fast-equals": "^2.0.3" + "fast-equals": "^2.0.3", + "png-chunks-extract": "^1.0.0" } } diff --git a/packages/presentation/src/image.ts b/packages/presentation/src/image.ts new file mode 100644 index 00000000000..8b6aa18031f --- /dev/null +++ b/packages/presentation/src/image.ts @@ -0,0 +1,101 @@ +// +// Copyright © 2024 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. +// + +import extract from 'png-chunks-extract' + +export async function getImageSize ( + file: File, + src: string +): Promise<{ width: number, height: number, pixelRatio: number }> { + const size = isPng(file) ? await getPngImageSize(file) : undefined + + const promise = new Promise<{ width: number, height: number, pixelRatio: number }>((resolve, reject) => { + const img = new Image() + + img.onload = () => { + resolve({ + width: size?.width ?? img.naturalWidth, + height: size?.height ?? img.naturalHeight, + pixelRatio: size?.pixelRatio ?? 1 + }) + } + + img.onerror = reject + img.src = src + }) + + return await promise +} + +function isPng (file: File): boolean { + return file.type === 'image/png' +} + +async function getPngImageSize (file: File): Promise<{ width: number, height: number, pixelRatio: number } | undefined> { + if (!isPng(file)) { + return undefined + } + + try { + const buffer = await file.arrayBuffer() + const chunks = extract(new Uint8Array(buffer)) + + const pHYsChunk = chunks.find((chunk) => chunk.name === 'pHYs') + const iHDRChunk = chunks.find((chunk) => chunk.name === 'IHDR') + + if (pHYsChunk === undefined || iHDRChunk === undefined) { + return undefined + } + + // See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html + // Section 4.1.1. IHDR Image header + // Section 4.2.4.2. pHYs Physical pixel dimensions + const idhrData = parseIHDR(new DataView(iHDRChunk.data.buffer)) + const physData = parsePhys(new DataView(pHYsChunk.data.buffer)) + + if (physData.unit === 0 && physData.ppux === physData.ppuy) { + const pixelRatio = Math.round(physData.ppux / 2834.5) + return { + width: idhrData.width, + height: idhrData.height, + pixelRatio + } + } + } catch (err) { + console.error(err) + return undefined + } + + return undefined +} + +// See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html +// Section 4.1.1. IHDR Image header +function parseIHDR (view: DataView): { width: number, height: number } { + return { + width: view.getUint32(0), + height: view.getUint32(4) + } +} + +// See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html +// Section 4.2.4.2. pHYs Physical pixel dimensions +function parsePhys (view: DataView): { ppux: number, ppuy: number, unit: number } { + return { + ppux: view.getUint32(0), + ppuy: view.getUint32(4), + unit: view.getUint8(4) + } +} diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index 101944593fb..3e940ecaa2b 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -56,3 +56,4 @@ export * from './pipeline' export * from './components/extensions/manager' export * from './rules' export * from './search' +export * from './image' diff --git a/packages/text-editor/package.json b/packages/text-editor/package.json index 1aa72eeac00..8665ceadbab 100644 --- a/packages/text-editor/package.json +++ b/packages/text-editor/package.json @@ -34,8 +34,7 @@ "jest": "^29.7.0", "ts-jest": "^29.1.1", "@types/jest": "^29.5.5", - "svelte-eslint-parser": "^0.33.1", - "@types/png-chunks-extract": "^1.0.2" + "svelte-eslint-parser": "^0.33.1" }, "dependencies": { "@hcengineering/presentation": "^0.6.2", @@ -80,7 +79,6 @@ "rfc6902": "^5.0.1", "diff": "^5.1.0", "slugify": "^1.6.6", - "lib0": "^0.2.88", - "png-chunks-extract": "^1.0.0" + "lib0": "^0.2.88" } } diff --git a/packages/text-editor/src/components/extension/imageExt.ts b/packages/text-editor/src/components/extension/imageExt.ts index 0fd97328e4f..fb2f35c5fba 100644 --- a/packages/text-editor/src/components/extension/imageExt.ts +++ b/packages/text-editor/src/components/extension/imageExt.ts @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { PDFViewer } from '@hcengineering/presentation' +import { PDFViewer, getImageSize } from '@hcengineering/presentation' import { ImageNode, type ImageOptions as ImageNodeOptions } from '@hcengineering/text' import { type IconSize, getIconSize2x, showPopup } from '@hcengineering/ui' import { mergeAttributes, nodeInputRule } from '@tiptap/core' import { type Node as ProseMirrorNode } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' import { type EditorView } from '@tiptap/pm/view' -import extract from 'png-chunks-extract' +import { setPlatformStatus, unknownError } from '@hcengineering/platform' /** * @public @@ -318,76 +318,27 @@ async function handleImageUpload ( attachFile: FileAttachFunction, uploadUrl: string ): Promise { - const size = await getImageSize(file) const attached = await attachFile(file) - if (attached !== undefined) { - if (attached.type.includes('image')) { - const image = new Image() - image.onload = () => { - const node = view.state.schema.nodes.image.create({ - 'file-id': attached.file, - width: size?.width ?? image.naturalWidth - }) - const transaction = view.state.tr.insert(pos?.pos ?? 0, node) - view.dispatch(transaction) - } - image.src = getFileUrl(attached.file, 'full', uploadUrl) - } - } -} -async function getImageSize (file: File): Promise<{ width: number, height: number } | undefined> { - if (file.type !== 'image/png') { - return undefined + if (attached === undefined) { + return } - try { - const buffer = await file.arrayBuffer() - const chunks = extract(new Uint8Array(buffer)) - - const pHYsChunk = chunks.find((chunk) => chunk.name === 'pHYs') - const iHDRChunk = chunks.find((chunk) => chunk.name === 'IHDR') - - if (pHYsChunk === undefined || iHDRChunk === undefined) { - return undefined - } - - // See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html - // Section 4.1.1. IHDR Image header - // Section 4.2.4.2. pHYs Physical pixel dimensions - const idhrData = parseIHDR(new DataView(iHDRChunk.data.buffer)) - const physData = parsePhys(new DataView(pHYsChunk.data.buffer)) - - if (physData.unit === 0 && physData.ppux === physData.ppuy) { - const pixelRatio = Math.round(physData.ppux / 2834.5) - return { - width: Math.round(idhrData.width / pixelRatio), - height: Math.round(idhrData.height / pixelRatio) - } - } - } catch (err) { - console.error(err) - return undefined + if (!attached.type.includes('image')) { + return } - return undefined -} + try { + const size = await getImageSize(file, getFileUrl(attached.file, 'full', uploadUrl)) + const node = view.state.schema.nodes.image.create({ + 'file-id': attached.file, + width: Math.round(size.width / size.pixelRatio) + }) -// See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html -// Section 4.1.1. IHDR Image header -function parseIHDR (view: DataView): { width: number, height: number } { - return { - width: view.getUint32(0), - height: view.getUint32(4) - } -} + const transaction = view.state.tr.insert(pos?.pos ?? 0, node) -// See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html -// Section 4.2.4.2. pHYs Physical pixel dimensions -function parsePhys (view: DataView): { ppux: number, ppuy: number, unit: number } { - return { - ppux: view.getUint32(0), - ppuy: view.getUint32(4), - unit: view.getUint8(4) + view.dispatch(transaction) + } catch (e) { + void setPlatformStatus(unknownError(e)) } } diff --git a/plugins/activity-resources/src/components/activity-message/ActivityMessagePresenter.svelte b/plugins/activity-resources/src/components/activity-message/ActivityMessagePresenter.svelte index 837b147bc9a..3dbd6842a93 100644 --- a/plugins/activity-resources/src/components/activity-message/ActivityMessagePresenter.svelte +++ b/plugins/activity-resources/src/components/activity-message/ActivityMessagePresenter.svelte @@ -35,6 +35,7 @@ export let hoverable = true export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover' export let withShowMore: boolean = true + export let attachmentImageSize: 'x-large' | undefined = undefined export let showLinksPreview = true export let onClick: (() => void) | undefined = undefined export let onReply: (() => void) | undefined = undefined @@ -65,6 +66,7 @@ hoverable, hoverStyles, withShowMore, + attachmentImageSize, showLinksPreview, onClick, onReply diff --git a/plugins/attachment-resources/src/components/AttachmentDocList.svelte b/plugins/attachment-resources/src/components/AttachmentDocList.svelte index 56310a8b60a..099c7e5b268 100644 --- a/plugins/attachment-resources/src/components/AttachmentDocList.svelte +++ b/plugins/attachment-resources/src/components/AttachmentDocList.svelte @@ -19,9 +19,11 @@ import attachment from '../plugin' import AttachmentList from './AttachmentList.svelte' + import { AttachmentImageSize } from '../types' export let value: Doc & { attachments?: number } export let attachments: Attachment[] | undefined = undefined + export let imageSize: AttachmentImageSize = 'auto' const query = createQuery() const savedAttachmentsQuery = createQuery() @@ -57,4 +59,4 @@ }) - + diff --git a/plugins/attachment-resources/src/components/AttachmentImagePreview.svelte b/plugins/attachment-resources/src/components/AttachmentImagePreview.svelte new file mode 100644 index 00000000000..27631f85745 --- /dev/null +++ b/plugins/attachment-resources/src/components/AttachmentImagePreview.svelte @@ -0,0 +1,128 @@ + + + + + +{value.name} + + diff --git a/plugins/attachment-resources/src/components/AttachmentList.svelte b/plugins/attachment-resources/src/components/AttachmentList.svelte index 073ef6106ad..12a56534114 100644 --- a/plugins/attachment-resources/src/components/AttachmentList.svelte +++ b/plugins/attachment-resources/src/components/AttachmentList.svelte @@ -16,16 +16,23 @@ import { Attachment } from '@hcengineering/attachment' import { Ref } from '@hcengineering/core' import { Scroller } from '@hcengineering/ui' + import AttachmentPreview from './AttachmentPreview.svelte' + import { AttachmentImageSize } from '../types' export let attachments: Attachment[] = [] export let savedAttachmentsIds: Ref[] = [] + export let imageSize: AttachmentImageSize | undefined = undefined {#if attachments.length} {#each attachments as attachment} - + {/each} {/if} diff --git a/plugins/attachment-resources/src/components/AttachmentPopup.svelte b/plugins/attachment-resources/src/components/AttachmentPopup.svelte index d637654fe1b..a83e8aacf52 100644 --- a/plugins/attachment-resources/src/components/AttachmentPopup.svelte +++ b/plugins/attachment-resources/src/components/AttachmentPopup.svelte @@ -18,9 +18,11 @@ import { Attachment } from '@hcengineering/attachment' import { createQuery, getClient } from '@hcengineering/presentation' import { ActionIcon, IconAdd, Label, Loading } from '@hcengineering/ui' + import { setPlatformStatus, unknownError } from '@hcengineering/platform' + import { AttachmentPresenter } from '..' import attachment from '../plugin' - import { uploadFile } from '../utils' + import { getAttachmentMetadata, uploadFile } from '../utils' // export let attachments: number export let object: Doc @@ -51,14 +53,21 @@ } async function createAttachment (file: File) { - const uuid = await uploadFile(file) - await client.addCollection(attachment.class.Attachment, object.space, object._id, object._class, 'attachments', { - name: file.name, - file: uuid, - type: file.type, - size: file.size, - lastModified: file.lastModified - }) + try { + const uuid = await uploadFile(file) + const metadata = await getAttachmentMetadata(file, uuid) + + await client.addCollection(attachment.class.Attachment, object.space, object._id, object._class, 'attachments', { + name: file.name, + file: uuid, + type: file.type, + size: file.size, + lastModified: file.lastModified, + metadata + }) + } catch (e) { + void setPlatformStatus(unknownError(e)) + } } async function fileSelected (): Promise { diff --git a/plugins/attachment-resources/src/components/AttachmentPreview.svelte b/plugins/attachment-resources/src/components/AttachmentPreview.svelte index ca20c47dcac..25a4344c3d1 100644 --- a/plugins/attachment-resources/src/components/AttachmentPreview.svelte +++ b/plugins/attachment-resources/src/components/AttachmentPreview.svelte @@ -15,19 +15,25 @@ --> + + + + diff --git a/plugins/attachment-resources/src/index.ts b/plugins/attachment-resources/src/index.ts index 575e478880d..bd0e896e173 100644 --- a/plugins/attachment-resources/src/index.ts +++ b/plugins/attachment-resources/src/index.ts @@ -43,6 +43,8 @@ import AttachmentPreview from './components/AttachmentPreview.svelte' import AttachmentsUpdatedMessage from './components/activity/AttachmentsUpdatedMessage.svelte' import { deleteFile, uploadFile } from './utils' +export * from './types' + export { AddAttachment, AttachmentDroppable, diff --git a/plugins/attachment-resources/src/types.ts b/plugins/attachment-resources/src/types.ts new file mode 100644 index 00000000000..542b8a10f80 --- /dev/null +++ b/plugins/attachment-resources/src/types.ts @@ -0,0 +1 @@ +export type AttachmentImageSize = 'x-large' | 'auto' diff --git a/plugins/attachment-resources/src/utils.ts b/plugins/attachment-resources/src/utils.ts index 7ba771a418c..ca2cab4f23d 100644 --- a/plugins/attachment-resources/src/utils.ts +++ b/plugins/attachment-resources/src/utils.ts @@ -14,7 +14,7 @@ // limitations under the License. // -import { type Attachment } from '@hcengineering/attachment' +import { type Attachment, type AttachmentMetadata } from '@hcengineering/attachment' import { type Class, concatLink, @@ -24,7 +24,7 @@ import { type Space, type TxOperations as Client } from '@hcengineering/core' -import presentation from '@hcengineering/presentation' +import presentation, { getFileUrl, getImageSize } from '@hcengineering/presentation' import { PlatformError, Severity, Status, getMetadata, setPlatformStatus, unknownError } from '@hcengineering/platform' import attachment from './plugin' @@ -87,13 +87,16 @@ export async function createAttachments ( const file = list.item(index) if (file !== null) { const uuid = await uploadFile(file) + const metadata = await getAttachmentMetadata(file, uuid) + await client.addCollection(attachmentClass, space, objectId, objectClass, 'attachments', { ...extraData, name: file.name, file: uuid, type: file.type, size: file.size, - lastModified: file.lastModified + lastModified: file.lastModified, + metadata }) } } @@ -118,3 +121,50 @@ export function getType (type: string): 'image' | 'video' | 'audio' | 'pdf' | 'o return 'other' } + +export async function getAttachmentMetadata (file: File, uuid: string): Promise { + const type = getType(file.type) + + if (type === 'video') { + const size = await getVideoSize(uuid) + + if (size === undefined) { + return undefined + } + + return { + originalHeight: size.height, + originalWidth: size.width + } + } + + if (type === 'image') { + const size = await getImageSize(file, getFileUrl(uuid, 'full')) + + return { + originalHeight: size.height, + originalWidth: size.width, + pixelRatio: size.pixelRatio + } + } + + return undefined +} + +async function getVideoSize (uuid: string): Promise<{ width: number, height: number } | undefined> { + const promise = new Promise<{ width: number, height: number }>((resolve, reject) => { + const element = document.createElement('video') + + element.onloadedmetadata = () => { + const height = element.videoHeight + const width = element.videoWidth + + resolve({ height, width }) + } + + element.onerror = reject + element.src = getFileUrl(uuid, 'full') + }) + + return await promise +} diff --git a/plugins/attachment/src/index.ts b/plugins/attachment/src/index.ts index 857eb6ffb5b..468993295cc 100644 --- a/plugins/attachment/src/index.ts +++ b/plugins/attachment/src/index.ts @@ -33,6 +33,30 @@ export interface Attachment extends AttachedDoc { pinned?: boolean // If defined and true, will be shown in top of attachments collection readonly?: boolean // If readonly, user will not be able to remove or modify this attachment + + metadata?: AttachmentMetadata +} + +/** + * @public + */ +export type AttachmentMetadata = ImageMetadata | VideoMetadata + +/** + * @public + */ +export interface ImageMetadata { + originalWidth: number + originalHeight: number + pixelRatio: number +} + +/** + * @public + */ +export interface VideoMetadata { + originalWidth: number + originalHeight: number } /** diff --git a/plugins/chunter-resources/src/components/ChannelScrollView.svelte b/plugins/chunter-resources/src/components/ChannelScrollView.svelte index 896fc66fe84..d3ac2ab699a 100644 --- a/plugins/chunter-resources/src/components/ChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ChannelScrollView.svelte @@ -512,6 +512,7 @@ isHighlighted={isSelected} shouldScroll={isSelected} withShowMore={false} + attachmentImageSize="x-large" showLinksPreview={false} /> diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte index e7fef0b7407..04b64521fb1 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte @@ -15,10 +15,9 @@