Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Image Uploads #287

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
714c73e
Add library multiparty to parse form data.
Nov 18, 2023
b166eb8
Use BoardDataList instead of plain object
Nov 18, 2023
8fa13cb
Return public interface for Sockets object
Nov 18, 2023
073ff19
Image asset uploads and updates.
Nov 18, 2023
b23bf11
Validate image size before allowing upload.
Nov 18, 2023
7abbda4
Get MIME type before returning image assets.
Nov 18, 2023
ff2768f
Remove old TODO
Nov 18, 2023
f278e68
Use XHR to upload images. Allows access to progress.
Nov 18, 2023
6b847cf
Move all image upload stuff to image tool
Nov 18, 2023
bf67be1
Use image tool to initiate upload
Nov 18, 2023
15781ee
Delete images if they are no longer in use.
Nov 18, 2023
f1cd94d
Drop onto board, not canvas.
Nov 18, 2023
daed5e3
Remove unnecessary console.log
Nov 18, 2023
962bb22
Use cell cursor for image tool.
Nov 18, 2023
9bf0e3f
Disable image upload if Image tool is in block list
Nov 18, 2023
5d82a78
Remove tmp file after image upload.
Nov 18, 2023
d09411b
Don't block main handler fn when serving board image assets.
Nov 18, 2023
84ff1e1
Validate image uploads on client and server.
Nov 18, 2023
1a815a6
Add log for when unused images are purged
Nov 18, 2023
c321964
Ensure that board image asset URLs always point to current domain.
Nov 19, 2023
1e3708e
Use fs.promises to read board image assets.
Nov 19, 2023
7fe1fe3
Use board name and asset ID to build image href
Nov 20, 2023
b0e47a1
Fix image purge.
Nov 20, 2023
231d3fd
Attach click and drag/drop handlers to canvas, not drawingArea
Nov 20, 2023
dfd96b1
Add tests for image upload tool.
Nov 20, 2023
05d0ea2
Merge branch 'master' into master
atomdmac Nov 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client-data/board.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
<script src="../tools/grid/grid.js"></script>
<script src="../tools/download/download.js"></script>
<script src="../tools/zoom/zoom.js"></script>
<script src="../tools/image/image.js"></script>
{{#moderator}}<script src="../tools/clear/clear.js"></script>{{/moderator}}
<script src="../js/canvascolor.js"></script>
</body>
Expand Down
39 changes: 39 additions & 0 deletions client-data/tools/image/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
293 changes: 293 additions & 0 deletions client-data/tools/image/image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@

/**
* WHITEBOPHIR
*********************************************************
* @licstart The following is the entire license notice for the
* JavaScript code in this page.
*
* Copyright (C) 2013 Ophir LOJKINE
*
*
* The JavaScript code in this page is free software: you can
* redistribute it and/or modify it under the terms of the GNU
* General Public License (GNU GPL) as published by the Free Software
* Foundation, either version 3 of the License, or (at your option)
* any later version. The code is distributed WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
*
* As additional permission under GNU GPL version 3 section 7, you
* may distribute non-source (e.g., minimized or compacted) forms of
* that code without the copy of the GNU GPL normally required by
* section 4, provided you include this license notice and a URL
* through which recipients can access the Corresponding Source.
*
* @licend
*/

(function () { //Code isolation
const drawingArea = document.getElementById('board');
const canvas = document.getElementById('canvas');
const newImageDropPoint = {
x: 0,
y: 0,
};

const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';

function onFileInputChange(event) {
uploadImage(event.target.files[0], {
x: newImageDropPoint.x,
y: newImageDropPoint.y,
});
}

function promptForImage(x, y, event) {
// Get the position of the click on the canvas so when the user uploads
// an image, we can draw it at the same position.
newImageDropPoint.x = x;
newImageDropPoint.y = y;
fileInput.click();
}

function draw(data) {
Tools.drawingEvent = true;
switch (data.type) {
case "image":
createImageElement(data);
break;
case "update":
var image = svg.getElementById(data['id']);
if (!image) {
console.error("Image: No image provided!", data['id']);
}
updateImageElement(image, data);
break;
default:
console.error("Image: Draw instruction with unknown type. ", data);
break;
}
}

/**
* Gets the absolute URL of a relative URL. Ensures that the URL points to
* the same origin as the current page.
*/
function getAbsoluteImageUrl(relativeUrl) {
const normalizedUrl = relativeUrl.startsWith('/') ? relativeUrl : `/${relativeUrl}`;
return `${window.location.origin}${normalizedUrl}`;
}

var svg = Tools.svg;
/**
* Creates a new image element on the canvas, or updates an existing image
* with new information.
* @param {Object} data - The data to use to create the image.
*/
function createImageElement(data) {
var img = svg.getElementById(data.id) || Tools.createSVGElement("image");
img.setAttribute("id", data.id);
img.setAttribute("href", getAbsoluteImageUrl(data.src));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is complicated. Can we remove the src altogether ?

Suggested change
img.setAttribute("href", getAbsoluteImageUrl(data.src));
img.setAttribute("href", "./images/" + data.id);

that would stress me less :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would the browser know where to pull the image to display from?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The browser would make a request to boards/{boardname}/images/{image_id}, the server would check that the image exists, and if so, serve it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yours is a much better approach 😅 This has been updated.

img.setAttribute("x", data.x);
img.setAttribute("y", data.y);

updateImageElement(img, data);
Tools.drawingArea.appendChild(img);
return img;
}

/**
* Updates the image element with new data.
*/
function updateImageElement(imageElement, data) {
imageElement.x.baseVal.value = Math.min(data['x2'], data['x']);
imageElement.y.baseVal.value = Math.min(data['y2'], data['y']);
imageElement.width.baseVal.value = Math.abs(data['x2'] - data['x']);
imageElement.height.baseVal.value = Math.abs(data['y2'] - data['y']);
}

/**
* Get the name of the current board based on the current URL.
* @returns {string} - The name of the current board.
*/
function getCurrentBoardName() {
let boardName = window.location.pathname.split('/');
boardName = boardName[boardName.length - 1];
boardName = boardName.split('#').shift();
return boardName;
}

/**
* Loads the image from the filesystem to generate a preview while uploading
* occurs as well as to get the dimensions of the image.
* @param {File} image - The image to preview.
* @returns {Promise} - A promise that resolves with the image preview.
*/
async function previewImage(image) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (event) {
const img = new Image();
img.src = event.target.result;
img.onload = function () {
resolve(img);
}
img.onerror = function (error) {
reject(error);
}
}
reader.readAsDataURL(image);
});
}

/**
* Uploads an image to the server, draws it on the canvas optimistically.
* @param {File} image - The image to upload.
* @param {Object} position - The position to draw the image on the canvas.
* @param {number} position.x - The x coordinate of the image.
* @param {number} position.y - The y coordinate of the image.
* @returns {Promise} - A promise that resolves when the image has been uploaded.
*/
async function uploadImage(image, position) {
const id = Tools.generateUID();
const ImageTool = Tools.list["Image"];

// Get a preview of the image
const previewElement = await previewImage(image);

const dimensions = {
x: previewElement.width,
y: previewElement.height
};

// Optimistically draw the image on the canvas before uploading.
ImageTool.draw({
id,
type: "image",
src: previewElement.src,
opacity: 0.5,
x: position.x,
y: position.y,
x2: position.x + dimensions.x,
y2: position.y + dimensions.y,
});

// Upload the image to the server
const formData = new FormData();
formData.append('image', image);
formData.append('id', id);
formData.append('position', JSON.stringify(position));
formData.append('dimensions', JSON.stringify(dimensions));

return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();

function onError(error) {
alert('An error occurred while attempti')
console.log('error: ', error);
reject(error);
}

function onProgress(event) {
// TODO: Show a loading indicator while the image is uploading.
console.log('progress: ', event);
}

function onLoad(response) {
if (xhr.status >= 400) {
alert('A server error occurred while uploading the image.')
reject(response);
console.log('onLoad: ', response);
}
resolve(response);
}

xhr.open('POST', `/image-upload/${getCurrentBoardName()}`, true);
xhr.onerror = onError;
xhr.onprogress = onProgress;
xhr.onload = onLoad;
xhr.send(formData);
});
}

/**
* Handles the drop event on the canvas.
* @param {Event} event - The drop event.
*/
function onUploadEvent(event) {
const scale = Tools.getScale();
const position = {
x: event.clientX / scale,
y: event.clientY / scale
};
uploadImage(event.dataTransfer.files[0], position);
}

function checkFileIsImage(file) {
return file.type.startsWith('image/');
}

function onDrop(event) {
if (!checkFileIsImage(event.dataTransfer.files[0])) {
alert('File type not supported.');
return;
}
onUploadEvent(event);
}

/**
* Called when the tool is selected.
*/
function onStart() {
fileInput.addEventListener('change', onFileInputChange);
}

/**
* Called when the tool is deselected.
*/
function onQuit() {
fileInput.removeEventListener('change', onFileInputChange);
}

// List of all drag/drop events.
const events = [
"drag",
"dragend",
"dragenter",
"dragleave",
"dragover",
"dragstart",
"drop"
];

// Ignore all default handling of drag/drop events on the canvas.
function preventDefault(e) {
e.preventDefault();
e.stopPropagation();
}

events.forEach((eventName) => {
drawingArea.addEventListener(eventName, preventDefault, false);
});

drawingArea.addEventListener("drop", onDrop, false);

var imageTool = {
"name": "Image",
"shortcut": "i",
"listeners": {
"press": promptForImage,
},
"onstart": onStart,
"onquit": onQuit,
"secondary": null,
"draw": draw,
"mouseCursor": "cell",
"icon": "tools/image/icon.svg",
"stylesheet": ""
};
Tools.add(imageTool);

})(); //End of code isolation
Loading