v1.6.0

<bmx-image-editor>

A picture editor for the page: crop to a shape, rotate, flip and straighten; adjust light and colour; apply a look; draw arrows, boxes, text, highlights and numbered steps; redact anything private for good; and save at a size and format, as a file, a blob or straight into a form.

23 properties · 7 events · 18 methods · 5 parts

Example

Open one of your own phone photos (drop it on the editor, paste it, or press Choose a picture after Clear) to see the location and camera details it carries, and that the saved copy has none.

Clear Copy the edits as JSON

Edit each picture before it is uploaded

Show markup
<bmx-image-editor
  id="ex-image"
  lang="en-GB"
  label="Photo editor"
  aspect-ratios="free,original,1:1,4:5,3:2,16:9"
  actions="reset,download"
  type="image/jpeg"
  style="--bmx-image-editor-height: 40rem"
></bmx-image-editor>

<p class="note" id="ex-image-out">Open one of your own phone photos (drop it on the editor, paste it, or press Choose a picture after Clear) to see the location and camera details it carries, and that the saved copy has none.</p>

<div class="row" style="gap: 0.5rem; flex-wrap: wrap">
  <bmx-button variant="outline" tone="neutral" id="ex-image-clear">Clear</bmx-button>
  <bmx-button variant="outline" tone="neutral" id="ex-image-json">Copy the edits as JSON</bmx-button>
</div>

<h3>Edit each picture before it is uploaded</h3>

<bmx-upload
  id="ex-image-upload"
  label="Profile picture"
  accept="image/*"
  max-size="2097152"
  auto-upload="false"
  description="Choose a photo: crop it round, adjust it, then press Done. It is resized to 512 pixels, cleaned of its metadata, and kept under 2 MB."
></bmx-upload>

<bmx-dialog id="ex-image-dialog" heading="Crop your picture" size="lg" dismissible="false">
  <bmx-image-editor
    id="ex-image-avatar"
    tools="crop,adjust,filter"
    aspect="1:1"
    lock-aspect
    crop-shape="circle"
    max-width="512"
    max-height="512"
    type="image/png"
    actions="cancel,apply"
    style="--bmx-image-editor-height: 30rem"
  ></bmx-image-editor>
</bmx-dialog>

<script type="module">
  await Promise.all(['bmx-image-editor', 'bmx-upload', 'bmx-dialog'].map(tag => customElements.whenDefined(tag)));

  const editor = document.getElementById('ex-image');
  const out = document.getElementById('ex-image-out');

  // A stand-in photo drawn on the page, with a parcel label to redact.
  const sample = () => {
    const c = document.createElement('canvas');
    c.width = 1600;
    c.height = 1067;
    const g = c.getContext('2d');
    const sky = g.createLinearGradient(0, 0, 0, 640);
    sky.addColorStop(0, '#5b8fc7');
    sky.addColorStop(1, '#f2c48d');
    g.fillStyle = sky;
    g.fillRect(0, 0, 1600, 1067);
    g.fillStyle = '#fff3c4';
    g.beginPath();
    g.arc(1180, 330, 70, 0, Math.PI * 2);
    g.fill();
    const hill = (color, base, peaks) => {
      g.fillStyle = color;
      g.beginPath();
      g.moveTo(0, 1067);
      peaks.forEach(([x, y]) => g.lineTo(x, y));
      g.lineTo(1600, base);
      g.lineTo(1600, 1067);
      g.fill();
    };
    hill('#7d6a8f', 560, [[0, 520], [260, 330], [480, 470], [760, 260], [1040, 480], [1300, 380], [1600, 520]]);
    hill('#4c5d6e', 640, [[0, 640], [300, 520], [620, 620], [900, 500], [1240, 640], [1600, 560]]);
    const lake = g.createLinearGradient(0, 660, 0, 1067);
    lake.addColorStop(0, '#6f97b8');
    lake.addColorStop(1, '#2d4a63');
    g.fillStyle = lake;
    g.fillRect(0, 680, 1600, 387);
    g.fillStyle = '#2f3b2a';
    g.fillRect(0, 900, 1600, 167);
    g.save();
    g.translate(980, 780);
    g.rotate(-0.06);
    g.fillStyle = '#f7f3ea';
    g.fillRect(0, 0, 440, 230);
    g.fillStyle = '#1f2933';
    g.font = '600 30px system-ui, sans-serif';
    g.fillText('DELIVER TO', 28, 52);
    g.font = '28px system-ui, sans-serif';
    ['Ann Lee', '14 Harbour Road', 'Portree IV51 9EX'].forEach((line, i) => g.fillText(line, 28, 100 + i * 40));
    g.restore();
    return new Promise(resolve => c.toBlob(resolve, 'image/jpeg', 0.92));
  };

  await editor.loadImage(await sample(), 'lakeside.jpg');

  editor.addEventListener('bmxImageLoad', e => {
    const m = e.detail.metadata;
    out.textContent = m?.identifying
      ? `Opened ${e.detail.name}: ${[m.location && 'a location', m.camera && `the camera (${m.camera})`, m.serialNumber && 'a serial number', m.taken && 'the time it was taken'].filter(Boolean).join(', ')}. None of it is saved.`
      : `Opened ${e.detail.name}, ${e.detail.width} × ${e.detail.height}. It carries nothing private.`;
  });
  editor.addEventListener('bmxImageExport', e => (out.textContent = `Saved ${e.detail.file.name}, ${e.detail.width} × ${e.detail.height}, ${Math.round(e.detail.file.size / 1024)} KB.`));

  document.getElementById('ex-image-clear').addEventListener('click', () => editor.clear());
  document.getElementById('ex-image-json').addEventListener('click', async () => {
    const json = JSON.stringify(await editor.getEdits());
    await navigator.clipboard?.writeText(json).catch(() => undefined);
    out.textContent = `Edits: ${json.length > 160 ? `${json.slice(0, 160)}…` : json}`;
  });

  // The upload hands each file to the editor and waits for Done or Cancel.
  const dialog = document.getElementById('ex-image-dialog');
  const avatar = document.getElementById('ex-image-avatar');
  document.getElementById('ex-image-upload').prepareFile = async file => {
    if (!file.type.startsWith('image/')) return file;
    await dialog.openDialog();
    const edited = await avatar.editFile(file);
    await dialog.closeDialog();
    return edited;
  };
</script>

Everything happens in the browser. The picture is never sent anywhere, and what is saved carries no location, camera, owner or other details the original held - the editor reports what it found and removes it. A picture that was not changed is cleaned without being compressed again.

const file = await editor.editFile(fileFromUpload); // resolves on Done, or null on Cancel

The edits are plain JSON (getEdits, setEdits), so they can be stored and opened again on the same picture later.

Properties

PropertyAttributeTypeDefaultDescription
actions actions readonly BmxImageAction[] | string 'reset,download' The buttons in the footer: any of reset, cancel, apply, download. editFile adds cancel and apply while it waits.
aspect aspect string — The crop shape at the start ("1:1"). With lock-aspect, the only one.
aspectRatios aspect-ratios readonly string[] | string 'free,original,1:1,4:5,3:2,4:3,16:9' The crop shapes offered: "free", "original", or ratios such as "16:9".
background background string '#ffffff' Painted behind transparent parts when saving as JPEG.
cropShape crop-shape 'rect' | 'circle' 'rect' Show the crop as a circle, and save the picture round (transparent outside it in PNG and WebP).
disabled disabled boolean false
edits edits Partial<BmxImageEdits> | string — Edits to apply to the picture when it opens (JSON in markup). Kept up to date as the picture is edited.
fileName file-name string — The saved file's name. Default: the original's, with the new format's extension.
fontFamily font-family string 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' The font text marks are drawn in, as CSS font-family.
label label string — What the editor is, for screen readers.
lockAspect lock-aspect boolean false Keep the crop to aspect and offer no other shape: an avatar, a banner.
maxHeight max-height number — The tallest the saved picture may be.
maxPixels max-pixels number 16_777_216 The most pixels the saved picture may have. Default 16.7 million, the most some phones can draw.
maxWidth max-width number — The widest the saved picture may be.
name name string — The form field name. The edited picture is submitted as a file.
palette palette readonly string[] | string ['#e03131', '#f08c00', '#ffd43b', '#2f9e44', '#1971c2', '#7048e8', '#111111', '#ffffff'] Colours offered for marks: a list, or comma-separated in markup.
quality quality number 0.9 Quality for JPEG and WebP, from 0 to 1.
required required boolean false A picture must be opened before the form can be sent.
src src string — The picture to open: a URL (a picture from another site must allow CORS), a data URL or an object URL. Or call loadImage with a file.
strings strings Partial<BmxImageEditorStrings> | string — Words the editor shows or says: any of BmxImageEditorStrings. JSON in markup.
tool tool BmxImageTool — The tool open at the start.
tools tools readonly BmxImageTool[] | string ['crop', 'adjust', 'filter', 'annotate', 'redact', 'resize'] The tools offered, in order: any of crop, adjust, filter, annotate, redact, resize. A list, or comma-separated in markup.
type type string — The format saved: image/jpeg, image/png or image/webp. Default: the original's, or PNG.

Events

EventDetailDescription
bmxImageApply BmxImageFileDetail The reader pressed Done: the finished file.
bmxImageCancel void The reader pressed Cancel.
bmxImageEdit BmxImageEditDetail The edits changed.
bmxImageError BmxImageErrorDetail A picture could not be opened or saved.
bmxImageExport BmxImageFileDetail The picture was saved: downloaded, or made into a file or blob.
bmxImageLoad BmxImageLoadDetail A picture opened. metadata says what it carried besides pixels.
bmxImageToolChange BmxImageToolDetail The reader chose another tool.

Methods

MethodSignatureDescription
cleanFile cleanFile(file: Blob, name?: string) => Promise<File> Takes the location, camera and other details out of any JPEG, PNG or WebP file, without changing a pixel or compressing it again. Needs no picture open.
clear clear() => Promise<void> Closes the picture.
download download(name?: string, options?: BmxImageExportOptions) => Promise<void> Saves the edited picture to the reader's downloads.
editFile editFile(file: Blob, name?: string) => Promise<File | null> Opens a file to edit and waits: resolves with the edited file when the reader presses Done, or null when they press Cancel. For an upload field: upload.prepareFile = file => editor.editFile(file).
flip flip(axis?: "horizontal" | "vertical") => Promise<void> Mirrors the picture.
getEdits getEdits() => Promise<BmxImageEdits> The edits, as JSON-ready data.
getMetadata getMetadata() => Promise<BmxImageMetadata | null> What the open picture carried besides pixels (location, camera, owner), or null.
loadImage loadImage(source: BmxImageSource, name?: string) => Promise<void> Opens a picture: a file, a blob, a URL, an <img>, a canvas, an ImageBitmap or ImageData. Resolves when it is shown.
redo redo() => Promise<void>
reset reset() => Promise<void> Back to the picture as it opened. Undo can step back from it.
rotate rotate(direction?: "left" | "right") => Promise<void> Turns the picture a quarter.
setEdits setEdits(edits: Partial<BmxImageEdits> | string) => Promise<void> Replaces the edits, from data or JSON. Undo can step back from it.
setFocus setFocus(options?: FocusOptions) => Promise<void>
setTool setTool(tool: BmxImageTool) => Promise<void> Opens a tool.
toBlob toBlob(options?: BmxImageExportOptions) => Promise<Blob> The edited picture as a Blob.
toDataURL toDataURL(options?: BmxImageExportOptions) => Promise<string> The edited picture as a data URL.
toFile toFile(name?: string, options?: BmxImageExportOptions) => Promise<File> The edited picture as a File.
undo undo() => Promise<void>

CSS shadow parts

PartDescription
empty what shows before a picture is opened.
footer the size, the privacy note and the actions.
panel the current tool's settings.
stage the area the picture is shown in.
toolbar the row of tools and commands.

CSS custom properties

PropertyDescription
--bmx-image-editor-crop The crop's edge and handles.
--bmx-image-editor-height How tall the editor is. Default 36rem.
--bmx-image-editor-selection The outline and handles of the selected mark.
--bmx-image-editor-shade Over the part the crop leaves out.
--bmx-image-editor-stage Behind the picture.