@marianmeres/stuic 3.134.0 → 3.136.0
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/dist/components/CommentInput/CommentInput.svelte +186 -348
- package/dist/components/CommentInput/CommentInput.svelte.d.ts +42 -44
- package/dist/components/CommentInput/README.md +104 -77
- package/dist/components/CommentInput/index.css +29 -259
- package/dist/components/CommentInput/index.d.ts +1 -2
- package/dist/components/CommentInput/index.js +1 -1
- package/dist/components/Input/FieldAssets.svelte +179 -96
- package/dist/components/Input/FieldAssets.svelte.d.ts +9 -0
- package/dist/components/Input/README.md +23 -3
- package/dist/components/MarkdownEditor/MarkdownEditor.svelte +10 -0
- package/dist/components/MarkdownEditor/MarkdownEditor.svelte.d.ts +1 -0
- package/package.json +1 -1
- package/dist/components/CommentInput/_internal/render.d.ts +0 -24
- package/dist/components/CommentInput/_internal/render.js +0 -47
|
@@ -1,36 +1,23 @@
|
|
|
1
1
|
/******************************************************************************
|
|
2
2
|
CommentInput
|
|
3
3
|
|
|
4
|
-
A GitHub-style comment
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
A GitHub-style comment composer built on top of `MarkdownEditor` (a rich
|
|
5
|
+
WYSIWYG / source surface) with an avatar gutter and a submit/cancel footer.
|
|
6
|
+
|
|
7
|
+
The editor card, formatting toolbar, mode toggle and field chrome
|
|
8
|
+
(label / description / validation / sizing / theming) all come from
|
|
9
|
+
`MarkdownEditor` and its shared `InputWrap` — so the box inherits the
|
|
10
|
+
`--stuic-markdown-editor-*` / `--stuic-input-*` theming for free. This
|
|
11
|
+
stylesheet only lays out the gutter + footer and tightens the spacing so the
|
|
12
|
+
editor and its buttons read as a single composer unit.
|
|
12
13
|
******************************************************************************/
|
|
13
14
|
|
|
14
15
|
.stuic-comment-input {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
--
|
|
20
|
-
--stuic-comment-input-border,
|
|
21
|
-
var(--stuic-input-border, currentColor)
|
|
22
|
-
);
|
|
23
|
-
--_bg: var(--stuic-comment-input-bg, var(--stuic-input-bg, transparent));
|
|
24
|
-
--_pad-x: var(
|
|
25
|
-
--stuic-comment-input-padding-x,
|
|
26
|
-
var(--stuic-input-padding-x-md, 0.75rem)
|
|
27
|
-
);
|
|
28
|
-
--_pad-y: var(--stuic-comment-input-padding-y, var(--stuic-input-padding-y-md, 0.5rem));
|
|
29
|
-
--_min-height: var(--stuic-comment-input-min-height, 5rem);
|
|
30
|
-
--_font-size: var(
|
|
31
|
-
--stuic-comment-input-font-size,
|
|
32
|
-
var(--stuic-input-font-size-md, 0.9375rem)
|
|
33
|
-
);
|
|
16
|
+
/* The embedded MarkdownEditor defaults to a tall editing surface (12rem md);
|
|
17
|
+
for a comment composer we want the lighter, GitHub-like proportions of the
|
|
18
|
+
original box. Funnel a comment-scoped token into the editor's min-height var
|
|
19
|
+
so it stays themeable (and still overridable via the editor token directly). */
|
|
20
|
+
--stuic-markdown-editor-min-height: var(--stuic-comment-input-min-height, 5rem);
|
|
34
21
|
|
|
35
22
|
display: flex;
|
|
36
23
|
align-items: flex-start;
|
|
@@ -39,31 +26,24 @@
|
|
|
39
26
|
min-width: 0;
|
|
40
27
|
}
|
|
41
28
|
|
|
29
|
+
.stuic-comment-input[data-size="sm"] {
|
|
30
|
+
--stuic-comment-input-min-height: 4rem;
|
|
31
|
+
}
|
|
32
|
+
.stuic-comment-input[data-size="lg"] {
|
|
33
|
+
--stuic-comment-input-min-height: 7rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
42
36
|
.stuic-comment-input-avatar {
|
|
43
37
|
flex: 0 0 auto;
|
|
44
38
|
padding-top: 0.125rem;
|
|
45
39
|
}
|
|
46
40
|
|
|
47
|
-
|
|
48
|
-
.stuic-comment-input-editor {
|
|
41
|
+
.stuic-comment-input-main {
|
|
49
42
|
display: flex;
|
|
50
43
|
flex-direction: column;
|
|
51
44
|
flex: 1 1 auto;
|
|
52
45
|
min-width: 0;
|
|
53
46
|
max-width: 100%;
|
|
54
|
-
background: var(--_bg);
|
|
55
|
-
border: var(--stuic-border-width, 1px) solid var(--_border-color);
|
|
56
|
-
border-radius: var(--_radius);
|
|
57
|
-
color: var(--stuic-input-text, inherit);
|
|
58
|
-
overflow: hidden;
|
|
59
|
-
transition: border-color var(--stuic-transition, 150ms);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.stuic-comment-input-editor:focus-within {
|
|
63
|
-
border-color: var(
|
|
64
|
-
--stuic-comment-input-border-focus,
|
|
65
|
-
var(--stuic-input-border-focus, currentColor)
|
|
66
|
-
);
|
|
67
47
|
}
|
|
68
48
|
|
|
69
49
|
.stuic-comment-input.disabled {
|
|
@@ -71,196 +51,12 @@
|
|
|
71
51
|
pointer-events: none;
|
|
72
52
|
}
|
|
73
53
|
|
|
74
|
-
/*
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
padding: 0.25rem 0.375rem;
|
|
81
|
-
background: var(--stuic-comment-input-bar-bg, transparent);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.stuic-comment-input-tabs {
|
|
85
|
-
display: flex;
|
|
86
|
-
align-items: center;
|
|
87
|
-
gap: 0.125rem;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
.stuic-comment-input-tabs button {
|
|
91
|
-
display: inline-flex;
|
|
92
|
-
align-items: center;
|
|
93
|
-
justify-content: center;
|
|
94
|
-
height: 2rem;
|
|
95
|
-
padding: 0 0.625rem;
|
|
96
|
-
font-size: 0.8125rem;
|
|
97
|
-
line-height: 1;
|
|
98
|
-
color: inherit;
|
|
99
|
-
opacity: 0.7;
|
|
100
|
-
background: transparent;
|
|
101
|
-
border: none;
|
|
102
|
-
border-radius: var(--stuic-radius, 0.375rem);
|
|
103
|
-
transition:
|
|
104
|
-
background-color var(--stuic-transition, 150ms),
|
|
105
|
-
opacity var(--stuic-transition, 150ms);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
.stuic-comment-input-tabs button:hover:not(:disabled) {
|
|
109
|
-
background: var(--stuic-comment-input-tab-hover-bg, rgb(0 0 0 / 0.06));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
.stuic-comment-input-tabs button[data-active="true"] {
|
|
113
|
-
opacity: 1;
|
|
114
|
-
font-weight: 600;
|
|
115
|
-
background: var(--stuic-comment-input-tab-active-bg, rgb(0 0 0 / 0.06));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
.stuic-comment-input-tabs button:disabled {
|
|
119
|
-
opacity: 0.4;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
.stuic-comment-input-hint {
|
|
123
|
-
font-size: 0.75rem;
|
|
124
|
-
opacity: 0.5;
|
|
125
|
-
padding-right: 0.25rem;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
:root.dark .stuic-comment-input-tabs button:hover:not(:disabled),
|
|
129
|
-
:root.dark .stuic-comment-input-tabs button[data-active="true"] {
|
|
130
|
-
background: var(--stuic-comment-input-tab-active-bg, rgb(255 255 255 / 0.08));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
@media (pointer: coarse) {
|
|
134
|
-
.stuic-comment-input-tabs button {
|
|
135
|
-
height: 2.5rem;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/* --- Surface (textarea + preview share the same box) ---------------------- */
|
|
140
|
-
.stuic-comment-input-surface {
|
|
141
|
-
min-width: 0;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/* The inactive tab panel is removed via the `hidden` attribute; the explicit
|
|
145
|
-
rule guarantees it collapses regardless of any utility-layer display. The
|
|
146
|
-
write panel stays in the DOM while hidden so its textarea still submits and
|
|
147
|
-
validates. */
|
|
148
|
-
.stuic-comment-input-panel[hidden] {
|
|
149
|
-
display: none;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
.stuic-comment-input-textarea {
|
|
153
|
-
display: block;
|
|
154
|
-
width: 100%;
|
|
155
|
-
min-height: var(--_min-height);
|
|
156
|
-
max-width: 100%;
|
|
157
|
-
margin: 0;
|
|
158
|
-
padding: var(--_pad-y) var(--_pad-x);
|
|
159
|
-
font-size: var(--_font-size);
|
|
160
|
-
line-height: 1.6;
|
|
161
|
-
color: inherit;
|
|
162
|
-
background: transparent;
|
|
163
|
-
border: none;
|
|
164
|
-
outline: none;
|
|
165
|
-
box-shadow: none;
|
|
166
|
-
resize: none;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/* --- Preview -------------------------------------------------------------- */
|
|
170
|
-
.stuic-comment-input-preview {
|
|
171
|
-
min-height: var(--_min-height);
|
|
172
|
-
padding: var(--_pad-y) var(--_pad-x);
|
|
173
|
-
font-size: var(--_font-size);
|
|
174
|
-
line-height: 1.6;
|
|
175
|
-
overflow-wrap: break-word;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
.stuic-comment-input-preview-empty {
|
|
179
|
-
opacity: 0.5;
|
|
180
|
-
font-style: italic;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
.stuic-comment-input-preview > * + * {
|
|
184
|
-
margin-top: 0.75em;
|
|
185
|
-
}
|
|
186
|
-
.stuic-comment-input-preview h1 {
|
|
187
|
-
font-size: 1.5em;
|
|
188
|
-
font-weight: 700;
|
|
189
|
-
line-height: 1.2;
|
|
190
|
-
}
|
|
191
|
-
.stuic-comment-input-preview h2 {
|
|
192
|
-
font-size: 1.3em;
|
|
193
|
-
font-weight: 700;
|
|
194
|
-
line-height: 1.25;
|
|
195
|
-
}
|
|
196
|
-
.stuic-comment-input-preview h3 {
|
|
197
|
-
font-size: 1.15em;
|
|
198
|
-
font-weight: 600;
|
|
199
|
-
}
|
|
200
|
-
.stuic-comment-input-preview ul,
|
|
201
|
-
.stuic-comment-input-preview ol {
|
|
202
|
-
padding-left: 1.5em;
|
|
203
|
-
}
|
|
204
|
-
.stuic-comment-input-preview ul {
|
|
205
|
-
list-style: disc;
|
|
206
|
-
}
|
|
207
|
-
.stuic-comment-input-preview ol {
|
|
208
|
-
list-style: decimal;
|
|
209
|
-
}
|
|
210
|
-
.stuic-comment-input-preview a {
|
|
211
|
-
text-decoration: underline;
|
|
212
|
-
}
|
|
213
|
-
.stuic-comment-input-preview blockquote {
|
|
214
|
-
padding-left: 1em;
|
|
215
|
-
border-left: 3px solid var(--_border-color);
|
|
216
|
-
opacity: 0.85;
|
|
217
|
-
}
|
|
218
|
-
.stuic-comment-input-preview code {
|
|
219
|
-
font-family: var(
|
|
220
|
-
--stuic-comment-input-font-mono,
|
|
221
|
-
ui-monospace,
|
|
222
|
-
SFMono-Regular,
|
|
223
|
-
Menlo,
|
|
224
|
-
monospace
|
|
225
|
-
);
|
|
226
|
-
font-size: 0.9em;
|
|
227
|
-
padding: 0.1em 0.3em;
|
|
228
|
-
border-radius: 0.25rem;
|
|
229
|
-
background: var(--stuic-comment-input-code-bg, rgb(0 0 0 / 0.06));
|
|
230
|
-
}
|
|
231
|
-
.stuic-comment-input-preview pre {
|
|
232
|
-
font-family: var(
|
|
233
|
-
--stuic-comment-input-font-mono,
|
|
234
|
-
ui-monospace,
|
|
235
|
-
SFMono-Regular,
|
|
236
|
-
Menlo,
|
|
237
|
-
monospace
|
|
238
|
-
);
|
|
239
|
-
padding: 0.75em 1em;
|
|
240
|
-
border-radius: var(--stuic-radius, 0.375rem);
|
|
241
|
-
background: var(--stuic-comment-input-code-bg, rgb(0 0 0 / 0.06));
|
|
242
|
-
overflow-x: auto;
|
|
243
|
-
}
|
|
244
|
-
.stuic-comment-input-preview pre code {
|
|
245
|
-
padding: 0;
|
|
246
|
-
background: transparent;
|
|
247
|
-
}
|
|
248
|
-
.stuic-comment-input-preview hr {
|
|
249
|
-
border: none;
|
|
250
|
-
border-top: var(--stuic-border-width, 1px) solid var(--_border-color);
|
|
251
|
-
}
|
|
252
|
-
.stuic-comment-input-preview table {
|
|
253
|
-
border-collapse: collapse;
|
|
254
|
-
}
|
|
255
|
-
.stuic-comment-input-preview th,
|
|
256
|
-
.stuic-comment-input-preview td {
|
|
257
|
-
border: var(--stuic-border-width, 1px) solid var(--_border-color);
|
|
258
|
-
padding: 0.25em 0.5em;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
:root.dark .stuic-comment-input-preview code,
|
|
262
|
-
:root.dark .stuic-comment-input-preview pre {
|
|
263
|
-
background: var(--stuic-comment-input-code-bg, rgb(255 255 255 / 0.08));
|
|
54
|
+
/* The embedded MarkdownEditor wraps itself in InputWrap, which adds a bottom
|
|
55
|
+
margin meant for standalone fields. Inside the composer we want the footer to
|
|
56
|
+
sit directly beneath the editor, so collapse that margin — the footer adds its
|
|
57
|
+
own small top gap instead. */
|
|
58
|
+
.stuic-comment-input-main > .stuic-input {
|
|
59
|
+
margin-bottom: 0;
|
|
264
60
|
}
|
|
265
61
|
|
|
266
62
|
/* --- Footer (button row) -------------------------------------------------- */
|
|
@@ -269,7 +65,7 @@
|
|
|
269
65
|
align-items: center;
|
|
270
66
|
justify-content: space-between;
|
|
271
67
|
gap: 0.5rem;
|
|
272
|
-
padding: 0.5rem;
|
|
68
|
+
padding-top: var(--stuic-comment-input-footer-gap, 0.5rem);
|
|
273
69
|
}
|
|
274
70
|
|
|
275
71
|
.stuic-comment-input-footer-start,
|
|
@@ -282,29 +78,3 @@
|
|
|
282
78
|
.stuic-comment-input-footer-end {
|
|
283
79
|
margin-left: auto;
|
|
284
80
|
}
|
|
285
|
-
|
|
286
|
-
/* --- Size variants -------------------------------------------------------- */
|
|
287
|
-
.stuic-comment-input[data-size="sm"] {
|
|
288
|
-
--_pad-x: var(--stuic-comment-input-padding-x, var(--stuic-input-padding-x-sm, 0.5rem));
|
|
289
|
-
--_pad-y: var(
|
|
290
|
-
--stuic-comment-input-padding-y,
|
|
291
|
-
var(--stuic-input-padding-y-sm, 0.375rem)
|
|
292
|
-
);
|
|
293
|
-
--_font-size: var(
|
|
294
|
-
--stuic-comment-input-font-size,
|
|
295
|
-
var(--stuic-input-font-size-sm, 0.8125rem)
|
|
296
|
-
);
|
|
297
|
-
--_min-height: var(--stuic-comment-input-min-height, 4rem);
|
|
298
|
-
}
|
|
299
|
-
.stuic-comment-input[data-size="lg"] {
|
|
300
|
-
--_pad-x: var(--stuic-comment-input-padding-x, var(--stuic-input-padding-x-lg, 1rem));
|
|
301
|
-
--_pad-y: var(
|
|
302
|
-
--stuic-comment-input-padding-y,
|
|
303
|
-
var(--stuic-input-padding-y-lg, 0.75rem)
|
|
304
|
-
);
|
|
305
|
-
--_font-size: var(
|
|
306
|
-
--stuic-comment-input-font-size,
|
|
307
|
-
var(--stuic-input-font-size-lg, 1.0625rem)
|
|
308
|
-
);
|
|
309
|
-
--_min-height: var(--stuic-comment-input-min-height, 7rem);
|
|
310
|
-
}
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export { default as CommentInput, type Props as CommentInputProps, type CommentInputMode, } from "./CommentInput.svelte";
|
|
2
|
-
export type { MarkdownRenderer } from "./_internal/render.js";
|
|
1
|
+
export { default as CommentInput, type Props as CommentInputProps, type CommentInputMode, DEFAULT_COMMENT_TOOLBAR, } from "./CommentInput.svelte";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default as CommentInput, } from "./CommentInput.svelte";
|
|
1
|
+
export { default as CommentInput, DEFAULT_COMMENT_TOOLBAR, } from "./CommentInput.svelte";
|
|
@@ -187,6 +187,15 @@
|
|
|
187
187
|
* reserved for file drops); full keyboard + aria-live announcements. Default `false`.
|
|
188
188
|
*/
|
|
189
189
|
ordered?: boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
|
|
192
|
+
* the field. Focus-scoped — the paste is consumed only while the field (or a
|
|
193
|
+
* control inside it) holds focus; clicking anywhere in the field focuses it,
|
|
194
|
+
* so no Tab is needed. Routed through the same validation + upload path as
|
|
195
|
+
* drag and the file picker, so `accept`, `cardinality` and `processAssets`
|
|
196
|
+
* all apply. No-op unless `processAssets` is provided. Default `false`.
|
|
197
|
+
*/
|
|
198
|
+
pasteable?: boolean;
|
|
190
199
|
//
|
|
191
200
|
classWrap?: string;
|
|
192
201
|
}
|
|
@@ -252,6 +261,7 @@
|
|
|
252
261
|
classControls = "",
|
|
253
262
|
isLoading = false,
|
|
254
263
|
ordered = false,
|
|
264
|
+
pasteable = false,
|
|
255
265
|
parseValue = (strigifiedModels: string) => {
|
|
256
266
|
const val = strigifiedModels ?? "[]";
|
|
257
267
|
try {
|
|
@@ -280,6 +290,10 @@
|
|
|
280
290
|
let inputEl = $state<HTMLInputElement>()!;
|
|
281
291
|
// Outer wrapper for scrollIntoView and focus targeting.
|
|
282
292
|
let wrapEl: HTMLDivElement | undefined = $state();
|
|
293
|
+
// The asset grid container, which lives INSIDE InputWrap's `.input-wrap` box.
|
|
294
|
+
// `focusForPaste` focuses this (not `wrapEl`, an ancestor) so the `:focus-within`
|
|
295
|
+
// ring on `.input-wrap` actually lights up on click.
|
|
296
|
+
let gridEl: HTMLDivElement | undefined = $state();
|
|
283
297
|
let hiddenInputEl: HTMLInputElement | undefined = $state();
|
|
284
298
|
let assetsPreview: AssetsPreview = $state()!;
|
|
285
299
|
|
|
@@ -412,6 +426,160 @@
|
|
|
412
426
|
focusMovedButton(to, to < from ? "prev" : "next");
|
|
413
427
|
}
|
|
414
428
|
|
|
429
|
+
// Funnels every file source (drop, picker, paste) through one path so they all
|
|
430
|
+
// share the same accept/cardinality checks, optimistic tiles and processAssets
|
|
431
|
+
// upload. Accepts a plain File[] too (paste builds one); the spread normalizes
|
|
432
|
+
// both it and a FileList.
|
|
433
|
+
function handleIncomingFiles(files: FileList | File[] | null, wasDrop = false) {
|
|
434
|
+
clog.debug(`processFiles`, wasDrop ? "[DROPPED]" : "", files);
|
|
435
|
+
|
|
436
|
+
// Copy the File objects into a stable array, then IMMEDIATELY release the
|
|
437
|
+
// hidden <input type="file">'s retained FileList. A native file input
|
|
438
|
+
// keeps its FileList after a selection until it is reset (form reset or
|
|
439
|
+
// `value = ""`), and its `change` event (wired by the `fileDropzone`
|
|
440
|
+
// action) calls back into this handler. Without this reset, any later
|
|
441
|
+
// stray `change` re-runs this with the SAME file still in `inputEl.files`,
|
|
442
|
+
// pushing a duplicate optimistic asset and firing a real re-upload.
|
|
443
|
+
// `onSubmitValidityCheck` used to dispatch exactly such a synthetic
|
|
444
|
+
// `change` on submit (now fixed there too — belt and braces). Clearing
|
|
445
|
+
// also restores the ability to re-select the same file twice in a row (an
|
|
446
|
+
// unchanged value emits no `change`). The blob URLs we create below are
|
|
447
|
+
// independent of the input, so clearing here is safe.
|
|
448
|
+
const incoming = [...(files ?? [])];
|
|
449
|
+
if (inputEl) inputEl.value = "";
|
|
450
|
+
|
|
451
|
+
// Nothing to consume — a cancelled picker, or a stray/synthetic `change`
|
|
452
|
+
// on an already-cleared input. Bail before touching state or calling
|
|
453
|
+
// processAssets (which would otherwise run with an empty batch).
|
|
454
|
+
if (!incoming.length) return;
|
|
455
|
+
|
|
456
|
+
if (accept && incoming.some((f) => !is_accepted_type(accept, f.type))) {
|
|
457
|
+
const msg = t("invalid_type", { accept });
|
|
458
|
+
if (notifications) notifications.error(msg);
|
|
459
|
+
else alert(msg);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const cardErrMsg = t("cardinality_reached", { max: cardinality });
|
|
464
|
+
// `>=` (not `>`): refuse the moment the field already holds `cardinality`
|
|
465
|
+
// assets, instead of optimistically adding one past the limit and relying
|
|
466
|
+
// on the validator to reject it afterwards (the off-by-one that made the
|
|
467
|
+
// single-cardinality symptom loud).
|
|
468
|
+
if (assets.length >= cardinality) {
|
|
469
|
+
if (notifications) notifications.error(cardErrMsg);
|
|
470
|
+
else alert(cardErrMsg);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const toBeProcessed: FieldAsset[] = [];
|
|
475
|
+
|
|
476
|
+
for (const file of incoming) {
|
|
477
|
+
if (assets.length >= cardinality) {
|
|
478
|
+
notifications ? notifications.error(cardErrMsg) : alert(cardErrMsg);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// this create a unique blob url, which we'll use as id as well
|
|
483
|
+
const url = URL.createObjectURL(file);
|
|
484
|
+
const asset: FieldAsset = {
|
|
485
|
+
id: url,
|
|
486
|
+
url: { thumb: url, full: url, original: url },
|
|
487
|
+
type: file.type,
|
|
488
|
+
name: file.name,
|
|
489
|
+
meta: { isUploading: true },
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// ASAP optimistic UI update
|
|
493
|
+
assets.push(asset);
|
|
494
|
+
|
|
495
|
+
// prepare data for server upload
|
|
496
|
+
toBeProcessed.push(asset);
|
|
497
|
+
}
|
|
498
|
+
value = serializeValue(assets);
|
|
499
|
+
|
|
500
|
+
if (typeof processAssets === "function") {
|
|
501
|
+
isUploading = true;
|
|
502
|
+
processAssets(toBeProcessed, onProgress)
|
|
503
|
+
.then((uploaded: FieldAssetWithBlobUrl[]) => {
|
|
504
|
+
// clog("uploaded", uploaded);
|
|
505
|
+
for (const ass of uploaded ?? []) {
|
|
506
|
+
ass.meta ??= {};
|
|
507
|
+
ass.meta.isUploading = false;
|
|
508
|
+
if (ass.blobUrl) {
|
|
509
|
+
const idx = assets.findIndex((a) => a.id === ass.blobUrl);
|
|
510
|
+
if (idx > -1) {
|
|
511
|
+
ass.meta ??= {};
|
|
512
|
+
ass.meta.isUploading = false;
|
|
513
|
+
assets[idx] = ass;
|
|
514
|
+
} else {
|
|
515
|
+
clog.error(`Asset idx ${idx} not found?!?`, ass);
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
clog.warn(`Missing blobUrl in`, ass);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
value = serializeValue(assets);
|
|
522
|
+
})
|
|
523
|
+
.catch((e) => notifications?.error(`${e}`))
|
|
524
|
+
.finally(() => (isUploading = false));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- Clipboard paste (opt-in via `pasteable`) -------------------------------
|
|
529
|
+
// Focus-scoped: the paste listener lives on the field wrapper, so it only fires
|
|
530
|
+
// while the field (or a control inside it) holds focus. `focusForPaste` focuses
|
|
531
|
+
// the wrapper on any click within it, so a click + Ctrl/Cmd-V works with no Tab.
|
|
532
|
+
// Pasted files go through `handleIncomingFiles`, inheriting accept/cardinality
|
|
533
|
+
// validation and the processAssets upload — no synthetic `change` event.
|
|
534
|
+
function extractClipboardFiles(e: ClipboardEvent): File[] {
|
|
535
|
+
const dt = e.clipboardData;
|
|
536
|
+
if (!dt) return [];
|
|
537
|
+
const out: File[] = [];
|
|
538
|
+
// Prefer items (lets us keep only file-kind entries, e.g. a pasted
|
|
539
|
+
// screenshot); fall back to `.files` for browsers that only populate that.
|
|
540
|
+
for (let i = 0; i < (dt.items?.length ?? 0); i++) {
|
|
541
|
+
const it = dt.items[i];
|
|
542
|
+
if (it?.kind === "file") {
|
|
543
|
+
const f = it.getAsFile();
|
|
544
|
+
if (f) out.push(f);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!out.length && dt.files?.length) out.push(...dt.files);
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function handlePaste(e: ClipboardEvent) {
|
|
552
|
+
if (!pasteable || typeof processAssets !== "function") return;
|
|
553
|
+
const files = extractClipboardFiles(e);
|
|
554
|
+
if (!files.length) return; // let plain-text pastes etc. pass through
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
handleIncomingFiles(files);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Focus the wrapper on any click inside it so a following paste lands here.
|
|
560
|
+
// Capture phase: fires even though the inner thumbnail/control buttons
|
|
561
|
+
// stopPropagation, and even in browsers that don't focus <button> on click
|
|
562
|
+
// (Safari/Firefox on macOS). Skipped when focus is already inside the field —
|
|
563
|
+
// paste bubbles from any focused descendant anyway.
|
|
564
|
+
function focusForPaste() {
|
|
565
|
+
if (!pasteable || !wrapEl) return;
|
|
566
|
+
if (wrapEl.contains(document.activeElement)) return;
|
|
567
|
+
// Focus a node INSIDE `.input-wrap` (the grid) so its `:focus-within` ring
|
|
568
|
+
// shows; fall back to the wrapper when the grid isn't mounted (loading).
|
|
569
|
+
(gridEl ?? wrapEl).focus({ preventScroll: true });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
$effect(() => {
|
|
573
|
+
if (!pasteable || !wrapEl) return;
|
|
574
|
+
const el = wrapEl;
|
|
575
|
+
el.addEventListener("paste", handlePaste);
|
|
576
|
+
el.addEventListener("click", focusForPaste, true);
|
|
577
|
+
return () => {
|
|
578
|
+
el.removeEventListener("paste", handlePaste);
|
|
579
|
+
el.removeEventListener("click", focusForPaste, true);
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
|
|
415
583
|
onDestroy(() => {
|
|
416
584
|
try {
|
|
417
585
|
assets.forEach((a) => {
|
|
@@ -437,7 +605,11 @@
|
|
|
437
605
|
<!-- screen-reader announcements for reorder actions -->
|
|
438
606
|
<div class="sr-only" aria-live="polite" aria-atomic="true">{liveAnnouncement}</div>
|
|
439
607
|
{/if}
|
|
440
|
-
<div
|
|
608
|
+
<div
|
|
609
|
+
class="p-2 flex items-center gap-0.5 flex-wrap w-full focus:outline-none"
|
|
610
|
+
bind:this={gridEl}
|
|
611
|
+
tabindex="-1"
|
|
612
|
+
>
|
|
441
613
|
{#each assets as asset, idx (asset.id)}
|
|
442
614
|
{@const { thumb, full, original } = asset_urls(asset)}
|
|
443
615
|
{@const _is_img = isImage(asset.type ?? thumb)}
|
|
@@ -571,100 +743,7 @@
|
|
|
571
743
|
use:fileDropzone={() => ({
|
|
572
744
|
enabled: typeof processAssets === "function",
|
|
573
745
|
inputEl,
|
|
574
|
-
processFiles
|
|
575
|
-
clog.debug(`processFiles`, wasDrop ? "[DROPPED]" : "", files);
|
|
576
|
-
|
|
577
|
-
// Copy the File objects into a stable array, then IMMEDIATELY release the
|
|
578
|
-
// hidden <input type="file">'s retained FileList. A native file input
|
|
579
|
-
// keeps its FileList after a selection until it is reset (form reset or
|
|
580
|
-
// `value = ""`), and its `change` event (wired by the `fileDropzone`
|
|
581
|
-
// action) calls back into this handler. Without this reset, any later
|
|
582
|
-
// stray `change` re-runs processFiles with the SAME file still in
|
|
583
|
-
// `inputEl.files`, pushing a duplicate optimistic asset and firing a real
|
|
584
|
-
// re-upload. `onSubmitValidityCheck` used to dispatch exactly such a
|
|
585
|
-
// synthetic `change` on submit (now fixed there too — belt and braces).
|
|
586
|
-
// Clearing also restores the ability to re-select the same file twice in
|
|
587
|
-
// a row (an unchanged value emits no `change`). The blob URLs we create
|
|
588
|
-
// below are independent of the input, so clearing here is safe.
|
|
589
|
-
const incoming = [...(files ?? [])];
|
|
590
|
-
if (inputEl) inputEl.value = "";
|
|
591
|
-
|
|
592
|
-
// Nothing to consume — a cancelled picker, or a stray/synthetic `change`
|
|
593
|
-
// on an already-cleared input. Bail before touching state or calling
|
|
594
|
-
// processAssets (which would otherwise run with an empty batch).
|
|
595
|
-
if (!incoming.length) return;
|
|
596
|
-
|
|
597
|
-
if (accept && incoming.some((f) => !is_accepted_type(accept, f.type))) {
|
|
598
|
-
const msg = t("invalid_type", { accept });
|
|
599
|
-
if (notifications) notifications.error(msg);
|
|
600
|
-
else alert(msg);
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const cardErrMsg = t("cardinality_reached", { max: cardinality });
|
|
605
|
-
// `>=` (not `>`): refuse the moment the field already holds `cardinality`
|
|
606
|
-
// assets, instead of optimistically adding one past the limit and relying
|
|
607
|
-
// on the validator to reject it afterwards (the off-by-one that made the
|
|
608
|
-
// single-cardinality symptom loud).
|
|
609
|
-
if (assets.length >= cardinality) {
|
|
610
|
-
if (notifications) notifications.error(cardErrMsg);
|
|
611
|
-
else alert(cardErrMsg);
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
const toBeProcessed: FieldAsset[] = [];
|
|
616
|
-
|
|
617
|
-
for (const file of incoming) {
|
|
618
|
-
if (assets.length >= cardinality) {
|
|
619
|
-
notifications ? notifications.error(cardErrMsg) : alert(cardErrMsg);
|
|
620
|
-
break;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// this create a unique blob url, which we'll use as id as well
|
|
624
|
-
const url = URL.createObjectURL(file);
|
|
625
|
-
const asset: FieldAsset = {
|
|
626
|
-
id: url,
|
|
627
|
-
url: { thumb: url, full: url, original: url },
|
|
628
|
-
type: file.type,
|
|
629
|
-
name: file.name,
|
|
630
|
-
meta: { isUploading: true },
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
// ASAP optimistic UI update
|
|
634
|
-
assets.push(asset);
|
|
635
|
-
|
|
636
|
-
// prepare data for server upload
|
|
637
|
-
toBeProcessed.push(asset);
|
|
638
|
-
}
|
|
639
|
-
value = serializeValue(assets);
|
|
640
|
-
|
|
641
|
-
if (typeof processAssets === "function") {
|
|
642
|
-
isUploading = true;
|
|
643
|
-
processAssets(toBeProcessed, onProgress)
|
|
644
|
-
.then((uploaded: FieldAssetWithBlobUrl[]) => {
|
|
645
|
-
// clog("uploaded", uploaded);
|
|
646
|
-
for (const ass of uploaded ?? []) {
|
|
647
|
-
ass.meta ??= {};
|
|
648
|
-
ass.meta.isUploading = false;
|
|
649
|
-
if (ass.blobUrl) {
|
|
650
|
-
const idx = assets.findIndex((a) => a.id === ass.blobUrl);
|
|
651
|
-
if (idx > -1) {
|
|
652
|
-
ass.meta ??= {};
|
|
653
|
-
ass.meta.isUploading = false;
|
|
654
|
-
assets[idx] = ass;
|
|
655
|
-
} else {
|
|
656
|
-
clog.error(`Asset idx ${idx} not found?!?`, ass);
|
|
657
|
-
}
|
|
658
|
-
} else {
|
|
659
|
-
clog.warn(`Missing blobUrl in`, ass);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
value = serializeValue(assets);
|
|
663
|
-
})
|
|
664
|
-
.catch((e) => notifications?.error(`${e}`))
|
|
665
|
-
.finally(() => (isUploading = false));
|
|
666
|
-
}
|
|
667
|
-
},
|
|
746
|
+
processFiles: handleIncomingFiles,
|
|
668
747
|
allowClick: !assets?.length,
|
|
669
748
|
})}
|
|
670
749
|
>
|
|
@@ -684,7 +763,11 @@
|
|
|
684
763
|
{classLabel}
|
|
685
764
|
{classLabelBox}
|
|
686
765
|
{classInputBox}
|
|
687
|
-
{
|
|
766
|
+
classInputBoxWrap={twMerge(
|
|
767
|
+
pasteable &&
|
|
768
|
+
"focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-(--stuic-color-ring)",
|
|
769
|
+
classInputBoxWrap
|
|
770
|
+
)}
|
|
688
771
|
{classInputBoxWrapInvalid}
|
|
689
772
|
{classDescBox}
|
|
690
773
|
{classDescBoxToggle}
|
|
@@ -75,6 +75,15 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
75
75
|
* reserved for file drops); full keyboard + aria-live announcements. Default `false`.
|
|
76
76
|
*/
|
|
77
77
|
ordered?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
|
|
80
|
+
* the field. Focus-scoped — the paste is consumed only while the field (or a
|
|
81
|
+
* control inside it) holds focus; clicking anywhere in the field focuses it,
|
|
82
|
+
* so no Tab is needed. Routed through the same validation + upload path as
|
|
83
|
+
* drag and the file picker, so `accept`, `cardinality` and `processAssets`
|
|
84
|
+
* all apply. No-op unless `processAssets` is provided. Default `false`.
|
|
85
|
+
*/
|
|
86
|
+
pasteable?: boolean;
|
|
78
87
|
classWrap?: string;
|
|
79
88
|
}
|
|
80
89
|
declare const FieldAssets: import("svelte").Component<Props, {
|