|
2 | 2 | import { Avatar, AvatarFallback } from "$lib/components/ui/avatar"; |
3 | 3 | import { Button } from "$lib/components/ui/button"; |
4 | 4 | import { Textarea } from "$lib/components/ui/textarea"; |
| 5 | + import InlineImageButton from "$lib/components/InlineImageButton.svelte"; |
5 | 6 | import { parse } from "marked"; |
6 | 7 | import { processAutoLinks } from "$lib/autolink"; |
| 8 | + import { resolveAttachmentRefs } from "$lib/resolve-attachment-refs"; |
| 9 | + import { InlineImageEditor } from "$lib/inline-image-editor.svelte"; |
7 | 10 | import DOMPurify from "dompurify"; |
8 | 11 | import type { AutoLinkContext } from "$lib/autolink"; |
9 | 12 | import type { Comment } from "$lib/types/api"; |
|
13 | 16 | let { |
14 | 17 | comments = [] as Comment[], |
15 | 18 | currentUserId = 0, |
| 19 | + taskId = 0, |
| 20 | + attachmentsMap = new Map<number, string>(), |
16 | 21 | autoLinkCtx = {} as AutoLinkContext, |
17 | 22 | onEdit = async (_commentId: number, _content: string) => {}, |
18 | 23 | onDelete = async (_commentId: number) => {}, |
19 | 24 | }: { |
20 | 25 | comments: Comment[]; |
21 | 26 | currentUserId: number; |
| 27 | + taskId?: number; |
| 28 | + attachmentsMap: Map<number, string>; |
22 | 29 | autoLinkCtx: AutoLinkContext; |
23 | 30 | onEdit: (commentId: number, content: string) => Promise<void>; |
24 | 31 | onDelete: (commentId: number) => Promise<void>; |
|
27 | 34 | let editingId = $state<number | null>(null); |
28 | 35 | let editText = $state(""); |
29 | 36 | let saving = $state(false); |
| 37 | + let editTextarea: HTMLTextAreaElement | undefined = $state(); |
| 38 | + let fileInput: HTMLInputElement | undefined = $state(); |
| 39 | +
|
| 40 | + const images = new InlineImageEditor({ |
| 41 | + getTaskId: () => taskId, |
| 42 | + getValue: () => editText, |
| 43 | + setValue: (v) => { |
| 44 | + editText = v; |
| 45 | + }, |
| 46 | + getTextarea: () => editTextarea, |
| 47 | + getSessionId: () => editingId, |
| 48 | + }); |
30 | 49 |
|
31 | 50 | function formatDateTime(d: string): string { |
32 | 51 | return new Date(d).toLocaleString(); |
|
44 | 63 | function renderMarkdown(text: string): string { |
45 | 64 | if (!text) return ""; |
46 | 65 | return DOMPurify.sanitize( |
47 | | - parse(processAutoLinks(text, autoLinkCtx), { |
48 | | - async: false, |
49 | | - }) as string, |
| 66 | + parse( |
| 67 | + processAutoLinks( |
| 68 | + resolveAttachmentRefs(text, attachmentsMap), |
| 69 | + autoLinkCtx, |
| 70 | + ), |
| 71 | + { async: false }, |
| 72 | + ) as string, |
50 | 73 | ); |
51 | 74 | } |
52 | 75 |
|
53 | 76 | function startEdit(comment: Comment) { |
| 77 | + if (images.uploading) return; |
54 | 78 | editingId = comment.id; |
55 | 79 | editText = comment.content; |
56 | 80 | } |
57 | 81 |
|
58 | 82 | function cancelEdit() { |
| 83 | + if (images.uploading) return; |
59 | 84 | editingId = null; |
60 | 85 | editText = ""; |
61 | 86 | } |
|
71 | 96 | await onEdit(commentId, editText); |
72 | 97 | editingId = null; |
73 | 98 | editText = ""; |
74 | | - } catch (e: any) { |
75 | | - toast.error(e.message || "Failed to update comment"); |
| 99 | + } catch (e) { |
| 100 | + toast.error(e instanceof Error ? e.message : "Failed to update comment"); |
76 | 101 | } finally { |
77 | 102 | saving = false; |
78 | 103 | } |
|
97 | 122 | </div> |
98 | 123 |
|
99 | 124 | {#if editingId === comment.id} |
100 | | - <Textarea bind:value={editText} class="mb-2 min-h-16" /> |
| 125 | + <!-- svelte-ignore a11y_no_static_element_interactions --> |
| 126 | + <div |
| 127 | + ondragover={(e) => { |
| 128 | + if (saving) { |
| 129 | + e.preventDefault(); |
| 130 | + return; |
| 131 | + } |
| 132 | + images.handleDragOver(e); |
| 133 | + }} |
| 134 | + ondrop={(e) => { |
| 135 | + if (saving) { |
| 136 | + e.preventDefault(); |
| 137 | + return; |
| 138 | + } |
| 139 | + images.handleImageDrop(e); |
| 140 | + }} |
| 141 | + > |
| 142 | + <Textarea |
| 143 | + bind:value={editText} |
| 144 | + bind:this={editTextarea} |
| 145 | + onpaste={(e) => { |
| 146 | + if (!saving) images.handleImagePaste(e); |
| 147 | + }} |
| 148 | + class="mb-2 min-h-16" |
| 149 | + /> |
| 150 | + </div> |
| 151 | + <div class="mb-2 flex items-center gap-2"> |
| 152 | + <InlineImageButton |
| 153 | + uploading={images.uploading} |
| 154 | + disabled={!taskId || saving} |
| 155 | + onclick={() => fileInput?.click()} |
| 156 | + /> |
| 157 | + <input |
| 158 | + type="file" |
| 159 | + disabled={saving || images.uploading} |
| 160 | + bind:this={fileInput} |
| 161 | + accept="image/png,image/jpeg,image/gif,image/webp" |
| 162 | + class="hidden" |
| 163 | + onchange={(e) => images.handleImagePick(e)} |
| 164 | + /> |
| 165 | + </div> |
101 | 166 | <div class="flex gap-2"> |
102 | 167 | <Button |
103 | 168 | size="sm" |
104 | 169 | onclick={() => handleEdit(comment.id)} |
105 | | - disabled={saving} |
| 170 | + disabled={saving || images.uploading} |
106 | 171 | > |
107 | 172 | {saving ? "Saving..." : "Save"} |
108 | 173 | </Button> |
109 | | - <Button size="sm" variant="ghost" onclick={cancelEdit} |
110 | | - >Cancel</Button |
| 174 | + <Button |
| 175 | + size="sm" |
| 176 | + variant="ghost" |
| 177 | + onclick={cancelEdit} |
| 178 | + disabled={images.uploading} |
111 | 179 | > |
| 180 | + Cancel |
| 181 | + </Button> |
112 | 182 | </div> |
113 | 183 | {:else} |
114 | 184 | <div class="prose prose-sm dark:prose-invert max-w-none text-sm"> |
|
117 | 187 | {#if canManageComment(comment)} |
118 | 188 | <div class="mt-1 flex gap-2"> |
119 | 189 | <button |
120 | | - class="text-muted-foreground hover:text-foreground cursor-pointer text-xs underline underline-offset-2" |
| 190 | + class="text-muted-foreground hover:text-foreground cursor-pointer text-xs underline underline-offset-2 disabled:opacity-50 disabled:no-underline" |
| 191 | + disabled={images.uploading} |
121 | 192 | onclick={() => startEdit(comment)}>Edit</button |
122 | 193 | > |
123 | 194 | <button |
124 | | - class="text-muted-foreground hover:text-destructive cursor-pointer text-xs underline underline-offset-2" |
| 195 | + class="text-muted-foreground hover:text-destructive cursor-pointer text-xs underline underline-offset-2 disabled:opacity-50 disabled:no-underline" |
| 196 | + disabled={images.uploading} |
125 | 197 | onclick={async () => { |
126 | 198 | if (confirm("Delete this comment?")) { |
127 | 199 | try { |
128 | 200 | await onDelete(comment.id); |
129 | | - } catch (e: any) { |
130 | | - toast.error(e.message || "Failed to delete comment"); |
| 201 | + } catch (e) { |
| 202 | + toast.error( |
| 203 | + e instanceof Error |
| 204 | + ? e.message |
| 205 | + : "Failed to delete comment", |
| 206 | + ); |
131 | 207 | } |
132 | 208 | } |
133 | 209 | }}>Delete</button |
|
0 commit comments