Fossil SCM

fossil-scm / src / fossil.attach.js
Blame History Raw 642 lines
1
"use strict";
2
/**
3
Utility for interactive file attachment. Supports attachment
4
selection from a file dialog, from the clipboard, or drag/drop.
5
6
Requires that window.fossil has already been set up.
7
Depends on fossil.dom.
8
*/
9
(function(namespace){
10
"use strict";
11
const F = namespace, D = F.dom;
12
13
let idCounter = 0;
14
15
/**
16
Implements a multi-file selector widget. Intended to be plugged
17
in to places in Fossil's UI where attachments can be assigned to
18
an artifact.
19
*/
20
class Attacher {
21
/* Options. */
22
#opt;
23
/* List of objects representing each row. */
24
#rows = [];
25
/* DOM elements */
26
#e = Object.create(null);
27
/* Proxy for various events this object fires. */
28
#events = new EventTarget();
29
30
/**
31
Options:
32
33
opt.container: Optional DOM element to append the resulting
34
widget to. If not set, the client can get access to the widget
35
element using this.body.
36
37
opt.addButtonLabel: optional label for the "add attachment"
38
button, defaulting to something generic.
39
40
opt.limit: optional max number of attachments to allow. This
41
defaults to "some sensible value".
42
43
opt.startWith[=0]: if >0 then that many file selection widgets
44
are automatically activated, as if the user had tapped the Add
45
button that many times.
46
47
opt.description[=true]: if true then show the file description
48
field, otherwise elide it.
49
50
opt.reverse[=false]: reverses the flow of the widget such that
51
the Add button stays on the top and rows are ordered
52
most-recently-added.
53
54
opt.controls = [array of DOM elements]. Optional DOM elements
55
to inject into the UI element which wraps the "Add" button.
56
See this.controlsElement.
57
58
opt.listener = function or object: {add: func, remove: func,
59
populate: func}: if these are functions they are registered as
60
listeners for 'entry-added', 'entry-removed', and/or
61
'entry-populated' events, described below. opt.listener.all, if
62
set, is used as a fallback for any of 'add', 'remove', or
63
'populate' which are not set. If opt.listener is a function
64
then it behaves as if listener={all: thatFunction}.
65
66
Events:
67
68
This class fires CustomEvents for certain changes:
69
70
'entry-added' and 'entry-removed' trigger when an attachment
71
entry row is added/removed. Its event.detail is:
72
73
{attacher: this, row: object, type: 'same as event type'}.
74
75
'entry-populated' is triggered when a visible entry gets
76
content attached to it, with the same detail structure as
77
described above.
78
79
The public structure of the row object passed to each is
80
currently TBD.
81
*/
82
constructor(opt){
83
this.#opt = opt = F.nu({
84
addButtonLabel: false,
85
startWith: 0,
86
limit: 0,
87
dryRun: undefined,
88
description: true,
89
reverse: false
90
}, opt);
91
this.#e.body = D.addClass(D.div(), 'Attacher');
92
if( opt.reverse ) this.#e.body.classList.add('reverse');
93
const eBtnAdd = this.#e.btnAdd = D.addClass(
94
D.button(this.#opt.addButtonLabel || 'Add attachment',
95
()=>this.#addRow()),
96
'attach-add-button'
97
);
98
eBtnAdd.type = 'button';
99
opt.ownsAddButton = true;
100
this.#e.err = D.addClass(D.div(), 'error', 'hidden');
101
this.#e.body.append(this.#e.err);
102
this.#e.err.addEventListener('dblclick',()=>this.reportError());
103
104
const eControls = this.#e.controls =
105
D.addClass(D.div(), 'attach-controls');
106
eControls.append(eBtnAdd);
107
if( opt.container ){
108
opt.container.appendChild(this.#e.body);
109
}
110
this.#e.body.appendChild(eControls);
111
if( opt.listener ){
112
const doCb = (eventType, key)=>{
113
const f = (opt.listener instanceof Function)
114
? opt.listener
115
: (opt.listener[key] || opt.listener.all);
116
if( f instanceof Function ){
117
this.addEventListener(eventType, f);
118
}
119
};
120
doCb('entry-added', 'add');
121
doCb('entry-removed', 'remove');
122
doCb('entry-populated', 'populate');
123
}
124
if( opt.dryRun ){
125
/* Add dry-run toggle for testing. */
126
const eLbl = D.label(false, "Dry-run?");
127
const eCb = D.checkbox(true);
128
eLbl.append(eCb);
129
eControls.append(eLbl);
130
eCb.checked = opt.dryRun = true;
131
eCb.addEventListener('change',()=>opt.dryRun=eCb.checked);
132
}
133
if( Array.isArray(opt.controls) ){
134
eControls.append(...opt.controls);
135
}
136
if( opt.startWith > 0 ){
137
for(let i = 0; i < opt.startWith; ++i ){
138
this.#addRow();
139
}
140
}else{
141
this.#updateControls();
142
}
143
}
144
145
146
get widget(){
147
return this.#e.body;
148
}
149
150
addEventListener(...args){
151
return this.#events.addEventListener(...args);
152
}
153
154
removeEventListener(...args){
155
return this.#events.removeEventListener(...args);
156
}
157
158
/** Returns true if any visible input widgets have content
159
selected. */
160
get isPopulated(){
161
for(let r of this.#rows){
162
if( r.file ) return true;
163
}
164
return false;
165
}
166
167
get isDryRun(){
168
return !!this.#opt.dryRun;
169
}
170
/**
171
Returns the DOM element (div.attach-controls) which wraps the
172
"Add" button. Clients may add buttons to it.
173
*/
174
get controlsElement(){
175
return this.#e.controls;
176
}
177
178
/**
179
Reports an error by appending each argument to the error widget
180
and unhiding it. If passed no arugments, it clears and hides
181
the error widget.
182
*/
183
reportError(...msg){
184
const e = this.#e.err;
185
D.clearElement(e);
186
if( msg.length ){
187
e.classList.remove('hidden');
188
e.append(...msg);
189
}else{
190
e.classList.add('hidden');
191
}
192
}
193
194
#removeRow(rowObj){
195
rowObj.e.row.remove();
196
this.#rows = this.#rows.filter(v=>v!==rowObj);
197
this.#updateControls();
198
this.#events.dispatchEvent(
199
new CustomEvent('entry-removed',{
200
detail: F.nu({
201
type: 'entry-removed',
202
row: rowObj,
203
attacher: this
204
})
205
})
206
);
207
}
208
209
/**
210
Removes all attachments and clears the error state.
211
*/
212
clear(){
213
for(const r of [...this.#rows/*clone because this updates #rows*/]){
214
this.#removeRow(r);
215
}
216
this.reportError();
217
}
218
219
/**
220
Hides or shows the Add button, as appropriate.
221
*/
222
#updateControls(){
223
const b = this.#e.btnAdd;
224
if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
225
b.classList.add('hidden');
226
D.disable(b);
227
//F.toast.warning("Attachment form limit reached.");
228
}else{
229
b.classList.remove('hidden');
230
D.enable(b);
231
if( this.#opt.ownsAddButton ){
232
this.#e.body.append(this.#e.controls/*move to the end*/);
233
}
234
}
235
}
236
237
/**
238
Returns the "Add" button widget, Passing control of it to the
239
caller so that they can place it in another location. This
240
object will still manage its enabled/disabled/hidden state but
241
will no longer move it when adding a row.
242
*/
243
takeAddButton(){
244
if( this.#opt.ownsAddButton ){
245
this.#opt.ownsAddButton;
246
}
247
return this.#e.btnAdd;
248
}
249
/**
250
Sets rowObj.e.err up with an error message, or clears it if
251
passed only 1 argument.
252
*/
253
#rowError(rowObj,...msg){
254
let e = rowObj.e.err;
255
if( e ){
256
D.clearElement(e);
257
}else{
258
if( !msg.length ) return;
259
e = rowObj.e.err = D.addClass(D.span(), 'error');
260
rowObj.e.info.append(e);
261
}
262
if( msg.length ){
263
e.append(...msg);
264
e.classList.remove('hidden');
265
}else{
266
e.classList.add('hidden');
267
}
268
}
269
270
#addRow(){
271
const id = ++idCounter;
272
const rowObj = F.nu({
273
id, file: null, mimeType: ''
274
});
275
const eRow = D.addClass(D.div(), 'attach-row');
276
const eDropzone = D.addClass(D.div(), 'attach-dropzone');
277
const eFile = D.addClass(
278
D.input('file'), 'attach-file-input', 'hidden'
279
);
280
const eInfo = D.addClass(D.span(), 'attach-row-info');
281
const eFilename = D.append(
282
D.addClass(D.span(), 'attach-filename'),
283
"Select/drop file or click the outer border and tap your "+
284
"platform's conventional <paste> keyboard shortcut."
285
);
286
const eSize = D.addClass(D.span(), 'attach-size');
287
eInfo.append(eFilename, eSize);
288
const eDesc = this.#opt.description
289
? D.addClass(
290
D.attr(D.textarea(), 'placeholder',
291
'Optional description...'),
292
'attach-desc'
293
)
294
: undefined;
295
const eRemove = D.addClass(
296
D.button('X', (ev)=>{
297
ev.stopPropagation();
298
this.#removeRow(rowObj);
299
}),
300
'attach-row-remove'
301
);
302
eRemove.setAttribute('title', 'Remove this attachment.');
303
eRemove.type = 'button';
304
305
D.append(eDropzone, eInfo, eFile, eRemove);
306
eDropzone.addEventListener('click', ()=>eFile.click());
307
eFile.addEventListener('change', (ev)=>{
308
if( ev.target.files.length ){
309
this.#injestBlob(rowObj, ev.target.files[0]);
310
}
311
});
312
313
eDropzone.addEventListener('dragover', (ev)=>{
314
ev.preventDefault();
315
eDropzone.classList.add('dragover');
316
});
317
eDropzone.addEventListener('dragleave', (ev)=>{
318
eDropzone.classList.remove('dragover');
319
});
320
const handleDrop = (ev, theRealRowObj)=>{
321
ev.preventDefault();
322
eDropzone.classList.remove('dragover');
323
if( ev.dataTransfer.files.length ){
324
const r = theRealRowObj || rowObj;
325
this.#injestBlob(r, ev.dataTransfer.files[0]);
326
}
327
};
328
/* Isn't working? eBtnAdd.addEventListener('drop', (ev)=>{
329
this.#addRow();
330
handleDrop(ev, this.#rows[this.#rows.length-1]);
331
});*/
332
eDropzone.addEventListener('drop', handleDrop);
333
const pasteImage = (event, item)=>{
334
if( item.type.indexOf('image') === 0 ) {
335
event.preventDefault();
336
const blob = item.getAsFile();
337
if( blob.name === 'image.png' ){
338
/* Workaround to attempt to avoid name collisions when pasting
339
multiple images. We cannot, at this level, unambiguously
340
distinguish a ctrl-v of bitmap data vs a ctrl-v of an image
341
file copied via a desktop file manager. */
342
rowObj.overrideName = `pasted-image-${Date.now()}.png`;
343
}
344
this.#injestBlob(rowObj, blob);
345
return true;
346
}
347
return false;
348
};
349
eDesc?.addEventListener?.('paste', (e) => {
350
e.stopPropagation();
351
const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
352
if( !items ) return;
353
for( let i = 0; i < items.length; ++i ){
354
const item = items[i];
355
if( pasteImage(e, item) ){
356
break;
357
}
358
}
359
});
360
eRow.addEventListener('paste', (e) => {
361
const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
362
if( !items ) return;
363
for( let i = 0; i < items.length; ++i ){
364
const item = items[i];
365
if( pasteImage(e, item) ){
366
break;
367
}else if( item.type === 'text/plain' ){
368
e.preventDefault();
369
item.getAsString((text) => {
370
rowObj.overrideName = `pasted-text-${Date.now()}.txt`;
371
const blob = new File([text], rowObj.overrideName,
372
{type: 'text/plain'});
373
this.#injestBlob(rowObj, blob);
374
});
375
break;
376
}else if(0){
377
e.preventDefault();
378
const blob = undefined /* ??? */;
379
this.#injestBlob(rowObj, blob);
380
break;
381
}
382
}
383
});
384
eRow.append(eDropzone);
385
if( eDesc ) eRow.append(eDesc);
386
rowObj.e = F.nu({
387
dropzone: eDropzone,
388
info: eInfo,
389
filename: eFilename,
390
size: eSize,
391
desc: eDesc,
392
row: eRow,
393
remove: eRemove
394
});
395
this.#e.body.append(eRow);
396
this.#rows.push( rowObj );
397
this.#updateControls();
398
this.#events.dispatchEvent(
399
new CustomEvent('entry-added',{
400
detail: F.nu({
401
type: 'entry-added',
402
row: rowObj,
403
attacher: this
404
})
405
})
406
);
407
if( 0 ){
408
/* To allow immediate ctrl-v, we need a trick...
409
But don't do this because it will interfere with, e.g.,
410
the forum editor. */
411
D.attr(eRow, 'tabindex', '-1');
412
eRow.focus();
413
}
414
}
415
416
#rowMatchingName(name){
417
for(let r of this.#rows){
418
if( r.file?.name===name ) return r;
419
}
420
}
421
422
/**
423
Injects the given File object as the attached content for the
424
given row. If the object's name collides with another row,
425
rowObj is removed from this widget and the old row is instead
426
re-populated with the new file.
427
428
If rowObj.overrideName is set then the given file gets wrapped
429
with that name before attaching it, and that property is
430
removed from rowObj. This is intended only for communicating
431
auto-generated names for pasted data.
432
*/
433
#injestBlob(rowObj, file){
434
if( !file ) return;
435
const old = this.#rowMatchingName(file.name);
436
if( rowObj.overrideName ){
437
if( rowObj.overrideName !== file.name ){
438
file = new File([file], rowObj.overrideName, {type: file.type});
439
}
440
rowObj.overrideName = undefined;
441
}
442
if( old && rowObj !== old ){
443
/*
444
Fossil attachments treat the name as a unique-per-target
445
key, with the newest one being the primary. If a name is
446
given twice, remove the new entry and reuse the older
447
one. There are conceivable, but also unlikely, cases where
448
this will have unintended side-effects, e.g. attaching both
449
/foo/bar and /baz/bar, but that seems like a lesser evil
450
than attaching the same file N times, leading to N
451
attachment artifacts.
452
*/
453
/* recycle `old` instead to avoid UI flicker. */
454
this.#rowError(old);
455
this.#removeRow(rowObj);
456
rowObj = old;
457
}
458
459
let szLbl;
460
if( file.size < 500000 ){
461
szLbl = file.size + ' bytes';
462
}else if( file.size < 1000000 ){
463
szLbl = (file.size / 1024).toFixed(2)+' KB';
464
}else{
465
szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
466
}
467
this.#rowError(rowObj);
468
rowObj.file = file;
469
rowObj.mimeType = file.type || 'application/octet-stream';
470
D.clearElement(rowObj.e.filename).append(file.name || 'Pasted Content');
471
D.clearElement(rowObj.e.size).append(szLbl, ' ', rowObj.mimeType || '');
472
rowObj.e.dropzone.classList.add('populated');
473
if( rowObj.e.desc ){
474
rowObj.e.desc.classList.remove('hidden');
475
}
476
if( rowObj.e.thumbnail ){
477
rowObj.e.thumbnail.remove();
478
rowObj.e.thumbnail = undefined;
479
}
480
if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){
481
/* Add a thumbnail */
482
const img = rowObj.e.thumbnail = D.img();
483
rowObj.e.dropzone.insertBefore(img, rowObj.e.remove);
484
img.classList.add('thumbnail');
485
const reader = new FileReader();
486
reader.onload = (e)=>img.setAttribute('src', e.target.result);
487
reader.readAsDataURL(file);
488
}
489
if( file.size>F.config.attachmentSizeLimit ){
490
/* Problem: tapping this link propagates its click event through
491
to eDropzone. Thus... */
492
const eLink = D.a(F.repoUrl('help/attachment-size-limit'),'limit');
493
eLink.addEventListener('click', ev=>ev.stopPropagation());
494
this.#rowError(rowObj, "Too large: ", eLink,
495
" is ",F.config.attachmentSizeLimit," bytes");
496
rowObj.ok = false;
497
}else if( !file.size ){
498
this.#rowError(rowObj, "Cannot attach zero-byte files.");
499
rowObj.ok = false;
500
}else{
501
rowObj.ok = true;
502
}
503
this.#events.dispatchEvent(
504
new CustomEvent('entry-populated',{
505
detail: F.nu({
506
type: 'entry-populated',
507
row: rowObj,
508
attacher: this
509
})
510
})
511
);
512
}
513
514
/**
515
Returns an array of objects describing the currently-selected
516
attachments.
517
*/
518
collectState(){
519
const rv = [];
520
for(let r of this.#rows){
521
if( !r.e.dropzone?.classList?.contains?.('populated') ){
522
continue;
523
}
524
rv.push(F.nu({
525
name: r.name || r.file.name,
526
content: r.file,
527
description: r.e.desc?.value ?? '',
528
mimeType: r.mimeType
529
}));
530
}
531
return rv;
532
}
533
534
/**
535
Populates the given FormData object with entries named
536
${namePrefix}${N}, each representing a selected file and N
537
being a 1-based incremental counter. For entries which have a
538
description, it also sets ${namePrefix}${N}_desc.
539
*/
540
populateFormData(fd, namePrefix='file'){
541
const st = this.collectState();
542
let i = 0;
543
for( ; i < st.length; ++i){
544
const s = st[i];
545
const suffix = i+1;
546
fd.append(`${namePrefix}${suffix}`, s.content, s.name);
547
const d = s.description?.trim?.();
548
if( d ){
549
fd.append(`${namePrefix}${suffix}_desc`, d);
550
}
551
}
552
return i;
553
}
554
}/*Attacher*/;
555
F.Attacher = Attacher;
556
557
F.onPageLoad(function(){
558
const eAttachWrapper = document.querySelector('#attachadd-form-wrapper');
559
if( eAttachWrapper ){
560
/* This page is /attachadd v2 or a workalike. eAttachWrapper holds
561
input[type=hidden] fields for use in attaching files and is
562
where we inject a file attachment widget. */
563
eAttachWrapper.classList.remove('hidden');
564
const urlArgs = new URLSearchParams(window.location.search);
565
let zTarget = urlArgs.get('target');
566
let zTo = urlArgs.get('to') || urlArgs.get('from');
567
const eBtnSubmit = D.button("Submit");
568
eBtnSubmit.type = 'button';
569
const updateBtnSubmit = (attacher)=>{
570
if( attacher.isPopulated ){
571
eBtnSubmit.removeAttribute('disabled');
572
}else{
573
eBtnSubmit.setAttribute('disabled', '');
574
}
575
};
576
const cbAttacherChange = (ev)=>{
577
const a = ev.detail.attacher;
578
updateBtnSubmit(a);
579
};
580
const att = new Attacher({
581
container: eAttachWrapper,
582
startWith: 1,
583
listener: cbAttacherChange,
584
controls: [eBtnSubmit],
585
description: true
586
});
587
eBtnSubmit.addEventListener('click', async (ev)=>{
588
att.reportError();
589
const li = att.collectState();
590
if( !li.length ) return;
591
if( eBtnSubmit.dataset.submitted ) return;
592
eBtnSubmit.dataset.submitted = 1;
593
D.disable(eBtnSubmit);
594
const fd = new FormData();
595
att.populateFormData(fd);
596
for( const eIn of eAttachWrapper.querySelectorAll(
597
'input[type="hidden"]'
598
) ){
599
/* Copy over hidden input fields emitted by the server. */
600
if( eIn.name==='target' ){
601
zTarget = eIn.value;
602
}else if( eIn.name==='to' || (eIn.name==='from' && !zTo) ){
603
zTo = eIn.value;
604
}
605
fd.append(eIn.name, eIn.value)
606
}
607
if( att.isDryRun ){
608
fd.append('dryrun', '1');
609
}
610
let err;
611
const resp = await window.fetch(F.repoUrl('attachadd_ajax_post'), {
612
method: 'POST',
613
body: fd
614
}).catch((e)=>{
615
err = e;
616
});
617
D.enable(eBtnSubmit);
618
delete eBtnSubmit.dataset.submitted;
619
const jr = err ? undefined : await resp.json().catch(()=>{});
620
if( err || jr?.error || !resp.ok ){
621
const msg = err ? err.message : (jr?.error || resp.statusText);
622
att.reportError("Attaching failed: ", msg);
623
}else{
624
att.clear();
625
let to = zTo || jr?.redirect;
626
if( to ){
627
if( '/'!==to[0] ){
628
to = F.repoUrl(to);
629
}
630
window.location = to;
631
}else if( zTarget ){
632
window.location = '?target='+zTarget+'&'+Date.now();
633
}
634
}
635
})/*submit handler*/;
636
updateBtnSubmit(att);
637
F.page.attacher = att /* only for testing via dev console */;
638
}/* /attachadd */
639
})/*onPageLoad()*/;
640
641
})(window.fossil);
642

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button