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;