@joewinke/jatui 0.1.11 → 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.
- 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 +21 -15
- 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/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 +15 -9
- package/src/lib/components/InputDialog.svelte +327 -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/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/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/index.ts +91 -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/smartImageEditor.ts +39 -0
- package/src/lib/types/templateVars.ts +36 -0
- package/src/lib/utils/dateFormatters.ts +12 -10
- package/src/lib/utils/taskUtils.ts +21 -7
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from "svelte"
|
|
3
|
+
import type { MarkupShape, MarkupTool, Point } from './markup'
|
|
4
|
+
import {
|
|
5
|
+
MARKUP_COLORS,
|
|
6
|
+
DEFAULT_STROKE_WIDTH,
|
|
7
|
+
renderAllShapes,
|
|
8
|
+
renderShape,
|
|
9
|
+
nextShapeId,
|
|
10
|
+
} from './markup'
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
imageUrl: string
|
|
14
|
+
initialShapes?: MarkupShape[]
|
|
15
|
+
onsave: (shapes: MarkupShape[]) => void
|
|
16
|
+
oncancel: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let { imageUrl, initialShapes = [], onsave, oncancel }: Props = $props()
|
|
20
|
+
|
|
21
|
+
let tool = $state<MarkupTool>("arrow")
|
|
22
|
+
let color = $state(MARKUP_COLORS[0])
|
|
23
|
+
// svelte-ignore state_referenced_locally
|
|
24
|
+
let shapes = $state<MarkupShape[]>([...initialShapes])
|
|
25
|
+
|
|
26
|
+
// Canvas refs
|
|
27
|
+
let baseCanvas: HTMLCanvasElement | undefined = $state()
|
|
28
|
+
let overlayCanvas: HTMLCanvasElement | undefined = $state()
|
|
29
|
+
|
|
30
|
+
// Image dimensions (native)
|
|
31
|
+
let imgW = $state(0)
|
|
32
|
+
let imgH = $state(0)
|
|
33
|
+
let loaded = $state(false)
|
|
34
|
+
let loadError = $state(false)
|
|
35
|
+
|
|
36
|
+
// Drawing state
|
|
37
|
+
type DrawState = "idle" | "drawing" | "typing"
|
|
38
|
+
let drawState = $state<DrawState>("idle")
|
|
39
|
+
let startPoint: Point = { x: 0, y: 0 }
|
|
40
|
+
let freehandPoints: Point[] = []
|
|
41
|
+
|
|
42
|
+
// Text input
|
|
43
|
+
let textInput: HTMLInputElement | undefined = $state()
|
|
44
|
+
let textInputPos = $state({ x: 0, y: 0 })
|
|
45
|
+
let textInputCss = $state({ left: "0px", top: "0px" })
|
|
46
|
+
let textValue = $state("")
|
|
47
|
+
|
|
48
|
+
let baseImage: HTMLImageElement | null = null
|
|
49
|
+
|
|
50
|
+
onMount(() => {
|
|
51
|
+
const img = new Image()
|
|
52
|
+
img.crossOrigin = "anonymous"
|
|
53
|
+
img.onload = () => {
|
|
54
|
+
imgW = img.naturalWidth
|
|
55
|
+
imgH = img.naturalHeight
|
|
56
|
+
baseImage = img
|
|
57
|
+
loaded = true
|
|
58
|
+
requestAnimationFrame(() => redrawBase(img))
|
|
59
|
+
}
|
|
60
|
+
img.onerror = () => {
|
|
61
|
+
loadError = true
|
|
62
|
+
}
|
|
63
|
+
img.src = imageUrl
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
function redrawBase(img?: HTMLImageElement) {
|
|
67
|
+
if (!baseCanvas) return
|
|
68
|
+
const ctx = baseCanvas.getContext("2d")
|
|
69
|
+
if (!ctx) return
|
|
70
|
+
if (!img) img = baseImage ?? undefined
|
|
71
|
+
if (!img) return
|
|
72
|
+
baseCanvas.width = imgW
|
|
73
|
+
baseCanvas.height = imgH
|
|
74
|
+
ctx.drawImage(img, 0, 0, imgW, imgH)
|
|
75
|
+
renderAllShapes(ctx, shapes)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clearOverlay() {
|
|
79
|
+
if (!overlayCanvas) return
|
|
80
|
+
const ctx = overlayCanvas.getContext("2d")
|
|
81
|
+
if (!ctx) return
|
|
82
|
+
overlayCanvas.width = imgW
|
|
83
|
+
overlayCanvas.height = imgH
|
|
84
|
+
ctx.clearRect(0, 0, imgW, imgH)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function canvasCoords(e: MouseEvent): Point {
|
|
88
|
+
if (!overlayCanvas) return { x: 0, y: 0 }
|
|
89
|
+
const rect = overlayCanvas.getBoundingClientRect()
|
|
90
|
+
const scaleX = imgW / rect.width
|
|
91
|
+
const scaleY = imgH / rect.height
|
|
92
|
+
return {
|
|
93
|
+
x: (e.clientX - rect.left) * scaleX,
|
|
94
|
+
y: (e.clientY - rect.top) * scaleY,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function canvasToCss(p: Point): { left: string; top: string } {
|
|
99
|
+
if (!overlayCanvas) return { left: "0px", top: "0px" }
|
|
100
|
+
const rect = overlayCanvas.getBoundingClientRect()
|
|
101
|
+
return {
|
|
102
|
+
left: `${rect.left + p.x / (imgW / rect.width)}px`,
|
|
103
|
+
top: `${rect.top + p.y / (imgH / rect.height)}px`,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildShape(end: Point): MarkupShape | null {
|
|
108
|
+
const base = { color, strokeWidth: DEFAULT_STROKE_WIDTH }
|
|
109
|
+
switch (tool) {
|
|
110
|
+
case "arrow":
|
|
111
|
+
return {
|
|
112
|
+
...base,
|
|
113
|
+
id: nextShapeId(),
|
|
114
|
+
type: "arrow",
|
|
115
|
+
start: startPoint,
|
|
116
|
+
end,
|
|
117
|
+
}
|
|
118
|
+
case "rectangle":
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
id: nextShapeId(),
|
|
122
|
+
type: "rectangle",
|
|
123
|
+
start: startPoint,
|
|
124
|
+
end,
|
|
125
|
+
}
|
|
126
|
+
case "ellipse":
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
id: nextShapeId(),
|
|
130
|
+
type: "ellipse",
|
|
131
|
+
start: startPoint,
|
|
132
|
+
end,
|
|
133
|
+
}
|
|
134
|
+
case "cloud":
|
|
135
|
+
return {
|
|
136
|
+
...base,
|
|
137
|
+
id: nextShapeId(),
|
|
138
|
+
type: "cloud",
|
|
139
|
+
start: startPoint,
|
|
140
|
+
end,
|
|
141
|
+
}
|
|
142
|
+
case "freehand":
|
|
143
|
+
return {
|
|
144
|
+
...base,
|
|
145
|
+
id: nextShapeId(),
|
|
146
|
+
type: "freehand",
|
|
147
|
+
points: [...freehandPoints, end],
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onPointerDown(e: MouseEvent) {
|
|
155
|
+
if (drawState === "typing") {
|
|
156
|
+
commitText()
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const p = canvasCoords(e)
|
|
160
|
+
if (tool === "text") {
|
|
161
|
+
drawState = "typing"
|
|
162
|
+
textInputPos = p
|
|
163
|
+
textInputCss = canvasToCss(p)
|
|
164
|
+
textValue = ""
|
|
165
|
+
requestAnimationFrame(() => textInput?.focus())
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
drawState = "drawing"
|
|
169
|
+
startPoint = p
|
|
170
|
+
freehandPoints = [p]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function onPointerMove(e: MouseEvent) {
|
|
174
|
+
if (drawState !== "drawing") return
|
|
175
|
+
const p = canvasCoords(e)
|
|
176
|
+
if (tool === "freehand") freehandPoints.push(p)
|
|
177
|
+
clearOverlay()
|
|
178
|
+
const preview = buildShape(p)
|
|
179
|
+
if (preview && overlayCanvas) {
|
|
180
|
+
const ctx = overlayCanvas.getContext("2d")
|
|
181
|
+
if (ctx) renderShape(ctx, preview)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function onPointerUp(e: MouseEvent) {
|
|
186
|
+
if (drawState !== "drawing") return
|
|
187
|
+
const p = canvasCoords(e)
|
|
188
|
+
const shape = buildShape(p)
|
|
189
|
+
if (shape) {
|
|
190
|
+
shapes = [...shapes, shape]
|
|
191
|
+
}
|
|
192
|
+
drawState = "idle"
|
|
193
|
+
freehandPoints = []
|
|
194
|
+
clearOverlay()
|
|
195
|
+
redrawBase()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function commitText() {
|
|
199
|
+
if (textValue.trim()) {
|
|
200
|
+
const shape: MarkupShape = {
|
|
201
|
+
id: nextShapeId(),
|
|
202
|
+
type: "text",
|
|
203
|
+
color,
|
|
204
|
+
strokeWidth: DEFAULT_STROKE_WIDTH,
|
|
205
|
+
position: textInputPos,
|
|
206
|
+
content: textValue.trim(),
|
|
207
|
+
fontSize: 20,
|
|
208
|
+
}
|
|
209
|
+
shapes = [...shapes, shape]
|
|
210
|
+
redrawBase()
|
|
211
|
+
}
|
|
212
|
+
textValue = ""
|
|
213
|
+
drawState = "idle"
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function handleTextKeydown(e: KeyboardEvent) {
|
|
217
|
+
e.stopPropagation()
|
|
218
|
+
if (e.key === "Enter") {
|
|
219
|
+
e.preventDefault()
|
|
220
|
+
commitText()
|
|
221
|
+
} else if (e.key === "Escape") {
|
|
222
|
+
e.preventDefault()
|
|
223
|
+
textValue = ""
|
|
224
|
+
drawState = "idle"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function undo() {
|
|
229
|
+
if (shapes.length === 0) return
|
|
230
|
+
shapes = shapes.slice(0, -1)
|
|
231
|
+
redrawBase()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function clearAll() {
|
|
235
|
+
shapes = []
|
|
236
|
+
redrawBase()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function handleSave() {
|
|
240
|
+
onsave(shapes)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
244
|
+
e.stopPropagation()
|
|
245
|
+
if (e.key === "Escape" && drawState !== "typing") {
|
|
246
|
+
oncancel()
|
|
247
|
+
}
|
|
248
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "z") {
|
|
249
|
+
e.preventDefault()
|
|
250
|
+
undo()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const toolDefs: { tool: MarkupTool; label: string; icon: string }[] = [
|
|
255
|
+
{ tool: "arrow", label: "Arrow", icon: "M5 19L19 5M19 5H9M19 5V15" },
|
|
256
|
+
{ tool: "rectangle", label: "Box", icon: "M3 3h18v18H3z" },
|
|
257
|
+
{
|
|
258
|
+
tool: "ellipse",
|
|
259
|
+
label: "Circle",
|
|
260
|
+
icon: "", // special SVG
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
tool: "cloud",
|
|
264
|
+
label: "Cloud",
|
|
265
|
+
icon: "", // special SVG
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
tool: "freehand",
|
|
269
|
+
label: "Draw",
|
|
270
|
+
icon: "M3 17c1-2 3-6 5-6s3 4 5 4 3-6 5-6 2 4 3 4",
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
tool: "text",
|
|
274
|
+
label: "Text",
|
|
275
|
+
icon: "M6 4v16M18 4v16M6 12h12M8 4h-4M20 4h-4M8 20h-4M20 20h-4",
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
</script>
|
|
279
|
+
|
|
280
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
281
|
+
<div
|
|
282
|
+
class="fixed inset-0 z-50 bg-black/90 flex flex-col items-center"
|
|
283
|
+
onkeydown={handleKeydown}
|
|
284
|
+
onkeyup={(e) => e.stopPropagation()}
|
|
285
|
+
onkeypress={(e) => e.stopPropagation()}
|
|
286
|
+
>
|
|
287
|
+
<!-- Toolbar -->
|
|
288
|
+
<div
|
|
289
|
+
class="flex items-center gap-1.5 px-3 py-2 bg-base-300 border-b border-base-content/10 w-full flex-shrink-0 flex-wrap"
|
|
290
|
+
>
|
|
291
|
+
<!-- Tool buttons -->
|
|
292
|
+
<div class="flex items-center gap-1">
|
|
293
|
+
{#each toolDefs as t}
|
|
294
|
+
<button
|
|
295
|
+
class="btn btn-xs gap-1 {tool === t.tool
|
|
296
|
+
? 'btn-primary'
|
|
297
|
+
: 'btn-ghost text-base-content/60 hover:text-base-content'}"
|
|
298
|
+
onclick={() => {
|
|
299
|
+
tool = t.tool
|
|
300
|
+
}}
|
|
301
|
+
title={t.label}
|
|
302
|
+
>
|
|
303
|
+
{#if t.tool === "ellipse"}
|
|
304
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
|
305
|
+
<ellipse
|
|
306
|
+
cx="12"
|
|
307
|
+
cy="12"
|
|
308
|
+
rx="10"
|
|
309
|
+
ry="7"
|
|
310
|
+
stroke="currentColor"
|
|
311
|
+
stroke-width="2"
|
|
312
|
+
fill="none"
|
|
313
|
+
/>
|
|
314
|
+
</svg>
|
|
315
|
+
{:else if t.tool === "cloud"}
|
|
316
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
|
317
|
+
<path
|
|
318
|
+
d="M6 19a4 4 0 01-.88-7.903A5 5 0 1115.9 6h.1a4 4 0 110 8h-4"
|
|
319
|
+
stroke="currentColor"
|
|
320
|
+
stroke-width="2"
|
|
321
|
+
stroke-linecap="round"
|
|
322
|
+
stroke-linejoin="round"
|
|
323
|
+
fill="none"
|
|
324
|
+
/>
|
|
325
|
+
</svg>
|
|
326
|
+
{:else}
|
|
327
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
|
328
|
+
<path
|
|
329
|
+
d={t.icon}
|
|
330
|
+
stroke="currentColor"
|
|
331
|
+
stroke-width="2"
|
|
332
|
+
stroke-linecap="round"
|
|
333
|
+
stroke-linejoin="round"
|
|
334
|
+
fill="none"
|
|
335
|
+
/>
|
|
336
|
+
</svg>
|
|
337
|
+
{/if}
|
|
338
|
+
<span class="hidden sm:inline text-[11px]">{t.label}</span>
|
|
339
|
+
</button>
|
|
340
|
+
{/each}
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div class="divider divider-horizontal mx-0.5 h-5"></div>
|
|
344
|
+
|
|
345
|
+
<!-- Color swatches -->
|
|
346
|
+
<div class="flex items-center gap-1">
|
|
347
|
+
{#each MARKUP_COLORS as c}
|
|
348
|
+
<button
|
|
349
|
+
class="w-5 h-5 rounded-full border-2 transition-transform {color === c
|
|
350
|
+
? 'border-white scale-110'
|
|
351
|
+
: 'border-transparent hover:scale-105'}"
|
|
352
|
+
style="background: {c}; {c === '#111827'
|
|
353
|
+
? 'border-color: #6b7280;'
|
|
354
|
+
: ''}"
|
|
355
|
+
onclick={() => {
|
|
356
|
+
color = c
|
|
357
|
+
}}
|
|
358
|
+
title={c}
|
|
359
|
+
></button>
|
|
360
|
+
{/each}
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<div class="divider divider-horizontal mx-0.5 h-5"></div>
|
|
364
|
+
|
|
365
|
+
<!-- Actions -->
|
|
366
|
+
<div class="flex items-center gap-1">
|
|
367
|
+
<button
|
|
368
|
+
class="btn btn-xs btn-ghost text-base-content/60"
|
|
369
|
+
onclick={undo}
|
|
370
|
+
disabled={shapes.length === 0}
|
|
371
|
+
title="Undo (Ctrl+Z)"
|
|
372
|
+
>
|
|
373
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
|
374
|
+
<path
|
|
375
|
+
d="M3 10h10a5 5 0 015 5v0a5 5 0 01-5 5H8M3 10l4-4M3 10l4 4"
|
|
376
|
+
stroke="currentColor"
|
|
377
|
+
stroke-width="2"
|
|
378
|
+
stroke-linecap="round"
|
|
379
|
+
stroke-linejoin="round"
|
|
380
|
+
/>
|
|
381
|
+
</svg>
|
|
382
|
+
<span class="hidden sm:inline">Undo</span>
|
|
383
|
+
</button>
|
|
384
|
+
<button
|
|
385
|
+
class="btn btn-xs btn-ghost text-base-content/60"
|
|
386
|
+
onclick={clearAll}
|
|
387
|
+
disabled={shapes.length === 0}
|
|
388
|
+
title="Clear all"
|
|
389
|
+
>
|
|
390
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
|
391
|
+
<path
|
|
392
|
+
d="M3 6h18M8 6V4h8v2M10 11v6M14 11v6M5 6l1 14h12l1-14"
|
|
393
|
+
stroke="currentColor"
|
|
394
|
+
stroke-width="2"
|
|
395
|
+
stroke-linecap="round"
|
|
396
|
+
stroke-linejoin="round"
|
|
397
|
+
/>
|
|
398
|
+
</svg>
|
|
399
|
+
<span class="hidden sm:inline">Clear</span>
|
|
400
|
+
</button>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
<div class="flex-1"></div>
|
|
404
|
+
|
|
405
|
+
<!-- Save/Cancel -->
|
|
406
|
+
<div class="flex items-center gap-1.5">
|
|
407
|
+
<button class="btn btn-xs btn-ghost" onclick={oncancel}>Cancel</button>
|
|
408
|
+
<button class="btn btn-xs btn-primary" onclick={handleSave}>
|
|
409
|
+
Save Markup
|
|
410
|
+
</button>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<!-- Canvas area -->
|
|
415
|
+
<div
|
|
416
|
+
class="flex-1 flex items-center justify-center relative overflow-hidden p-5"
|
|
417
|
+
>
|
|
418
|
+
{#if loadError}
|
|
419
|
+
<div class="text-center text-error">
|
|
420
|
+
<p>Failed to load image</p>
|
|
421
|
+
</div>
|
|
422
|
+
{:else if loaded}
|
|
423
|
+
<canvas
|
|
424
|
+
bind:this={baseCanvas}
|
|
425
|
+
width={imgW}
|
|
426
|
+
height={imgH}
|
|
427
|
+
class="markup-base-canvas rounded"
|
|
428
|
+
></canvas>
|
|
429
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
430
|
+
<canvas
|
|
431
|
+
bind:this={overlayCanvas}
|
|
432
|
+
width={imgW}
|
|
433
|
+
height={imgH}
|
|
434
|
+
class="markup-overlay-canvas"
|
|
435
|
+
class:cursor-crosshair={tool !== "text"}
|
|
436
|
+
class:cursor-text={tool === "text"}
|
|
437
|
+
onmousedown={onPointerDown}
|
|
438
|
+
onmousemove={onPointerMove}
|
|
439
|
+
onmouseup={onPointerUp}
|
|
440
|
+
></canvas>
|
|
441
|
+
{:else}
|
|
442
|
+
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
443
|
+
{/if}
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<!-- Text input overlay -->
|
|
447
|
+
{#if drawState === "typing"}
|
|
448
|
+
<input
|
|
449
|
+
bind:this={textInput}
|
|
450
|
+
type="text"
|
|
451
|
+
class="fixed bg-transparent border border-dashed border-white/50 outline-none text-base font-bold font-sans px-1 py-0.5 z-[60] min-w-[80px]"
|
|
452
|
+
style="left: {textInputCss.left}; top: {textInputCss.top}; color: {color};"
|
|
453
|
+
bind:value={textValue}
|
|
454
|
+
onkeydown={handleTextKeydown}
|
|
455
|
+
onblur={commitText}
|
|
456
|
+
placeholder="Type here..."
|
|
457
|
+
/>
|
|
458
|
+
{/if}
|
|
459
|
+
|
|
460
|
+
<!-- Shapes count indicator -->
|
|
461
|
+
{#if shapes.length > 0}
|
|
462
|
+
<div
|
|
463
|
+
class="absolute bottom-4 left-4 badge badge-neutral badge-sm opacity-60"
|
|
464
|
+
>
|
|
465
|
+
{shapes.length} shape{shapes.length !== 1 ? "s" : ""}
|
|
466
|
+
</div>
|
|
467
|
+
{/if}
|
|
468
|
+
</div>
|
|
469
|
+
|
|
470
|
+
<style>
|
|
471
|
+
.markup-base-canvas,
|
|
472
|
+
.markup-overlay-canvas {
|
|
473
|
+
max-width: calc(100vw - 80px);
|
|
474
|
+
max-height: calc(100vh - 120px);
|
|
475
|
+
object-fit: contain;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.markup-base-canvas {
|
|
479
|
+
display: block;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.markup-overlay-canvas {
|
|
483
|
+
position: absolute;
|
|
484
|
+
}
|
|
485
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte"
|
|
3
|
+
import type { MarkupShape } from './markup'
|
|
4
|
+
import { renderAllShapes } from './markup'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
shapes: MarkupShape[]
|
|
8
|
+
imageUrl: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { shapes, imageUrl }: Props = $props()
|
|
12
|
+
|
|
13
|
+
let canvas: HTMLCanvasElement | undefined = $state()
|
|
14
|
+
let imgW = $state(0)
|
|
15
|
+
let imgH = $state(0)
|
|
16
|
+
let loaded = $state(false)
|
|
17
|
+
|
|
18
|
+
function render() {
|
|
19
|
+
if (!canvas || !loaded || shapes.length === 0) return
|
|
20
|
+
const ctx = canvas.getContext("2d")
|
|
21
|
+
if (!ctx) return
|
|
22
|
+
canvas.width = imgW
|
|
23
|
+
canvas.height = imgH
|
|
24
|
+
ctx.clearRect(0, 0, imgW, imgH)
|
|
25
|
+
renderAllShapes(ctx, shapes)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
onMount(() => {
|
|
29
|
+
const img = new Image()
|
|
30
|
+
img.crossOrigin = "anonymous"
|
|
31
|
+
img.onload = () => {
|
|
32
|
+
imgW = img.naturalWidth
|
|
33
|
+
imgH = img.naturalHeight
|
|
34
|
+
loaded = true
|
|
35
|
+
requestAnimationFrame(render)
|
|
36
|
+
}
|
|
37
|
+
img.src = imageUrl
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Re-render when shapes change
|
|
41
|
+
$effect(() => {
|
|
42
|
+
if (shapes && loaded) {
|
|
43
|
+
render()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
{#if loaded && shapes.length > 0}
|
|
49
|
+
<canvas
|
|
50
|
+
bind:this={canvas}
|
|
51
|
+
width={imgW}
|
|
52
|
+
height={imgH}
|
|
53
|
+
class="absolute inset-0 w-full h-full pointer-events-none z-[5]"
|
|
54
|
+
></canvas>
|
|
55
|
+
{/if}
|