Embedding images in notebook entries (#7048)

* initial drag drop, wip

* images work as snapshots, but need to disable navigate to actions

* embed image name

* works now with images, need to be refactor so can duplicate code for entries too

* works dropping on entries too

* handle remote images too

* add e2e test

* spelling

* address most PR comments
This commit is contained in:
Scott Bell
2023-09-18 19:56:49 +02:00
committed by GitHub
parent c7b5ecbd68
commit 541a022f36
8 changed files with 229 additions and 89 deletions

View File

@ -24,9 +24,11 @@
This test suite is dedicated to tests which verify the basic operations surrounding Notebooks. This test suite is dedicated to tests which verify the basic operations surrounding Notebooks.
*/ */
const fs = require('fs').promises;
const { test, expect } = require('../../../../pluginFixtures'); const { test, expect } = require('../../../../pluginFixtures');
// const { expandTreePaneItemByName, createDomainObjectWithDefaults } = require('../../../../appActions'); const { createDomainObjectWithDefaults } = require('../../../../appActions');
// const nbUtils = require('../../../../helper/notebookUtils');
const NOTEBOOK_NAME = 'Notebook';
test.describe('Snapshot Menu tests', () => { test.describe('Snapshot Menu tests', () => {
test.fixme( test.fixme(
@ -161,3 +163,57 @@ test.describe('Snapshot Container tests', () => {
} }
); );
}); });
test.describe('Snapshot image tests', () => {
test.beforeEach(async ({ page }) => {
//Navigate to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Notebook
await createDomainObjectWithDefaults(page, {
type: NOTEBOOK_NAME
});
});
test('Can drop an image onto a notebook and create a new entry', async ({ page }) => {
const imageData = await fs.readFile('src/images/favicons/favicon-96x96.png');
const imageArray = new Uint8Array(imageData);
const fileData = Array.from(imageArray);
const dropTransfer = await page.evaluateHandle((data) => {
const dataTransfer = new DataTransfer();
const file = new File([new Uint8Array(data)], 'favicon-96x96.png', { type: 'image/png' });
dataTransfer.items.add(file);
return dataTransfer;
}, fileData);
await page.dispatchEvent('.c-notebook__drag-area', 'drop', { dataTransfer: dropTransfer });
// be sure that entry was created
await expect(page.getByText('favicon-96x96.png')).toBeVisible();
// click on image (need to click twice to focus)
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
// expect large image to be displayed
await expect(page.getByRole('dialog').getByText('favicon-96x96.png')).toBeVisible();
await page.getByLabel('Close').click();
// drop another image onto the entry
await page.dispatchEvent('.c-snapshots', 'drop', { dataTransfer: dropTransfer });
// expect two embedded images now
expect(await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).count()).toBe(2);
await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More options').click();
await page.getByRole('menuitem', { name: /Remove This Embed/ }).click();
await page.getByRole('button', { name: 'Ok', exact: true }).click();
// expect one embedded image now as we deleted the other
expect(await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).count()).toBe(1);
});
});

View File

@ -165,6 +165,7 @@ import { isNotebookViewType, RESTRICTED_NOTEBOOK_TYPE } from '../notebook-consta
import { import {
addNotebookEntry, addNotebookEntry,
createNewEmbed, createNewEmbed,
createNewImageEmbed,
getEntryPosById, getEntryPosById,
getNotebookEntries, getNotebookEntries,
mutateObject, mutateObject,
@ -615,12 +616,31 @@ export default {
this.openmct.editor.cancel(); this.openmct.editor.cancel();
} }
}, },
async dropOnEntry(event) { async dropOnEntry(dropEvent) {
event.preventDefault(); dropEvent.preventDefault();
event.stopImmediatePropagation(); dropEvent.stopImmediatePropagation();
const snapshotId = event.dataTransfer.getData('openmct/snapshot/id'); const localImageDropped = dropEvent.dataTransfer.files?.[0]?.type.includes('image');
if (snapshotId.length) { const imageUrl = dropEvent.dataTransfer.getData('URL');
const snapshotId = dropEvent.dataTransfer.getData('openmct/snapshot/id');
if (localImageDropped) {
// local image dropped from disk (file)
const imageData = dropEvent.dataTransfer.files[0];
const imageEmbed = await createNewImageEmbed(imageData, this.openmct, imageData?.name);
this.newEntry(imageEmbed);
} else if (imageUrl) {
// remote image dropped (URL)
try {
const response = await fetch(imageUrl);
const imageData = await response.blob();
const imageEmbed = await createNewImageEmbed(imageData, this.openmct);
this.newEntry(imageEmbed);
} catch (error) {
this.openmct.notifications.alert(`Unable to add image: ${error.message} `);
console.error(`Problem embedding remote image`, error);
}
} else if (snapshotId.length) {
// snapshot object
const snapshot = this.snapshotContainer.getSnapshot(snapshotId); const snapshot = this.snapshotContainer.getSnapshot(snapshotId);
this.newEntry(snapshot.embedObject); this.newEntry(snapshot.embedObject);
this.snapshotContainer.removeSnapshot(snapshotId); this.snapshotContainer.removeSnapshot(snapshotId);
@ -631,11 +651,9 @@ export default {
namespace namespace
); );
saveNotebookImageDomainObject(this.openmct, notebookImageDomainObject); saveNotebookImageDomainObject(this.openmct, notebookImageDomainObject);
} else {
return; // plain domain object
} const data = dropEvent.dataTransfer.getData('openmct/domain-object-path');
const data = event.dataTransfer.getData('openmct/domain-object-path');
const objectPath = JSON.parse(data); const objectPath = JSON.parse(data);
const bounds = this.openmct.time.bounds(); const bounds = this.openmct.time.bounds();
const snapshotMeta = { const snapshotMeta = {
@ -647,6 +665,7 @@ export default {
const embed = await createNewEmbed(snapshotMeta); const embed = await createNewEmbed(snapshotMeta);
this.newEntry(embed); this.newEntry(embed);
}
}, },
focusOnEntryId() { focusOnEntryId() {
if (!this.focusEntryId) { if (!this.focusEntryId) {

View File

@ -27,13 +27,13 @@
@mouseleave="hideToolTip" @mouseleave="hideToolTip"
> >
<div v-if="embed.snapshot" class="c-ne__embed__snap-thumb" @click="openSnapshot()"> <div v-if="embed.snapshot" class="c-ne__embed__snap-thumb" @click="openSnapshot()">
<img :src="thumbnailImage" /> <img :src="thumbnailImage" :alt="`${embed.name} thumbnail`" />
</div> </div>
<div class="c-ne__embed__info"> <div class="c-ne__embed__info">
<div class="c-ne__embed__name"> <div class="c-ne__embed__name">
<a class="c-ne__embed__link" :class="embed.cssClass" @click="navigateToItemInTime">{{ <a class="c-ne__embed__link" :class="embed.cssClass" @click="navigateToItemInTime">
embed.name {{ embed.name }}
}}</a> </a>
<button <button
class="c-ne__embed__actions c-icon-button icon-3-dots" class="c-ne__embed__actions c-icon-button icon-3-dots"
title="More options" title="More options"
@ -144,6 +144,7 @@ export default {
this.menuActions.splice(0, this.menuActions.length, viewSnapshot); this.menuActions.splice(0, this.menuActions.length, viewSnapshot);
} }
if (this.embed.domainObject) {
const navigateToItem = { const navigateToItem = {
id: 'navigateToItem', id: 'navigateToItem',
cssClass: this.embed.cssClass, cssClass: this.embed.cssClass,
@ -169,6 +170,7 @@ export default {
}; };
this.menuActions.push(...[quickView, navigateToItem, navigateToItemInTime]); this.menuActions.push(...[quickView, navigateToItem, navigateToItemInTime]);
}
if (!this.isLocked) { if (!this.isLocked) {
const removeEmbed = { const removeEmbed = {
@ -183,6 +185,9 @@ export default {
} }
}, },
async setEmbedObjectPath() { async setEmbedObjectPath() {
if (!this.embed.domainObject) {
return;
}
this.objectPath = await this.openmct.objects.getOriginalPath( this.objectPath = await this.openmct.objects.getOriginalPath(
this.embed.domainObject.identifier this.embed.domainObject.identifier
); );
@ -260,6 +265,11 @@ export default {
this.openmct.router.navigate(url); this.openmct.router.navigate(url);
}, },
navigateToItemInTime() { navigateToItemInTime() {
if (!this.embed.historicLink) {
// no historic link available
return;
}
const hash = this.embed.historicLink; const hash = this.embed.historicLink;
const bounds = this.openmct.time.bounds(); const bounds = this.openmct.time.bounds();

View File

@ -143,7 +143,7 @@ import Moment from 'moment';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue'; import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue';
import { createNewEmbed, selectEntry } from '../utils/notebook-entries'; import { createNewEmbed, createNewImageEmbed, selectEntry } from '../utils/notebook-entries';
import { import {
saveNotebookImageDomainObject, saveNotebookImageDomainObject,
updateNamespaceOfDomainObject updateNamespaceOfDomainObject
@ -359,11 +359,32 @@ export default {
this.enableEmbedsWrapperScroll = embedsTotalWidth > embedsWrapperLength; this.enableEmbedsWrapperScroll = embedsTotalWidth > embedsWrapperLength;
} }
}, },
async dropOnEntry($event) { async dropOnEntry(dropEvent) {
$event.stopImmediatePropagation(); dropEvent.stopImmediatePropagation();
const snapshotId = $event.dataTransfer.getData('openmct/snapshot/id'); const localImageDropped = dropEvent.dataTransfer.files?.[0]?.type.includes('image');
if (snapshotId.length) { const snapshotId = dropEvent.dataTransfer.getData('openmct/snapshot/id');
const imageUrl = dropEvent.dataTransfer.getData('URL');
if (localImageDropped) {
// local image dropped from disk (file)
const imageData = dropEvent.dataTransfer.files[0];
const imageEmbed = await createNewImageEmbed(imageData, this.openmct, imageData?.name);
this.entry.embeds.push(imageEmbed);
this.manageEmbedLayout();
} else if (imageUrl) {
try {
// remote image dropped (URL)
const response = await fetch(imageUrl);
const imageData = await response.blob();
const imageEmbed = await createNewImageEmbed(imageData, this.openmct);
this.entry.embeds.push(imageEmbed);
this.manageEmbedLayout();
} catch (error) {
this.openmct.notifications.alert(`Unable to add image: ${error.message} `);
console.error(`Problem embedding remote image`, error);
}
} else if (snapshotId.length) {
// snapshot object
const snapshot = this.snapshotContainer.getSnapshot(snapshotId); const snapshot = this.snapshotContainer.getSnapshot(snapshotId);
this.entry.embeds.push(snapshot.embedObject); this.entry.embeds.push(snapshot.embedObject);
this.snapshotContainer.removeSnapshot(snapshotId); this.snapshotContainer.removeSnapshot(snapshotId);
@ -375,7 +396,8 @@ export default {
); );
saveNotebookImageDomainObject(this.openmct, notebookImageDomainObject); saveNotebookImageDomainObject(this.openmct, notebookImageDomainObject);
} else { } else {
const data = $event.dataTransfer.getData('openmct/domain-object-path'); // plain domain object
const data = dropEvent.dataTransfer.getData('openmct/domain-object-path');
const objectPath = JSON.parse(data); const objectPath = JSON.parse(data);
await this.addNewEmbed(objectPath); await this.addNewEmbed(objectPath);
} }

View File

@ -1,6 +1,11 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import objectLink from '../../../ui/mixins/object-link'; import objectLink from '../../../ui/mixins/object-link';
import {
createNotebookImageDomainObject,
getThumbnailURLFromImageUrl,
saveNotebookImageDomainObject
} from './notebook-image';
async function getUsername(openmct) { async function getUsername(openmct) {
let username = null; let username = null;
@ -115,24 +120,67 @@ export function getHistoricLinkInFixedMode(openmct, bounds, historicLink) {
return params.join('&'); return params.join('&');
} }
export async function createNewEmbed(snapshotMeta, snapshot = '') { export function createNewImageEmbed(image, openmct, imageName = '') {
const { bounds, link, objectPath, openmct } = snapshotMeta; return new Promise((resolve) => {
const domainObject = objectPath[0]; const reader = new FileReader();
const domainObjectType = openmct.types.get(domainObject.type); reader.onloadend = async () => {
const base64Data = reader.result;
const blobUrl = URL.createObjectURL(image);
const imageDomainObject = createNotebookImageDomainObject(base64Data);
await saveNotebookImageDomainObject(openmct, imageDomainObject);
const imageThumbnailURL = await getThumbnailURLFromImageUrl(blobUrl);
const cssClass = const snapshot = {
domainObjectType && domainObjectType.definition fullSizeImageObjectIdentifier: imageDomainObject.identifier,
thumbnailImage: {
src: imageThumbnailURL
}
};
const embedMetaData = {
bounds: openmct.time.bounds(),
link: null,
objectPath: null,
openmct,
userImage: true,
imageName
};
const createdEmbed = await createNewEmbed(embedMetaData, snapshot);
resolve(createdEmbed);
};
reader.readAsDataURL(image);
});
}
export async function createNewEmbed(snapshotMeta, snapshot = '') {
const { bounds, link, objectPath, openmct, userImage } = snapshotMeta;
let name = null;
let type = null;
let cssClass = 'icon-object-unknown';
let domainObject = null;
let historicLink = null;
if (objectPath?.length > 0) {
domainObject = objectPath[0];
const domainObjectType = openmct.types.get(domainObject.type);
cssClass = domainObjectType?.definition
? domainObjectType.definition.cssClass ? domainObjectType.definition.cssClass
: 'icon-object-unknown'; : 'icon-object-unknown';
const date = openmct.time.now(); name = domainObject.name;
const historicLink = link type = domainObject.identifier.key;
historicLink = link
? getHistoricLinkInFixedMode(openmct, bounds, link) ? getHistoricLinkInFixedMode(openmct, bounds, link)
: objectLink.computed.objectLink.call({ : objectLink.computed.objectLink.call({
objectPath, objectPath,
openmct openmct
}); });
const name = domainObject.name; } else if (userImage) {
const type = domainObject.identifier.key; cssClass = 'icon-image';
name = snapshotMeta.imageName;
}
const date = openmct.time.now();
const createdBy = await getUsername(openmct); const createdBy = await getUsername(openmct);
return { return {

View File

@ -33,7 +33,7 @@ export function getThumbnailURLFromCanvas(canvas, size = DEFAULT_SIZE) {
return thumbnailCanvas.toDataURL('image/png'); return thumbnailCanvas.toDataURL('image/png');
} }
export function getThumbnailURLFromimageUrl(imageUrl, size = DEFAULT_SIZE) { export function getThumbnailURLFromImageUrl(imageUrl, size = DEFAULT_SIZE) {
return new Promise((resolve) => { return new Promise((resolve) => {
const image = new Image(); const image = new Image();
@ -43,7 +43,6 @@ export function getThumbnailURLFromimageUrl(imageUrl, size = DEFAULT_SIZE) {
image.onload = function () { image.onload = function () {
canvas.getContext('2d').drawImage(image, 0, 0, size.width, size.height); canvas.getContext('2d').drawImage(image, 0, 0, size.width, size.height);
resolve(canvas.toDataURL('image/png')); resolve(canvas.toDataURL('image/png'));
}; };
@ -51,22 +50,8 @@ export function getThumbnailURLFromimageUrl(imageUrl, size = DEFAULT_SIZE) {
}); });
} }
export function saveNotebookImageDomainObject(openmct, object) { export async function saveNotebookImageDomainObject(openmct, object) {
return new Promise((resolve, reject) => { await openmct.objects.save(object);
openmct.objects
.save(object)
.then((result) => {
if (result) {
resolve(object);
} else {
reject();
}
})
.catch((e) => {
console.error(e);
reject();
});
});
} }
export function updateNotebookImageDomainObject(openmct, identifier, fullSizeImage) { export function updateNotebookImageDomainObject(openmct, identifier, fullSizeImage) {

View File

@ -1,7 +1,7 @@
import { mutateObject } from './notebook-entries'; import { mutateObject } from './notebook-entries';
import { import {
createNotebookImageDomainObject, createNotebookImageDomainObject,
getThumbnailURLFromimageUrl, getThumbnailURLFromImageUrl,
saveNotebookImageDomainObject, saveNotebookImageDomainObject,
updateNamespaceOfDomainObject updateNamespaceOfDomainObject
} from './notebook-image'; } from './notebook-image';
@ -33,7 +33,7 @@ export function notebookImageMigration(openmct, domainObject) {
const snapshot = embed.snapshot; const snapshot = embed.snapshot;
const fullSizeImageURL = snapshot.src; const fullSizeImageURL = snapshot.src;
if (fullSizeImageURL) { if (fullSizeImageURL) {
const thumbnailImageURL = await getThumbnailURLFromimageUrl(fullSizeImageURL); const thumbnailImageURL = await getThumbnailURLFromImageUrl(fullSizeImageURL);
const object = createNotebookImageDomainObject(fullSizeImageURL); const object = createNotebookImageDomainObject(fullSizeImageURL);
const notebookImageDomainObject = updateNamespaceOfDomainObject( const notebookImageDomainObject = updateNamespaceOfDomainObject(
object, object,

View File

@ -1,6 +1,6 @@
import Painterro from 'painterro'; import Painterro from 'painterro';
import { getThumbnailURLFromimageUrl } from './notebook-image'; import { getThumbnailURLFromImageUrl } from './notebook-image';
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
activeColor: '#ff0000', activeColor: '#ff0000',
@ -63,7 +63,7 @@ export default class PainterroInstance {
reader.readAsDataURL(url); reader.readAsDataURL(url);
reader.onloadend = async () => { reader.onloadend = async () => {
const fullSizeImageURL = reader.result; const fullSizeImageURL = reader.result;
const thumbnailURL = await getThumbnailURLFromimageUrl(fullSizeImageURL); const thumbnailURL = await getThumbnailURLFromImageUrl(fullSizeImageURL);
const snapshotObject = { const snapshotObject = {
fullSizeImage: { fullSizeImage: {
src: fullSizeImageURL, src: fullSizeImageURL,