@joewinke/jatui 0.1.11 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/package.json +2 -1
- package/src/lib/actions/railNav.ts +473 -0
- package/src/lib/components/AnnotationLayer.svelte +108 -0
- package/src/lib/components/AnnotationPanel.svelte +319 -0
- package/src/lib/components/AudioWaveform.svelte +9 -5
- package/src/lib/components/AvailabilityModal.svelte +7 -3
- package/src/lib/components/AvatarUpload.svelte +27 -4
- package/src/lib/components/BookingForm.svelte +11 -9
- package/src/lib/components/BurndownChart.svelte +778 -0
- package/src/lib/components/Button.svelte +10 -1
- package/src/lib/components/CalendarPicker.svelte +3 -3
- package/src/lib/components/Card.svelte +2 -2
- package/src/lib/components/ChipInput.svelte +8 -3
- package/src/lib/components/ColorSelector.svelte +17 -13
- package/src/lib/components/CommentThread.svelte +773 -0
- package/src/lib/components/ConfirmDialog.svelte +348 -0
- package/src/lib/components/ConfirmModal.svelte +78 -11
- package/src/lib/components/ContextMenu.svelte +59 -19
- package/src/lib/components/CountdownTimer.svelte +1 -1
- package/src/lib/components/DateRangePicker.svelte +6 -4
- package/src/lib/components/Drawer.svelte +36 -3
- package/src/lib/components/EntityPreviewCard.svelte +104 -0
- package/src/lib/components/FileDropzone.svelte +493 -0
- package/src/lib/components/FilePicker.svelte +83 -14
- package/src/lib/components/FileThumbnail.svelte +80 -0
- package/src/lib/components/FilterDropdown.svelte +11 -11
- package/src/lib/components/GPSTracker.svelte +202 -0
- package/src/lib/components/HunkDiffView.svelte +348 -0
- package/src/lib/components/ImageLightbox.svelte +274 -0
- package/src/lib/components/ImageUpload.svelte +58 -9
- package/src/lib/components/InlineEdit.svelte +6 -2
- package/src/lib/components/InputDialog.svelte +327 -0
- package/src/lib/components/KeyboardShortcutsOverlay.svelte +296 -0
- package/src/lib/components/LazyImage.svelte +1 -0
- package/src/lib/components/LinkShortener.svelte +1 -1
- package/src/lib/components/LoadingSpinner.svelte +6 -2
- package/src/lib/components/LocationMap.svelte +186 -0
- package/src/lib/components/MapView.svelte +341 -0
- package/src/lib/components/MarkupEditor.svelte +485 -0
- package/src/lib/components/MarkupOverlay.svelte +55 -0
- package/src/lib/components/MediaWorkbench.svelte +871 -0
- package/src/lib/components/MilestoneCard.svelte +1 -1
- package/src/lib/components/MilestoneTimeline.svelte +1 -1
- package/src/lib/components/Modal.svelte +39 -4
- package/src/lib/components/PDFViewer.svelte +105 -0
- package/src/lib/components/PdfThumbnail.svelte +3 -1
- package/src/lib/components/PhoneInput.svelte +1 -1
- package/src/lib/components/ResizablePanel.svelte +4 -4
- package/src/lib/components/SearchDropdown.svelte +26 -13
- package/src/lib/components/SelectInput.svelte +26 -4
- package/src/lib/components/SidebarUserFooter.svelte +1 -1
- package/src/lib/components/SignaturePad.svelte +8 -4
- package/src/lib/components/SmartImageEditor.svelte +720 -0
- package/src/lib/components/SortDropdown.svelte +9 -3
- package/src/lib/components/Sparkline.svelte +9 -0
- package/src/lib/components/StatusBadge.svelte +20 -18
- package/src/lib/components/TextArea.svelte +24 -5
- package/src/lib/components/TextInput.svelte +29 -6
- package/src/lib/components/ThemeSelector.svelte +15 -4
- package/src/lib/components/TimeSlotPicker.svelte +7 -7
- package/src/lib/components/UserAvatar.svelte +14 -1
- package/src/lib/components/VariablePicker.svelte +170 -0
- package/src/lib/components/VoicePlayer.svelte +4 -3
- package/src/lib/components/linked-columns/LinkedColumns.svelte +520 -0
- package/src/lib/components/markup.ts +287 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
- package/src/lib/components/messaging/ChannelList.svelte +1 -1
- package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
- package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
- package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
- package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
- package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
- package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
- package/src/lib/components/messaging/MessageInput.svelte +1 -1
- package/src/lib/components/messaging/MessageItem.svelte +6 -3
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
- package/src/lib/components/messaging/StartDMModal.svelte +1 -1
- package/src/lib/components/pipeline/Pipeline.svelte +4 -4
- package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
- package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
- package/src/lib/components/replay/ChapterTimeline.svelte +326 -0
- package/src/lib/components/session-nav/transcriptModel.ts +352 -0
- package/src/lib/index.ts +138 -0
- package/src/lib/stores/confirmDialog.svelte.ts +48 -0
- package/src/lib/stores/inputDialog.svelte.ts +51 -0
- package/src/lib/styles/rail.css +63 -0
- package/src/lib/types/annotation.ts +38 -0
- package/src/lib/types/comments.ts +97 -0
- package/src/lib/types/entityPreview.ts +45 -0
- package/src/lib/types/filePicker.ts +2 -0
- package/src/lib/types/googleMaps.d.ts +51 -0
- package/src/lib/types/maps.ts +43 -0
- package/src/lib/types/smartImageEditor.ts +39 -0
- package/src/lib/types/templateVars.ts +36 -0
- package/src/lib/utils/dateFormatters.ts +12 -10
- package/src/lib/utils/googleMapsLoader.ts +84 -0
- package/src/lib/utils/taskUtils.ts +21 -7
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// ─── Annotation drawing primitives ───────────────────────────────────────
|
|
3
|
+
type ShapeType = 'arrow' | 'rectangle' | 'ellipse' | 'freehand' | 'text'
|
|
4
|
+
type Point = { x: number; y: number }
|
|
5
|
+
type AnnotShape =
|
|
6
|
+
| { id: number; type: 'arrow'; color: string; sw: number; start: Point; end: Point }
|
|
7
|
+
| { id: number; type: 'rectangle'; color: string; sw: number; start: Point; end: Point }
|
|
8
|
+
| { id: number; type: 'ellipse'; color: string; sw: number; start: Point; end: Point }
|
|
9
|
+
| { id: number; type: 'freehand'; color: string; sw: number; points: Point[] }
|
|
10
|
+
| { id: number; type: 'text'; color: string; sw: number; position: Point; content: string; fontSize: number }
|
|
11
|
+
|
|
12
|
+
const ANNOT_COLORS = ['#ef4444', '#eab308', '#3b82f6', '#111827', '#ffffff']
|
|
13
|
+
const ANNOT_SW = 3
|
|
14
|
+
|
|
15
|
+
function renderAnnotShape(ctx: CanvasRenderingContext2D, shape: AnnotShape) {
|
|
16
|
+
ctx.save()
|
|
17
|
+
ctx.strokeStyle = shape.color
|
|
18
|
+
ctx.lineWidth = shape.sw
|
|
19
|
+
ctx.lineCap = 'round'
|
|
20
|
+
ctx.lineJoin = 'round'
|
|
21
|
+
switch (shape.type) {
|
|
22
|
+
case 'arrow': {
|
|
23
|
+
ctx.beginPath()
|
|
24
|
+
ctx.moveTo(shape.start.x, shape.start.y)
|
|
25
|
+
ctx.lineTo(shape.end.x, shape.end.y)
|
|
26
|
+
ctx.stroke()
|
|
27
|
+
const angle = Math.atan2(shape.end.y - shape.start.y, shape.end.x - shape.start.x)
|
|
28
|
+
const hl = 14, ha = Math.PI / 7
|
|
29
|
+
ctx.beginPath()
|
|
30
|
+
ctx.moveTo(shape.end.x, shape.end.y)
|
|
31
|
+
ctx.lineTo(shape.end.x - hl * Math.cos(angle - ha), shape.end.y - hl * Math.sin(angle - ha))
|
|
32
|
+
ctx.moveTo(shape.end.x, shape.end.y)
|
|
33
|
+
ctx.lineTo(shape.end.x - hl * Math.cos(angle + ha), shape.end.y - hl * Math.sin(angle + ha))
|
|
34
|
+
ctx.stroke()
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
case 'rectangle': {
|
|
38
|
+
const x = Math.min(shape.start.x, shape.end.x)
|
|
39
|
+
const y = Math.min(shape.start.y, shape.end.y)
|
|
40
|
+
const w = Math.abs(shape.end.x - shape.start.x)
|
|
41
|
+
const h = Math.abs(shape.end.y - shape.start.y)
|
|
42
|
+
ctx.strokeRect(x, y, w, h)
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
case 'ellipse': {
|
|
46
|
+
const cx = (shape.start.x + shape.end.x) / 2
|
|
47
|
+
const cy = (shape.start.y + shape.end.y) / 2
|
|
48
|
+
const rx = Math.abs(shape.end.x - shape.start.x) / 2
|
|
49
|
+
const ry = Math.abs(shape.end.y - shape.start.y) / 2
|
|
50
|
+
if (rx < 1 || ry < 1) break
|
|
51
|
+
ctx.beginPath()
|
|
52
|
+
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
|
|
53
|
+
ctx.stroke()
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
case 'freehand': {
|
|
57
|
+
if (shape.points.length < 2) break
|
|
58
|
+
ctx.beginPath()
|
|
59
|
+
ctx.moveTo(shape.points[0].x, shape.points[0].y)
|
|
60
|
+
for (let i = 1; i < shape.points.length; i++) ctx.lineTo(shape.points[i].x, shape.points[i].y)
|
|
61
|
+
ctx.stroke()
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
case 'text': {
|
|
65
|
+
if (!shape.content) break
|
|
66
|
+
ctx.font = `bold ${shape.fontSize}px sans-serif`
|
|
67
|
+
ctx.textBaseline = 'top'
|
|
68
|
+
ctx.strokeStyle = '#000000'
|
|
69
|
+
ctx.lineWidth = 2
|
|
70
|
+
ctx.lineJoin = 'round'
|
|
71
|
+
ctx.strokeText(shape.content, shape.position.x, shape.position.y)
|
|
72
|
+
ctx.fillStyle = shape.color
|
|
73
|
+
ctx.fillText(shape.content, shape.position.x, shape.position.y)
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
ctx.restore()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Component props + core state ────────────────────────────────────────
|
|
81
|
+
type Tab = "generate" | "edit" | "remove-bg" | "annotate"
|
|
82
|
+
|
|
83
|
+
let {
|
|
84
|
+
value = $bindable(null),
|
|
85
|
+
uploadEndpoint = "/api/media",
|
|
86
|
+
onComplete = undefined,
|
|
87
|
+
accept = "image/png,image/jpeg,image/webp,image/gif",
|
|
88
|
+
} = $props<{
|
|
89
|
+
value?: string | null
|
|
90
|
+
uploadEndpoint?: string
|
|
91
|
+
onComplete?: (url: string) => void
|
|
92
|
+
accept?: string
|
|
93
|
+
}>()
|
|
94
|
+
|
|
95
|
+
let open = $state(false)
|
|
96
|
+
let activeTab = $state<Tab>("generate")
|
|
97
|
+
let workingUrl = $state<string | null>(null)
|
|
98
|
+
let error = $state("")
|
|
99
|
+
let busy = $state(false)
|
|
100
|
+
let generatePrompt = $state("")
|
|
101
|
+
let generateAspect = $state("")
|
|
102
|
+
let editInstruction = $state("")
|
|
103
|
+
|
|
104
|
+
const previewUrl = $derived(workingUrl ?? value)
|
|
105
|
+
|
|
106
|
+
// ─── Annotation state ────────────────────────────────────────────────────
|
|
107
|
+
let annotTool = $state<ShapeType>('arrow')
|
|
108
|
+
let annotColor = $state(ANNOT_COLORS[0])
|
|
109
|
+
let annotShapes = $state<AnnotShape[]>([])
|
|
110
|
+
let annotDrawState = $state<'idle' | 'drawing' | 'typing'>('idle')
|
|
111
|
+
let imgW = $state(0)
|
|
112
|
+
let imgH = $state(0)
|
|
113
|
+
let annotLoaded = $state(false)
|
|
114
|
+
let baseCanvas = $state<HTMLCanvasElement | undefined>(undefined)
|
|
115
|
+
let overlayCanvas = $state<HTMLCanvasElement | undefined>(undefined)
|
|
116
|
+
let annotTextInput = $state<HTMLInputElement | undefined>(undefined)
|
|
117
|
+
let annotTextInputCss = $state({ left: '0px', top: '0px' })
|
|
118
|
+
let annotTextValue = $state('')
|
|
119
|
+
|
|
120
|
+
// Non-reactive draw buffers
|
|
121
|
+
let _annotImg: HTMLImageElement | null = null
|
|
122
|
+
let _annotStartPoint: Point = { x: 0, y: 0 }
|
|
123
|
+
let _annotFreehandPts: Point[] = []
|
|
124
|
+
let _annotNextId = 1
|
|
125
|
+
let _annotTextPos: Point = { x: 0, y: 0 }
|
|
126
|
+
|
|
127
|
+
// Load source image when annotate tab opens or source URL changes
|
|
128
|
+
$effect(() => {
|
|
129
|
+
if (activeTab !== 'annotate') {
|
|
130
|
+
annotLoaded = false
|
|
131
|
+
annotShapes = []
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const url = workingUrl ?? value
|
|
135
|
+
if (!url) return
|
|
136
|
+
annotLoaded = false
|
|
137
|
+
annotShapes = []
|
|
138
|
+
_annotImg = null
|
|
139
|
+
_annotNextId = 1
|
|
140
|
+
const img = new Image()
|
|
141
|
+
img.onload = () => {
|
|
142
|
+
imgW = img.naturalWidth
|
|
143
|
+
imgH = img.naturalHeight
|
|
144
|
+
_annotImg = img
|
|
145
|
+
annotLoaded = true
|
|
146
|
+
}
|
|
147
|
+
img.src = url
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Draw base canvas once both image and canvas element are ready
|
|
151
|
+
$effect(() => {
|
|
152
|
+
if (!annotLoaded || !baseCanvas) return
|
|
153
|
+
requestAnimationFrame(() => redrawAnnotBase())
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
function redrawAnnotBase() {
|
|
157
|
+
if (!baseCanvas || !_annotImg) return
|
|
158
|
+
const ctx = baseCanvas.getContext('2d')
|
|
159
|
+
if (!ctx) return
|
|
160
|
+
ctx.clearRect(0, 0, imgW, imgH)
|
|
161
|
+
ctx.drawImage(_annotImg, 0, 0, imgW, imgH)
|
|
162
|
+
for (const s of annotShapes) renderAnnotShape(ctx, s)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function clearAnnotOverlay() {
|
|
166
|
+
if (!overlayCanvas) return
|
|
167
|
+
const ctx = overlayCanvas.getContext('2d')
|
|
168
|
+
if (!ctx) return
|
|
169
|
+
ctx.clearRect(0, 0, imgW, imgH)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function canvasCoords(e: MouseEvent): Point {
|
|
173
|
+
if (!overlayCanvas) return { x: 0, y: 0 }
|
|
174
|
+
const rect = overlayCanvas.getBoundingClientRect()
|
|
175
|
+
return {
|
|
176
|
+
x: (e.clientX - rect.left) * (imgW / rect.width),
|
|
177
|
+
y: (e.clientY - rect.top) * (imgH / rect.height),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function canvasToCss(p: Point): { left: string; top: string } {
|
|
182
|
+
if (!overlayCanvas) return { left: '0px', top: '0px' }
|
|
183
|
+
const rect = overlayCanvas.getBoundingClientRect()
|
|
184
|
+
return {
|
|
185
|
+
left: `${rect.left + p.x / (imgW / rect.width)}px`,
|
|
186
|
+
top: `${rect.top + p.y / (imgH / rect.height)}px`,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildAnnotShape(end: Point): AnnotShape | null {
|
|
191
|
+
const base = { id: _annotNextId++, color: annotColor, sw: ANNOT_SW }
|
|
192
|
+
switch (annotTool) {
|
|
193
|
+
case 'arrow': return { ...base, type: 'arrow', start: { ..._annotStartPoint }, end }
|
|
194
|
+
case 'rectangle': return { ...base, type: 'rectangle', start: { ..._annotStartPoint }, end }
|
|
195
|
+
case 'ellipse': return { ...base, type: 'ellipse', start: { ..._annotStartPoint }, end }
|
|
196
|
+
case 'freehand': return { ...base, type: 'freehand', points: [..._annotFreehandPts, end] }
|
|
197
|
+
default: return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function onAnnotPointerDown(e: MouseEvent) {
|
|
202
|
+
if (annotDrawState === 'typing') { commitAnnotText(); return }
|
|
203
|
+
const p = canvasCoords(e)
|
|
204
|
+
if (annotTool === 'text') {
|
|
205
|
+
annotDrawState = 'typing'
|
|
206
|
+
_annotTextPos = p
|
|
207
|
+
annotTextInputCss = canvasToCss(p)
|
|
208
|
+
annotTextValue = ''
|
|
209
|
+
requestAnimationFrame(() => annotTextInput?.focus())
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
annotDrawState = 'drawing'
|
|
213
|
+
_annotStartPoint = p
|
|
214
|
+
_annotFreehandPts = [p]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function onAnnotPointerMove(e: MouseEvent) {
|
|
218
|
+
if (annotDrawState !== 'drawing') return
|
|
219
|
+
const p = canvasCoords(e)
|
|
220
|
+
if (annotTool === 'freehand') _annotFreehandPts.push(p)
|
|
221
|
+
clearAnnotOverlay()
|
|
222
|
+
const preview = buildAnnotShape(p)
|
|
223
|
+
if (preview && overlayCanvas) {
|
|
224
|
+
const ctx = overlayCanvas.getContext('2d')
|
|
225
|
+
if (ctx) renderAnnotShape(ctx, preview)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function onAnnotPointerUp(e: MouseEvent) {
|
|
230
|
+
if (annotDrawState !== 'drawing') return
|
|
231
|
+
const p = canvasCoords(e)
|
|
232
|
+
const shape = buildAnnotShape(p)
|
|
233
|
+
if (shape) annotShapes = [...annotShapes, shape]
|
|
234
|
+
annotDrawState = 'idle'
|
|
235
|
+
_annotFreehandPts = []
|
|
236
|
+
clearAnnotOverlay()
|
|
237
|
+
redrawAnnotBase()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function onAnnotCanvasLeave() {
|
|
241
|
+
if (annotDrawState === 'drawing') {
|
|
242
|
+
annotDrawState = 'idle'
|
|
243
|
+
_annotFreehandPts = []
|
|
244
|
+
clearAnnotOverlay()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function commitAnnotText() {
|
|
249
|
+
if (annotTextValue.trim()) {
|
|
250
|
+
const shape: AnnotShape = {
|
|
251
|
+
id: _annotNextId++,
|
|
252
|
+
type: 'text',
|
|
253
|
+
color: annotColor,
|
|
254
|
+
sw: ANNOT_SW,
|
|
255
|
+
position: { ..._annotTextPos },
|
|
256
|
+
content: annotTextValue.trim(),
|
|
257
|
+
fontSize: 20,
|
|
258
|
+
}
|
|
259
|
+
annotShapes = [...annotShapes, shape]
|
|
260
|
+
redrawAnnotBase()
|
|
261
|
+
}
|
|
262
|
+
annotTextValue = ''
|
|
263
|
+
annotDrawState = 'idle'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function handleAnnotTextKeydown(e: KeyboardEvent) {
|
|
267
|
+
if (e.key === 'Enter') { e.preventDefault(); commitAnnotText() }
|
|
268
|
+
else if (e.key === 'Escape') { e.preventDefault(); annotTextValue = ''; annotDrawState = 'idle' }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function annotUndo() {
|
|
272
|
+
if (annotShapes.length === 0) return
|
|
273
|
+
annotShapes = annotShapes.slice(0, -1)
|
|
274
|
+
redrawAnnotBase()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function annotClear() {
|
|
278
|
+
annotShapes = []
|
|
279
|
+
redrawAnnotBase()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function doAnnotateSave() {
|
|
283
|
+
if (!_annotImg || imgW === 0 || busy) return
|
|
284
|
+
error = ""
|
|
285
|
+
busy = true
|
|
286
|
+
try {
|
|
287
|
+
const offscreen = document.createElement('canvas')
|
|
288
|
+
offscreen.width = imgW
|
|
289
|
+
offscreen.height = imgH
|
|
290
|
+
const ctx = offscreen.getContext('2d')
|
|
291
|
+
if (!ctx) throw new Error('Canvas context unavailable')
|
|
292
|
+
ctx.drawImage(_annotImg, 0, 0, imgW, imgH)
|
|
293
|
+
for (const s of annotShapes) renderAnnotShape(ctx, s)
|
|
294
|
+
const blob = await new Promise<Blob>((resolve, reject) =>
|
|
295
|
+
offscreen.toBlob((b) => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png')
|
|
296
|
+
)
|
|
297
|
+
if (workingUrl?.startsWith("blob:")) URL.revokeObjectURL(workingUrl)
|
|
298
|
+
workingUrl = URL.createObjectURL(blob)
|
|
299
|
+
annotShapes = []
|
|
300
|
+
activeTab = "edit"
|
|
301
|
+
} catch (e) {
|
|
302
|
+
error = e instanceof Error ? e.message : String(e)
|
|
303
|
+
} finally {
|
|
304
|
+
busy = false
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Core modal actions ───────────────────────────────────────────────────
|
|
309
|
+
function openModal() {
|
|
310
|
+
workingUrl = null
|
|
311
|
+
error = ""
|
|
312
|
+
generatePrompt = ""
|
|
313
|
+
editInstruction = ""
|
|
314
|
+
activeTab = value ? "edit" : "generate"
|
|
315
|
+
open = true
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function closeModal() {
|
|
319
|
+
if (workingUrl?.startsWith("blob:")) URL.revokeObjectURL(workingUrl)
|
|
320
|
+
workingUrl = null
|
|
321
|
+
error = ""
|
|
322
|
+
open = false
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function doGenerate() {
|
|
326
|
+
if (!generatePrompt.trim() || busy) return
|
|
327
|
+
error = ""
|
|
328
|
+
busy = true
|
|
329
|
+
try {
|
|
330
|
+
const res = await fetch("/api/media/generate", {
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: { "Content-Type": "application/json" },
|
|
333
|
+
body: JSON.stringify({
|
|
334
|
+
prompt: generatePrompt,
|
|
335
|
+
...(generateAspect && { aspect_ratio: generateAspect }),
|
|
336
|
+
}),
|
|
337
|
+
})
|
|
338
|
+
const body = await res.json().catch(() => ({})) as { error?: string; url?: string }
|
|
339
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
|
340
|
+
if (!body.url) throw new Error("No URL returned")
|
|
341
|
+
workingUrl = body.url
|
|
342
|
+
} catch (e) {
|
|
343
|
+
error = e instanceof Error ? e.message : String(e)
|
|
344
|
+
} finally {
|
|
345
|
+
busy = false
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function doEdit() {
|
|
350
|
+
const sourceUrl = workingUrl ?? value
|
|
351
|
+
if (!sourceUrl || !editInstruction.trim() || busy) return
|
|
352
|
+
error = ""
|
|
353
|
+
busy = true
|
|
354
|
+
try {
|
|
355
|
+
const res = await fetch("/api/media/edit", {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { "Content-Type": "application/json" },
|
|
358
|
+
body: JSON.stringify({ imageUrl: sourceUrl, instruction: editInstruction }),
|
|
359
|
+
})
|
|
360
|
+
const body = await res.json().catch(() => ({})) as { error?: string; url?: string }
|
|
361
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
|
362
|
+
if (!body.url) throw new Error("No URL returned")
|
|
363
|
+
workingUrl = body.url
|
|
364
|
+
} catch (e) {
|
|
365
|
+
error = e instanceof Error ? e.message : String(e)
|
|
366
|
+
} finally {
|
|
367
|
+
busy = false
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function doRemoveBg() {
|
|
372
|
+
const sourceUrl = workingUrl ?? value
|
|
373
|
+
if (!sourceUrl || busy) return
|
|
374
|
+
error = ""
|
|
375
|
+
busy = true
|
|
376
|
+
try {
|
|
377
|
+
const imgRes = await fetch(sourceUrl)
|
|
378
|
+
if (!imgRes.ok) throw new Error("Could not fetch source image")
|
|
379
|
+
const blob = await imgRes.blob()
|
|
380
|
+
const fd = new FormData()
|
|
381
|
+
fd.append("image", blob, "source.png")
|
|
382
|
+
const res = await fetch("/api/media/remove-bg", { method: "POST", body: fd })
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
const msg = await res.text().catch(() => res.statusText)
|
|
385
|
+
throw new Error(msg || `HTTP ${res.status}`)
|
|
386
|
+
}
|
|
387
|
+
const pngBlob = await res.blob()
|
|
388
|
+
if (workingUrl?.startsWith("blob:")) URL.revokeObjectURL(workingUrl)
|
|
389
|
+
workingUrl = URL.createObjectURL(pngBlob)
|
|
390
|
+
} catch (e) {
|
|
391
|
+
error = e instanceof Error ? e.message : String(e)
|
|
392
|
+
} finally {
|
|
393
|
+
busy = false
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function doSave() {
|
|
398
|
+
const result = workingUrl ?? value
|
|
399
|
+
if (!result || busy) return
|
|
400
|
+
error = ""
|
|
401
|
+
busy = true
|
|
402
|
+
try {
|
|
403
|
+
let finalUrl = result
|
|
404
|
+
if (result.startsWith("blob:")) {
|
|
405
|
+
const blob = await fetch(result).then((r) => r.blob())
|
|
406
|
+
const fd = new FormData()
|
|
407
|
+
fd.append("files", blob, "image.png")
|
|
408
|
+
const res = await fetch(uploadEndpoint, { method: "POST", body: fd })
|
|
409
|
+
const body = await res.json().catch(() => ({})) as { error?: string; uploaded?: { url: string }[]; url?: string }
|
|
410
|
+
if (!res.ok) throw new Error(body.error ?? `Upload failed: HTTP ${res.status}`)
|
|
411
|
+
finalUrl = body.uploaded?.[0]?.url ?? body.url ?? result
|
|
412
|
+
}
|
|
413
|
+
if (value?.startsWith("blob:") && finalUrl !== value) URL.revokeObjectURL(value)
|
|
414
|
+
value = finalUrl
|
|
415
|
+
onComplete?.(finalUrl)
|
|
416
|
+
workingUrl = null
|
|
417
|
+
open = false
|
|
418
|
+
} catch (e) {
|
|
419
|
+
error = e instanceof Error ? e.message : String(e)
|
|
420
|
+
} finally {
|
|
421
|
+
busy = false
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function handleTriggerFile(e: Event) {
|
|
426
|
+
const file = (e.target as HTMLInputElement).files?.[0]
|
|
427
|
+
if (!file) return
|
|
428
|
+
if (value?.startsWith("blob:")) URL.revokeObjectURL(value)
|
|
429
|
+
value = URL.createObjectURL(file)
|
|
430
|
+
;(e.target as HTMLInputElement).value = ""
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Tool metadata
|
|
434
|
+
const annotToolMeta: { type: ShapeType; label: string; path?: string; isEllipse?: boolean }[] = [
|
|
435
|
+
{ type: 'arrow', label: 'Arrow', path: 'M5 19L19 5M19 5H9M19 5V15' },
|
|
436
|
+
{ type: 'ellipse', label: 'Oval', isEllipse: true },
|
|
437
|
+
{ type: 'rectangle', label: 'Rect', path: 'M3 5h18v14H3z' },
|
|
438
|
+
{ type: 'freehand', label: 'Draw', path: 'M3 17c1-2 3-6 5-6s3 4 5 4 3-6 5-6 2 4 3 4' },
|
|
439
|
+
{ type: 'text', label: 'Text', path: 'M6 4v16M18 4v16M6 12h12M8 4H4M20 4h-4M8 20H4M20 20h-4' },
|
|
440
|
+
]
|
|
441
|
+
</script>
|
|
442
|
+
|
|
443
|
+
<!-- ─── Trigger ──────────────────────────────────────────────────────────── -->
|
|
444
|
+
<div class="w-full">
|
|
445
|
+
{#if value}
|
|
446
|
+
<div class="relative group overflow-hidden rounded-lg border border-base-content/10 bg-base-200">
|
|
447
|
+
<img src={value} alt="Current media" class="w-full aspect-video object-contain checkerboard" />
|
|
448
|
+
<div class="absolute inset-0 bg-base-content/0 group-hover:bg-base-content/30 transition-colors duration-150 flex items-center justify-center">
|
|
449
|
+
<button
|
|
450
|
+
type="button"
|
|
451
|
+
class="btn btn-sm btn-primary gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
|
452
|
+
onclick={openModal}
|
|
453
|
+
aria-label="Edit image"
|
|
454
|
+
>
|
|
455
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
|
456
|
+
Edit
|
|
457
|
+
</button>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
{:else}
|
|
461
|
+
<div class="w-full rounded-lg border-2 border-dashed border-base-content/20 bg-base-200/40 flex flex-col items-center justify-center gap-3 py-10 px-4">
|
|
462
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-base-content/30"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
|
463
|
+
<p class="text-sm text-base-content/50">No image</p>
|
|
464
|
+
<div class="flex gap-2">
|
|
465
|
+
<button type="button" class="btn btn-primary btn-sm gap-1.5" onclick={openModal}>
|
|
466
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
|
467
|
+
Generate
|
|
468
|
+
</button>
|
|
469
|
+
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
470
|
+
<label class="btn btn-ghost btn-sm cursor-pointer">
|
|
471
|
+
Upload
|
|
472
|
+
<input type="file" class="hidden" {accept} onchange={handleTriggerFile} />
|
|
473
|
+
</label>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
{/if}
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<!-- ─── Modal ────────────────────────────────────────────────────────────── -->
|
|
480
|
+
{#if open}
|
|
481
|
+
<div class="modal modal-open" role="dialog" aria-modal="true" aria-labelledby="mw-title">
|
|
482
|
+
<div class="modal-box max-w-3xl p-0 overflow-hidden flex flex-col max-h-[90vh]">
|
|
483
|
+
|
|
484
|
+
<!-- Header -->
|
|
485
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-base-content/10 shrink-0">
|
|
486
|
+
<h3 id="mw-title" class="font-semibold text-base">
|
|
487
|
+
{value ? "Edit Image" : "Create Image"}
|
|
488
|
+
</h3>
|
|
489
|
+
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={closeModal} aria-label="Close">
|
|
490
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
491
|
+
</button>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<!-- Body: two-column -->
|
|
495
|
+
<div class="flex flex-1 min-h-0">
|
|
496
|
+
|
|
497
|
+
<!-- Left: preview / annotation canvas -->
|
|
498
|
+
<div class="w-72 shrink-0 border-r border-base-content/10 flex items-center justify-center relative bg-base-100 overflow-hidden">
|
|
499
|
+
{#if busy}
|
|
500
|
+
<div class="absolute inset-0 bg-base-200/60 flex flex-col items-center justify-center gap-2 z-10">
|
|
501
|
+
<span class="loading loading-spinner loading-md text-primary"></span>
|
|
502
|
+
<span class="text-xs text-base-content/60">Processing…</span>
|
|
503
|
+
</div>
|
|
504
|
+
{/if}
|
|
505
|
+
|
|
506
|
+
{#if activeTab === 'annotate' && previewUrl}
|
|
507
|
+
{#if annotLoaded}
|
|
508
|
+
<!-- Dual-canvas stack: base (rendered image + shapes) + overlay (drawing preview) -->
|
|
509
|
+
<div class="flex items-center justify-center w-full h-full p-2">
|
|
510
|
+
<div class="relative">
|
|
511
|
+
<canvas
|
|
512
|
+
bind:this={baseCanvas}
|
|
513
|
+
width={imgW}
|
|
514
|
+
height={imgH}
|
|
515
|
+
style="max-width: 252px; max-height: 320px; display: block;"
|
|
516
|
+
></canvas>
|
|
517
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
518
|
+
<canvas
|
|
519
|
+
bind:this={overlayCanvas}
|
|
520
|
+
width={imgW}
|
|
521
|
+
height={imgH}
|
|
522
|
+
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
|
523
|
+
class={annotDrawState === 'typing' ? 'cursor-text' : 'cursor-crosshair'}
|
|
524
|
+
onmousedown={onAnnotPointerDown}
|
|
525
|
+
onmousemove={onAnnotPointerMove}
|
|
526
|
+
onmouseup={onAnnotPointerUp}
|
|
527
|
+
onmouseleave={onAnnotCanvasLeave}
|
|
528
|
+
></canvas>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
{:else}
|
|
532
|
+
<!-- Loading spinner while image decodes -->
|
|
533
|
+
<span class="loading loading-spinner loading-sm text-base-content/30"></span>
|
|
534
|
+
{/if}
|
|
535
|
+
{:else if previewUrl}
|
|
536
|
+
<img src={previewUrl} alt="Preview" class="w-full h-full object-contain checkerboard" />
|
|
537
|
+
{:else}
|
|
538
|
+
<div class="flex flex-col items-center gap-2 p-6 text-center">
|
|
539
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-base-content/20"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
|
540
|
+
<span class="text-xs text-base-content/40">Preview will appear here</span>
|
|
541
|
+
</div>
|
|
542
|
+
{/if}
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<!-- Right: tabs + content -->
|
|
546
|
+
<div class="flex-1 flex flex-col min-w-0 min-h-0">
|
|
547
|
+
|
|
548
|
+
<!-- Tabs -->
|
|
549
|
+
<div class="tabs tabs-boxed gap-0 rounded-none px-3 py-2.5 bg-base-200/50 border-b border-base-content/10 shrink-0 flex-nowrap">
|
|
550
|
+
<button
|
|
551
|
+
type="button"
|
|
552
|
+
class="tab tab-sm gap-1.5 whitespace-nowrap {activeTab === 'generate' ? 'tab-active' : ''}"
|
|
553
|
+
onclick={() => { activeTab = "generate"; error = "" }}
|
|
554
|
+
>
|
|
555
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
|
556
|
+
Generate
|
|
557
|
+
</button>
|
|
558
|
+
<button
|
|
559
|
+
type="button"
|
|
560
|
+
class="tab tab-sm gap-1.5 whitespace-nowrap {activeTab === 'edit' ? 'tab-active' : ''}"
|
|
561
|
+
onclick={() => { activeTab = "edit"; error = "" }}
|
|
562
|
+
>
|
|
563
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
|
564
|
+
Edit with AI
|
|
565
|
+
</button>
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
class="tab tab-sm gap-1.5 whitespace-nowrap {activeTab === 'remove-bg' ? 'tab-active' : ''}"
|
|
569
|
+
onclick={() => { activeTab = "remove-bg"; error = "" }}
|
|
570
|
+
>
|
|
571
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>
|
|
572
|
+
Remove BG
|
|
573
|
+
</button>
|
|
574
|
+
<button
|
|
575
|
+
type="button"
|
|
576
|
+
class="tab tab-sm gap-1.5 whitespace-nowrap {activeTab === 'annotate' ? 'tab-active' : ''}"
|
|
577
|
+
onclick={() => { activeTab = "annotate"; error = "" }}
|
|
578
|
+
>
|
|
579
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
|
580
|
+
Annotate
|
|
581
|
+
</button>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
<!-- Tab content -->
|
|
585
|
+
<div class="flex-1 overflow-y-auto p-4">
|
|
586
|
+
|
|
587
|
+
{#if activeTab === "generate"}
|
|
588
|
+
<div class="space-y-3">
|
|
589
|
+
<p class="text-xs text-base-content/60">Describe the image you want to create from scratch.</p>
|
|
590
|
+
<label class="form-control w-full" for="mw-prompt">
|
|
591
|
+
<div class="label pb-1">
|
|
592
|
+
<span class="label-text font-medium text-sm">Prompt</span>
|
|
593
|
+
</div>
|
|
594
|
+
<textarea
|
|
595
|
+
id="mw-prompt"
|
|
596
|
+
class="textarea textarea-bordered textarea-sm w-full text-sm"
|
|
597
|
+
rows={4}
|
|
598
|
+
placeholder="A professional product photo of a wooden cutting board on a marble counter, natural lighting, minimalist style…"
|
|
599
|
+
bind:value={generatePrompt}
|
|
600
|
+
disabled={busy}
|
|
601
|
+
></textarea>
|
|
602
|
+
</label>
|
|
603
|
+
<label class="form-control w-full" for="mw-aspect">
|
|
604
|
+
<div class="label pb-1">
|
|
605
|
+
<span class="label-text text-sm">Aspect ratio</span>
|
|
606
|
+
</div>
|
|
607
|
+
<div class="select select-sm select-bordered w-full">
|
|
608
|
+
<select id="mw-aspect" bind:value={generateAspect} disabled={busy}>
|
|
609
|
+
<option value="">Auto</option>
|
|
610
|
+
<option value="1:1">1:1 — Square</option>
|
|
611
|
+
<option value="4:3">4:3 — Landscape</option>
|
|
612
|
+
<option value="16:9">16:9 — Widescreen</option>
|
|
613
|
+
<option value="3:4">3:4 — Portrait</option>
|
|
614
|
+
<option value="9:16">9:16 — Tall</option>
|
|
615
|
+
</select>
|
|
616
|
+
</div>
|
|
617
|
+
</label>
|
|
618
|
+
<button
|
|
619
|
+
type="button"
|
|
620
|
+
class="btn btn-primary btn-sm w-full gap-1.5"
|
|
621
|
+
onclick={doGenerate}
|
|
622
|
+
disabled={busy || !generatePrompt.trim()}
|
|
623
|
+
>
|
|
624
|
+
{#if busy}
|
|
625
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
626
|
+
Generating…
|
|
627
|
+
{:else}
|
|
628
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
|
629
|
+
Generate Image
|
|
630
|
+
{/if}
|
|
631
|
+
</button>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
{:else if activeTab === "edit"}
|
|
635
|
+
<div class="space-y-3">
|
|
636
|
+
{#if !previewUrl}
|
|
637
|
+
<div class="rounded-lg bg-warning/10 border border-warning/20 p-3 text-xs text-warning">
|
|
638
|
+
No image to edit yet — generate or upload one first.
|
|
639
|
+
</div>
|
|
640
|
+
{:else}
|
|
641
|
+
<p class="text-xs text-base-content/60">Describe how to change the image shown in the preview.</p>
|
|
642
|
+
{/if}
|
|
643
|
+
<label class="form-control w-full" for="mw-instruction">
|
|
644
|
+
<div class="label pb-1">
|
|
645
|
+
<span class="label-text font-medium text-sm">Instruction</span>
|
|
646
|
+
</div>
|
|
647
|
+
<textarea
|
|
648
|
+
id="mw-instruction"
|
|
649
|
+
class="textarea textarea-bordered textarea-sm w-full text-sm"
|
|
650
|
+
rows={4}
|
|
651
|
+
placeholder="Change the background to a light gray studio backdrop. Keep the product in place…"
|
|
652
|
+
bind:value={editInstruction}
|
|
653
|
+
disabled={busy || !previewUrl}
|
|
654
|
+
></textarea>
|
|
655
|
+
</label>
|
|
656
|
+
<button
|
|
657
|
+
type="button"
|
|
658
|
+
class="btn btn-primary btn-sm w-full gap-1.5"
|
|
659
|
+
onclick={doEdit}
|
|
660
|
+
disabled={busy || !previewUrl || !editInstruction.trim()}
|
|
661
|
+
>
|
|
662
|
+
{#if busy}
|
|
663
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
664
|
+
Applying…
|
|
665
|
+
{:else}
|
|
666
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
|
667
|
+
Apply Edit
|
|
668
|
+
{/if}
|
|
669
|
+
</button>
|
|
670
|
+
</div>
|
|
671
|
+
|
|
672
|
+
{:else if activeTab === "remove-bg"}
|
|
673
|
+
<div class="space-y-3">
|
|
674
|
+
{#if !previewUrl}
|
|
675
|
+
<div class="rounded-lg bg-warning/10 border border-warning/20 p-3 text-xs text-warning">
|
|
676
|
+
No image loaded — generate or upload one first.
|
|
677
|
+
</div>
|
|
678
|
+
{:else}
|
|
679
|
+
<p class="text-xs text-base-content/60">
|
|
680
|
+
Removes the background from the image in the preview, leaving only the subject on a transparent PNG.
|
|
681
|
+
</p>
|
|
682
|
+
<div class="rounded-lg bg-base-200/60 border border-base-content/10 p-3 flex items-start gap-3">
|
|
683
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-base-content/40 mt-0.5 shrink-0"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
|
684
|
+
<p class="text-xs text-base-content/60">
|
|
685
|
+
Works best on product photos and portraits. Processing takes 1–6 seconds.
|
|
686
|
+
</p>
|
|
687
|
+
</div>
|
|
688
|
+
{/if}
|
|
689
|
+
<button
|
|
690
|
+
type="button"
|
|
691
|
+
class="btn btn-primary btn-sm w-full gap-1.5"
|
|
692
|
+
onclick={doRemoveBg}
|
|
693
|
+
disabled={busy || !previewUrl}
|
|
694
|
+
>
|
|
695
|
+
{#if busy}
|
|
696
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
697
|
+
Removing background…
|
|
698
|
+
{:else}
|
|
699
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>
|
|
700
|
+
Remove Background
|
|
701
|
+
{/if}
|
|
702
|
+
</button>
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
{:else if activeTab === "annotate"}
|
|
706
|
+
<div class="space-y-4">
|
|
707
|
+
{#if !previewUrl}
|
|
708
|
+
<div class="rounded-lg bg-warning/10 border border-warning/20 p-3 text-xs text-warning">
|
|
709
|
+
No image loaded — generate or upload one first.
|
|
710
|
+
</div>
|
|
711
|
+
{:else}
|
|
712
|
+
<!-- Tool picker -->
|
|
713
|
+
<div>
|
|
714
|
+
<p class="text-xs font-medium text-base-content/50 mb-2">Tool</p>
|
|
715
|
+
<div class="flex gap-1 flex-wrap">
|
|
716
|
+
{#each annotToolMeta as tm}
|
|
717
|
+
<button
|
|
718
|
+
type="button"
|
|
719
|
+
class="btn btn-xs gap-1 {annotTool === tm.type ? 'btn-primary' : 'btn-ghost border border-base-content/15'}"
|
|
720
|
+
onclick={() => { annotTool = tm.type }}
|
|
721
|
+
title={tm.label}
|
|
722
|
+
>
|
|
723
|
+
{#if tm.isEllipse}
|
|
724
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
|
725
|
+
<ellipse cx="12" cy="12" rx="10" ry="7" stroke="currentColor" stroke-width="2" fill="none"/>
|
|
726
|
+
</svg>
|
|
727
|
+
{:else if tm.path}
|
|
728
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
|
729
|
+
<path d={tm.path} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
730
|
+
</svg>
|
|
731
|
+
{/if}
|
|
732
|
+
{tm.label}
|
|
733
|
+
</button>
|
|
734
|
+
{/each}
|
|
735
|
+
</div>
|
|
736
|
+
</div>
|
|
737
|
+
|
|
738
|
+
<!-- Color picker -->
|
|
739
|
+
<div>
|
|
740
|
+
<p class="text-xs font-medium text-base-content/50 mb-2">Color</p>
|
|
741
|
+
<div class="flex gap-2">
|
|
742
|
+
{#each ANNOT_COLORS as c}
|
|
743
|
+
<button
|
|
744
|
+
type="button"
|
|
745
|
+
class="w-6 h-6 rounded-full border-2 transition-transform duration-100 {annotColor === c ? 'scale-125 border-base-content' : 'border-transparent hover:scale-110'}"
|
|
746
|
+
style="background: {c}; {c === '#111827' ? 'outline: 1px solid oklch(var(--bc)/0.2);' : ''}"
|
|
747
|
+
onclick={() => { annotColor = c }}
|
|
748
|
+
aria-label="Color {c}"
|
|
749
|
+
></button>
|
|
750
|
+
{/each}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
<!-- Undo / Clear -->
|
|
755
|
+
<div class="flex gap-2">
|
|
756
|
+
<button
|
|
757
|
+
type="button"
|
|
758
|
+
class="btn btn-ghost btn-sm gap-1.5"
|
|
759
|
+
onclick={annotUndo}
|
|
760
|
+
disabled={annotShapes.length === 0}
|
|
761
|
+
title="Undo (last shape)"
|
|
762
|
+
>
|
|
763
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10h10a5 5 0 015 5v0a5 5 0 01-5 5H8M3 10l4-4M3 10l4 4"/></svg>
|
|
764
|
+
Undo
|
|
765
|
+
</button>
|
|
766
|
+
<button
|
|
767
|
+
type="button"
|
|
768
|
+
class="btn btn-ghost btn-sm gap-1.5"
|
|
769
|
+
onclick={annotClear}
|
|
770
|
+
disabled={annotShapes.length === 0}
|
|
771
|
+
title="Clear all annotations"
|
|
772
|
+
>
|
|
773
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M10 11v6M14 11v6M5 6l1 14h12l1-14"/></svg>
|
|
774
|
+
Clear
|
|
775
|
+
</button>
|
|
776
|
+
</div>
|
|
777
|
+
|
|
778
|
+
<!-- Apply button -->
|
|
779
|
+
<button
|
|
780
|
+
type="button"
|
|
781
|
+
class="btn btn-primary btn-sm w-full gap-1.5"
|
|
782
|
+
onclick={doAnnotateSave}
|
|
783
|
+
disabled={busy || !annotLoaded}
|
|
784
|
+
>
|
|
785
|
+
{#if busy}
|
|
786
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
787
|
+
Applying…
|
|
788
|
+
{:else}
|
|
789
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 12 2 2 4-4"/><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
|
790
|
+
Apply Annotations
|
|
791
|
+
{/if}
|
|
792
|
+
</button>
|
|
793
|
+
<p class="text-xs text-base-content/40 leading-relaxed">
|
|
794
|
+
Flattens drawings onto the image. Then click <strong>Save</strong> to upload.
|
|
795
|
+
</p>
|
|
796
|
+
{/if}
|
|
797
|
+
</div>
|
|
798
|
+
{/if}
|
|
799
|
+
|
|
800
|
+
</div>
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
|
|
804
|
+
<!-- Footer -->
|
|
805
|
+
<div class="flex items-center justify-between px-5 py-3 border-t border-base-content/10 shrink-0 gap-4">
|
|
806
|
+
<div class="flex-1 min-w-0">
|
|
807
|
+
{#if error}
|
|
808
|
+
<p class="text-error text-xs line-clamp-2" role="alert">{error}</p>
|
|
809
|
+
{/if}
|
|
810
|
+
</div>
|
|
811
|
+
<div class="flex gap-2 shrink-0">
|
|
812
|
+
<button type="button" class="btn btn-ghost btn-sm" onclick={closeModal} disabled={busy}>
|
|
813
|
+
Cancel
|
|
814
|
+
</button>
|
|
815
|
+
<button
|
|
816
|
+
type="button"
|
|
817
|
+
class="btn btn-primary btn-sm gap-1.5"
|
|
818
|
+
onclick={doSave}
|
|
819
|
+
disabled={busy || !previewUrl}
|
|
820
|
+
>
|
|
821
|
+
{#if busy}
|
|
822
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
823
|
+
{:else}
|
|
824
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>
|
|
825
|
+
{/if}
|
|
826
|
+
Save
|
|
827
|
+
</button>
|
|
828
|
+
</div>
|
|
829
|
+
</div>
|
|
830
|
+
|
|
831
|
+
</div>
|
|
832
|
+
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close"></button>
|
|
833
|
+
</div>
|
|
834
|
+
|
|
835
|
+
<!-- Text annotation overlay input (fixed, appears at cursor position on canvas) -->
|
|
836
|
+
{#if annotDrawState === 'typing'}
|
|
837
|
+
<input
|
|
838
|
+
bind:this={annotTextInput}
|
|
839
|
+
type="text"
|
|
840
|
+
class="annot-text-input"
|
|
841
|
+
style="left: {annotTextInputCss.left}; top: {annotTextInputCss.top}; color: {annotColor};"
|
|
842
|
+
bind:value={annotTextValue}
|
|
843
|
+
onkeydown={handleAnnotTextKeydown}
|
|
844
|
+
onblur={commitAnnotText}
|
|
845
|
+
placeholder="Type label…"
|
|
846
|
+
aria-label="Annotation text"
|
|
847
|
+
/>
|
|
848
|
+
{/if}
|
|
849
|
+
{/if}
|
|
850
|
+
|
|
851
|
+
<style>
|
|
852
|
+
.checkerboard {
|
|
853
|
+
background-color: var(--color-base-100);
|
|
854
|
+
background-image: repeating-conic-gradient(var(--color-base-200) 0% 25%, transparent 0% 50%);
|
|
855
|
+
background-size: 16px 16px;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.annot-text-input {
|
|
859
|
+
position: fixed;
|
|
860
|
+
background: transparent;
|
|
861
|
+
border: 1px dashed rgba(255, 255, 255, 0.5);
|
|
862
|
+
outline: none;
|
|
863
|
+
font-size: 16px;
|
|
864
|
+
font-weight: bold;
|
|
865
|
+
font-family: sans-serif;
|
|
866
|
+
padding: 2px 4px;
|
|
867
|
+
z-index: 9999;
|
|
868
|
+
min-width: 80px;
|
|
869
|
+
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
|
|
870
|
+
}
|
|
871
|
+
</style>
|