From b8e2d28a20f9b7327315cf98dc344ed807e4450b Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Thu, 25 Jan 2024 16:17:26 +0700 Subject: [PATCH] UBERF-5021 Parse image size when dropping to editor (#4443) Signed-off-by: Alexander Onnikov --- common/config/rush/pnpm-lock.yaml | 25 +++- packages/text-editor/package.json | 6 +- .../src/components/ImageStyleToolbar.svelte | 3 +- .../src/components/extension/imageExt.ts | 108 +++++++++++++++--- packages/theme/styles/_text-editor.scss | 5 +- 5 files changed, 128 insertions(+), 19 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index eb710e84ef..fce483a518 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -821,6 +821,9 @@ dependencies: '@types/pdfkit': specifier: ~0.12.3 version: 0.12.12 + '@types/png-chunks-extract': + specifier: ^1.0.2 + version: 1.0.2 '@types/qs': specifier: ~6.9.7 version: 6.9.8 @@ -1037,6 +1040,9 @@ dependencies: pdfkit: specifier: ~0.13.0 version: 0.13.0 + png-chunks-extract: + specifier: ^1.0.0 + version: 1.0.0 postcss: specifier: ^8.4.20 version: 8.4.31 @@ -7295,6 +7301,10 @@ packages: '@types/node': 16.11.68 dev: false + /@types/png-chunks-extract@1.0.2: + resolution: {integrity: sha512-z6djfFIbrrddtunoMJBOPlyZrnmeuG1kkvHUNi2QfpOb+JMMLuLliHHTmMyRi7k7LiTAut0HbdGCF6ibDtQAHQ==} + dev: false + /@types/pretty-hrtime@1.0.1: resolution: {integrity: sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==} dev: false @@ -9070,6 +9080,11 @@ packages: typescript: 5.2.2 dev: false + /crc-32@0.3.0: + resolution: {integrity: sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==} + engines: {node: '>=0.8'} + dev: false + /create-jest@29.7.0(@types/node@16.11.68)(ts-node@10.9.1): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -14053,6 +14068,12 @@ packages: fsevents: 2.3.2 dev: false + /png-chunks-extract@1.0.0: + resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==} + dependencies: + crc-32: 0.3.0 + dev: false + /png-js@1.0.0: resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} dev: false @@ -23684,7 +23705,7 @@ packages: dev: false file:projects/text-editor.tgz(@types/node@16.11.68)(bufferutil@4.0.7)(esbuild@0.16.17)(postcss-load-config@4.0.1)(postcss@8.4.31)(prosemirror-model@1.19.3)(ts-node@10.9.1): - resolution: {integrity: sha512-q4IthAiGXw8l3HkEitwxvXppLyB/mszif5wHdy0jcaz7Yc0moJOzS6ElJ3AgA/0IQHxKB7ladDAkU/wklo7icw==, tarball: file:projects/text-editor.tgz} + resolution: {integrity: sha512-iPBMTKEApdZq+f8GfFNZHYKFBWwfm0z5+MVzB4Tm3FU+kjd0dl4h11aaz0s6mtOHeEQetgrKE+82yGBStsfmbQ==, tarball: file:projects/text-editor.tgz} id: file:projects/text-editor.tgz name: '@rush-temp/text-editor' version: 0.0.0 @@ -23717,6 +23738,7 @@ packages: '@tiptap/suggestion': 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@2.1.12) '@types/diff': 5.0.5 '@types/jest': 29.5.5 + '@types/png-chunks-extract': 1.0.2 '@typescript-eslint/eslint-plugin': 6.11.0(@typescript-eslint/parser@6.11.0)(eslint@8.54.0)(typescript@5.2.2) '@typescript-eslint/parser': 6.11.0(eslint@8.54.0)(typescript@5.2.2) diff: 5.1.0 @@ -23728,6 +23750,7 @@ packages: eslint-plugin-svelte: 2.35.0(eslint@8.54.0)(svelte@4.2.5)(ts-node@10.9.1) jest: 29.7.0(@types/node@16.11.68)(ts-node@10.9.1) lib0: 0.2.88 + png-chunks-extract: 1.0.0 prettier: 3.1.0 prettier-plugin-svelte: 3.1.0(prettier@3.1.0)(svelte@4.2.5) prosemirror-codemark: 0.4.2(prosemirror-model@1.19.3) diff --git a/packages/text-editor/package.json b/packages/text-editor/package.json index 4eca9395ed..b270e68638 100644 --- a/packages/text-editor/package.json +++ b/packages/text-editor/package.json @@ -33,7 +33,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/presentation": "^0.6.2", @@ -77,6 +78,7 @@ "rfc6902": "^5.0.1", "diff": "^5.1.0", "slugify": "^1.6.6", - "lib0": "^0.2.88" + "lib0": "^0.2.88", + "png-chunks-extract": "^1.0.0" } } diff --git a/packages/text-editor/src/components/ImageStyleToolbar.svelte b/packages/text-editor/src/components/ImageStyleToolbar.svelte index 15f91147fa..06bb86ac0d 100644 --- a/packages/text-editor/src/components/ImageStyleToolbar.svelte +++ b/packages/text-editor/src/components/ImageStyleToolbar.svelte @@ -63,7 +63,8 @@ return { id: `#imageWidth${it}`, label: it === plugin.string.Unset ? it : getEmbeddedLabel(it), - action: () => textEditor.commands.setImageSize({ width: it === plugin.string.Unset ? undefined : it }), + action: () => + textEditor.commands.setImageSize({ width: it === plugin.string.Unset ? undefined : it, height: undefined }), category: { label: plugin.string.Width } diff --git a/packages/text-editor/src/components/extension/imageExt.ts b/packages/text-editor/src/components/extension/imageExt.ts index 0871d827f8..0fd97328e4 100644 --- a/packages/text-editor/src/components/extension/imageExt.ts +++ b/packages/text-editor/src/components/extension/imageExt.ts @@ -19,6 +19,7 @@ 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' /** * @public @@ -205,7 +206,9 @@ export const ImageExtension = ImageNode.extend({ }, addProseMirrorPlugins () { - const opt = this.options + const attachFile = this.options.attachFile + const uploadUrl = this.options.uploadUrl + function handleDrop ( view: EditorView, pos: { pos: number, inside: number } | null, @@ -216,7 +219,7 @@ export const ImageExtension = ImageNode.extend({ for (const uri of uris) { if (uri !== '') { const url = new URL(uri) - if (opt.uploadUrl === undefined || !url.href.includes(opt.uploadUrl)) { + if (uploadUrl === undefined || !url.href.includes(uploadUrl)) { continue } @@ -242,25 +245,18 @@ export const ImageExtension = ImageNode.extend({ } const files = dataTransfer?.files - if (files !== undefined && opt.attachFile !== undefined) { + if (files !== undefined && attachFile !== undefined) { for (let i = 0; i < files.length; i++) { const file = files.item(i) if (file != null) { result = true - void opt.attachFile(file).then((id) => { - if (id !== undefined) { - if (id.type.includes('image')) { - const node = view.state.schema.nodes.image.create({ 'file-id': id.file }) - const transaction = view.state.tr.insert(pos?.pos ?? 0, node) - view.dispatch(transaction) - } - } - }) + void handleImageUpload(file, view, pos, attachFile, uploadUrl) } } } return result } + return [ new Plugin({ key: new PluginKey('handle-image-paste'), @@ -283,7 +279,12 @@ export const ImageExtension = ImageNode.extend({ if (dataTransfer !== null) { return handleDrop(view, view.posAtCoords({ left: event.x, top: event.y }), dataTransfer) } - }, + } + } + }), + new Plugin({ + key: new PluginKey('handle-image-open'), + props: { handleDoubleClickOn (view, pos, node, nodePos, event) { if (node.type.name !== 'image') { return @@ -309,3 +310,84 @@ export const ImageExtension = ImageNode.extend({ ] } }) + +async function handleImageUpload ( + file: File, + view: EditorView, + pos: { pos: number, inside: number } | null, + 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 + } + + 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 + } + + 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/theme/styles/_text-editor.scss b/packages/theme/styles/_text-editor.scss index 8d1bfb67b4..f28fd193ab 100644 --- a/packages/theme/styles/_text-editor.scss +++ b/packages/theme/styles/_text-editor.scss @@ -223,8 +223,9 @@ .ProseMirror-selectednode { img { - box-shadow: 0 0 0 2px var(--text-editor-selected-node-color); - border-radius: 0.125rem; + outline: 2px solid var(--primary-button-outline); + outline-offset: 2px; + border-radius: 2px; } }