Reference for the rotate / crop / free-rotation-dial / save pipeline in the web editor. This area has eaten many debugging sessions. Read this before touching any of the files listed below.
Last working fix: f4af41ea6 — "stop Svelte style= blob from wiping
cropArea width/height" (2026-04-23).
Path Role
----------------------------------------------------------------- ----------------------------
web/src/lib/components/asset-viewer/editor/editor-panel.svelte Sidebar + orchestration
web/src/lib/components/asset-viewer/editor/transform-tool/
crop-area.svelte Crop viewport + rotation dial
web/src/lib/managers/edit/transform-manager.svelte.ts Transform state + geometry
web/src/lib/managers/edit/adjust-manager.svelte.ts Light/color sliders (filter)
web/src/lib/managers/edit/edit-manager.svelte.ts Tool orchestration + save
server/src/utils/transform.ts getOutputDimensions math
server/src/repositories/media.repository.ts applyEdits (server pipeline)
The DOM hierarchy inside the crop tool:
.canvas-container
.crop-viewport (overflow:hidden, flex-center)
button.crop-area (overflow:hidden, contain:paint)
<img> (the preview image)
.crop-frame (white border + corner handles)
.overlay (dim mask with clipPath hole)
.rotation-dial-wrapper (±45° dial)
Two independent CSS transforms stack:
img.transform = scale(imageScale) rotate(freeRotation deg)
* optional scaleX(-1) / scaleY(-1) for mirror
cropArea.transform = translate(tx,ty) rotate(imageRotation) scale(cropZoom)
imageScale is the cover-scale formula, computed in crop-area.svelte:
scale = max(cosθ + (H/W)·sinθ, (W/H)·sinθ + cosθ)
At θ = 0 → 1.00. At θ = 7° on a 3:4 portrait → ~1.17. At θ = 45° → ~1.77. This exists so the rotated image still fully covers its unrotated layout box — without it, four transparent triangular wedges appear at the corners of the crop frame, and every user who has seen that has called it "rotation is broken".
imageRotation is the axis-aligned component (multiples of 90° from the
orientation buttons). freeRotation is the ±45° dial value.
normalizedRotation and totalRotation are derived sums used for save.
cropZoom is a Google-Photos-style "zoom into the crop after you resize
it" — only applied when freeRotation == 0 (see invariant #2 below).
Invariant Enforced by
---------------------------------------------------------- -----------------------------------
cropArea inline width/height == displayedImageWidth/Height applyImageSize (called from
onImageLoad, resizeCanvas)
domImg inline width/height == displayedImageWidth/Height applyImageSize
region is always inside displayedImage bounds onImageLoad, moveCrop, resizeCrop
cropZoom stays at 1 whenever freeRotation != 0 zoomToFillCrop early-return +
cropAreaStyle fallback
cropImageSize = imgElement's intrinsic dims onImageLoad
cropImageScale = min(viewportW/imgW, viewportH/imgH) calculateScale
Client transformManager.getEdits():
- If crop region differs from full display → emit Crop edit.
- Convert region: display-coords → preview-coords → original-coords.
- Apply mirror unmirror if mirror is active (crop is in unmirrored original image space on the server).
- Emit Mirror and Rotate edits (rotation includes normalized 90° increment plus freeRotation).
Server applyEdits (media.repository.ts) runs in a fixed order:
extract crop → mirror → axis-aligned rotate (0/90/180/270) →
free rotation → inscribed extract.
Server getOutputDimensions (server/src/utils/transform.ts) computes
the final asset dimensions using the same inscribed-rect formula as the
client cover-scale. Critical details:
Math.round(totalAngle/90)(NOT floor) — negative angles like -5° normalize to 355°; floor gives quadrant 3 (=270°), round gives quadrant 4 (=360°, i.e. no swap) which matches applyEdits.- Inscribed height uses the original width, not the reassigned one.
Math.abs(freeAngle)before the sin — negative angles flip sin's sign and produce iW > W otherwise.
The bug that ate ~20 iterations. If an element has inline styles set
imperatively (e.g. element.style.width = '600px') AND also has a
style={someReactiveString} binding, every time the derived re-runs
Svelte replaces the entire style attribute via setAttribute('style',
...) — wiping the imperative inline styles.
This was breaking the crop-area: applyImageSize set inline width/height, then any cropAreaStyle re-derive (on rotation, on drag, on isInteracting flip) wiped them. cropArea fell back to CSS max-width: 100% / max-height: 100%, ballooned to viewport, and the rotated image's cover-scale extension that had been clipped was suddenly visible.
Fix: use per-property directives — style:transform={...},
style:transition={...}, style:filter={...}. These go through
element.style.setProperty and don't clobber other properties.
Rule: never mix style={blob} with imperative element.style.prop = ... on the same element. Pick one.
Rejected every time (commits 881c2d92b, d81c8a5e6, d9d773748 — all reverted). Produces transparent triangular wedges inside the crop frame that users call "rotation is broken". Keep imageScale.
Rejected (commits 76426dfb8, 614cfdb09, d9d773748 — all reverted). Users expect the frame to stay where they drew it. Don't move it automatically when rotation changes.
Before the fix in commit df9ac394c/6b5b37b04, cropZoom's scale() was
applied to cropArea while imageScale's scale() was applied to .
After a crop-drag-release at 7° rotation, zoomToFillCrop would set
cropZoom to 3 and the visible image became 3 × 1.17 = 3.5×. Users
reported it as "image jumps out of scale". Fix: pin cropZoom to 1
whenever freeRotation != 0 (in cropAreaStyle and in zoomToFillCrop).
Svelte 5 tracks state reads across method-call boundaries. An effect
that calls a method which both reads this.region and writes
this.region = { ...} re-fires infinitely because the write always
creates a new object reference. Symptom: both the rotation dial and the
crop-frame became unresponsive (commit d9d773748, reverted).
Fix: value-equality guard before writing, or call from handlers
instead of an effect.
Three separate bugs in getOutputDimensions for negative angles — fixed
in a single commit earlier. See section 4. Any change to the server
math must preserve Math.round (not floor), original-width use, and
Math.abs(freeAngle).
HTMLImageElement's load event can fire synchronously when the src is
already in cache. If the listener is attached after .src = url, the
load callback may never run → transformManager stays at defaults
→ save serializes a 100×100 top-left crop.
Fix (in onActivate):
- attach
loadlistener before setting .src - after setting .src, check
img.complete && img.naturalWidth > 0and invoke onImageLoad manually
CropArea.svelte only mounts in crop mode. onImageLoad bails if cropAreaEl isn't bound. If the user saves from adjust mode, getEdits() would return state defaults — overwriting any real saved crop with a 100×100 top-left rect.
Fix: store initialEdits at onActivate; isImageLoaded flag; in
getEdits() pass through initialEdits if !isImageLoaded; rehydrate
from CropArea's onMount via rehydrateIfReady().
Creates a feedback loop because JS drives img size via applyImageSize. Observe the outer canvasContainer instead.
Needs to swap img.width and img.height when computing scale — the rotated content's bounding box is the swapped dims. Without this, a rotated portrait extends past the viewport horizontally.
Server pipeline is crop → mirror → axis-align rotate → free rotate → inscribed extract. The final step shrinks a W×H crop to
((W cosθ − H sinθ)/cos 2θ) × ((H cosθ − W sinθ)/cos 2θ). Without
compensation the editor shows W×H (via imageScale cover-up of the
rotated image) but the saved file is the inscribed output — always
narrower and at a different aspect ratio.
Fix: in TransformManager.getEdits() expand the Crop before sending,
applying the cover-scale inverse:
W' = W cosθ + H sinθ, H' = H cosθ + W sinθ. Server inscribes
W'×H' back to exactly W×H. Formula is symmetric in W,H so it works
regardless of 90°/270° axis-align rotation. Clamp to original image
bounds after expansion in case it exceeds the image edge.
HTMLImageElement's .width / .height / .naturalWidth / .complete
are plain native properties, not Svelte state. Reading them inside a
$derived.by(...) does NOT subscribe the derived to their changes —
so the derived only re-runs when other tracked deps change.
Concrete trap: on re-opening an asset with a pre-existing free
rotation, onActivate sets freeRotation before the preview finishes
loading. If imageScale is computed as
$derived.by(() => ...img.width...), the derived runs once with
img.width = 0, returns 1 (no cover-scale), and never re-runs when
the image finally loads. Result: black triangular wedges appear at
the crop-frame corners in the editor.
Fix: read a $state field that IS updated on image load. For this
editor, use transformManager.cropImageSize (set in onImageLoad)
instead of img.width / img.height.
Last working fix: commit for the "rotate → save → edit again"
bug. Search reading cropImageSize in crop-area.svelte.
Before pushing any change to the files in section 1:
- Open an untouched photo. Rotate dial to +7°. Image stays the same visual size, no wedges visible in the frame. Frame stays full-size.
- Release dial. Image stays as it was (no snap).
- Drag a crop-frame corner inward while rotated. Image must NOT stretch, zoom, or shift. Frame shrinks normally. [This is the regression gate — the old style-blob bug failed here.]
- Release the corner. No sudden zoom. Frame stays where dragged.
- Return rotation to 0°. Drag a frame corner. Frame shrinks, then on release the image zooms in to fit the crop (Google-Photos style). This is intentional behavior at 0°.
- Rotate 90° via the orientation button. Image orients correctly and fits the viewport.
- Save a rotated+cropped edit. Re-open. The editor shows the saved state with the same framing; aspect ratio did not drift.
- Save an adjust-only edit (don't enter crop mode). The saved output preserves any pre-existing crop/rotation.
- Save a rotated crop. Close the editor. Re-open on the SAME asset. The crop frame shows cover-scaled rotated content with NO black/transparent wedges at the frame corners. [Gate for the non-reactive img.width trap in §5.11.]
For diagnostics without reverting, add console.info lines tagged
[editor-debug] inside:
- transformManager.zoomToFillCrop (start + early-return branches)
- transformManager.onImageLoad (after state set)
- crop-area.svelte's cropTransform derived (log computed tx/ty/zoom)
- crop-area.svelte's imageScale derived (log theta, scale)
Always remove after debugging — the user has asked that debug logs not ship in deploys.