Skip to content
Open
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
103 changes: 17 additions & 86 deletions frontend/components/Item/CreateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -235,16 +235,7 @@
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
size="icon"
type="button"
variant="default"
@click.prevent="
async () => {
await rotateBase64Image90Deg(photo.fileBase64, index);
}
"
>
<Button size="icon" type="button" variant="default" @click.prevent="rotatePhoto(index)">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard rotate against concurrent clicks to avoid lost rotations.

rotatePhoto can run concurrently for the same index; rapid clicks may apply only one effective 90° turn instead of cumulative turns.

🛠️ Suggested fix (per-photo in-flight guard + button disable)
+  const rotatingPhotoIndexes = ref<Set<number>>(new Set());
+
   async function rotatePhoto(index: number) {
     const photo = form.photos[index];
-    if (!photo) {
+    if (!photo || rotatingPhotoIndexes.value.has(index)) {
       return;
     }

     try {
+      rotatingPhotoIndexes.value.add(index);
       photo.fileBase64 = await rotateImageDataUrl90Deg(photo.fileBase64);
       photo.file = dataUrlToFile(photo.fileBase64, photo.photoName);
     } catch (error) {
       toast.error(t("components.item.create_modal.toast.rotate_process_failed"));
       console.error(error);
+    } finally {
+      rotatingPhotoIndexes.value.delete(index);
     }
   }
-                  <Button size="icon" type="button" variant="default" `@click.prevent`="rotatePhoto(index)">
+                  <Button
+                    size="icon"
+                    type="button"
+                    variant="default"
+                    :disabled="rotatingPhotoIndexes.has(index)"
+                    `@click.prevent`="rotatePhoto(index)"
+                  >

Also applies to: 560-573

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/Item/CreateModal.vue` at line 238, rotatePhoto can be
invoked concurrently for the same index, losing intermediate rotations; add a
per-photo in-flight guard (e.g., a Set or map keyed by index) in the component
state used by rotatePhoto to return early if that index is already processing,
and also bind the rotate button's disabled state (the Button with
`@click.prevent`="rotatePhoto(index)") to that in-flight flag so rapid clicks are
ignored until the rotation promise completes; ensure the guard is cleared when
rotatePhoto's async work finishes or errors so future rotations are allowed.

<MdiRotateClockwise />
<div class="sr-only">{{ $t("components.item.create_modal.rotate_photo") }}</div>
</Button>
Expand Down Expand Up @@ -566,6 +557,21 @@
}
}

async function rotatePhoto(index: number) {
const photo = form.photos[index];
if (!photo) {
return;
}

try {
photo.fileBase64 = await rotateImageDataUrl90Deg(photo.fileBase64);
photo.file = dataUrlToFile(photo.fileBase64, photo.photoName);
} catch (error) {
toast.error(t("components.item.create_modal.toast.rotate_process_failed"));
console.error(error);
}
}

onMounted(() => {
const cleanup = registerOpenDialogCallback(DialogID.CreateItem, async params => {
// needed since URL will be cleared in the next step => ParentId Selection should stay though
Expand Down Expand Up @@ -617,7 +623,7 @@
photoName: "product_view.jpg",
fileBase64: params.product.imageBase64,
primary: form.photos.length === 0,
file: dataURLtoFile(params.product.imageBase64, "product_view.jpg"),
file: dataUrlToFile(params.product.imageBase64, "product_view.jpg"),
});
}
}
Expand Down Expand Up @@ -730,81 +736,6 @@
}
}

function dataURLtoFile(dataURL: string, fileName: string) {
try {
const arr = dataURL.split(",");
const mimeMatch = arr[0]!.match(/:(.*?);/);
if (!mimeMatch || !mimeMatch[1]) {
throw new Error("Invalid data URL format");
}
const mime = mimeMatch[1];

// Validate mime type is an image
if (!mime.startsWith("image/")) {
throw new Error("Invalid mime type, expected image");
}

const bstr = atob(arr[arr.length - 1]!);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], fileName, { type: mime });
} catch (error) {
console.error("Error converting data URL to file:", error);
// Return a fallback or rethrow based on your error handling strategy
throw error;
}
}

async function rotateBase64Image90Deg(base64Image: string, index: number) {
// Create an off-screen canvas
const offScreenCanvas = document.createElement("canvas");
const offScreenCanvasCtx = offScreenCanvas.getContext("2d");

if (!offScreenCanvasCtx) {
toast.error(t("components.item.create_modal.toast.no_canvas_support"));
return;
}

// Create an image
const img = new Image();

// Create a promise to handle the image loading
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("Failed to load image"));
img.src = base64Image;
}).catch(error => {
toast.error(t("components.item.create_modal.toast.rotate_failed", { error: error.message }));
});

// Set its dimensions to rotated size
offScreenCanvas.height = img.width;
offScreenCanvas.width = img.height;

// Rotate and draw source image into the off-screen canvas
offScreenCanvasCtx.rotate((90 * Math.PI) / 180);
offScreenCanvasCtx.translate(0, -offScreenCanvas.width);
offScreenCanvasCtx.drawImage(img, 0, 0);

const imageType = base64Image.match(/^data:(.+);base64/)?.[1] || "image/jpeg";

// Encode image to data-uri with base64
try {
form.photos[index]!.fileBase64 = offScreenCanvas.toDataURL(imageType, 100);
form.photos[index]!.file = dataURLtoFile(form.photos[index]!.fileBase64, form.photos[index]!.photoName);
} catch (error) {
toast.error(t("components.item.create_modal.toast.rotate_process_failed"));
console.error(error);
} finally {
// Clean up resources
offScreenCanvas.width = 0;
offScreenCanvas.height = 0;
}
}

function openQrScannerPage() {
openDialog(DialogID.Scanner);
}
Expand Down
Loading
Loading