| name | floating-image-editor |
| description | Create an interactive floating image viewer/editor with free positioning, resize, crop, inline zoom/pan, and SVG annotations. Use when building image display components that need to be freely positioned, resized, cropped, annotated with shapes/text, and zoomed inline. |
| argument-hint | [container-selector] |
Floating Image Editor Pattern
Create an interactive image component where images float freely over content, can be moved/resized by dragging, cropped visually, zoomed/panned inline, and annotated with SVG shapes.
Reference implementation: apps/pwa/public/baader-200-learn-embed.html
Architecture Overview
Container (position:relative, overflow:auto)
├── Content (text, tables, etc.)
└── .img-card (position:absolute, display:flex, flex-direction:column)
├── .img-edit-bar (controls: move, crop, annotate, remove)
├── .img-body (flex:1, overflow:hidden, position:relative)
│ ├── <div> zoom-wrapper (position:absolute, inset:0, transform-origin:0 0)
│ │ └── <img> (width:100%, height:100%, object-fit:fill)
│ ├── <svg class="ann-svg"> (annotations overlay)
│ └── zoom-hint label
├── .ann-toolbar (drawing tools, shown when annotating)
├── .img-caption
└── .img-resize-handle (corner ↘)
1. Data Model
Each image stores these fields (all optional except url):
interface FloatingImage {
url: string
caption?: string
layout?: { x: number, y: number, w?: number, h?: number }
crop?: { x: number, y: number, w: number, h: number }
annotations?: Annotation[]
}
interface Annotation {
id: string
type: 'circle' | 'rect' | 'arrow' | 'polygon' | 'text'
x: number
y: number
x2?: number
y2?: number
w?: number
h?: number
r?: number
points?: {x:number, y:number}[]
label?: string
color?: string
strokeW?: number
opacity?: number
noStroke?: boolean
}
Coordinates use percentages (0-100 for annotations, 0-1 for crop) for resolution independence.
2. CSS Requirements
Image Card (absolute positioned, flex column)
.img-card {
position: absolute;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 6px;
border: 1px solid #252a38;
background: #0a0c12;
z-index: 2;
}
Image Body (flex:1, clips content)
.img-body {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
CRITICAL: Image fills container
Problem: <img> is a "replaced element" — browsers ignore flex:1 on it and use natural size.
Solution: Wrap <img> in a regular <div> that receives flex:1:
.img-body { flex: 1; min-height: 0; overflow: hidden; position: relative; }
.img-body img { width: 100%; height: 100%; object-fit: fill; }
DO NOT put flex:1 directly on <img> — it won't work.
Crop Display
Crop uses overflow:hidden + scale/offset (NOT clip-path which leaves empty space):
.img-body.cropped img { position: absolute; object-fit: fill; }
var scaleW = (100 / crop.w).toFixed(2);
var scaleH = (100 / crop.h).toFixed(2);
var offsetX = (-crop.x / crop.w * 100).toFixed(2);
var offsetY = (-crop.y / crop.h * 100).toFixed(2);
img.style = `width:${scaleW}%; height:${scaleH}%; left:${offsetX}%; top:${offsetY}%`;
SVG Annotations Overlay
.ann-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 4;
}
.ann-svg.drawing { pointer-events: all; cursor: crosshair; }
SVG must use viewBox="0 0 100 100" with preserveAspectRatio="none" so annotation coordinates (0-100%) map directly.
3. Free Move (drag to position)
Drag the edit-bar to move the entire card:
function startMove(e) {
if (e.target.closest('.img-resize-handle') || e.target.closest('button')) return;
if (annotationModeActive) return;
var card = this;
var startX = e.clientX, startY = e.clientY;
var origLeft = card.offsetLeft, origTop = card.offsetTop;
function onMove(ev) {
card.style.left = (origLeft + ev.clientX - startX) + 'px';
card.style.top = (origTop + ev.clientY - startY) + 'px';
}
function onUp() {
image.layout.x = card.offsetLeft;
image.layout.y = card.offsetTop;
image.layout.w = card.offsetWidth;
image.layout.h = card.offsetHeight;
();
.(, onMove);
.(, onUp);
}
.(, onMove);
.(, onUp);
}
4. Free Resize (drag corner)
Resize handle at bottom-right corner changes both width AND height:
function startResize(e) {
if (annotationModeActive) return;
var card = this.closest('.img-card');
var startX = e.clientX, startY = e.clientY;
var startW = card.offsetWidth, startH = card.offsetHeight;
function onMove(ev) {
card.style.width = Math.max(80, startW + ev.clientX - startX) + 'px';
card.style.height = Math.max(60, startH + ev.clientY - startY) + 'px';
}
function onUp() {
image.layout.w = card.offsetWidth;
image.layout.h = card.offsetHeight;
save();
}
}
5. Inline Zoom & Pan
Scroll wheel zooms, drag pans when zoomed. Bounded to not show outside crop region.
Key insight: Apply transform to a wrapper div inside .img-body, not to .img-body itself (which has overflow:hidden that must stay unscaled).
var zoomWrap = document.createElement('div');
zoomWrap.style.cssText = 'position:absolute;inset:0;transform-origin:0 0;';
body.insertBefore(zoomWrap, body.firstChild);
zoomWrap.style.transform = `translate(${tx}px,${ty}px) scale(${zoom})`;
var maxTx = 0, minTx = -containerWidth * (zoom - 1);
var maxTy = 0, minTy = -containerHeight * (zoom - 1);
tx = Math.max(minTx, Math.min(maxTx, tx));
ty = Math.max(minTy, Math.min(maxTy, ty));
IMPORTANT: Disable zoom/pan when annotation mode is active.
6. SVG Annotations
Rendering (viewBox coordinates 0-100):
<svg class="ann-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<circle cx="50" cy="30" r="5" stroke="#ff4444" fill="#ff4444"
fill-opacity="0.3" stroke-width="2"/>
<rect x="10" y="10" width="20" height="15" stroke="#44aaff" fill="#44aaff"
fill-opacity="0.15" stroke-width="2"/>
<line x1="10" y1="50" x2="40" y2="50" stroke="#44ff44" stroke-width="2"
marker-end="url(#arrowhead)"/>
<polygon points= = =
= =/>
Label
Drawing flow:
- mousedown → record start point (converted to 0-100 coordinates)
- mousemove → render temporary preview shape
- mouseup → save shape to data model, re-render
Coordinate conversion:
function svgCoords(clientX, clientY, svgElement) {
var rect = svgElement.getBoundingClientRect();
return {
x: (clientX - rect.left) / rect.width * 100,
y: (clientY - rect.top) / rect.height * 100
};
}
Use SVG attributes (NOT CSS) for fill-opacity:
<circle fill-opacity="0.5" stroke-width="3" ... />
<circle style="fill-opacity:0.5; stroke-width:3" ... />
Editing existing annotations:
- Click shape → select (visual highlight with animation)
- Drag selected shape → move (update coordinates in data model)
- Edit panel: change color, opacity, stroke width, label text
- Click empty area → deselect
Locking during annotation:
When annotation mode is active, block ALL other interactions:
if (annotationModeActive) return;
7. Common Pitfalls
<img> ignores flex:1 — Always wrap in a <div> for flex sizing
- clip-path:inset() for crop leaves empty space — Use overflow:hidden + scale/offset instead
- fill-opacity in CSS style ignored in SVG — Use SVG attributes directly
- Transform on overflow:hidden container scales the clip — Apply transform to inner wrapper
- Drag events fire during annotation drawing — Check annotation state in all drag handlers
- Container needs min-height — Absolutely positioned children don't contribute to parent height; calculate from positions
8. Admin Protection
Gate all editing behind authentication. The pattern uses a lock/unlock flow:
- Initially locked (readonly)
- User clicks lock button → prompt for password
- Password verified against backend → unlock editing
- Edit bar, resize handle, annotation tools only visible when unlocked