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.
On this page
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
- Go to Media Gallery in the main navigation.
- Hover over any image thumbnail — action buttons appear in the top-right corner.
- Click the pencil icon (✏️) to launch the Image Editor with that image pre-loaded.
- Make your edits using the tools in the left rail. The active tool’s controls sit in the bottom toolbar.
- Click Done (or press
Ctrl+S/⌘S). The editor sends instructions, and the server bakes them onto the original. - 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)
- In any content form, upload or select an image using the MediaUpload widget.
- Click the Edit button on the preview to open the editor.
- 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
ImageDatawith a transferable buffer. - Fallback: if
Workercannot 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:
- Create a folder in
src/components/image-editor/widgets/your-tool/. - Add an
index.tswith the default export matching theEditorWidgetinterface. - Create
tool.sveltefor the canvas interaction logic. - 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) => voidhasImage— 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), andMod+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"witharia-modal="true"and a focus trap. The sidebar usesrole="tablist"withorientation="vertical"; tool buttons userole="tab"witharia-selectedandaria-posinset/aria-setsize. The toolbar usesrole="toolbar". Status changes are announced viaaria-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+Ssaves.Escapeasks 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-labelor 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:
- Accessible UI: The editor interface itself is keyboard-navigable (WCAG 2.2 AA).
- Support Accessible Content: The MediaUpload widget prompts for alt text when
altText: trueis 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
- Media API Reference — Full technical spec for the
/api/media/manipulateendpoint. - MediaUpload Widget — Widget documentation with field configuration examples.
- Accessibility Guide — WCAG compliance requirements and testing procedures.
- Contributing Docs — Documentation standards and PR workflow.