v8.76.0

Building a basic JavaScript photo editor

We use the willRenderToolbar hook to add buttons to the UI and create a basic photo editor.

<!DOCTYPE html>

<link rel="stylesheet" href="./pintura.css" />

<div class="my-editor"></div>

<script type="module">
    import {
        appendDefaultEditor,
        createDefaultImageWriter,
        createNode,
        appendNode,
        findNode,
        insertNodeBefore,
    } from './pintura.js';

    //
    // helper functions
    //
    const h = (name, attributes, children = []) => {
        const el = document.createElement(name);
        const descriptors = Object.getOwnPropertyDescriptors(el.__proto__);
        for (const key in attributes) {
            if (key === 'style') {
                el.style.cssText = attributes[key];
            } else if (
                (descriptors[key] && descriptors[key].set) ||
                /textContent|innerHTML/.test(key) ||
                typeof attributes[key] === 'function'
            ) {
                el[key] = attributes[key];
            } else {
                el.setAttribute(key, attributes[key]);
            }
        }
        children.forEach((child) => el.appendChild(child));
        return el;
    };

    const downloadFile = (file) => {
        // Create a hidden link and set the URL using `createObjectURL`
        const link = document.createElement('a');
        link.style.display = 'none';
        link.href = URL.createObjectURL(file);
        link.download = file.name;

        // We need to add the link to the DOM for "click()" to work
        document.body.appendChild(link);
        link.click();

        // To make this work on Firefox we need to wait a short moment before clean up
        setTimeout(() => {
            URL.revokeObjectURL(link.href);
            link.parentNode.removeChild(link);
        }, 0);
    };

    const browse = () =>
        new Promise((resolve) => {
            // create file input box
            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.style.position = 'absolute';
            fileInput.style.visibility = 'hidden';
            fileInput.accept = 'image/*';
            fileInput.onchange = function () {
                const [file] = fileInput.files;

                // remove for safari
                fileInput.remove();

                if (!file) return resolve(undefined);
                resolve(file);
            };

            // add to doc for safari
            document.body.append(fileInput);

            // open browse window
            fileInput.click();
        });

    const isSafari = () =>
        /^((?!chrome|android).)*safari/i.test(navigator.userAgent);

    // safari claims to be able to copy but can't, firefox can't
    const canCopyToClipboard = !isSafari() && 'ClipboardItem' in window;

    const wrapIcon = (svg) =>
        `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${svg}</svg>`;

    const getCropSelectPresetOptions = (ratios) => [
        [undefined, 'Custom'],
        ...ratios.map(([a, b]) => [a / b, `${a}:${b}`]),
    ];

    //
    // custom menu
    //
    const attachToggleFullscreen = (editor, toolbar, env, redraw) => {
        const isFullscreen =
            document.documentElement.hasAttribute('data-fullscreen');

        insertNodeBefore(
            createNode('Button', 'toggle-fullscreen', {
                label: 'Toggle fullscreen',
                hideLabel: true,
                icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${
                    isFullscreen
                        ? '<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"></path>'
                        : '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>'
                }</svg>`,
                onclick: () => {
                    document.documentElement.toggleAttribute('data-fullscreen');
                    redraw();
                },
            }),
            'select-photo',
            toolbar[0]
        );
    };

    const attachSelectPhoto = (editor, toolbar) => {
        insertNodeBefore(
            createNode('Button', 'select-photo', {
                label: 'Select Photo',
                onclick: () => {
                    browse().then((file) => {
                        editor.src = file;
                    });
                },
            }),
            'alpha-set',
            toolbar[0]
        );
    };

    const attachExportMenu = (editor, toolbar) => {
        // skip not ready yet
        if (!editor.imageFile) return toolbar;

        // get file type
        const fileType = editor.imageFile.type || 'image/png';

        // create panel content
        const form = h(
            'form',
            {
                class: 'export-form',
                onchange: () => {
                    const { outputPNG, outputQualityRow } = window;

                    // hide quality for png
                    outputQualityRow.disabled = outputPNG.checked;

                    // get values and update editor output settings
                    editor.imageWriter = createDefaultImageWriter({
                        quality: parseFloat(outputQualityRange.value),
                        mimeType: form.elements['outputType'].value,
                    });
                },
            },
            [
                // format row
                h(
                    'fieldset',
                    { class: 'outputTypes' },
                    [
                        h('legend', { textContent: 'Image format' }),
                        // jpeg
                        h('input', {
                            id: 'outputJPEG',
                            type: 'radio',
                            name: 'outputType',
                            value: 'image/jpeg',
                            checked: fileType === 'image/jpeg',
                        }),
                        h('label', { for: 'outputJPEG', textContent: 'JPEG' }),
                        // webp

                        !isSafari() &&
                            h('input', {
                                id: 'outputWEBP',
                                type: 'radio',
                                name: 'outputType',
                                value: 'image/webp',
                                checked: fileType === 'image/webp',
                            }),

                        !isSafari() &&
                            h('label', {
                                for: 'outputWEBP',
                                textContent: 'WEBP',
                            }),

                        // png
                        h('input', {
                            id: 'outputPNG',
                            type: 'radio',
                            name: 'outputType',
                            value: 'image/png',
                            checked: fileType === 'image/png',
                        }),
                        h('label', { for: 'outputPNG', textContent: 'PNG' }),
                    ].filter(Boolean)
                ),

                // compression row
                h(
                    'fieldset',
                    {
                        id: 'outputQualityRow',
                        disabled: fileType === 'image/png',
                    },
                    [
                        h('legend', { textContent: 'Image quality' }),
                        h('div', { class: 'number-slider' }, [
                            h('input', {
                                id: 'outputQualityRange',
                                type: 'range',
                                min: 0,
                                max: 1,
                                value: 1,
                                step: 0.01,
                                oninput: () => {
                                    const {
                                        outputQualityRange,
                                        outputQualityNumber,
                                    } = window;
                                    outputQualityNumber.value = Math.round(
                                        outputQualityRange.value * 100
                                    );
                                },
                            }),
                            h('input', {
                                id: 'outputQualityNumber',
                                type: 'number',
                                min: 0,
                                value: 100,
                                max: 100,
                                step: 1,
                                oninput: () => {
                                    const {
                                        outputQualityRange,
                                        outputQualityNumber,
                                    } = window;
                                    outputQualityRange.value =
                                        outputQualityNumber.value / 100;
                                },
                            }),
                            h('span', {
                                textContent: '%',
                            }),
                        ]),
                    ]
                ),

                // download button
                h('div', { class: 'outputActions' }, [
                    h('button', {
                        type: 'button',
                        innerHTML: `<span>${wrapIcon(
                            '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line>'
                        )} <span>Download</span></span>`,
                        onclick: () => {
                            editor.processImage().then(({ dest }) => {
                                downloadFile(dest);
                            });
                        },
                    }),

                    // copy button
                    h('button', {
                        id: 'outputCopy',
                        type: 'button',
                        title: 'Can only copy PNG',
                        innerHTML: `<span>${wrapIcon(
                            '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>'
                        )}<span>Copy</span></span>`,
                        onclick: (e) => {
                            editor
                                .processImage({
                                    imageWriter: createDefaultImageWriter({
                                        mimeType: 'image/png',
                                    }),
                                })
                                .then(({ dest }) => {
                                    const item = new ClipboardItem({
                                        'image/png': dest,
                                    });

                                    navigator.clipboard
                                        .write([item])
                                        .then(() => {
                                            editor.status =
                                                'Copied to clipboard!';
                                        })
                                        .catch((error) => {
                                            editor.status =
                                                "Couldn't copy to clipboard";
                                            console.log(error);
                                        })
                                        .finally(() => {
                                            setTimeout(() => {
                                                editor.status = undefined;
                                            }, 750);
                                        });
                                });
                        },
                        ...(!canCopyToClipboard ? { hidden: true } : {}),
                    }),

                    // print
                    h('button', {
                        type: 'button',
                        title: 'Print',
                        innerHTML: `<span>${wrapIcon(
                            '<polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect>'
                        )}</svg></span>`,
                        onclick: () => {
                            editor
                                .processImage({
                                    imageWriter: createDefaultImageWriter({
                                        mimeType: 'image/png',
                                    }),
                                })
                                .then(({ dest, imageState }) => {
                                    const { width, height } = imageState.crop;
                                    const img = new Image();
                                    img.src = URL.createObjectURL(dest);
                                    img.className = 'for-printer';
                                    img.dataset.orientation =
                                        width / height >= 1
                                            ? 'landscape'
                                            : 'portrait';
                                    document.body.append(img);

                                    // write print css
                                    let pageStyle =
                                        document.getElementById('print-style');
                                    if (!pageStyle) {
                                        pageStyle = h('style', {
                                            id: 'print-style',
                                        });
                                        document.head.append(pageStyle);
                                    }

                                    // update print page orientation
                                    pageStyle.textContent = `@page {size:A4 ${img.dataset.orientation};margin:0}`;

                                    // wait for image to load just to be sure
                                    img.onload = () => {
                                        window.print();
                                    };
                                });
                        },
                    }),
                ]),
            ]
        );

        // append
        appendNode(
            createNode('Panel', 'export', {
                buttonLabel: 'Export',
                buttonClass: 'PinturaButtonExport',
                root: form,
            }),
            findNode('gamma', toolbar)
        );
    };

    // clean up for-print after print
    window.addEventListener('afterprint', () => {
        Array.from(document.querySelectorAll('.for-printer')).forEach(
            (element) => {
                const src = element.src;
                if (src) URL.revokeObjectURL(src);
                element.remove();
            }
        );
    });

    //
    // editor
    //
    const editor = appendDefaultEditor('.my-editor', {
        enableDropImage: true,
        enablePasteImage: true,
        enableBrowseImage: true,
        enableButtonExport: false,
        imageWriter: {
            quality: 1,
        },
        utils: [
            'crop',
            'filter',
            'finetune',
            'annotate',
            'frame',
            'redact',
            'resize',
        ],
        cropSelectPresetFilter: 'landscape',
        cropSelectPresetOptions: getCropSelectPresetOptions([
            [1, 1],
            [2, 1],
            [3, 2],
            [7, 5],
            [4, 3],
            [5, 4],
            [16, 10],
            [16, 9],
            [21, 9],
            [1, 2],
            [2, 3],
            [5, 7],
            [3, 4],
            [4, 5],
            [10, 16],
            [9, 16],
            [9, 21],
        ]),
    });

    editor.willRenderToolbar = (toolbar, env, redraw) => {
        attachSelectPhoto(editor, toolbar);
        attachToggleFullscreen(editor, toolbar, env, redraw);
        attachExportMenu(editor, toolbar);
        return [...toolbar];
    };
</script>

<style>
    html {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
            Helvetica, Arial, sans-serif;
    }

    body {
        padding: 40px;
    }

    /* default editor */
    .my-editor {
        height: 600px;
        box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
    }

    /* fullscreen mode */
    html[data-fullscreen] .my-editor {
        position: fixed;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        z-index: 2147483647;
        margin: 0;
    }

    /* export menu */
    .export-form {
        padding: 0.75em;
        width: 14em;
    }

    .export-form label,
    .export-form legend {
        display: block;
        font-size: 0.75em;
        margin-bottom: 0.5em;
    }

    .export-form > * + * {
        margin-top: 0.75em;
    }

    .export-form input[type='number'] {
        -moz-appearance: textfield;
        width: 2em;
        line-height: 1.75;
        font-size: 0.75em;
        padding: 0;
        border-radius: 0.3125em;
    }

    .export-form input::-webkit-outer-spin-button,
    .export-form input::-webkit-inner-spin-button {
        -webkit-appearance: none;
        margin: 0;
    }

    .export-form input[type='radio'] {
        display: none;
    }

    .export-form input + label {
        display: inline-block;
        border: 1px solid #000;
        padding: 0.25em 0.5em;
        margin-right: 0.75em;
        font-size: 0.75em;
        border-radius: 0.3125em;
        cursor: pointer;
    }

    .export-form input:checked + label {
        background: #000;
        color: #fff;
    }

    .export-form .number-slider {
        position: relative;
        display: flex;
        align-items: center;
    }

    .export-form .number-slider input[type='range'] {
        flex: 1;
    }

    .export-form .number-slider input[type='number'] {
        margin-left: 1em;
        text-align: right;
        padding-right: 1.5em;
        padding-left: 0.25em;
    }

    .export-form .number-slider span {
        position: absolute;
        font-size: 0.75em;
        right: 0;
        margin-right: 0.3125em;
    }

    .export-form fieldset[disabled] {
        filter: grayscale(100%);
        opacity: 0.25;
    }

    .export-form button {
        cursor: pointer;
        font-size: 0.75em;
        padding: 0 1em;
        line-height: 2.25;
        border-radius: 0.3125em;
        color: #000;
        background: #fdba07;
    }

    .export-form button[disabled] {
        filter: grayscale(100%);
        opacity: 0.5;
    }

    .export-form button svg {
        margin-left: -0.125em;
        margin-right: 0.5em;
        width: 1em;
        height: 1em;
    }

    .export-form button svg:only-child {
        margin: 0;
    }

    .export-form button > span {
        display: flex;
        align-items: center;
    }

    .export-form .outputActions {
        display: flex;
        justify-content: space-between;
    }

    /* range styles */
    .export-form input[type='range'] {
        -webkit-appearance: none;
    }

    .export-form input[type='range']::-moz-range-thumb {
        border: none;
        border-radius: 0.75em;
        margin-top: -0.375em;
        width: 0.75em;
        height: 0.75em;
        background: #000;
        cursor: pointer;
    }

    .export-form input[type='range']::-webkit-slider-thumb {
        -webkit-appearance: none;
        border-radius: 0.75em;
        margin-top: -0.375em;
        width: 0.75em;
        height: 0.75em;
        background: #000;
        cursor: pointer;
    }

    .export-form input[type='range']::-webkit-slider-runnable-track {
        background: #000;
        height: 1px;
    }

    .export-form input[type='range']::-moz-range-track {
        background: #000;
        height: 1px;
    }
</style>