Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions src/components/VisualPlacementEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export function VisualPlacementEditor({ documentSize, design, mockup, settings,
const aspect = documentSize.width > 0 && documentSize.height > 0 ? documentSize.width / documentSize.height : 1.5;

const metrics = useMemo(() => computePlacementMetrics(settings, designSize), [designSize, settings]);
const usesCoverClip = mode === "image" && settings.fitMode === "cover" && Boolean(design);
const targetWidth = Math.max(0.001, metrics.targetWidth);
const targetHeight = Math.max(0.001, metrics.targetHeight);

function pointerPercent(event: React.PointerEvent): { x: number; y: number } | null {
const element = ref.current;
Expand Down Expand Up @@ -177,28 +180,54 @@ export function VisualPlacementEditor({ documentSize, design, mockup, settings,
<span>{slotLabel}</span>
</div>

{usesCoverClip && design ? (
<div
className="live-editor__cover-clip"
style={{
left: `${metrics.targetLeft}%`,
top: `${metrics.targetTop}%`,
width: `${metrics.targetWidth}%`,
height: `${metrics.targetHeight}%`,
}}
>
<div
className="live-editor__cover-design"
style={{
left: `${((metrics.displayLeft - metrics.targetLeft) / targetWidth) * 100}%`,
top: `${((metrics.displayTop - metrics.targetTop) / targetHeight) * 100}%`,
width: `${(metrics.displayWidth / targetWidth) * 100}%`,
height: `${(metrics.displayHeight / targetHeight) * 100}%`,
opacity: clamp(settings.opacity, 0, 100) / 100,
transform: `rotate(${settings.rotation}deg)`,
}}
>
<img src={design.url} alt="" aria-hidden="true" />
</div>
</div>
) : null}

<div
className="live-editor__design-box live-editor__design-box--mockcity"
style={{
left: `${metrics.displayLeft}%`,
top: `${metrics.displayTop}%`,
width: `${metrics.displayWidth}%`,
height: `${metrics.displayHeight}%`,
opacity: clamp(settings.opacity, 0, 100) / 100,
opacity: usesCoverClip ? 1 : clamp(settings.opacity, 0, 100) / 100,
transform: `rotate(${settings.rotation}deg)`,
}}
onPointerDown={(event) => beginDrag(event, "design-move")}
title="Drag design"
>
{design ? (
{design && !usesCoverClip ? (
<img
src={design.url}
alt={design.file.name}
style={{ objectFit: settings.fitMode === "cover" ? "cover" : "contain" }}
/>
) : (
) : !design ? (
<span>No design</span>
)}
) : null}

<button
type="button"
Expand Down
18 changes: 18 additions & 0 deletions src/coverClip.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.live-editor__cover-clip {
overflow: hidden;
pointer-events: none;
position: absolute;
z-index: 3;
}

.live-editor__cover-design {
position: absolute;
transform-origin: center center;
}

.live-editor__cover-design img {
display: block;
height: 100%;
object-fit: fill;
width: 100%;
}
50 changes: 50 additions & 0 deletions src/lib/renderImageMockup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import type { MockupSettings } from "../types";
import { computePlacementMetrics } from "./renderPlacement";
import { clipCoverPlacement } from "./renderImageMockup";

const settings: MockupSettings = {
smartObjectName: "Image Canvas",
left: 0,
top: 0,
width: 100,
height: 100,
areaLeftPercent: 10,
areaTopPercent: 20,
areaWidthPercent: 40,
areaHeightPercent: 20,
rotation: 15,
opacity: 100,
fitMode: "cover",
anchor: "center",
};

describe("cover-mode clipping", () => {
it("clips to the target rectangle before the rotated design is drawn", () => {
const metrics = computePlacementMetrics(settings, { width: 1000, height: 2000 });
const ctx = {
beginPath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
};

expect(clipCoverPlacement(ctx, settings, metrics, 1000, 500)).toBe(true);
expect(ctx.beginPath).toHaveBeenCalledTimes(1);
expect(ctx.rect).toHaveBeenCalledWith(100, 100, 400, 100);
expect(ctx.clip).toHaveBeenCalledTimes(1);
});

it("does not clip contain mode", () => {
const containSettings = { ...settings, fitMode: "contain" as const };
const metrics = computePlacementMetrics(containSettings, { width: 1000, height: 2000 });
const ctx = {
beginPath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
};

expect(clipCoverPlacement(ctx, containSettings, metrics, 1000, 500)).toBe(false);
expect(ctx.rect).not.toHaveBeenCalled();
expect(ctx.clip).not.toHaveBeenCalled();
});
});
29 changes: 28 additions & 1 deletion src/lib/renderImageMockup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { loadImageFromFile } from "./images";
import { computePlacementMetrics, clamp } from "./renderPlacement";
import { computePlacementMetrics, clamp, type PlacementMetrics } from "./renderPlacement";
import type { MockupSettings } from "../types";

interface ClipContext {
beginPath(): void;
rect(x: number, y: number, width: number, height: number): void;
clip(): void;
}

function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
Expand All @@ -11,6 +17,26 @@ function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
});
}

export function clipCoverPlacement(
ctx: ClipContext,
settings: MockupSettings,
metrics: PlacementMetrics,
canvasWidth: number,
canvasHeight: number,
): boolean {
if (settings.fitMode !== "cover") return false;

const clipX = (metrics.targetLeft / 100) * canvasWidth;
const clipY = (metrics.targetTop / 100) * canvasHeight;
const clipW = (metrics.targetWidth / 100) * canvasWidth;
const clipH = (metrics.targetHeight / 100) * canvasHeight;

ctx.beginPath();
ctx.rect(clipX, clipY, clipW, clipH);
ctx.clip();
return true;
}

export async function renderImageMockup(mockup: File, design: File, settings: MockupSettings): Promise<Blob> {
const [mockupImage, designImage] = await Promise.all([
loadImageFromFile(mockup),
Expand Down Expand Up @@ -40,6 +66,7 @@ export async function renderImageMockup(mockup: File, design: File, settings: Mo
const drawH = (metrics.displayHeight / 100) * height;

ctx.save();
clipCoverPlacement(ctx, settings, metrics, width, height);
ctx.globalAlpha = clamp(settings.opacity, 0, 100) / 100;
ctx.translate(drawX + drawW / 2, drawY + drawH / 2);
ctx.rotate((settings.rotation * Math.PI) / 180);
Expand Down
1 change: 1 addition & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
import "./coverClip.css";

const isStaticDemo = import.meta.env.MODE === "demo";

Expand Down