js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
287 lines (286 loc) • 13.3 kB
JavaScript
import ImageComponent from '../../../components/ImageComponent.mjs';
import Erase from '../../../commands/Erase.mjs';
import EditorImage from '../../../image/EditorImage.mjs';
import uniteCommands from '../../../commands/uniteCommands.mjs';
import SelectionTool from '../../../tools/SelectionTool/SelectionTool.mjs';
import { Mat33, Vec2 } from '@js-draw/math';
import BaseWidget from '../BaseWidget.mjs';
import { EditorEventType } from '../../../types.mjs';
import { toolbarCSSPrefix } from '../../constants.mjs';
import makeFileInput from '../components/makeFileInput.mjs';
import { MutableReactiveValue } from '../../../util/ReactiveValue.mjs';
import bytesToSizeString from '../../../util/bytesToSizeString.mjs';
import { ImageWrapper } from './ImageWrapper.mjs';
import makeSnappedList from '../components/makeSnappedList.mjs';
import fileToImages from './fileToImages.mjs';
/**
* Provides a widget that allows inserting or modifying raster images.
*
* It's possible to customize the file picker used by this widget through {@link EditorSettings.image}.
*
* @example
* ```ts,runnable
* import { Editor, makeEdgeToolbar, InsertImageWidget } from 'js-draw';
*
* const editor = new Editor(document.body);
* const toolbar = makeEdgeToolbar(editor);
*
* toolbar.addWidget(new InsertImageWidget(editor));
* ```
*/
class InsertImageWidget extends BaseWidget {
constructor(editor, localization) {
localization ??= editor.localization;
super(editor, 'insert-image-widget', localization);
// Make the dropdown showable
this.container.classList.add('dropdownShowable');
editor.notifier.on(EditorEventType.SelectionUpdated, (event) => {
if (event.kind === EditorEventType.SelectionUpdated && this.isDropdownVisible()) {
this.updateInputs();
}
});
this.images = MutableReactiveValue.fromInitialValue([]);
this.images.onUpdateAndNow(() => {
this.onImageDataUpdate();
});
}
getTitle() {
return this.localizationTable.image;
}
createIcon() {
return this.editor.icons.makeInsertImageIcon();
}
setDropdownVisible(visible) {
super.setDropdownVisible(visible);
// Update the dropdown just before showing.
if (this.isDropdownVisible()) {
this.updateInputs();
}
else {
// Allow any previously-selected files to be freed.
this.selectedFiles?.set([]);
}
}
handleClick() {
this.setDropdownVisible(!this.isDropdownVisible());
}
fillDropdown(dropdown) {
const container = document.createElement('div');
container.classList.add('insert-image-widget-dropdown-content', `${toolbarCSSPrefix}spacedList`, `${toolbarCSSPrefix}nonbutton-controls-main-list`);
const { container: chooseImageRow, selectedFiles } = makeFileInput(this.localizationTable.chooseFile, this.editor, {
accepts: 'image/*',
allowMultiSelect: true,
customPickerAction: this.editor.getCurrentSettings().image?.showImagePicker,
});
const altTextRow = document.createElement('div');
this.imagesPreview = makeSnappedList(this.images);
this.statusView = document.createElement('div');
const actionButtonRow = document.createElement('div');
actionButtonRow.classList.add('action-button-row');
this.statusView.classList.add('insert-image-image-status-view');
this.submitButton = document.createElement('button');
this.selectedFiles = selectedFiles;
this.imageAltTextInput = document.createElement('input');
// Label the alt text input
const imageAltTextLabel = document.createElement('label');
const altTextInputId = `insert-image-alt-text-input-${InsertImageWidget.nextInputId++}`;
this.imageAltTextInput.setAttribute('id', altTextInputId);
imageAltTextLabel.htmlFor = altTextInputId;
imageAltTextLabel.innerText = this.localizationTable.inputAltText;
this.imageAltTextInput.type = 'text';
this.imageAltTextInput.placeholder = this.localizationTable.describeTheImage;
this.statusView.setAttribute('aria-live', 'polite');
this.submitButton.innerText = this.localizationTable.submit;
this.imagesPreview.visibleItem.onUpdateAndNow(() => this.onImageDataUpdate());
this.imageAltTextInput.oninput = () => {
const currentImage = this.imagesPreview.visibleItem.get();
if (currentImage) {
currentImage.setAltText(this.imageAltTextInput.value);
this.submitButton.style.display = '';
}
};
this.selectedFiles.onUpdateAndNow(async (files) => {
if (files.length === 0) {
this.images.set([]);
return;
}
const previews = (await Promise.all(files.map(async (imageFile) => {
let renderableImages;
try {
renderableImages = await fileToImages(imageFile);
}
catch (error) {
console.error('Image load error', error);
const errorMessage = this.localizationTable.imageLoadError(error);
this.statusView.innerText = errorMessage;
return [];
}
return renderableImages.map((image) => {
const { wrapper, preview } = ImageWrapper.fromRenderable(image, () => this.onImageDataUpdate());
return {
data: wrapper,
element: preview,
};
});
}))).flat();
this.images.set(previews);
});
altTextRow.replaceChildren(imageAltTextLabel, this.imageAltTextInput);
actionButtonRow.replaceChildren(this.submitButton);
container.replaceChildren(chooseImageRow, altTextRow, this.imagesPreview.container, this.statusView, actionButtonRow);
dropdown.replaceChildren(container);
return true;
}
onImageDataUpdate() {
if (!this.imagesPreview)
return;
const currentImage = this.imagesPreview.visibleItem.get();
const base64Data = currentImage?.getBase64Url();
this.imageAltTextInput.value = currentImage?.getAltText() ?? '';
if (base64Data) {
this.submitButton.disabled = false;
this.submitButton.style.display = '';
this.updateImageSizeDisplay();
}
else {
this.submitButton.disabled = true;
this.submitButton.style.display = 'none';
this.statusView.innerText = '';
this.submitButton.disabled = true;
}
if (this.images.get().length <= 1) {
this.submitButton.innerText = this.localizationTable.submit;
}
else {
this.submitButton.innerText = this.localizationTable.addAll;
}
}
hideDialog() {
this.setDropdownVisible(false);
}
updateImageSizeDisplay() {
const currentImage = this.imagesPreview.visibleItem.get();
const imageData = currentImage?.getBase64Url() ?? '';
const { size, units } = bytesToSizeString(imageData.length);
const sizeText = document.createElement('span');
sizeText.innerText = this.localizationTable.imageSize(Math.round(size), units);
// Add a button to allow decreasing the size of large images.
const decreaseSizeButton = document.createElement('button');
decreaseSizeButton.innerText = this.localizationTable.decreaseImageSize;
decreaseSizeButton.onclick = () => {
currentImage?.decreaseSize();
};
const resetSizeButton = document.createElement('button');
resetSizeButton.innerText = this.localizationTable.resetImage;
resetSizeButton.onclick = () => {
currentImage?.reset();
};
this.statusView.replaceChildren(sizeText);
if (currentImage?.isLarge()) {
this.statusView.appendChild(decreaseSizeButton);
}
else if (currentImage?.isChanged()) {
this.statusView.appendChild(resetSizeButton);
}
else {
const hasLargeOrChangedImages = this.images
.get()
.some((image) => image.data?.isChanged() || image.data?.isLarge());
if (hasLargeOrChangedImages) {
// Still show the button -- prevents the layout from readjusting while
// scrolling through the image list
decreaseSizeButton.disabled = true;
this.statusView.appendChild(decreaseSizeButton);
}
}
}
updateInputs() {
const resetInputs = () => {
this.selectedFiles?.set([]);
this.imageAltTextInput.value = '';
this.submitButton.disabled = true;
this.statusView.innerText = '';
this.submitButton.style.display = '';
};
resetInputs();
const selectionTools = this.editor.toolController.getMatchingTools(SelectionTool);
const selectedObjects = selectionTools.map((tool) => tool.getSelectedObjects()).flat();
// Check: Is there a selected image that can be edited?
let editingImage = null;
if (selectedObjects.length === 1 && selectedObjects[0] instanceof ImageComponent) {
editingImage = selectedObjects[0];
const image = new Image();
const imageWrapper = ImageWrapper.fromSrcAndPreview(editingImage.getURL(), image, () => this.onImageDataUpdate());
imageWrapper.setAltText(editingImage.getAltText() ?? '');
this.images.set([{ data: imageWrapper, element: image }]);
}
else if (selectedObjects.length > 0) {
// If not, clear the selection.
selectionTools.forEach((tool) => tool.clearSelection());
}
// Show the submit button only when there is data to submit.
this.submitButton.style.display = 'none';
this.submitButton.onclick = async () => {
const newComponents = [];
let transform = Mat33.identity;
let fullBBox = null;
for (const { data: imageWrapper } of this.images.get()) {
if (!imageWrapper) {
continue;
}
const image = new Image();
image.src = imageWrapper.getBase64Url();
const altText = imageWrapper.getAltText();
if (altText) {
image.setAttribute('alt', altText);
}
let component;
try {
component = await ImageComponent.fromImage(image, transform);
}
catch (error) {
console.error('Error loading image', error);
this.statusView.innerText = this.localizationTable.imageLoadError(error);
return;
}
const componentBBox = component.getBBox();
if (componentBBox.area === 0) {
this.statusView.innerText = this.localizationTable.errorImageHasZeroSize;
return;
}
newComponents.push(component);
fullBBox ??= componentBBox;
fullBBox.union(componentBBox);
// Update the transform for the next item.
const shift = Vec2.of(0, componentBBox.height);
transform = transform.rightMul(Mat33.translation(shift));
}
if (newComponents.length) {
if (!fullBBox) {
throw new Error('Logic error: Full bounding box must be calculated when components are to be added.');
}
this.hideDialog();
if (editingImage) {
const eraseCommand = new Erase([editingImage]);
// Try to preserve the original width
const originalTransform = editingImage.getTransformation();
// || 1: Prevent division by zero
const originalWidth = editingImage.getBBox().width || 1;
const newWidth = fullBBox.transformedBoundingBox(originalTransform).width || 1;
const widthAdjustTransform = Mat33.scaling2D(originalWidth / newWidth);
const commands = [];
for (const component of newComponents) {
commands.push(EditorImage.addComponent(component), component.transformBy(originalTransform.rightMul(widthAdjustTransform)), component.setZIndex(editingImage.getZIndex()));
}
this.editor.dispatch(uniteCommands([...commands, eraseCommand]));
selectionTools[0]?.setSelection(newComponents);
}
else {
await this.editor.addAndCenterComponents(newComponents);
}
}
};
}
}
InsertImageWidget.nextInputId = 0;
export default InsertImageWidget;