Fix ORA editor layer visibility and mask preview
- Layer visibility toggles now show/hide individual layer images on canvas - Added tint red checkbox per layer for manual mask verification - Mask preview modal uses CSS mask-image to show red overlay only on selected areas (white pixels become semi-transparent red, black pixels remain transparent) - Added spinner icons to Open and Extract Mask buttons during long operations
This commit is contained in:
@@ -99,7 +99,8 @@ All file paths are relative to the project root: `/home/noti/dev/ai-game-2`
|
|||||||
|
|
||||||
| Operation | Description |
|
| Operation | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| **Toggle Visibility** | Show/hide layer on canvas |
|
| **Toggle Visibility** | Show/hide specific layer image on canvas |
|
||||||
|
| **Tint Red Preview** | Apply red tint to layer for mask verification (client-only) |
|
||||||
| **Rename** | Change layer/entity name |
|
| **Rename** | Change layer/entity name |
|
||||||
| **Delete** | Remove layer from ORA |
|
| **Delete** | Remove layer from ORA |
|
||||||
| **Reorder** | Move layer up/down in stack (changes z-index) |
|
| **Reorder** | Move layer up/down in stack (changes z-index) |
|
||||||
@@ -109,18 +110,25 @@ All file paths are relative to the project root: `/home/noti/dev/ai-game-2`
|
|||||||
|
|
||||||
## Canvas Display
|
## Canvas Display
|
||||||
|
|
||||||
### Image Rendering
|
### Image Rendering
|
||||||
- Layers rendered as stacked `<img>` elements with CSS positioning
|
- Individual layers rendered as stacked `<img>` elements with CSS positioning
|
||||||
- Base layer at bottom, entity layers stacked above
|
- Layers stacked in order from list (visual z-index matches layer order)
|
||||||
- Visibility controlled by `opacity: 0` or `opacity: 1`
|
- Visibility togglecheckbox hides/shows each layer's image on canvas
|
||||||
|
- Tint checkbox applies semi-transparent red overlay for mask verification (client-only, visual aid)
|
||||||
- No server-side compositing for preview
|
- No server-side compositing for preview
|
||||||
|
|
||||||
### Layer DOM Structure
|
### Layer DOM Structure
|
||||||
```html
|
```html
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
<img src="/api/image/base" class="absolute inset-0">
|
<!-- Individual layers rendered in order (x-show controls visibility) -->
|
||||||
<img src="/api/image/door_0" class="absolute inset-0" style="opacity: 1">
|
<template x-for="layer in layers">
|
||||||
<img src="/api/image/chest_0" class="absolute inset-0" style="opacity: 0">
|
<img
|
||||||
|
x-show="layer.visible"
|
||||||
|
:src="'/api/image/layer/' + layer.name"
|
||||||
|
class="absolute inset-0"
|
||||||
|
:style="layer.tintRed ? 'mix-blend-multiply; opacity: 0.6;' : ''"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
<!-- Polygon overlay when active -->
|
<!-- Polygon overlay when active -->
|
||||||
<canvas id="polygon-canvas" class="absolute inset-0 pointer-events-auto"></canvas>
|
<canvas id="polygon-canvas" class="absolute inset-0 pointer-events-auto"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,9 +201,12 @@ canvas.addEventListener('click', (e) => {
|
|||||||
### Mask Preview Modal
|
### Mask Preview Modal
|
||||||
When mask is ready:
|
When mask is ready:
|
||||||
1. Full-screen modal appears
|
1. Full-screen modal appears
|
||||||
2. Shows base image with mask applied as colored tint overlay
|
2. Shows base image with semi-transparent red overlay where the mask is white (selected area)
|
||||||
|
- Uses CSS `mask-image` property to use grayscale values as alpha channel
|
||||||
|
- White pixels = fully opaque red tint, black/dark pixels = transparent (no tint)
|
||||||
|
- Dark/gray areas of the mask remain invisible so you can clearly see the mask boundary
|
||||||
3. Three buttons:
|
3. Three buttons:
|
||||||
- **Re-roll**: Re-run extraction with same params
|
- **Re-roll**: Re-run extraction with same params
|
||||||
- **Use This Mask**: Add masked layer to ORA, close modal
|
- **Use This Mask**: Add masked layer to ORA, close modal
|
||||||
- **Cancel**: Discard mask, close modal
|
- **Cancel**: Discard mask, close modal
|
||||||
|
|
||||||
@@ -355,6 +366,14 @@ Serve a layer PNG. Returns image data.
|
|||||||
#### `GET /api/image/base`
|
#### `GET /api/image/base`
|
||||||
Serve base/merged image.
|
Serve base/merged image.
|
||||||
|
|
||||||
|
#### `GET /api/image/layer/<layer_name>`
|
||||||
|
Serve a specific layer as image.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
- `ora_path`: Path to ORA file
|
||||||
|
|
||||||
|
**Response:** PNG image data or 404 if layer not found.
|
||||||
|
|
||||||
#### `GET /api/image/polygon`
|
#### `GET /api/image/polygon`
|
||||||
Serve polygon overlay image (for drawing mode).
|
Serve polygon overlay image (for drawing mode).
|
||||||
|
|
||||||
@@ -506,12 +525,13 @@ Check if temp file was modified.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
│ [Open: ________________________] [Open File] [Settings ⚙] │
|
│ [Open: ________________________] [🌀 Open] [Settings ⚙] │
|
||||||
├───────────────────┬──────────────────────────────────────────────────┤
|
├───────────────────┬──────────────────────────────────────────────────┤
|
||||||
│ LAYERS │ │
|
│ LAYERS │ │
|
||||||
│ ☑ base │ │
|
│ ☑ □ base │ │
|
||||||
│ ☑ door_0 │ [Image Canvas with │
|
│ ☑ □ door_0 │ [Image Canvas with │
|
||||||
│ ☐ chest_0 │ stacked layers] │
|
│ ☐ ☒ chest_0 │ stacked layers - visibility toggles │
|
||||||
|
│ │ show/hide individual layer images] │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ [Rename] [Delete] │ │
|
│ [Rename] [Delete] │ │
|
||||||
│ [▲ Up] [▼ Down] │ │
|
│ [▲ Up] [▼ Down] │ │
|
||||||
@@ -528,7 +548,7 @@ Check if temp file was modified.
|
|||||||
│ MASK EXTRACTION │ │
|
│ MASK EXTRACTION │ │
|
||||||
│ Subject: [______]│ │
|
│ Subject: [______]│ │
|
||||||
│ ☑ Use polygon │ │
|
│ ☑ Use polygon │ │
|
||||||
│ [Extract Mask] │ │
|
│ [🌀 Extract Mask]│ (spinner shown when extracting) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ─────────────────│ │
|
│ ─────────────────│ │
|
||||||
│ [Open in Krita] │ │
|
│ [Open in Krita] │ │
|
||||||
@@ -538,6 +558,8 @@ Check if temp file was modified.
|
|||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Legend: ☑ = visible checkbox, ☒ = tint red checkbox (red when checked)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mask Preview Modal
|
## Mask Preview Modal
|
||||||
@@ -547,7 +569,8 @@ Check if temp file was modified.
|
|||||||
│ EXTRACTED MASK │
|
│ EXTRACTED MASK │
|
||||||
│ ───────────────────────────────────────────────────── │
|
│ ───────────────────────────────────────────────────── │
|
||||||
│ │
|
│ │
|
||||||
│ [Base image with mask applied as tinted overlay] │
|
│ [Base image with RED mask overlay] │
|
||||||
|
│ (mask shown in semi-transparent red) │
|
||||||
│ │
|
│ │
|
||||||
│ [Re-roll] [Use This Mask] [Cancel] │
|
│ [Re-roll] [Use This Mask] [Cancel] │
|
||||||
│ │
|
│ │
|
||||||
@@ -557,6 +580,7 @@ Check if temp file was modified.
|
|||||||
- **Re-roll**: Re-runs extraction with same params
|
- **Re-roll**: Re-runs extraction with same params
|
||||||
- **Use This Mask**: Calls `POST /api/layer/add`, closes modal
|
- **Use This Mask**: Calls `POST /api/layer/add`, closes modal
|
||||||
- **Cancel**: Closes modal, mask discarded
|
- **Cancel**: Closes modal, mask discarded
|
||||||
|
- **Red tint**: Uses CSS filter to render grayscale mask as semi-transparent red
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
::-webkit-scrollbar-track { background: #374151; }
|
::-webkit-scrollbar-track { background: #374151; }
|
||||||
::-webkit-scrollbar-thumb { background: #6B7280; border-radius: 4px; }
|
::-webkit-scrollbar-thumb { background: #6B7280; border-radius: 4px; }
|
||||||
.polygon-point { position: absolute; width: 8px; height: 8px; background: #0F0; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; }
|
.polygon-point { position: absolute; width: 20px; height: 20px; background: #FFFFFF; border: 3px solid #FF0000; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; z-index: 100; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Alpine data component - MUST be defined before x-data directive -->
|
<!-- Alpine data component - MUST be defined before x-data directive -->
|
||||||
@@ -26,15 +26,21 @@
|
|||||||
imageWidth: 800,
|
imageWidth: 800,
|
||||||
imageHeight: 600,
|
imageHeight: 600,
|
||||||
|
|
||||||
|
// Display scale (percentage, default 25%)
|
||||||
|
scale: 25,
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
selectedLayer: null,
|
selectedLayer: null,
|
||||||
|
|
||||||
|
|
||||||
// Polygon state
|
// Polygon state
|
||||||
isDrawing: false,
|
isDrawing: false,
|
||||||
polygonPoints: [],
|
polygonPoints: [],
|
||||||
polygonColor: '#FF0000',
|
polygonColor: '#FF0000',
|
||||||
polygonWidth: 2,
|
polygonWidth: 2,
|
||||||
polygonPreviewUrl: null,
|
polygonPreviewUrl: null,
|
||||||
|
clickTimeout: null,
|
||||||
|
pendingPoint: null,
|
||||||
|
|
||||||
// Mask extraction state
|
// Mask extraction state
|
||||||
maskSubject: '',
|
maskSubject: '',
|
||||||
@@ -184,13 +190,27 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
startDrawing() {
|
startDrawing() {
|
||||||
console.log('[ORA EDITOR] Starting polygon drawing mode');
|
console.log('[ORA EDITOR] Starting polygon drawing mode');
|
||||||
this.isDrawing = true;
|
this.isDrawing = true;
|
||||||
this.polygonPoints = [];
|
this.polygonPoints = [];
|
||||||
|
|
||||||
|
// Reset click/double-click state for new drawing session
|
||||||
|
canvasDoubleClickPending = false;
|
||||||
|
|
||||||
|
// Clear the preview image from previous drawings (hide old overlay)
|
||||||
|
this.polygonPreviewUrl = null;
|
||||||
|
|
||||||
|
// Note: We don't clear backend storage here - let updatePolygonPreview() overwrite it later
|
||||||
|
|
||||||
// Setup canvas after it's created
|
// Setup canvas after it's created
|
||||||
setTimeout(() => this.setupCanvas(), 50);
|
setTimeout(() => {
|
||||||
|
this.setupCanvas();
|
||||||
|
const canvas = document.getElementById('polygonCanvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
},
|
},
|
||||||
|
|
||||||
setupCanvas() {
|
setupCanvas() {
|
||||||
@@ -220,18 +240,74 @@
|
|||||||
y = Math.max(0, Math.min(1, y));
|
y = Math.max(0, Math.min(1, y));
|
||||||
|
|
||||||
this.polygonPoints.push({ x, y });
|
this.polygonPoints.push({ x, y });
|
||||||
this.updatePolygonPreview();
|
this.drawPolygonOnCanvas();
|
||||||
},
|
},
|
||||||
|
|
||||||
async updatePolygonPreview() {
|
drawPolygonOnCanvas() {
|
||||||
|
const canvas = document.getElementById('polygonCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
if (this.polygonPoints.length < 2) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (this.polygonPoints.length < 2) return;
|
||||||
|
|
||||||
|
// Draw line segments connecting points
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = this.polygonColor;
|
||||||
|
ctx.lineWidth = this.polygonWidth;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
const startPoint = this.polygonPoints[0];
|
||||||
|
ctx.moveTo(startPoint.x * canvas.width, startPoint.y * canvas.height);
|
||||||
|
|
||||||
|
for (let i = 1; i < this.polygonPoints.length; i++) {
|
||||||
|
const point = this.polygonPoints[i];
|
||||||
|
ctx.lineTo(point.x * canvas.width, point.y * canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the polygon if we have 3+ points
|
||||||
|
if (this.polygonPoints.length >= 3) {
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw point markers
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
for (const point of this.polygonPoints) {
|
||||||
|
const px = point.x * canvas.width;
|
||||||
|
const py = point.y * canvas.height;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = this.polygonColor;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.arc(px, py, 6, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updatePolygonPreview() {
|
||||||
|
console.log('[UPDATE] Updating preview with', this.polygonPoints.length, 'points');
|
||||||
if (!this.oraPath || this.polygonPoints.length < 3) return;
|
if (!this.oraPath || this.polygonPoints.length < 3) return;
|
||||||
|
|
||||||
|
const pointsToSend = [...this.polygonPoints]; // Make a copy
|
||||||
|
console.log('[UPDATE] Sending points:', JSON.stringify(pointsToSend));
|
||||||
|
|
||||||
const response = await fetch('/api/polygon', {
|
const response = await fetch('/api/polygon', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ora_path: this.oraPath,
|
ora_path: this.oraPath,
|
||||||
points: this.polygonPoints,
|
points: pointsToSend,
|
||||||
color: this.polygonColor,
|
color: this.polygonColor,
|
||||||
width: this.polygonWidth
|
width: this.polygonWidth
|
||||||
})
|
})
|
||||||
@@ -239,24 +315,47 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.polygonPreviewUrl = data.overlay_url;
|
console.log('[UPDATE] Got preview URL:', data.overlay_url);
|
||||||
|
this.polygonPreviewUrl = data.overlay_url + '&ts=' + Date.now(); // Bust cache
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
finishDrawing() {
|
async finishDrawing() {
|
||||||
this.isDrawing = false;
|
console.log('[FINISH] finishDrawing called with', this.polygonPoints.length, 'points');
|
||||||
|
|
||||||
|
// Don't change isDrawing until after we've updated the preview!
|
||||||
if (this.polygonPoints.length >= 3) {
|
if (this.polygonPoints.length >= 3) {
|
||||||
// Keep polygon visible
|
await this.updatePolygonPreview();
|
||||||
|
console.log('[FINISH] Preview URL set to:', this.polygonPreviewUrl);
|
||||||
|
|
||||||
|
// Now exit drawing mode - this will show the img with new URL
|
||||||
|
this.isDrawing = false;
|
||||||
|
|
||||||
|
// Hide canvas
|
||||||
|
const canvas = document.getElementById('polygonCanvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.clearPolygon();
|
this.clearPolygon();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearPolygon() {
|
clearPolygon() {
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.polygonPoints = [];
|
this.polygonPoints = [];
|
||||||
this.polygonPreviewUrl = null;
|
this.polygonPreviewUrl = null;
|
||||||
|
|
||||||
|
// Hide canvas
|
||||||
|
const canvas = document.getElementById('polygonCanvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.oraPath) {
|
if (this.oraPath) {
|
||||||
fetch('/api/polygon/clear', {
|
fetch('/api/polygon/clear', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -299,8 +398,7 @@
|
|||||||
this.showMaskModal = true;
|
this.showMaskModal = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ORA EDITOR] Error extracting mask:', e);
|
console.error('[ORA EDITOR] Error extracting mask:', e);
|
||||||
document.getElementById('maskError').innerHTML = e.message;
|
this.lastError = e.message;
|
||||||
document.getElementById('maskError').style.display = 'block';
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isExtracting = false;
|
this.isExtracting = false;
|
||||||
}
|
}
|
||||||
@@ -383,8 +481,11 @@
|
|||||||
<button
|
<button
|
||||||
@click="openFile()"
|
@click="openFile()"
|
||||||
:disabled="!filePath || isLoading"
|
:disabled="!filePath || isLoading"
|
||||||
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded transition"
|
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded transition flex items-center justify-center gap-2"
|
||||||
>Open</button>
|
>
|
||||||
|
<span x-show="isLoading" class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white"></span>
|
||||||
|
<span x-text="isLoading ? 'Opening...' : 'Open'"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="openInKrita()"
|
@click="openInKrita()"
|
||||||
@@ -401,6 +502,18 @@
|
|||||||
:disabled="!oraPath || isLoading"
|
:disabled="!oraPath || isLoading"
|
||||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded transition"
|
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded transition"
|
||||||
>Save</button>
|
>Save</button>
|
||||||
|
<div class="flex items-center gap-2 bg-gray-800 rounded px-3 py-2 border border-gray-700">
|
||||||
|
<span class="text-sm text-gray-300">Scale:</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
x-model.number="scale"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
class="w-32 accent-blue-500"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-300 w-12" x-text="scale + '%'"></span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="showSettings = true"
|
@click="showSettings = true"
|
||||||
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded transition"
|
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded transition"
|
||||||
@@ -430,8 +543,16 @@
|
|||||||
:checked="layer.visible"
|
:checked="layer.visible"
|
||||||
@change="toggleVisibility(layer.name, $event.target.checked)"
|
@change="toggleVisibility(layer.name, $event.target.checked)"
|
||||||
class="w-4 h-4 rounded"
|
class="w-4 h-4 rounded"
|
||||||
|
title="Toggle visibility"
|
||||||
>
|
>
|
||||||
<span class="flex-1 text-sm truncate" x-text="layer.name"></span>
|
<span class="flex-1 text-sm truncate" x-text="layer.name"></span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="layer.tintRed || false"
|
||||||
|
@change="layer.tintRed = $event.target.checked"
|
||||||
|
class="w-4 h-4 rounded"
|
||||||
|
title="Tint red for preview"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -496,8 +617,9 @@
|
|||||||
<button
|
<button
|
||||||
@click="extractMask()"
|
@click="extractMask()"
|
||||||
:disabled="!maskSubject.trim() || isExtracting || !oraPath"
|
:disabled="!maskSubject.trim() || isExtracting || !oraPath"
|
||||||
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 px-4 py-2 rounded transition"
|
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 px-4 py-2 rounded transition flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
|
<span x-show="isExtracting" class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white"></span>
|
||||||
<span x-show="!isExtracting">Extract Mask</span>
|
<span x-show="!isExtracting">Extract Mask</span>
|
||||||
<span x-show="isExtracting">Extracting...</span>
|
<span x-show="isExtracting">Extracting...</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -510,18 +632,25 @@
|
|||||||
<!-- Main canvas area -->
|
<!-- Main canvas area -->
|
||||||
<main class="flex-1 bg-gray-800 rounded-lg border border-gray-700 p-4 relative overflow-auto" style="min-height: 600px;">
|
<main class="flex-1 bg-gray-800 rounded-lg border border-gray-700 p-4 relative overflow-auto" style="min-height: 600px;">
|
||||||
|
|
||||||
<div id="imageContainer" x-show="!isLoading && !error && layers.length > 0" class="relative inline-block"
|
<div id="imageContainer" x-show="!isLoading && !error && layers.length > 0" class="relative inline-block origin-top-left"
|
||||||
:style="`width: ${imageWidth}px; height: ${imageHeight}px; position: relative;`">
|
:style="`width: ${imageWidth}px; height: ${imageHeight}px; transform: scale(${scale / 100});`">
|
||||||
|
|
||||||
<!-- Layer display -->
|
<!-- Layer display -->
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
<!-- Base image -->
|
<!-- Individual layer images (rendered in order) -->
|
||||||
<img
|
<template x-for="layer in layers" :key="'layer-' + layer.name">
|
||||||
:src="'/api/image/base?ora_path=' + encodeURIComponent(oraPath)"
|
<img
|
||||||
class="absolute inset-0 w-full h-full object-contain"
|
x-show="layer.visible"
|
||||||
@load="initCanvas"
|
:src="'/api/image/layer/' + encodeURIComponent(layer.name) + '?ora_path=' + encodeURIComponent(oraPath)"
|
||||||
id="baseImage"
|
class="absolute inset-0 w-full h-full object-contain"
|
||||||
>
|
>
|
||||||
|
<!-- Red tint overlay when enabled -->
|
||||||
|
<div
|
||||||
|
x-show="layer.visible && layer.tintRed"
|
||||||
|
class="absolute inset-0 bg-red-500 mix-blend-multiply"
|
||||||
|
style="opacity: 0.5;"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Polygon points markers when drawing -->
|
<!-- Polygon points markers when drawing -->
|
||||||
<template x-for="(point, idx) in polygonPoints" :key="idx">
|
<template x-for="(point, idx) in polygonPoints" :key="idx">
|
||||||
@@ -545,8 +674,8 @@
|
|||||||
:width="imageWidth"
|
:width="imageWidth"
|
||||||
:height="imageHeight"
|
:height="imageHeight"
|
||||||
class="absolute inset-0 cursor-crosshair pointer-events-auto border-2 border-dashed border-blue-500 opacity-90"
|
class="absolute inset-0 cursor-crosshair pointer-events-auto border-2 border-dashed border-blue-500 opacity-90"
|
||||||
@click="handleCanvasClick"
|
@click="handleCanvasClick($event)"
|
||||||
@dblclick="handleCanvasDoubleClick"
|
@dblclick="handleCanvasDoubleClick($event)"
|
||||||
></canvas>
|
></canvas>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -578,13 +707,21 @@
|
|||||||
<div class="bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 border border-gray-600" style="max-height: 90vh; display: flex; flex-direction: column;">
|
<div class="bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 border border-gray-600" style="max-height: 90vh; display: flex; flex-direction: column;">
|
||||||
<h2 class="text-xl font-bold mb-4">Extracted Mask</h2>
|
<h2 class="text-xl font-bold mb-4">Extracted Mask</h2>
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto bg-gray-700 rounded mb-4 relative" :style="`min-height: 300px; max-height: 50vh;`">
|
<div class="flex-1 overflow-auto bg-gray-700 rounded mb-4 flex items-center justify-center p-4" :style="`min-height: 300px; max-height: 50vh;`">
|
||||||
<!-- Base image -->
|
<!-- Container for properly aligned base + mask -->
|
||||||
<img :src="'/api/image/base?ora_path=' + encodeURIComponent(oraPath)" class="relative w-full h-auto object-contain">
|
<div class="relative inline-block">
|
||||||
|
<!-- Base image -->
|
||||||
<!-- Mask overlay with tint -->
|
<img
|
||||||
<div x-show="tempMaskUrl" class="absolute inset-0 bg-green-500 opacity-30 pointer-events-none">
|
:src="'/api/image/base?ora_path=' + encodeURIComponent(oraPath)"
|
||||||
<img :src="tempMaskUrl" class="w-full h-auto object-contain mix-blend-screen">
|
class="border border-gray-600"
|
||||||
|
style="max-width: 100%; max-height: calc(50vh - 2rem);"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Semi-transparent red overlay with mask applied as alpha channel -->
|
||||||
|
<div x-show="tempMaskUrl"
|
||||||
|
class="absolute inset-0 pointer-events-none border border-gray-600"
|
||||||
|
:style="`background-color: rgba(255, 80, 80, 0.6); -webkit-mask-image: url(${tempMaskUrl}); mask-image: url(${tempMaskUrl}); -webkit-mask-size: cover; mask-size: cover; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;`">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -621,8 +758,10 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Minimal JS for canvas polygon drawing -->
|
<!-- Canvas click handler - works with Alpine -->
|
||||||
<script>
|
<script>
|
||||||
|
let canvasDoubleClickPending = false;
|
||||||
|
|
||||||
function initCanvas() {
|
function initCanvas() {
|
||||||
const canvas = document.getElementById('polygonCanvas');
|
const canvas = document.getElementById('polygonCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -632,39 +771,13 @@ function initCanvas() {
|
|||||||
canvas.style.height = canvas.parentElement.offsetHeight + 'px';
|
canvas.style.height = canvas.parentElement.offsetHeight + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access Alpine store for canvas click
|
|
||||||
function handleCanvasClick(e) {
|
function handleCanvasClick(e) {
|
||||||
const container = e.target.closest('[x-data]');
|
// Skip click if it was part of a double-click
|
||||||
if (!container) return;
|
if (canvasDoubleClickPending) {
|
||||||
|
return;
|
||||||
// Get click position
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
let x = (e.clientX - rect.left) / rect.width;
|
|
||||||
let y = (e.clientY - rect.top) / rect.height;
|
|
||||||
|
|
||||||
// Normalize to 0-1 range
|
|
||||||
x = Math.max(0, Math.min(1, x));
|
|
||||||
y = Math.max(0, Math.min(1, y));
|
|
||||||
|
|
||||||
// Get Alpine component and add point
|
|
||||||
const alpineComponent = $data(container);
|
|
||||||
if (alpineComponent) {
|
|
||||||
alpineComponent.addPolygonPoint(x, y);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const container = document.getElementById('imageContainer');
|
||||||
function handleCanvasDoubleClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const alpineComponent = $data(e.target.closest('[x-data]'));
|
|
||||||
if (alpineComponent) {
|
|
||||||
alpineComponent.finishDrawing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Canvas click handler - works with Alpine -->
|
|
||||||
<script>
|
|
||||||
function handleCanvasClick(e) {
|
|
||||||
const container = e.target.closest('#imageContainer');
|
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
@@ -679,8 +792,8 @@ function handleCanvasClick(e) {
|
|||||||
const alpineEl = container.closest('[x-data]');
|
const alpineEl = container.closest('[x-data]');
|
||||||
if (!alpineEl) return;
|
if (!alpineEl) return;
|
||||||
|
|
||||||
// Get the Alpine component instance using $data
|
// Get Alpine component instance using Alpine.$data
|
||||||
const store = $data(alpineEl);
|
const store = Alpine.$data(alpineEl);
|
||||||
if (!store) return;
|
if (!store) return;
|
||||||
|
|
||||||
store.addPolygonPoint(x, y);
|
store.addPolygonPoint(x, y);
|
||||||
@@ -688,13 +801,16 @@ function handleCanvasClick(e) {
|
|||||||
|
|
||||||
function handleCanvasDoubleClick(e) {
|
function handleCanvasDoubleClick(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const container = e.target.closest('#imageContainer');
|
// Mark that a double-click happened so we skip the subsequent clicks
|
||||||
|
canvasDoubleClickPending = true;
|
||||||
|
|
||||||
|
const container = document.getElementById('imageContainer');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const alpineEl = container.closest('[x-data]');
|
const alpineEl = container.closest('[x-data]');
|
||||||
if (!alpineEl) return;
|
if (!alpineEl) return;
|
||||||
|
|
||||||
const store = $data(alpineEl);
|
const store = Alpine.$data(alpineEl);
|
||||||
if (!store) return;
|
if (!store) return;
|
||||||
|
|
||||||
if (store.isDrawing && store.polygonPoints.length >= 3) {
|
if (store.isDrawing && store.polygonPoints.length >= 3) {
|
||||||
@@ -702,16 +818,7 @@ function handleCanvasDoubleClick(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for Alpine to initialize, then setup canvas
|
|
||||||
document.addEventListener('alpine:init', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
const canvas = document.getElementById('polygonCanvas');
|
|
||||||
if (canvas) {
|
|
||||||
canvas.addEventListener('click', handleCanvasClick);
|
|
||||||
canvas.addEventListener('dblclick', handleCanvasDoubleClick);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user