[Notebooks] Don't save images on the object (#3792)

* Create and store image data into new domain object of type 'notebookSnapshotImage'
* Reduced thumbnail size to 30px
* Image migration script for old notebooks.
* Saves thumbnail image on notebook instead of new object.
This commit is contained in:
Nikhil
2021-06-21 15:42:33 -07:00
committed by GitHub
parent fa5aceb7b3
commit 925518c83f
11 changed files with 302 additions and 56 deletions

View File

@ -41,10 +41,10 @@
"jsdoc": "^3.3.2", "jsdoc": "^3.3.2",
"karma": "5.1.1", "karma": "5.1.1",
"karma-chrome-launcher": "3.1.0", "karma-chrome-launcher": "3.1.0",
"karma-firefox-launcher": "1.3.0",
"karma-cli": "2.0.0", "karma-cli": "2.0.0",
"karma-coverage": "2.0.3", "karma-coverage": "2.0.3",
"karma-coverage-istanbul-reporter": "3.0.3", "karma-coverage-istanbul-reporter": "3.0.3",
"karma-firefox-launcher": "1.3.0",
"karma-html-reporter": "0.2.7", "karma-html-reporter": "0.2.7",
"karma-jasmine": "3.3.1", "karma-jasmine": "3.3.1",
"karma-sourcemap-loader": "0.3.7", "karma-sourcemap-loader": "0.3.7",
@ -60,7 +60,7 @@
"moment-timezone": "0.5.28", "moment-timezone": "0.5.28",
"node-bourbon": "^4.2.3", "node-bourbon": "^4.2.3",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"painterro": "^1.0.35", "painterro": "^1.2.56",
"printj": "^1.2.1", "printj": "^1.2.1",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"request": "^2.69.0", "request": "^2.69.0",

View File

@ -48,12 +48,12 @@ define(
* Converts an HTML element into a PNG or JPG Blob. * Converts an HTML element into a PNG or JPG Blob.
* @private * @private
* @param {node} element that will be converted to an image * @param {node} element that will be converted to an image
* @param {string} type of image to convert the element to. * @param {object} options Image options.
* @returns {promise} * @returns {promise}
*/ */
ExportImageService.prototype.renderElement = function (element, imageType, className) { ExportImageService.prototype.renderElement = function (element, {imageType, className, thumbnailSize}) {
const self = this;
const dialogService = this.dialogService; const dialogService = this.dialogService;
const dialog = dialogService.showBlockingMessage({ const dialog = dialogService.showBlockingMessage({
title: "Capturing...", title: "Capturing...",
hint: "Capturing an image", hint: "Capturing an image",
@ -90,7 +90,16 @@ define(
dialog.dismiss(); dialog.dismiss();
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
return canvas.toBlob(resolve, mimeType); if (thumbnailSize) {
const thumbnail = self.getThumbnail(canvas, mimeType, thumbnailSize);
return canvas.toBlob(blob => resolve({
blob,
thumbnail
}), mimeType);
}
return canvas.toBlob(blob => resolve({ blob }), mimeType);
}); });
}, function (error) { }, function (error) {
console.log('error capturing image', error); console.log('error capturing image', error);
@ -109,6 +118,17 @@ define(
}); });
}; };
ExportImageService.prototype.getThumbnail = function (canvas, mimeType, size) {
const thumbnailCanvas = document.createElement('canvas');
thumbnailCanvas.setAttribute('width', size.width);
thumbnailCanvas.setAttribute('height', size.height);
const ctx = thumbnailCanvas.getContext('2d');
ctx.globalCompositeOperation = "copy";
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, size.width, size.height);
return thumbnailCanvas.toDataURL(mimeType);
};
/** /**
* Takes a screenshot of a DOM node and exports to JPG. * Takes a screenshot of a DOM node and exports to JPG.
* @param {node} element to be exported * @param {node} element to be exported
@ -119,9 +139,13 @@ define(
ExportImageService.prototype.exportJPG = function (element, filename, className) { ExportImageService.prototype.exportJPG = function (element, filename, className) {
const processedFilename = replaceDotsWithUnderscores(filename); const processedFilename = replaceDotsWithUnderscores(filename);
return this.renderElement(element, "jpg", className).then(function (img) { return this.renderElement(element, {
saveAs(img, processedFilename); imageType: 'jpg',
}); className
})
.then(function (img) {
saveAs(img.blob, processedFilename);
});
}; };
/** /**
@ -134,9 +158,13 @@ define(
ExportImageService.prototype.exportPNG = function (element, filename, className) { ExportImageService.prototype.exportPNG = function (element, filename, className) {
const processedFilename = replaceDotsWithUnderscores(filename); const processedFilename = replaceDotsWithUnderscores(filename);
return this.renderElement(element, "png", className).then(function (img) { return this.renderElement(element, {
saveAs(img, processedFilename); imageType: 'png',
}); className
})
.then(function (img) {
saveAs(img.blob, processedFilename);
});
}; };
/** /**
@ -146,8 +174,12 @@ define(
* @returns {promise} * @returns {promise}
*/ */
ExportImageService.prototype.exportPNGtoSRC = function (element, className) { ExportImageService.prototype.exportPNGtoSRC = function (element, options) {
return this.renderElement(element, "png", className);
return this.renderElement(element, {
imageType: 'png',
...options
});
}; };
function replaceDotsWithUnderscores(filename) { function replaceDotsWithUnderscores(filename) {

View File

@ -4,7 +4,7 @@
class="c-ne__embed__snap-thumb" class="c-ne__embed__snap-thumb"
@click="openSnapshot()" @click="openSnapshot()"
> >
<img :src="embed.snapshot.src"> <img :src="thumbnailImage">
</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">
@ -25,11 +25,14 @@
<script> <script>
import Moment from 'moment'; import Moment from 'moment';
import PopupMenu from './PopupMenu.vue';
import PreviewAction from '../../../ui/preview/PreviewAction'; import PreviewAction from '../../../ui/preview/PreviewAction';
import RemoveDialog from '../utils/removeDialog'; import RemoveDialog from '../utils/removeDialog';
import PainterroInstance from '../utils/painterroInstance'; import PainterroInstance from '../utils/painterroInstance';
import SnapshotTemplate from './snapshot-template.html'; import SnapshotTemplate from './snapshot-template.html';
import { updateNotebookImageDomainObject } from '../utils/notebook-image';
import PopupMenu from './PopupMenu.vue';
import Vue from 'vue'; import Vue from 'vue';
export default { export default {
@ -59,6 +62,11 @@ export default {
computed: { computed: {
createdOn() { createdOn() {
return this.formatTime(this.embed.createdOn, 'YYYY-MM-DD HH:mm:ss'); return this.formatTime(this.embed.createdOn, 'YYYY-MM-DD HH:mm:ss');
},
thumbnailImage() {
return this.embed.snapshot.thumbnailImage
? this.embed.snapshot.thumbnailImage.src
: this.embed.snapshot.src;
} }
}, },
mounted() { mounted() {
@ -85,7 +93,7 @@ export default {
template: '<div id="snap-annotation"></div>' template: '<div id="snap-annotation"></div>'
}).$mount(); }).$mount();
const painterroInstance = new PainterroInstance(annotateVue.$el, this.updateSnapshot); const painterroInstance = new PainterroInstance(annotateVue.$el);
const annotateOverlay = this.openmct.overlays.overlay({ const annotateOverlay = this.openmct.overlays.overlay({
element: annotateVue.$el, element: annotateVue.$el,
size: 'large', size: 'large',
@ -102,10 +110,12 @@ export default {
{ {
label: 'Save', label: 'Save',
callback: () => { callback: () => {
painterroInstance.save(); painterroInstance.save((snapshotObject) => {
annotateOverlay.dismiss(); annotateOverlay.dismiss();
this.snapshotOverlay.dismiss(); this.snapshotOverlay.dismiss();
this.openSnapshot(); this.updateSnapshot(snapshotObject);
this.openSnapshotOverlay(snapshotObject.fullSizeImage.src);
});
} }
} }
], ],
@ -115,7 +125,19 @@ export default {
}); });
painterroInstance.intialize(); painterroInstance.intialize();
painterroInstance.show(this.embed.snapshot.src);
const fullSizeImageObjectIdentifier = this.embed.snapshot.fullSizeImageObjectIdentifier;
if (!fullSizeImageObjectIdentifier) {
// legacy image data stored in embed
painterroInstance.show(this.embed.snapshot.src);
return;
}
this.openmct.objects.get(fullSizeImageObjectIdentifier)
.then(object => {
painterroInstance.show(object.configuration.fullSizeImageURL);
});
}, },
changeLocation() { changeLocation() {
const hash = this.embed.historicLink; const hash = this.embed.historicLink;
@ -159,12 +181,29 @@ export default {
removeDialog.show(); removeDialog.show();
}, },
openSnapshot() { openSnapshot() {
const fullSizeImageObjectIdentifier = this.embed.snapshot.fullSizeImageObjectIdentifier;
if (!fullSizeImageObjectIdentifier) {
// legacy image data stored in embed
this.openSnapshotOverlay(this.embed.snapshot.src);
return;
}
this.openmct.objects.get(fullSizeImageObjectIdentifier)
.then(object => {
this.openSnapshotOverlay(object.configuration.fullSizeImageURL);
});
},
openSnapshotOverlay(src) {
const self = this; const self = this;
this.snapshot = new Vue({ this.snapshot = new Vue({
data: () => { data: () => {
return { return {
createdOn: this.createdOn, createdOn: this.createdOn,
embed: this.embed name: this.embed.name,
cssClass: this.embed.cssClass,
src
}; };
}, },
methods: { methods: {
@ -217,7 +256,9 @@ export default {
this.$emit('updateEmbed', embed); this.$emit('updateEmbed', embed);
}, },
updateSnapshot(snapshotObject) { updateSnapshot(snapshotObject) {
this.embed.snapshot = snapshotObject; this.embed.snapshot.thumbnailImage = snapshotObject.thumbnailImage;
updateNotebookImageDomainObject(this.openmct, this.embed.snapshot.fullSizeImageObjectIdentifier, snapshotObject.fullSizeImage);
this.updateEmbed(this.embed); this.updateEmbed(this.embed);
} }
} }

View File

@ -62,7 +62,6 @@
<NotebookEmbed v-for="embed in entry.embeds" <NotebookEmbed v-for="embed in entry.embeds"
:key="embed.id" :key="embed.id"
:embed="embed" :embed="embed"
:entry="entry"
@removeEmbed="removeEmbed" @removeEmbed="removeEmbed"
@updateEmbed="updateEmbed" @updateEmbed="updateEmbed"
/> />
@ -254,6 +253,7 @@ export default {
}, },
removeEmbed(id) { removeEmbed(id) {
const embedPosition = this.findPositionInArray(this.entry.embeds, id); const embedPosition = this.findPositionInArray(this.entry.embeds, id);
// TODO: remove notebook snapshot object using object remove API
this.entry.embeds.splice(embedPosition, 1); this.entry.embeds.splice(embedPosition, 1);
this.$emit('updateEntry', this.entry); this.$emit('updateEntry', this.entry);

View File

@ -4,9 +4,9 @@
<div class="l-browse-bar__start"> <div class="l-browse-bar__start">
<div class="l-browse-bar__object-name--w"> <div class="l-browse-bar__object-name--w">
<span class="c-object-label l-browse-bar__object-name" <span class="c-object-label l-browse-bar__object-name"
v-bind:class="embed.cssClass" v-bind:class="cssClass"
> >
<span class="c-object-label__name">{{ embed.name }}</span> <span class="c-object-label__name">{{ name }}</span>
</span> </span>
</div> </div>
</div> </div>
@ -40,7 +40,7 @@
<div <div
ref="snapshot-image" ref="snapshot-image"
class="c-notebook-snapshot__image" class="c-notebook-snapshot__image"
:style="{ backgroundImage: 'url(' + embed.snapshot.src + ')' }" :style="{ backgroundImage: 'url(' + src + ')' }"
> >
</div> </div>
</div> </div>

View File

@ -2,7 +2,10 @@ import CopyToNotebookAction from './actions/CopyToNotebookAction';
import Notebook from './components/Notebook.vue'; import Notebook from './components/Notebook.vue';
import NotebookSnapshotIndicator from './components/NotebookSnapshotIndicator.vue'; import NotebookSnapshotIndicator from './components/NotebookSnapshotIndicator.vue';
import SnapshotContainer from './snapshot-container'; import SnapshotContainer from './snapshot-container';
import {NOTEBOOK_TYPE} from './notebook-constants';
import { notebookImageMigration } from '../notebook/utils/notebook-migration';
import { NOTEBOOK_TYPE } from './notebook-constants';
import Vue from 'vue'; import Vue from 'vue';
export default function NotebookPlugin() { export default function NotebookPlugin() {
@ -85,6 +88,19 @@ export default function NotebookPlugin() {
}; };
openmct.types.addType(NOTEBOOK_TYPE, notebookType); openmct.types.addType(NOTEBOOK_TYPE, notebookType);
const notebookSnapshotImageType = {
name: 'Notebook Snapshot Image Storage',
description: 'Notebook Snapshot Image Storage object',
creatable: false,
initialize: domainObject => {
domainObject.configuration = {
fullSizeImageURL: undefined,
thumbnailImageURL: undefined
};
}
};
openmct.types.addType('notebookSnapshotImage', notebookSnapshotImageType);
const snapshotContainer = new SnapshotContainer(openmct); const snapshotContainer = new SnapshotContainer(openmct);
const notebookSnapshotIndicator = new Vue ({ const notebookSnapshotIndicator = new Vue ({
components: { components: {
@ -138,5 +154,16 @@ export default function NotebookPlugin() {
}; };
} }
}); });
openmct.objects.addGetInterceptor({
appliesTo: (identifier, domainObject) => {
return domainObject && domainObject.type === 'notebook';
},
invoke: (identifier, domainObject) => {
notebookImageMigration(openmct, domainObject);
return domainObject;
}
});
}; };
} }

View File

@ -1,6 +1,8 @@
import { addNotebookEntry, createNewEmbed } from './utils/notebook-entries'; import { addNotebookEntry, createNewEmbed } from './utils/notebook-entries';
import { getDefaultNotebook, getDefaultNotebookLink, setDefaultNotebook } from './utils/notebook-storage'; import { getDefaultNotebook, getDefaultNotebookLink, setDefaultNotebook } from './utils/notebook-storage';
import { NOTEBOOK_DEFAULT } from '@/plugins/notebook/notebook-constants'; import { NOTEBOOK_DEFAULT } from '@/plugins/notebook/notebook-constants';
import { createNotebookImageDomainObject, DEFAULT_SIZE } from './utils/notebook-image';
import SnapshotContainer from './snapshot-container'; import SnapshotContainer from './snapshot-container';
export default class Snapshot { export default class Snapshot {
@ -14,12 +16,17 @@ export default class Snapshot {
capture(snapshotMeta, notebookType, domElement) { capture(snapshotMeta, notebookType, domElement) {
const exportImageService = this.openmct.$injector.get('exportImageService'); const exportImageService = this.openmct.$injector.get('exportImageService');
exportImageService.exportPNGtoSRC(domElement, 's-status-taking-snapshot')
.then(function (blob) { const options = {
className: 's-status-taking-snapshot',
thumbnailSize: DEFAULT_SIZE
};
exportImageService.exportPNGtoSRC(domElement, options)
.then(function ({blob, thumbnail}) {
const reader = new window.FileReader(); const reader = new window.FileReader();
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
reader.onloadend = function () { reader.onloadend = function () {
this._saveSnapShot(notebookType, reader.result, snapshotMeta); this._saveSnapShot(notebookType, reader.result, thumbnail, snapshotMeta);
}.bind(this); }.bind(this);
}.bind(this)); }.bind(this));
} }
@ -27,16 +34,23 @@ export default class Snapshot {
/** /**
* @private * @private
*/ */
_saveSnapShot(notebookType, imageUrl, snapshotMeta) { _saveSnapShot(notebookType, fullSizeImageURL, thumbnailImageURL, snapshotMeta) {
const snapshot = imageUrl ? { src: imageUrl } : ''; createNotebookImageDomainObject(this.openmct, fullSizeImageURL)
const embed = createNewEmbed(snapshotMeta, snapshot); .then(object => {
if (notebookType === NOTEBOOK_DEFAULT) { const thumbnailImage = { src: thumbnailImageURL || '' };
this._saveToDefaultNoteBook(embed); const snapshot = {
fullSizeImageObjectIdentifier: object.identifier,
thumbnailImage
};
const embed = createNewEmbed(snapshotMeta, snapshot);
if (notebookType === NOTEBOOK_DEFAULT) {
this._saveToDefaultNoteBook(embed);
return; return;
} }
this._saveToNotebookSnapshots(embed); this._saveToNotebookSnapshots(embed);
});
} }
/** /**

View File

@ -0,0 +1,78 @@
import uuid from 'uuid';
export const DEFAULT_SIZE = {
width: 30,
height: 30
};
export function createNotebookImageDomainObject(openmct, fullSizeImageURL) {
const identifier = {
key: uuid(),
namespace: ''
};
const viewType = 'notebookSnapshotImage';
const object = {
name: 'Notebook Snapshot Image',
type: viewType,
identifier,
configuration: {
fullSizeImageURL
}
};
return new Promise((resolve, reject) => {
openmct.objects.save(object)
.then(result => {
if (result) {
resolve(object);
}
reject();
})
.catch(e => {
console.error(e);
reject();
});
});
}
export function getThumbnailURLFromCanvas(canvas, size = DEFAULT_SIZE) {
const thumbnailCanvas = document.createElement('canvas');
thumbnailCanvas.setAttribute('width', size.width);
thumbnailCanvas.setAttribute('height', size.height);
const ctx = thumbnailCanvas.getContext('2d');
ctx.globalCompositeOperation = "copy";
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, size.width, size.height);
return thumbnailCanvas.toDataURL('image/png');
}
export function getThumbnailURLFromimageUrl(imageUrl, size = DEFAULT_SIZE) {
return new Promise(resolve => {
const image = new Image();
const canvas = document.createElement('canvas');
canvas.width = size.width;
canvas.height = size.height;
image.onload = function () {
canvas.getContext('2d')
.drawImage(image, 0, 0, size.width, size.height);
resolve(canvas.toDataURL('image/png'));
};
image.src = imageUrl;
});
}
export function updateNotebookImageDomainObject(openmct, identifier, fullSizeImage) {
openmct.objects.get(identifier)
.then(domainObject => {
const configuration = domainObject.configuration;
configuration.fullSizeImageURL = fullSizeImage.src;
openmct.objects.mutate(domainObject, 'configuration', configuration);
});
}

View File

@ -0,0 +1,43 @@
import { createNotebookImageDomainObject, getThumbnailURLFromimageUrl } from './notebook-image';
import { mutateObject } from './notebook-entries';
export function notebookImageMigration(openmct, domainObject) {
const configuration = domainObject.configuration;
const notebookEntries = configuration.entries;
const imageMigrationVer = configuration.imageMigrationVer;
if (imageMigrationVer && imageMigrationVer === 'v1') {
return;
}
configuration.imageMigrationVer = 'v1';
// to avoid muliple notebookImageMigration calls updating images.
mutateObject(openmct, domainObject, 'configuration', configuration);
configuration.sections.forEach(section => {
const sectionId = section.id;
section.pages.forEach(page => {
const pageId = page.id;
const notebookSection = notebookEntries && notebookEntries[sectionId] || {};
const pageEntries = notebookSection && notebookSection[pageId] || [];
pageEntries.forEach(entry => {
entry.embeds.forEach(async (embed) => {
const snapshot = embed.snapshot;
const fullSizeImageURL = snapshot.src;
if (fullSizeImageURL) {
const thumbnailImageURL = await getThumbnailURLFromimageUrl(fullSizeImageURL);
const notebookImageDomainObject = await createNotebookImageDomainObject(openmct, fullSizeImageURL);
embed.snapshot = {
fullSizeImageObjectIdentifier: notebookImageDomainObject.identifier,
thumbnailImage: { src: thumbnailImageURL || '' }
};
mutateObject(openmct, domainObject, 'configuration.entries', notebookEntries);
}
});
});
});
});
}

View File

@ -1,4 +1,5 @@
import Painterro from 'painterro'; import Painterro from 'painterro';
import { getThumbnailURLFromimageUrl } from './notebook-image';
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
activeColor: '#ff0000', activeColor: '#ff0000',
@ -25,11 +26,11 @@ const DEFAULT_CONFIG = {
}; };
export default class PainterroInstance { export default class PainterroInstance {
constructor(element, saveCallback) { constructor(element) {
this.elementId = element.id; this.elementId = element.id;
this.isSave = false; this.isSave = false;
this.painterroInstance = null; this.painterroInstance = undefined;
this.saveCallback = saveCallback; this.saveCallback = undefined;
} }
dismiss() { dismiss() {
@ -46,31 +47,41 @@ export default class PainterroInstance {
this.painterro = Painterro(this.config); this.painterro = Painterro(this.config);
} }
save() { save(callback) {
this.saveCallback = callback;
this.isSave = true; this.isSave = true;
this.painterroInstance.save(); this.painterroInstance.save();
} }
saveHandler(image, done) { saveHandler(image, done) {
if (this.isSave) { if (this.isSave) {
const self = this;
const url = image.asBlob(); const url = image.asBlob();
const reader = new window.FileReader(); const reader = new window.FileReader();
reader.readAsDataURL(url); reader.readAsDataURL(url);
reader.onloadend = () => { reader.onloadend = async () => {
const snapshot = reader.result; const fullSizeImageURL = reader.result;
const thumbnailURL = await getThumbnailURLFromimageUrl(fullSizeImageURL);
const snapshotObject = { const snapshotObject = {
src: snapshot, fullSizeImage: {
type: url.type, src: fullSizeImageURL,
size: url.size, type: url.type,
modified: Date.now() size: url.size,
modified: Date.now()
},
thumbnailImage: {
src: thumbnailURL,
modified: Date.now()
}
}; };
self.saveCallback(snapshotObject); this.saveCallback(snapshotObject);
};
}
done(true); done(true);
};
} else {
done(true);
}
} }
show(src) { show(src) {

View File

@ -22,7 +22,7 @@
import CouchDocument from "./CouchDocument"; import CouchDocument from "./CouchDocument";
import CouchObjectQueue from "./CouchObjectQueue"; import CouchObjectQueue from "./CouchObjectQueue";
import NOTEBOOK_TYPE from '../../notebook/notebook-constants.js'; import { NOTEBOOK_TYPE } from '../../notebook/notebook-constants.js';
const REV = "_rev"; const REV = "_rev";
const ID = "_id"; const ID = "_id";