Fossil SCM

Add image attachment thumbnails to the attachment widget.

stephan 2026-06-02 20:48 UTC attach-v2
Commit 81389347e5337459ac712f1f25c339037123534cddb046fb5aeb007db643c85c
--- src/default.css
+++ src/default.css
@@ -2045,10 +2045,14 @@
20452045
.attach-container > .attach-row > .attach-dropzone.populated {
20462046
background-color: #f1f8e9;
20472047
border-color: #8bc34a;
20482048
border-style: solid;
20492049
text-align: left;
2050
+}
2051
+.attach-container > .attach-row > .attach-dropzone > .thumbnail {
2052
+ max-width: 10em;
2053
+ max-height: 10em;
20502054
}
20512055
.attach-container > .attach-row .attach-row-info {
20522056
font-family: monospace;
20532057
flex-grow: 1;
20542058
}
20552059
--- src/default.css
+++ src/default.css
@@ -2045,10 +2045,14 @@
2045 .attach-container > .attach-row > .attach-dropzone.populated {
2046 background-color: #f1f8e9;
2047 border-color: #8bc34a;
2048 border-style: solid;
2049 text-align: left;
 
 
 
 
2050 }
2051 .attach-container > .attach-row .attach-row-info {
2052 font-family: monospace;
2053 flex-grow: 1;
2054 }
2055
--- src/default.css
+++ src/default.css
@@ -2045,10 +2045,14 @@
2045 .attach-container > .attach-row > .attach-dropzone.populated {
2046 background-color: #f1f8e9;
2047 border-color: #8bc34a;
2048 border-style: solid;
2049 text-align: left;
2050 }
2051 .attach-container > .attach-row > .attach-dropzone > .thumbnail {
2052 max-width: 10em;
2053 max-height: 10em;
2054 }
2055 .attach-container > .attach-row .attach-row-info {
2056 font-family: monospace;
2057 flex-grow: 1;
2058 }
2059
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -34,36 +34,44 @@
3434
opt.limit: optional max number of attachments to allow. This
3535
defaults to "some sensible value".
3636
3737
opt.startWith[=0]: if >0 then that many file selection widgets
3838
are automatically activated, as if the user had tapped the Add
39
- button that many times.
39
+ button that many times. As a special case, if this is >0
40
+ and the user removes the last entry, a new one is added.
41
+
42
+ opt.controls = [array of DOM elements]. Optional DOM elements
43
+ to inject into the UI element which wraps the "Add" button.
44
+ See this.controlsElement.
4045
4146
opt.listener = {add: func, remove: func, populate: func}: if
4247
these are functions they are registered as listeners for
4348
'entry-added', 'entry-removed', and/or 'entry-populated'
44
- events, described below.
49
+ events, described below. opt.listener.all, if set, is used
50
+ as a fallback for any of 'add', 'remove', or 'populate'
51
+ which are not set.
4552
4653
Events:
4754
4855
This class fires CustomEvents for certain changes:
4956
5057
'entry-added' and 'entry-removed' trigger when an attachment
5158
entry row is added/removed. Its event.detail is {attacher:
52
- this, row: object}.
59
+ this, row: object, type: 'same as event type'}.
5360
5461
'entry-populated' is triggered when a visible entry gets
55
- content attached to it.
62
+ content attached to it, with the same detail structure as
63
+ described above.
5664
5765
The public structure of the row object passed to each is
5866
currently TBD.
5967
*/
6068
constructor(opt){
6169
this.#opt = opt = F.nu({
6270
addButtonLabel: false,
6371
startWith: 0,
64
- limit: 7
72
+ limit: 0
6573
}, opt);
6674
const eBtnAdd = this.#e.btnAdd = D.addClass(
6775
D.button(this.#opt.addButtonLabel || 'Add attachment',
6876
()=>this.#addRow()),
6977
'attach-add-button'
@@ -74,20 +82,23 @@
7482
eControls.append(eBtnAdd);
7583
this.#e.list = D.addClass(D.div(), 'attach-container');
7684
opt.container.appendChild(this.#e.list);
7785
this.#e.list.appendChild(eControls);
7886
if( opt.listener ){
79
- if( opt.listener.add instanceof Function ){
87
+ if( (opt.listener.add || opt.listener.all) instanceof Function ){
8088
this.addEventListener('entry-added', opt.listener.add);
8189
}
82
- if( opt.listener.remove instanceof Function ){
90
+ if( (opt.listener.remove || opt.listener.all) instanceof Function ){
8391
this.addEventListener('entry-removed', opt.listener.remove);
8492
}
85
- if( opt.listener.populate instanceof Function ){
93
+ if( (opt.listener.populate || opt.listener.all) instanceof Function ){
8694
this.addEventListener('entry-populated', opt.listener.populate);
8795
}
8896
}
97
+ if( Array.isArray(opt.controls) ){
98
+ eControls.append(...opt.controls);
99
+ }
89100
if( opt.startWith > 0 ){
90101
for(let i = 0; i < opt.startWith; ++i ){
91102
this.#addRow();
92103
}
93104
}else{
@@ -101,17 +112,23 @@
101112
102113
removeEventListener(...args){
103114
return this.#events.removeEventListener(...args);
104115
}
105116
117
+ /** Returns true if any visible input widgets have content
118
+ selected. */
106119
get isPopulated(){
107120
for(let r of this.#rows){
108121
if( r.file ) return true;
109122
}
110123
return false;
111124
}
112125
126
+ /**
127
+ Returns the DOM element (div.attach-controls) which wraps the
128
+ "Add" button. Clients may add buttons to it.
129
+ */
113130
get controlsElement(){
114131
return this.#e.controls;
115132
}
116133
117134
#removeRow(rowObj){
@@ -119,17 +136,18 @@
119136
this.#rows = this.#rows.filter(v=>v!==rowObj);
120137
this.#updateControls();
121138
this.#events.dispatchEvent(
122139
new CustomEvent('entry-removed',{
123140
detail: F.nu({
141
+ type: 'entry-removed',
124142
row: rowObj,
125143
attacher: this
126144
})
127145
})
128146
);
129147
if( 0===this.#rows.length
130
- && 1===this.#opt.startWith ){
148
+ && this.#opt.startWith>0 ){
131149
/* Intended primarily for /addattach. */
132150
this.#addRow();
133151
}
134152
}
135153
@@ -226,16 +244,18 @@
226244
D.append(eRow, eDropzone, eDesc);
227245
rowObj.eDropzone = eDropzone;
228246
rowObj.eInfo = eInfo;
229247
rowObj.eDesc = eDesc;
230248
rowObj.eRow = eRow;
249
+ rowObj.eRemove = eRemove;
231250
this.#e.list.append(eRow);
232251
this.#rows.push( rowObj );
233252
this.#updateControls();
234253
this.#events.dispatchEvent(
235254
new CustomEvent('entry-added',{
236255
detail: F.nu({
256
+ type: 'entry-added',
237257
row: rowObj,
238258
attacher: this
239259
})
240260
})
241261
);
@@ -293,13 +313,22 @@
293313
D.clearElement(rowObj.eInfo),
294314
lbl, D.br(), szLbl, ' ', rowObj.mimeType || ''
295315
);
296316
rowObj.eDropzone.classList.add('populated');
297317
rowObj.eDesc.classList.remove('hidden');
318
+ if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){
319
+ const img = D.img();
320
+ img.classList.add('thumbnail');
321
+ rowObj.eDropzone.insertBefore(img, rowObj.eRemove);
322
+ const reader = new FileReader();
323
+ reader.onload = (e)=>img.setAttribute('src', e.target.result);
324
+ reader.readAsDataURL(file);
325
+ }
298326
this.#events.dispatchEvent(
299327
new CustomEvent('entry-populated',{
300328
detail: F.nu({
329
+ type: 'entry-populated',
301330
row: rowObj,
302331
attacher: this
303332
})
304333
})
305334
);
@@ -360,29 +389,22 @@
360389
eBtnSubmit.removeAttribute('disabled');
361390
}else{
362391
eBtnSubmit.setAttribute('disabled', '');
363392
}
364393
};
365
- const cbAdd = (ev)=>{
366
- const a = ev.detail.attacher;
367
- updateBtnSubmit(a);
368
- };
369
- const cbRm = (ev)=>{
370
- const a = ev.detail.attacher;
371
- updateBtnSubmit(a);
372
- };
373
- const cbPopulated = (ev)=>{
394
+ const cbAttacherChange = (ev)=>{
374395
const a = ev.detail.attacher;
375396
updateBtnSubmit(a);
376397
};
377398
const cbSubmit = (ev)=>{
378399
};
379400
eBtnSubmit.addEventListener('click', cbSubmit, false);
380401
const att = new Attacher({
381402
container: eFormDiv,
382403
startWith: 1,
383
- listener: {add: cbAdd, remove: cbRm, populate: cbPopulated}
404
+ listener: F.nu({all: cbAttacherChange}),
405
+ controls: [eBtnSubmit]
384406
});
385
- att.controlsElement.append(eBtnSubmit);
407
+ updateBtnSubmit(att);
386408
}/* /attachaddV2 */
387409
388410
})(window.fossil);
389411
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -34,36 +34,44 @@
34 opt.limit: optional max number of attachments to allow. This
35 defaults to "some sensible value".
36
37 opt.startWith[=0]: if >0 then that many file selection widgets
38 are automatically activated, as if the user had tapped the Add
39 button that many times.
 
 
 
 
 
40
41 opt.listener = {add: func, remove: func, populate: func}: if
42 these are functions they are registered as listeners for
43 'entry-added', 'entry-removed', and/or 'entry-populated'
44 events, described below.
 
 
45
46 Events:
47
48 This class fires CustomEvents for certain changes:
49
50 'entry-added' and 'entry-removed' trigger when an attachment
51 entry row is added/removed. Its event.detail is {attacher:
52 this, row: object}.
53
54 'entry-populated' is triggered when a visible entry gets
55 content attached to it.
 
56
57 The public structure of the row object passed to each is
58 currently TBD.
59 */
60 constructor(opt){
61 this.#opt = opt = F.nu({
62 addButtonLabel: false,
63 startWith: 0,
64 limit: 7
65 }, opt);
66 const eBtnAdd = this.#e.btnAdd = D.addClass(
67 D.button(this.#opt.addButtonLabel || 'Add attachment',
68 ()=>this.#addRow()),
69 'attach-add-button'
@@ -74,20 +82,23 @@
74 eControls.append(eBtnAdd);
75 this.#e.list = D.addClass(D.div(), 'attach-container');
76 opt.container.appendChild(this.#e.list);
77 this.#e.list.appendChild(eControls);
78 if( opt.listener ){
79 if( opt.listener.add instanceof Function ){
80 this.addEventListener('entry-added', opt.listener.add);
81 }
82 if( opt.listener.remove instanceof Function ){
83 this.addEventListener('entry-removed', opt.listener.remove);
84 }
85 if( opt.listener.populate instanceof Function ){
86 this.addEventListener('entry-populated', opt.listener.populate);
87 }
88 }
 
 
 
89 if( opt.startWith > 0 ){
90 for(let i = 0; i < opt.startWith; ++i ){
91 this.#addRow();
92 }
93 }else{
@@ -101,17 +112,23 @@
101
102 removeEventListener(...args){
103 return this.#events.removeEventListener(...args);
104 }
105
 
 
106 get isPopulated(){
107 for(let r of this.#rows){
108 if( r.file ) return true;
109 }
110 return false;
111 }
112
 
 
 
 
113 get controlsElement(){
114 return this.#e.controls;
115 }
116
117 #removeRow(rowObj){
@@ -119,17 +136,18 @@
119 this.#rows = this.#rows.filter(v=>v!==rowObj);
120 this.#updateControls();
121 this.#events.dispatchEvent(
122 new CustomEvent('entry-removed',{
123 detail: F.nu({
 
124 row: rowObj,
125 attacher: this
126 })
127 })
128 );
129 if( 0===this.#rows.length
130 && 1===this.#opt.startWith ){
131 /* Intended primarily for /addattach. */
132 this.#addRow();
133 }
134 }
135
@@ -226,16 +244,18 @@
226 D.append(eRow, eDropzone, eDesc);
227 rowObj.eDropzone = eDropzone;
228 rowObj.eInfo = eInfo;
229 rowObj.eDesc = eDesc;
230 rowObj.eRow = eRow;
 
231 this.#e.list.append(eRow);
232 this.#rows.push( rowObj );
233 this.#updateControls();
234 this.#events.dispatchEvent(
235 new CustomEvent('entry-added',{
236 detail: F.nu({
 
237 row: rowObj,
238 attacher: this
239 })
240 })
241 );
@@ -293,13 +313,22 @@
293 D.clearElement(rowObj.eInfo),
294 lbl, D.br(), szLbl, ' ', rowObj.mimeType || ''
295 );
296 rowObj.eDropzone.classList.add('populated');
297 rowObj.eDesc.classList.remove('hidden');
 
 
 
 
 
 
 
 
298 this.#events.dispatchEvent(
299 new CustomEvent('entry-populated',{
300 detail: F.nu({
 
301 row: rowObj,
302 attacher: this
303 })
304 })
305 );
@@ -360,29 +389,22 @@
360 eBtnSubmit.removeAttribute('disabled');
361 }else{
362 eBtnSubmit.setAttribute('disabled', '');
363 }
364 };
365 const cbAdd = (ev)=>{
366 const a = ev.detail.attacher;
367 updateBtnSubmit(a);
368 };
369 const cbRm = (ev)=>{
370 const a = ev.detail.attacher;
371 updateBtnSubmit(a);
372 };
373 const cbPopulated = (ev)=>{
374 const a = ev.detail.attacher;
375 updateBtnSubmit(a);
376 };
377 const cbSubmit = (ev)=>{
378 };
379 eBtnSubmit.addEventListener('click', cbSubmit, false);
380 const att = new Attacher({
381 container: eFormDiv,
382 startWith: 1,
383 listener: {add: cbAdd, remove: cbRm, populate: cbPopulated}
 
384 });
385 att.controlsElement.append(eBtnSubmit);
386 }/* /attachaddV2 */
387
388 })(window.fossil);
389
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -34,36 +34,44 @@
34 opt.limit: optional max number of attachments to allow. This
35 defaults to "some sensible value".
36
37 opt.startWith[=0]: if >0 then that many file selection widgets
38 are automatically activated, as if the user had tapped the Add
39 button that many times. As a special case, if this is >0
40 and the user removes the last entry, a new one is added.
41
42 opt.controls = [array of DOM elements]. Optional DOM elements
43 to inject into the UI element which wraps the "Add" button.
44 See this.controlsElement.
45
46 opt.listener = {add: func, remove: func, populate: func}: if
47 these are functions they are registered as listeners for
48 'entry-added', 'entry-removed', and/or 'entry-populated'
49 events, described below. opt.listener.all, if set, is used
50 as a fallback for any of 'add', 'remove', or 'populate'
51 which are not set.
52
53 Events:
54
55 This class fires CustomEvents for certain changes:
56
57 'entry-added' and 'entry-removed' trigger when an attachment
58 entry row is added/removed. Its event.detail is {attacher:
59 this, row: object, type: 'same as event type'}.
60
61 'entry-populated' is triggered when a visible entry gets
62 content attached to it, with the same detail structure as
63 described above.
64
65 The public structure of the row object passed to each is
66 currently TBD.
67 */
68 constructor(opt){
69 this.#opt = opt = F.nu({
70 addButtonLabel: false,
71 startWith: 0,
72 limit: 0
73 }, opt);
74 const eBtnAdd = this.#e.btnAdd = D.addClass(
75 D.button(this.#opt.addButtonLabel || 'Add attachment',
76 ()=>this.#addRow()),
77 'attach-add-button'
@@ -74,20 +82,23 @@
82 eControls.append(eBtnAdd);
83 this.#e.list = D.addClass(D.div(), 'attach-container');
84 opt.container.appendChild(this.#e.list);
85 this.#e.list.appendChild(eControls);
86 if( opt.listener ){
87 if( (opt.listener.add || opt.listener.all) instanceof Function ){
88 this.addEventListener('entry-added', opt.listener.add);
89 }
90 if( (opt.listener.remove || opt.listener.all) instanceof Function ){
91 this.addEventListener('entry-removed', opt.listener.remove);
92 }
93 if( (opt.listener.populate || opt.listener.all) instanceof Function ){
94 this.addEventListener('entry-populated', opt.listener.populate);
95 }
96 }
97 if( Array.isArray(opt.controls) ){
98 eControls.append(...opt.controls);
99 }
100 if( opt.startWith > 0 ){
101 for(let i = 0; i < opt.startWith; ++i ){
102 this.#addRow();
103 }
104 }else{
@@ -101,17 +112,23 @@
112
113 removeEventListener(...args){
114 return this.#events.removeEventListener(...args);
115 }
116
117 /** Returns true if any visible input widgets have content
118 selected. */
119 get isPopulated(){
120 for(let r of this.#rows){
121 if( r.file ) return true;
122 }
123 return false;
124 }
125
126 /**
127 Returns the DOM element (div.attach-controls) which wraps the
128 "Add" button. Clients may add buttons to it.
129 */
130 get controlsElement(){
131 return this.#e.controls;
132 }
133
134 #removeRow(rowObj){
@@ -119,17 +136,18 @@
136 this.#rows = this.#rows.filter(v=>v!==rowObj);
137 this.#updateControls();
138 this.#events.dispatchEvent(
139 new CustomEvent('entry-removed',{
140 detail: F.nu({
141 type: 'entry-removed',
142 row: rowObj,
143 attacher: this
144 })
145 })
146 );
147 if( 0===this.#rows.length
148 && this.#opt.startWith>0 ){
149 /* Intended primarily for /addattach. */
150 this.#addRow();
151 }
152 }
153
@@ -226,16 +244,18 @@
244 D.append(eRow, eDropzone, eDesc);
245 rowObj.eDropzone = eDropzone;
246 rowObj.eInfo = eInfo;
247 rowObj.eDesc = eDesc;
248 rowObj.eRow = eRow;
249 rowObj.eRemove = eRemove;
250 this.#e.list.append(eRow);
251 this.#rows.push( rowObj );
252 this.#updateControls();
253 this.#events.dispatchEvent(
254 new CustomEvent('entry-added',{
255 detail: F.nu({
256 type: 'entry-added',
257 row: rowObj,
258 attacher: this
259 })
260 })
261 );
@@ -293,13 +313,22 @@
313 D.clearElement(rowObj.eInfo),
314 lbl, D.br(), szLbl, ' ', rowObj.mimeType || ''
315 );
316 rowObj.eDropzone.classList.add('populated');
317 rowObj.eDesc.classList.remove('hidden');
318 if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){
319 const img = D.img();
320 img.classList.add('thumbnail');
321 rowObj.eDropzone.insertBefore(img, rowObj.eRemove);
322 const reader = new FileReader();
323 reader.onload = (e)=>img.setAttribute('src', e.target.result);
324 reader.readAsDataURL(file);
325 }
326 this.#events.dispatchEvent(
327 new CustomEvent('entry-populated',{
328 detail: F.nu({
329 type: 'entry-populated',
330 row: rowObj,
331 attacher: this
332 })
333 })
334 );
@@ -360,29 +389,22 @@
389 eBtnSubmit.removeAttribute('disabled');
390 }else{
391 eBtnSubmit.setAttribute('disabled', '');
392 }
393 };
394 const cbAttacherChange = (ev)=>{
 
 
 
 
 
 
 
 
395 const a = ev.detail.attacher;
396 updateBtnSubmit(a);
397 };
398 const cbSubmit = (ev)=>{
399 };
400 eBtnSubmit.addEventListener('click', cbSubmit, false);
401 const att = new Attacher({
402 container: eFormDiv,
403 startWith: 1,
404 listener: F.nu({all: cbAttacherChange}),
405 controls: [eBtnSubmit]
406 });
407 updateBtnSubmit(att);
408 }/* /attachaddV2 */
409
410 })(window.fossil);
411

Keyboard Shortcuts

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