Skip to content

Documentation

Image Editor: Non-Destructive Canvas Editing

Complete guide to the SveltyCMS Image Editor β€” covering the editing workflow for creators, widget integration for developers, Web Worker offloading, compare slider, touch gestures, and the server-side Sharp.js baking pipeline.

6/26/2026
11 min read Edit on GitHub

The SveltyCMS Image Editor gives you professional-grade media manipulation directly inside the CMS. Every edit you make is applied to a high-resolution original on the server β€” never to a degraded browser preview. Your original files always stay safe.


πŸ‘€ For Content Creators & Admins

The Goal

You need to crop, adjust, annotate, or filter an image that’s already been uploaded to the Media Gallery β€” or that’s attached to a content entry via the MediaUpload widget.

How to Edit an Image

From the Media Gallery

  1. Go to Media Gallery in the main navigation.
  2. Hover over any image thumbnail β€” action buttons appear in the top-right corner.
  3. Click the pencil icon (✏️) to launch the Image Editor with that image pre-loaded.
  4. Make your edits using the tools in the bottom toolbar.
  5. Click Save (or press Ctrl+S / ⌘S) β€” your edits are sent to the server and applied.

From a Content Entry (Widget)

  1. In any content form, upload or select an image using the MediaUpload widget.
  2. Click the Edit button on the preview to open the editor.
  3. After saving, the edited version replaces the displayed image in that entry.

Core Editing Tools

Tool What It Does
Crop Trim edges, lock to 1:1 / 4:3 / 16:9 ratios, or free-form. Rule-of-thirds grid overlay built-in.
Focal Set the β€œfocus point” of your image β€” ensures key subjects stay visible in responsive layouts.
Rotate Rotate 90Β° left/right or flip horizontally/vertically.
FineTune Adjust brightness, contrast, saturation, temperature, and more.

| Blur | Paint blur regions over sensitive information or backgrounds. | | Annotate | Draw arrows, rectangles, or add text directly onto the image. | | Watermark | Apply a preset watermark with adjustable opacity and position. |

Keyboard Shortcuts

Shortcut Action
Mod + S Save Changes: Finalizes edits and triggers server-side baking.
Mod + Z Undo: Reverts the last state change.
Mod + Shift + Z Redo: Re-applies the next state change.
Escape Cancel: Discards current edits and closes the editor.
+ / = Zoom in: Increase canvas magnification.
- Zoom out: Decrease canvas magnification.
0 Reset zoom: Return to 100% and center the image.

All shortcuts are displayed on buttons via aria-keyshortcuts and are discoverable through tooltips.

Compare Mode

Click the Compare button in the toolbar to toggle a split-screen view β€” original on the left, edited on the right β€” so you can see exactly what changed.


πŸ› οΈ For Developers & Engineers

Architecture

The editor is built on a modular widget system where each tool registers itself via import.meta.glob auto-discovery. Pixel-level processing runs on a Web Worker; server-side baking runs via Sharp.js.

β”Œβ”€ Browser (Svelte 5) ──────────────────────────────────────────┐
β”‚  editor.svelte  ←──  image-editor-store.svelte.ts             β”‚
β”‚       β”‚                                                       β”‚
β”‚  editor-canvas.svelte  ←──  filter.worker.ts (Web Worker)     β”‚
β”‚       β”‚                                                       β”‚
β”‚  β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚
β”‚  β”‚ Crop β”‚ Blur β”‚ FineTune β”‚ Annotate β”‚ Watermark β”‚            β”‚
β”‚  β”‚ FocalPoint β”‚ Rotate β”‚ Zoom       (8 widgets)  β”‚            β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚ JSON instructions (POST /api/media/manipulate/:id)
β”Œβ”€ Server (Sharp.js) ──┴────────────────────────────────────────┐
β”‚  Sharp.js Pipeline  β†’  WebP + AVIF variants  β†’  Media Store   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Canvas Engine: Modern & Lightweight

  • Engine: Powered by svelte-canvas, a thin reactive wrapper for the native Web Canvas API.
  • Bundle Size: ~8KB gzipped β€” a 90%+ reduction vs. the 500KB+ Konva.js dependency.
  • Svelte 5 Runes: Uses $state() for deep reactivity, $derived() for computed values, and $effect() for side effects. No legacy stores.

Web Worker: Offloaded Pixel Processing

Sharpness and blur convolution (3Γ—3 kernel) run on a dedicated Web Worker thread to keep the main UI at 60fps during slider adjustments. The worker lives at src/components/image-editor/workers/filter.worker.ts and is initialized automatically on canvas mount.

  • What it handles: applySharpness() β€” unsharp mask sharpening and box blur softening. buildFilterString() β€” CSS filter string assembly.
  • Zero-copy transfer: Processed ImageData buffers are transferred via postMessage(buffer, [buffer]) for minimal latency.
  • Graceful fallback: If worker creation fails (e.g., CSP restrictions), processing falls back to the main thread silently.
  • Performance: A 1024Γ—768 image with sharpness = 36 processes in ~42ms on the worker thread vs. ~120ms on the main thread.

Touch Gestures: Mobile-First Canvas Control

The canvas supports native multi-touch gestures for zoom and pan on tablets and phones:

  • Pinch-to-zoom: Two fingers scale the viewport around the midpoint (1.5px dead zone to prevent jitter).
  • Two-finger pan: Translates the canvas simultaneously during pinch.
  • Single-finger tool delegation: Routes touch events to the active tool’s mouse handlers (e.g., drag crop handles).

Compare Slider: Split-Screen Preview

Toggle a vertical split-screen view to compare the original (left half) vs. edited (right half).

  • Activate: Click the β€œCompare” button in the bottom toolbar.
  • Divider: A dashed white line separates the two halves.
  • State: Stored as imageEditorStore.compareSliderPosition β€” auto-clamps to the 0–100 range (0 = off, 1–100 = split percentage).
  • Programmatic access: Set imageEditorStore.compareSliderPosition = 50 to enable at 50%.

Widget System: Auto-Discovery

Tools are automatically discovered from src/components/image-editor/widgets/ using import.meta.glob. Each tool exports an EditorWidget shape:

// src/components/image-editor/widgets/crop/index.ts
import type { Component } from "svelte";
import Tool from "./tool.svelte";

export default {
  key: "crop",
  title: "Crop",
  icon: "mdi:crop",
  tool: Tool as unknown as Component<Record<string, unknown>>,
};

Adding a new tool:

  1. Create a folder in src/components/image-editor/widgets/your-tool/.
  2. Add an index.ts with the default export matching the EditorWidget interface.
  3. Create tool.svelte for the canvas interaction logic.
  4. It appears automatically in the editor sidebar β€” no manual registration.

Plugin Extensibility (image_editor_tool Zone)

Plugins can register custom editing tools via the image_editor_tool injection zone. These appear alongside built-in widgets in the editor sidebar and receive the current editor context (activeState, onToolSelect, hasImage) as props.

Registering a plugin tool:

// src/plugins/my-image-tool/index.ts
export default {
  metadata: { id: "my-image-tool", name: "My Image Tool", version: "1.0.0", enabled: true },
  ui: {
    slots: [
      {
        id: "my-editor-tool",
        zone: "image_editor_tool",
        component: () => import("./editor-tool.svelte"),
      },
    ],
  },
};

Tool component receives these props:

  • activeState β€” currently active tool key (string)
  • onToolSelect β€” function to activate a tool (toolKey: string) => void
  • hasImage β€” whether an image is loaded (boolean)

Server-Side Baking (Sharp.js)

Note: The image editor’s primary image processing (filters, convolution, color adjustments) is handled client-side in a Web Worker for instant feedback. The server-side step described below is an optional persistence layer that re-applies lossless transformations to the original asset β€” it is not the primary processing path.

Instead of exporting a lossy blob from the browser, the editor sends a JSON instruction set to the server:

POST /api/media/manipulate/:mediaId
Content-Type: application/json

{
  "rotation": 90,
  "flipH": false,
  "flipV": false,
  "crop": { "x": 100, "y": 50, "width": 800, "height": 600 },
  "filters": { "brightness": 10, "contrast": 5, "saturation": 0 },
  "focalPoint": { "x": 35, "y": 65 },
  "saveBehavior": "new",
  "blurRegions": [],
  "watermarks": [],
  "annotations": []
}

The server uses Sharp.js to apply these transformations to the original, uncompressed asset and returns the new variant. This guarantees:

  • Zero-loss quality: No browser canvas recompression artifacts.
  • Non-destructive: The original is never modified β€” edited versions are saved as linked variants.
  • Multi-format: WebP and AVIF variants are generated automatically.

Integration: MediaUpload Widget

The MediaUpload widget (in src/widgets/core/media-upload/) embeds the editor directly:

<!-- Inside media-upload.svelte -->
{#if showEditor}
  <ImageEditorModal
    image={value}
    {watermarkPreset}
    onsave={handleEditorSave}
    close={() => (showEditor = false)}
  />
{/if}

To integrate the editor in a custom widget:

<script lang="ts">
  import ImageEditorModal from '@src/components/image-editor/image-editor-modal.svelte';
  let showEditor = $state(false);

  async function handleEditorSave(detail: any) {
    const { mediaId, manipulations } = detail;
    const response = await fetch(`/api/media/manipulate/${mediaId}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(manipulations)
    });
    const result = await response.json();
    // Update your local state with result.data
  }
</script>

<button onclick={() => (showEditor = true)}>Edit Image</button>

{#if showEditor}
  <ImageEditorModal
    image={someMediaObject}   <!-- MediaImage | File | string -->
    onsave={handleEditorSave}
    close={() => (showEditor = false)}
  />
{/if}

ImageEditorModal Props

Prop Type Required Description
image MediaImage \| File \| string Yes The image to edit. Pass a URL string, File object, or MediaImage with _id.
watermarkPreset WatermarkOptions \| null No Pre-configured watermark to auto-apply.
onsave (detail: SaveDetail) => void Yes Called with { mediaId, manipulations, focalPoint, saveBehavior }.
close () => void Yes Called when the user cancels or closes the editor.

State Management

The image-editor-store.svelte.ts manages all editor state with Svelte 5 runes:

import { imageEditorStore } from "@src/stores/image-editor-store.svelte";

// Core state (reactive via $state)
imageEditorStore.state.zoom; // number (0.1–5)
imageEditorStore.state.rotation; // number (degrees)
imageEditorStore.state.crop; // { x, y, width, height } | null
imageEditorStore.state.filters; // Record<string, number>
imageEditorStore.state.focalPoint; // { x: number, y: number }
imageEditorStore.state.activeState; // string β€” current tool key

// Compare slider (0 = off, 1–100 = split position)
imageEditorStore.compareSliderPosition; // number (get/set, auto-clamped)

// Derived
imageEditorStore.canUndoState; // boolean
imageEditorStore.canRedoState; // boolean

// Actions
imageEditorStore.setImageElement(img); // Set the source image
imageEditorStore.switchTool("crop"); // Activate a tool
imageEditorStore.takeSnapshot(); // Save undo point
imageEditorStore.handleUndo(); // Undo
imageEditorStore.handleRedo(); // Redo
imageEditorStore.reset(); // Clear all state

Progressive Loading & Smart Shimmer

For every processed image, the server pipeline extracts:

  • Dominant Color: The primary RGB color, used as a shimmer background in the Media Gallery.
  • Tiny Placeholder: A 32Γ—32px ultra-low-quality WebP base64 string for instant previews before the full image loads.

These are stored in media.metadata and consumed by the MediaGrid component.


β™Ώ Accessibility (WCAG 2.2 AA / WCAG 3.0)

The Image Editor is built for creators of all abilities:

  • Keyboard Navigation: Arrow keys navigate between tool buttons. Enter/Space activates a tool. All crop handles are focusable and adjustable via arrow keys. Home/End jump to first/last tool. Zoom shortcuts: +/-/0.
  • ARIA Integration: The editor modal uses role="dialog" with aria-modal="true" and a focus trap. The sidebar uses role="tablist" with orientation="vertical"; tool buttons use role="tab" with aria-selected and aria-posinset/aria-setsize. The toolbar uses role="toolbar". Status changes are announced via aria-live="assertive".
  • Focus Management: When the editor opens, focus moves to the modal container. TAB loops inside the modal (focus trap). When closing, focus returns to the trigger element (Media Gallery or widget). Mod+S saves, Escape closes with confirmation.
  • Contrast: Primary actions (Save button) exceed 12:1 contrast ratio. Chrome text uses subdued opacity typical of professional dark editing UIs (similar to Lightroom / Pintura). All functional UI elements have accessible names via aria-label or visible text.
  • Touch Accessibility: Pinch-to-zoom and two-finger pan support on mobile/tablet devices.
  • CSS Architecture: Styling uses 100% Tailwind CSS v4 utilities inline β€” zero external CSS files. Design tokens (--editor-chrome-* custom properties) are defined in a single component for consistent theming.

ATAG 2.0 Compliance (Authoring Tools)

Since SveltyCMS is an authoring tool:

  1. Accessible UI: The editor interface itself is keyboard-navigable (WCAG 2.2 AA).
  2. Support Accessible Content: The MediaUpload widget prompts for alt text when altText: true is configured in the field schema. Edited images retain their alt text through the manipulation pipeline.

πŸ§ͺ Testing

The image editor is covered by 73 unit tests across four test files:

Test File Tests Coverage
tests/unit/stores/image-editor-store.test.ts 26 Undo/redo, tool switching, compare slider, zoom, rotation, reset, save behavior, error handling
tests/unit/stores/filter-worker.test.ts 12 buildFilterString(), applySharpness() (no-op, sharpen, blur, edge cases, 1Γ—1, 1024Γ—768 performance)
tests/unit/components/image-editor/widget-registry.test.ts 27 Widget validation (12 cases), defaults (8), sorting (3), disabled filtering (2), category + key lookup
tests/unit/components/image-editor/plugin-tool-slot.test.ts 8 InjectionZone acceptance, slot registration, zone isolation, server actions, props, condition filtering

Run with:

bun test tests/unit/stores/image-editor-store.test.ts tests/unit/stores/filter-worker.test.ts tests/unit/components/image-editor/widget-registry.test.ts tests/unit/components/image-editor/plugin-tool-slot.test.ts

πŸ“š Related Documentation


Related

image-editorsvelte-canvasweb-workercanvasnon-destructivesharpaccessibilitytouch-gestures
Was this page helpful?