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.

10/11/2026
13 min read Edit on GitHub

This is the editing task in the media library. Storage, transforms, and the media pipeline are Media system.

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 left rail. The active tool’s controls sit in the bottom toolbar.
  5. Click Done (or press Ctrl+S / ⌘S). The editor sends instructions, and the server bakes them onto the original.
  6. To leave without saving, press Escape. The editor asks you to confirm. On a phone, the close button does the same thing. The desktop toolbar has no close button.

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 the frame. Presets: free, 1:1, 4:3, 3:2, 16:9, 9:16, 2:3, plus a circle. Type exact x, y, width, and height while Crop is open.
Focal Place one focus point, stored as a 0–100 percent on each axis. Saving writes that point onto the media record.
Rotate Turn by 90°, nudge by degrees, or flip horizontally and vertically.
Fine-Tune Brightness, contrast, exposure, highlights, shadows, saturation, vibrance, temperature, tint, clarity, and sharpness. Double-click a slider to reset.
Blur Cover a region. [ and ] change its strength. Delete removes the selected region.
Annotate Text, arrow, rectangle, and ellipse.
Watermark Text or image mark, with opacity and position.
Zoom Fit, fill, or 100%. The toolbar zoom control does the same without opening this tool.

Keyboard shortcuts

Mod is Ctrl on Windows and Linux, and Command on macOS. A shortcut does not fire while the cursor is in a text or number field. Tool keys register only while that tool is active, so they win over the same key on the editor.

These keys work from any tool:

Shortcut Action
Mod+S Save and bake on the server. The button label is Done.
Mod+Z Undo.
Mod+Shift+Z, Mod+Y Redo.
Escape Ask to discard, then close.
= or + Zoom in.
- Zoom out.
Mod+0 Fit the image in the canvas.
Mod+1 Actual pixels (100%), centered.
\ Toggle the before/after wipe.
[ and ] Move the wipe by 5% while it is visible. Blur uses these keys instead.

While a tool is active:

Tool Keys
Crop F free. 1 1:1. 2 4:3. 3 16:9. 4 3:2. 5 9:16. 6 2:3.
Rotate Left/Right arrow ±1°. Shift+arrow ±0.1°. Mod+arrow ±90°. H flip horizontal. V flip vertical. 0 reset angle.
Fine-Tune Up/Down ±1 on the current slider. Shift+arrow ±10. 0 reset that slider. C compare. Shift+A auto.
Blur [ / ] strength ±5. Delete or Backspace removes the selected region.
Annotate T text. A arrow. R rectangle. O ellipse. Delete removes the selection.
Watermark T text. I image. Delete removes the selection.

The main toolbar exposes aria-keyshortcuts for save, undo, redo, zoom, and compare. Other keys are named in the control tooltips.

Compare

The toolbar Compare button (\) splits the canvas: original on the start side, edited on the end side. Drag the line, or the round handle at its middle, to move the split. The position is compareSliderPosition from 0 (off) to 100. A drag stays between 1 and 100 so the wipe does not turn itself off. Both sides use the same crop, rotation, and flip. The start side draws the source without filters.


🛠️ 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.

Preview worker

Sharpness and clarity for the on-screen preview run in src/components/image-editor/workers/filter.worker.ts. The canvas does not run an idle animation loop (autoplay is off). A change to those two sliders debounces for 48ms, scales the longest edge to at most 1600 pixels, and posts the buffer to the worker. The painted bitmap is the preview. Saving still sends JSON, and Sharp.js bakes the original.

/mediagallery does not statically import the editor. Grid and table tiles use mediaDisplayUrl() (thumbnail, sm, or md). ImageEditorModal is loaded with import() when the user clicks Edit, and the editor then loads the original URL for the canvas.

  • Preview path: applySharpness() — unsharp mask and softening. Brightness, contrast, color, and the other fine-tune sliders stay CSS filters at draw time.
  • Transfer: the worker returns ImageData with a transferable buffer.
  • Fallback: if Worker cannot be constructed, the same function runs on the main thread and the failure is logged at debug level.

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 (for example, crop handles). A touch on the compare line drags the wipe instead.

Compare slider

imageEditorStore.compareSliderPosition clamps to 0–100. 0 hides the split. Any other value is the split percentage. Set it to 50 to open the wipe in the middle. See Compare for the drag handle and keys.

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)

The canvas preview is for feedback. The saved file is produced on the server from the original asset, so the browser does not upload a recompressed canvas.

The body must wrap the instruction set. A flat object is rejected with 400 and the message “Manipulation instruction set is required”.

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

{
  "manipulations": {
    "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": []
  }
}

focalPoint is a percentage, 0–100 on each axis. A stored fraction such as { "x": 0.5, "y": 0.5 } is scaled to { "x": 50, "y": 50 } before it is written into metadata.focalPoint. Integer 1 stays 1%.

Sharp.js applies the instructions to the original. saveBehavior: "new" stores a linked variant. Any other behavior, including "overwrite", updates the current record and keeps the previous file in version metadata. WebP and AVIF variants are generated by the media pipeline after that write.

Integration: MediaUpload Widget

The MediaUpload widget and the media gallery open the editor through the modal host. They do not mount ImageEditorModal in the page template. Both load it when Edit is clicked:

const { default: ImageEditorModal } =
  await import("@src/components/image-editor/image-editor-modal.svelte");
modalState.trigger(ImageEditorModal, {
  image: value,
  watermarkPreset,
  onsave: handleEditorSave,
});

The modal host supplies close. After a successful save, the widget calls modalState.close().

The save callback posts the envelope the API requires:

await fetch(`/api/media/manipulate/${mediaId}`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ manipulations }),
});

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 "@components/image-editor/image-editor-store.svelte";

imageEditorStore.state.zoom; // number, clamped 0.1–5
imageEditorStore.state.rotation; // degrees
imageEditorStore.state.crop; // { x, y, width, height } | null
imageEditorStore.state.filters; // Record<string, number>
imageEditorStore.state.focalPoint; // { x, y } in percent, default { x: 50, y: 50 }
imageEditorStore.state.activeState; // current tool key

imageEditorStore.compareSliderPosition; // 0 off, 1–100 split
imageEditorStore.fitToView(); // Mod+0
imageEditorStore.zoomActual(); // Mod+1, zoom 1 and translation 0
imageEditorStore.zoomBy(1.15);

imageEditorStore.switchTool("crop");
imageEditorStore.takeSnapshot();
imageEditorStore.handleUndo();
imageEditorStore.handleRedo();
imageEditorStore.reset();

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: On the tool rail, arrow keys move between tools, Enter or Space activates one, and Home and End jump to the ends. Zoom is +, -, Mod+0 (fit), and Mod+1 (actual pixels). Crop size can be typed in the x, y, width, and height fields. Crop handles themselves are pointer controls.
  • 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. Closing returns focus to the control that opened it. Mod+S saves. Escape asks before discarding.
  • 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

Unit coverage for the editor lives in these Vitest files (not bun:test):

Test file What it covers
tests/unit/components/image-editor/image-editor-store.test.ts Undo/redo, tools, compare clamp, zoom, fit, actual pixels, focal default and history, reset
tests/unit/stores/filter-worker.test.ts buildFilterString() and applySharpness(), including a 1024×768 budget under 200ms
tests/unit/components/image-editor/widget-registry.test.ts Widget validation, defaults, sorting, disabled filtering, category and key lookup
tests/unit/components/image-editor/plugin-tool-slot.test.ts image_editor_tool zone registration, isolation, props, and condition filtering
tests/unit/media/focal-point.test.ts Missing point, integer percents, 0–1 fractions, and clamping
tests/unit/utils/hotkeys-plus.test.ts + still matches when Shift is held; a letter shortcut does not match Shift+letter
tests/unit/components/image-editor/editor-shortcuts.test.ts Active tool wins [; Mod+0 and bare 0 stay separate; Backspace counts as Delete
tests/unit/components/image-editor/tool-controls.test.ts Crop pixel fields, and the shortcut names on Fine-Tune, Rotate, and Zoom
tests/unit/services/media-manipulation.test.ts Sharp bake, including a fractional focal point stored as a 0–100 percent

The gallery Playwright spec tests/e2e/routes/mediagallery/image-editor.spec.ts opens the editor, selects every tool, sets a 1:1 crop from the keyboard, zooms, toggles compare, and confirms discard. tests/integration/api/media-manipulate.test.ts uploads an image and checks that rotate and crop change the stored file, and that the focal point is saved as a percent.

bun x vitest run tests/unit/components/image-editor tests/unit/stores/filter-worker.test.ts tests/unit/media/focal-point.test.ts tests/unit/utils/hotkeys-plus.test.ts tests/unit/services/media-manipulation.test.ts

📚 Related Documentation


Related

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