mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
3bf1b60365
Adds a drag-to-crop modal on top of the AttachmentImage toolbar
introduced in TASK-879. The /transform endpoint already accepted
the "crop" operation shape from TASK-879 — this PR wires the editor
UI plus the supporting tests.
Editor:
- attachment-crop-modal.ts (new): pure-DOM crop modal in the same
style as the existing image lightbox. Returns a Promise that
resolves to the crop rect in ORIGINAL-IMAGE pixel coordinates
when the user clicks Apply, or null on cancel / dismiss /
image-load failure.
- Image fits to a centered <dialog> via flex layout; backdrop
click and Esc both cancel cleanly.
- Crop rectangle starts at 80% of the image, centered. Body is
a "move" handle; four corner handles resize.
- Aspect presets: Free, 1:1, 4:3, 16:9. Preset clicks snap the
current rect to the new ratio while preserving its center;
subsequent corner drags clamp to the locked ratio.
- Pointer events (touch + mouse for free) with setPointerCapture
so drag continues even if the cursor leaves the handle.
- Coordinate translation: rect in preview-pixel space →
naturalWidth / offsetWidth scale → original-image pixel
space. Result is clamped to natural bounds so a fractional-
rounding overrun doesn't push the rect off-image.
- attachment-image.ts: extracts swapNodeUuid() helper from
runRotate so runCrop can share the setNodeMarkup +
invalidate-old-metadata flow. The toolbar gains a fourth
button (⌶ Crop…) that opens the modal pointed at the original
variant. Per-format gating (refreshToolbarState) treats the
crop button identically to the rotate trio — both go through
/transform, so a libvips-only format (e.g. WebP on the pure-Go
build) disables the whole toolbar with the same explanatory
tooltip.
- app.css: full styling for the crop modal — header with aspect
toolbar, image stage with shadow-cutout overlay around the
crop rect, four corner handles, footer with Cancel + Apply.
Uses the existing CSS-variable palette so light/dark mode
track automatically.
Server tests (3 new):
- TestTransform_CropProducesNewBlobAtRectDimensions: end-to-end
PNG crop, verify the response dimensions AND that the served
bytes decode at the same dimensions (guards against an
encode-pipeline off-by-one).
- TestTransform_CropClipsToImageBounds: rect that extends past
the image boundary clips rather than 400ing — the editor's
rounding can produce rect+1px past natural width/height in
rare fractional-scale cases, and the processor's Crop
intersects with image bounds for exactly this reason.
- TestTransform_CropRejectsBadRect: missing rect, zero width,
negative xy, rect entirely outside → 400.
Parent: PLAN-866. Closes the editor-side image-tools track on top of
TASK-878 (Processor) and TASK-879 (rotate / transform endpoint).
418 lines
14 KiB
Go
418 lines
14 KiB
Go
//go:build !libvips
|
||
|
||
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"image"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// transformRequestBody marshals a transform request for tests. Mirrors
|
||
// the handler's internal struct so behavior under unknown / malformed
|
||
// bodies is observable.
|
||
type transformRequestBody struct {
|
||
Operation string `json:"operation"`
|
||
Degrees int `json:"degrees,omitempty"`
|
||
Rect *struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
W int `json:"w"`
|
||
H int `json:"h"`
|
||
} `json:"rect,omitempty"`
|
||
}
|
||
|
||
func doTransform(srv *Server, slug, attachmentID string, body transformRequestBody) *httptest.ResponseRecorder {
|
||
buf, _ := json.Marshal(body)
|
||
req := httptest.NewRequest("POST",
|
||
"/api/v1/workspaces/"+slug+"/attachments/"+attachmentID+"/transform",
|
||
bytes.NewReader(buf))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.RemoteAddr = "127.0.0.1:1234"
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
return rr
|
||
}
|
||
|
||
func uploadIntegrationPNG(t *testing.T, srv *Server, slug string, w, h int) (id, urlPath string) {
|
||
t.Helper()
|
||
body := makeIntegrationPNG(t, w, h)
|
||
rr := doMultipartUpload(srv, slug, "shot.png", body)
|
||
if rr.Code != http.StatusCreated {
|
||
t.Fatalf("seed upload status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
var resp struct {
|
||
ID string `json:"id"`
|
||
URL string `json:"url"`
|
||
}
|
||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||
t.Fatalf("decode upload resp: %v", err)
|
||
}
|
||
return resp.ID, resp.URL
|
||
}
|
||
|
||
func TestTransform_Rotate90SwapsWidthHeight(t *testing.T) {
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 800, 400)
|
||
srv.Stop() // drain thumbnail goroutine
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "rotate", Degrees: 90})
|
||
if rr.Code != http.StatusCreated {
|
||
t.Fatalf("rotate status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
var resp struct {
|
||
ID string `json:"id"`
|
||
Mime string `json:"mime"`
|
||
Width *int `json:"width"`
|
||
Height *int `json:"height"`
|
||
}
|
||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||
t.Fatalf("decode response: %v", err)
|
||
}
|
||
if resp.ID == "" || resp.ID == id {
|
||
t.Errorf("expected new attachment id, got %q (parent was %q)", resp.ID, id)
|
||
}
|
||
if resp.Width == nil || resp.Height == nil {
|
||
t.Fatal("response missing width/height")
|
||
}
|
||
if *resp.Width != 400 || *resp.Height != 800 {
|
||
t.Errorf("rotated dims = %dx%d, want 400x800 (90° swap)", *resp.Width, *resp.Height)
|
||
}
|
||
// PNG input → PNG output (preserves alpha policy).
|
||
if resp.Mime != "image/png" {
|
||
t.Errorf("rotated MIME = %q, want image/png", resp.Mime)
|
||
}
|
||
}
|
||
|
||
func TestTransform_Rotate180KeepsDimensions(t *testing.T) {
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 800, 400)
|
||
srv.Stop()
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "rotate", Degrees: 180})
|
||
if rr.Code != http.StatusCreated {
|
||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
var resp struct{ Width, Height *int }
|
||
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
|
||
if resp.Width == nil || resp.Height == nil || *resp.Width != 800 || *resp.Height != 400 {
|
||
t.Errorf("180° dims = %v×%v, want 800×400", resp.Width, resp.Height)
|
||
}
|
||
}
|
||
|
||
func TestTransform_RotateRejectsBadDegrees(t *testing.T) {
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 100, 100)
|
||
srv.Stop()
|
||
|
||
cases := []int{0, 45, 91, 360, -90}
|
||
for _, deg := range cases {
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "rotate", Degrees: deg})
|
||
if rr.Code != http.StatusBadRequest {
|
||
t.Errorf("degrees=%d status = %d, want 400", deg, rr.Code)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestTransform_UnknownOperationIs400(t *testing.T) {
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 100, 100)
|
||
srv.Stop()
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "polarize"})
|
||
if rr.Code != http.StatusBadRequest {
|
||
t.Errorf("unknown op status = %d, want 400", rr.Code)
|
||
}
|
||
}
|
||
|
||
func TestTransform_NonExistentAttachmentIs404(t *testing.T) {
|
||
srv, slug := testServerWithAttachments(t)
|
||
defer srv.Stop()
|
||
|
||
rr := doTransform(srv, slug, "00000000-0000-0000-0000-000000000000",
|
||
transformRequestBody{Operation: "rotate", Degrees: 90})
|
||
if rr.Code != http.StatusNotFound {
|
||
t.Errorf("status = %d, want 404", rr.Code)
|
||
}
|
||
}
|
||
|
||
func TestTransform_CrossWorkspaceReturns404(t *testing.T) {
|
||
// Upload into A, attempt transform from B — must be 404, not 403.
|
||
srv, slugA := testServerWithAttachments(t)
|
||
slugB := createWSForTest(t, srv)
|
||
id, _ := uploadIntegrationPNG(t, srv, slugA, 100, 100)
|
||
srv.Stop()
|
||
|
||
rr := doTransform(srv, slugB, id, transformRequestBody{Operation: "rotate", Degrees: 90})
|
||
if rr.Code != http.StatusNotFound {
|
||
t.Errorf("cross-workspace status = %d, want 404 (NOT 403)", rr.Code)
|
||
}
|
||
}
|
||
|
||
func TestTransform_DisabledWhenNoProcessor(t *testing.T) {
|
||
// Upload via a server WITH a processor (so we have a real attachment to point at),
|
||
// then unwire the processor and verify transform reports the disabled state.
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 100, 100)
|
||
srv.Stop()
|
||
|
||
srv.SetImageProcessor(nil)
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "rotate", Degrees: 90})
|
||
if rr.Code != http.StatusServiceUnavailable {
|
||
t.Errorf("no-processor status = %d, want 503", rr.Code)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), "image_processor_disabled") {
|
||
t.Errorf("error code missing from body: %s", rr.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestTransform_ProducesNewBlob(t *testing.T) {
|
||
// Sanity: rotating an image creates a fresh content-addressed
|
||
// blob with a different hash + size. Without this, a regression
|
||
// that returned the parent's row would silently break the
|
||
// editor's UUID-swap flow.
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 800, 400)
|
||
srv.Stop()
|
||
|
||
parent, err := srv.store.GetAttachment(id)
|
||
if err != nil || parent == nil {
|
||
t.Fatalf("get parent: %v", err)
|
||
}
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "rotate", Degrees: 90})
|
||
if rr.Code != http.StatusCreated {
|
||
t.Fatalf("status = %d", rr.Code)
|
||
}
|
||
var resp struct{ ID string }
|
||
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
|
||
|
||
derived, err := srv.store.GetAttachment(resp.ID)
|
||
if err != nil || derived == nil {
|
||
t.Fatalf("get derived: %v", err)
|
||
}
|
||
if derived.ContentHash == parent.ContentHash {
|
||
t.Errorf("derived hash matches parent (%s) — transform did not produce new bytes", parent.ContentHash)
|
||
}
|
||
// New row must inherit workspace + uploaded_by + item_id from parent.
|
||
if derived.WorkspaceID != parent.WorkspaceID {
|
||
t.Errorf("derived WorkspaceID = %q, want %q", derived.WorkspaceID, parent.WorkspaceID)
|
||
}
|
||
// Filename should carry the operation tag so a direct download
|
||
// surfaces what happened (UI nicety; the transform endpoint
|
||
// guarantees this filename shape).
|
||
if !strings.Contains(derived.Filename, "rotate") {
|
||
t.Errorf("derived filename %q missing rotate tag", derived.Filename)
|
||
}
|
||
}
|
||
|
||
func TestTransform_ServedDownloadDecodesAtNewDimensions(t *testing.T) {
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 800, 400)
|
||
srv.Stop()
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "rotate", Degrees: 90})
|
||
if rr.Code != http.StatusCreated {
|
||
t.Fatalf("status = %d", rr.Code)
|
||
}
|
||
var resp struct {
|
||
ID string `json:"id"`
|
||
URL string `json:"url"`
|
||
}
|
||
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
|
||
|
||
// Fetch the new blob via the standard GET handler.
|
||
req := httptest.NewRequest("GET", resp.URL, nil)
|
||
req.RemoteAddr = "127.0.0.1:1234"
|
||
getRR := httptest.NewRecorder()
|
||
srv.ServeHTTP(getRR, req)
|
||
if getRR.Code != http.StatusOK {
|
||
t.Fatalf("get rotated status = %d", getRR.Code)
|
||
}
|
||
out, _, err := image.Decode(bytes.NewReader(getRR.Body.Bytes()))
|
||
if err != nil {
|
||
t.Fatalf("decode rotated bytes: %v", err)
|
||
}
|
||
if out.Bounds().Dx() != 400 || out.Bounds().Dy() != 800 {
|
||
t.Errorf("served rotated dims = %v, want 400x800", out.Bounds())
|
||
}
|
||
}
|
||
|
||
func TestTransform_CropProducesNewBlobAtRectDimensions(t *testing.T) {
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 800, 400)
|
||
srv.Stop()
|
||
|
||
rect := &struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
W int `json:"w"`
|
||
H int `json:"h"`
|
||
}{X: 100, Y: 50, W: 300, H: 200}
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "crop", Rect: rect})
|
||
if rr.Code != http.StatusCreated {
|
||
t.Fatalf("crop status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
var resp struct {
|
||
ID string `json:"id"`
|
||
Width *int `json:"width"`
|
||
Height *int `json:"height"`
|
||
}
|
||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if resp.Width == nil || resp.Height == nil {
|
||
t.Fatal("response missing dimensions")
|
||
}
|
||
if *resp.Width != rect.W || *resp.Height != rect.H {
|
||
t.Errorf("crop dims = %dx%d, want %dx%d", *resp.Width, *resp.Height, rect.W, rect.H)
|
||
}
|
||
// Decode the served bytes to double-check the dimensions
|
||
// — guards against an off-by-one in the encode pipeline.
|
||
getReq := httptest.NewRequest("GET", "/api/v1/workspaces/"+slug+"/attachments/"+resp.ID, nil)
|
||
getReq.RemoteAddr = "127.0.0.1:1234"
|
||
getRR := httptest.NewRecorder()
|
||
srv.ServeHTTP(getRR, getReq)
|
||
if getRR.Code != http.StatusOK {
|
||
t.Fatalf("get cropped status = %d", getRR.Code)
|
||
}
|
||
out, _, err := image.Decode(bytes.NewReader(getRR.Body.Bytes()))
|
||
if err != nil {
|
||
t.Fatalf("decode cropped bytes: %v", err)
|
||
}
|
||
if out.Bounds().Dx() != rect.W || out.Bounds().Dy() != rect.H {
|
||
t.Errorf("served crop dims = %v, want %dx%d", out.Bounds(), rect.W, rect.H)
|
||
}
|
||
}
|
||
|
||
func TestTransform_CropClipsToImageBounds(t *testing.T) {
|
||
// Rect that extends past the image must clip rather than 400.
|
||
// The processor's Crop intersects with image bounds and the
|
||
// editor relies on this — its rounding can yield rect+1px past
|
||
// the natural width/height in rare fractional-scale cases.
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 100, 100)
|
||
srv.Stop()
|
||
|
||
rect := &struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
W int `json:"w"`
|
||
H int `json:"h"`
|
||
}{X: 50, Y: 50, W: 200, H: 200}
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "crop", Rect: rect})
|
||
if rr.Code != http.StatusCreated {
|
||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
var resp struct{ Width, Height *int }
|
||
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
|
||
// Clipped to (50,50)..(100,100) → 50×50 result.
|
||
if resp.Width == nil || resp.Height == nil || *resp.Width != 50 || *resp.Height != 50 {
|
||
t.Errorf("clipped dims = %v×%v, want 50×50", resp.Width, resp.Height)
|
||
}
|
||
}
|
||
|
||
func TestTransform_CropRejectsBadRect(t *testing.T) {
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 100, 100)
|
||
srv.Stop()
|
||
|
||
cases := []struct {
|
||
name string
|
||
body transformRequestBody
|
||
}{
|
||
{"missing rect", transformRequestBody{Operation: "crop"}},
|
||
{"zero width", transformRequestBody{Operation: "crop", Rect: &struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
W int `json:"w"`
|
||
H int `json:"h"`
|
||
}{0, 0, 0, 100}}},
|
||
{"negative xy", transformRequestBody{Operation: "crop", Rect: &struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
W int `json:"w"`
|
||
H int `json:"h"`
|
||
}{-10, -10, 50, 50}}},
|
||
{"rect entirely outside", transformRequestBody{Operation: "crop", Rect: &struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
W int `json:"w"`
|
||
H int `json:"h"`
|
||
}{500, 500, 100, 100}}},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
rr := doTransform(srv, slug, id, c.body)
|
||
if rr.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", rr.Code)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestTransform_DerivedRowInheritsUploadedByFromParent(t *testing.T) {
|
||
// A user who rotates someone else's upload should NOT take
|
||
// ownership of the resulting blob. The transform pipeline
|
||
// inherits parent.UploadedBy (same policy as the thumbnail
|
||
// pipeline) so audit attribution stays anchored to the
|
||
// original uploader.
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 800, 400)
|
||
srv.Stop()
|
||
|
||
parent, _ := srv.store.GetAttachment(id)
|
||
if parent == nil {
|
||
t.Fatal("parent missing")
|
||
}
|
||
parentUploader := parent.UploadedBy
|
||
|
||
rr := doTransform(srv, slug, id, transformRequestBody{Operation: "rotate", Degrees: 90})
|
||
if rr.Code != http.StatusCreated {
|
||
t.Fatalf("status = %d", rr.Code)
|
||
}
|
||
var resp struct{ ID string }
|
||
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
|
||
|
||
derived, _ := srv.store.GetAttachment(resp.ID)
|
||
if derived == nil {
|
||
t.Fatal("derived missing")
|
||
}
|
||
if derived.UploadedBy != parentUploader {
|
||
t.Errorf("derived UploadedBy = %q, want %q (inherited from parent)",
|
||
derived.UploadedBy, parentUploader)
|
||
}
|
||
}
|
||
|
||
func TestTransform_DeletedParentReturns404(t *testing.T) {
|
||
// Edge: the editor sends a transform after the user soft-deleted
|
||
// the original (race or stale UI). 404, not 500 / corrupt blob.
|
||
srv, slug := testServerWithAttachments(t)
|
||
id, _ := uploadIntegrationPNG(t, srv, slug, 100, 100)
|
||
srv.Stop()
|
||
|
||
// Manually soft-delete via the store's CreateAttachment-style row update.
|
||
// The handler checks DeletedAt — set it to a non-nil time.
|
||
parent, _ := srv.store.GetAttachment(id)
|
||
if parent == nil {
|
||
t.Fatal("seed attachment missing")
|
||
}
|
||
// We need a soft-delete path. The store doesn't expose one yet (TASK-886
|
||
// will add the GC path), so this test simulates the scenario by
|
||
// constructing a request against an ID that was never created.
|
||
// The shape is identical for the handler's purposes — both go through
|
||
// the same nil/DeletedAt check.
|
||
rr := doTransform(srv, slug, "00000000-0000-0000-0000-000000000000",
|
||
transformRequestBody{Operation: "rotate", Degrees: 90})
|
||
if rr.Code != http.StatusNotFound {
|
||
t.Errorf("status = %d, want 404", rr.Code)
|
||
}
|
||
}
|