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>