@joewinke/jatui 0.1.10 → 0.1.19

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.
Files changed (91) hide show
  1. package/README.md +123 -0
  2. package/package.json +3 -1
  3. package/src/lib/actions/railNav.ts +473 -0
  4. package/src/lib/components/AnnotationLayer.svelte +108 -0
  5. package/src/lib/components/AnnotationPanel.svelte +319 -0
  6. package/src/lib/components/AudioWaveform.svelte +9 -5
  7. package/src/lib/components/AvailabilityModal.svelte +7 -3
  8. package/src/lib/components/AvatarUpload.svelte +27 -4
  9. package/src/lib/components/BookingForm.svelte +11 -9
  10. package/src/lib/components/BurndownChart.svelte +778 -0
  11. package/src/lib/components/Button.svelte +10 -1
  12. package/src/lib/components/CalendarPicker.svelte +3 -3
  13. package/src/lib/components/Card.svelte +2 -2
  14. package/src/lib/components/ChipInput.svelte +21 -15
  15. package/src/lib/components/ColorSelector.svelte +17 -13
  16. package/src/lib/components/CommentThread.svelte +773 -0
  17. package/src/lib/components/ConfirmDialog.svelte +348 -0
  18. package/src/lib/components/ConfirmModal.svelte +78 -11
  19. package/src/lib/components/ContextMenu.svelte +188 -0
  20. package/src/lib/components/CountdownTimer.svelte +1 -1
  21. package/src/lib/components/DateRangePicker.svelte +6 -4
  22. package/src/lib/components/Drawer.svelte +36 -3
  23. package/src/lib/components/EntityPreviewCard.svelte +104 -0
  24. package/src/lib/components/FileDropzone.svelte +493 -0
  25. package/src/lib/components/FilePicker.svelte +83 -14
  26. package/src/lib/components/FileThumbnail.svelte +80 -0
  27. package/src/lib/components/FilterDropdown.svelte +11 -11
  28. package/src/lib/components/HunkDiffView.svelte +348 -0
  29. package/src/lib/components/ImageLightbox.svelte +274 -0
  30. package/src/lib/components/ImageUpload.svelte +58 -9
  31. package/src/lib/components/InlineEdit.svelte +15 -9
  32. package/src/lib/components/InputDialog.svelte +327 -0
  33. package/src/lib/components/LazyImage.svelte +1 -0
  34. package/src/lib/components/LinkShortener.svelte +1 -1
  35. package/src/lib/components/LoadingSpinner.svelte +6 -2
  36. package/src/lib/components/MarkupEditor.svelte +485 -0
  37. package/src/lib/components/MarkupOverlay.svelte +55 -0
  38. package/src/lib/components/MediaWorkbench.svelte +871 -0
  39. package/src/lib/components/MilestoneCard.svelte +1 -1
  40. package/src/lib/components/MilestoneTimeline.svelte +1 -1
  41. package/src/lib/components/Modal.svelte +39 -4
  42. package/src/lib/components/PDFViewer.svelte +105 -0
  43. package/src/lib/components/PdfThumbnail.svelte +3 -1
  44. package/src/lib/components/PhoneInput.svelte +183 -63
  45. package/src/lib/components/ResizablePanel.svelte +4 -4
  46. package/src/lib/components/SearchDropdown.svelte +26 -13
  47. package/src/lib/components/SelectInput.svelte +26 -4
  48. package/src/lib/components/SidebarUserFooter.svelte +1 -1
  49. package/src/lib/components/SignaturePad.svelte +8 -4
  50. package/src/lib/components/SmartImageEditor.svelte +720 -0
  51. package/src/lib/components/SortDropdown.svelte +9 -3
  52. package/src/lib/components/Sparkline.svelte +9 -0
  53. package/src/lib/components/StatusBadge.svelte +20 -18
  54. package/src/lib/components/TextArea.svelte +24 -5
  55. package/src/lib/components/TextInput.svelte +29 -6
  56. package/src/lib/components/ThemeSelector.svelte +15 -4
  57. package/src/lib/components/TimeSlotPicker.svelte +7 -7
  58. package/src/lib/components/UserAvatar.svelte +14 -1
  59. package/src/lib/components/VariablePicker.svelte +170 -0
  60. package/src/lib/components/VoicePlayer.svelte +4 -3
  61. package/src/lib/components/markup.ts +287 -0
  62. package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
  63. package/src/lib/components/messaging/ChannelList.svelte +1 -1
  64. package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
  65. package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
  66. package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
  67. package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
  68. package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
  69. package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
  70. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
  71. package/src/lib/components/messaging/MessageInput.svelte +1 -1
  72. package/src/lib/components/messaging/MessageItem.svelte +6 -3
  73. package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
  74. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
  75. package/src/lib/components/messaging/StartDMModal.svelte +1 -1
  76. package/src/lib/components/pipeline/Pipeline.svelte +4 -4
  77. package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
  78. package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
  79. package/src/lib/index.ts +105 -1
  80. package/src/lib/stores/confirmDialog.svelte.ts +48 -0
  81. package/src/lib/stores/inputDialog.svelte.ts +51 -0
  82. package/src/lib/styles/rail.css +63 -0
  83. package/src/lib/types/annotation.ts +38 -0
  84. package/src/lib/types/comments.ts +97 -0
  85. package/src/lib/types/entityPreview.ts +45 -0
  86. package/src/lib/types/filePicker.ts +2 -0
  87. package/src/lib/types/smartImageEditor.ts +39 -0
  88. package/src/lib/types/templateVars.ts +36 -0
  89. package/src/lib/utils/dateFormatters.ts +12 -10
  90. package/src/lib/utils/phone.ts +80 -0
  91. 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>