Project

General

Profile

Download (19.3 KB) Statistics
| Branch: | Tag: | Revision:

haketilo / html / options_main.js @ 4b59dced

1
/**
2
 * Hachette HTML options page main script
3
 *
4
 * Copyright (C) 2021 Wojtek Kosior
5
 * Redistribution terms are gathered in the `copyright' file.
6
 */
7

    
8
/*
9
 * IMPORTS_START
10
 * IMPORT get_remote_storage
11
 * IMPORT TYPE_PREFIX
12
 * IMPORT TYPE_NAME
13
 * IMPORT list_prefixes
14
 * IMPORT nice_name
15
 * IMPORT parse_json_with_schema
16
 * IMPORT get_template
17
 * IMPORT by_id
18
 * IMPORT matchers
19
 * IMPORT get_import_frame
20
 * IMPORTS_END
21
 */
22

    
23
var storage;
24

    
25
const item_li_template = get_template("item_li");
26
const bag_component_li_template = get_template("bag_component_li");
27
const chbx_component_li_template = get_template("chbx_component_li");
28
const radio_component_li_template = get_template("radio_component_li");
29
/* Make sure they are later cloned without id. */
30
item_li_template.removeAttribute("id");
31
bag_component_li_template.removeAttribute("id");
32
chbx_component_li_template.removeAttribute("id");
33
radio_component_li_template.removeAttribute("id");
34

    
35
function list_set_scrollbar(list_elem)
36
{
37
    const op = ((list_elem.children.length === 1 &&
38
		 list_elem.children[0].classList.contains("hide")) ||
39
		list_elem.children.length < 1) ? "remove" : "add";
40
    while (!list_elem.classList.contains("table_wrapper"))
41
	list_elem = list_elem.parentElement;
42
    list_elem.classList[op]("always_scrollbar");
43
}
44

    
45
function item_li_id(prefix, item)
46
{
47
    return `li_${prefix}_${item}`;
48
}
49

    
50
/* Insert into list of bags/pages/scripts/repos */
51
function add_li(prefix, item, at_the_end=false)
52
{
53
    let ul = ul_by_prefix[prefix];
54
    let li = item_li_template.cloneNode(true);
55
    li.id = item_li_id(prefix, item);
56

    
57
    let span = li.firstElementChild;
58
    span.textContent = item;
59

    
60
    let edit_button = span.nextElementSibling;
61
    edit_button.addEventListener("click", () => edit_item(prefix, item));
62

    
63
    let remove_button = edit_button.nextElementSibling;
64
    remove_button.addEventListener("click",
65
				   () => storage.remove(prefix, item));
66

    
67
    let export_button = remove_button.nextElementSibling;
68
    export_button.addEventListener("click",
69
				   () => export_item(prefix, item));
70
    if (prefix === TYPE_PREFIX.REPO)
71
	export_button.remove();
72

    
73
    if (!at_the_end) {
74
	for (let element of ul.ul.children) {
75
	    if (element.id < li.id || element.id.startsWith("work_"))
76
		continue;
77

    
78
	    ul.ul.insertBefore(li, element);
79
	    break;
80
	}
81
    }
82
    if (!li.parentElement) {
83
	if (ul.work_li !== ul.ul.lastElementChild)
84
	    ul.ul.appendChild(li);
85
	else
86
	    ul.work_li.before(li);
87
    }
88

    
89
    list_set_scrollbar(ul.ul);
90
}
91

    
92
const chbx_components_ul = by_id("chbx_components_ul");
93
const radio_components_ul = by_id("radio_components_ul");
94

    
95
function chbx_li_id(prefix, item)
96
{
97
    return `cli_${prefix}_${item}`;
98
}
99

    
100
function radio_li_id(prefix, item)
101
{
102
    return `rli_${prefix}_${item}`;
103
}
104

    
105
//TODO: refactor the 2 functions below
106

    
107
function add_chbx_li(prefix, name)
108
{
109
    if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix))
110
	return;
111

    
112
    let li = chbx_component_li_template.cloneNode(true);
113
    li.id = chbx_li_id(prefix, name);
114
    li.setAttribute("data-prefix", prefix);
115
    li.setAttribute("data-name", name);
116

    
117
    let chbx = li.firstElementChild.firstElementChild;
118
    let span = chbx.nextElementSibling;
119

    
120
    span.textContent = nice_name(prefix, name);
121

    
122
    chbx_components_ul.appendChild(li);
123
    list_set_scrollbar(chbx_components_ul);
124
}
125

    
126
var radio_component_none_li = by_id("radio_component_none_li");
127

    
128
function add_radio_li(prefix, name)
129
{
130
    if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix))
131
	return;
132

    
133
    let li = radio_component_li_template.cloneNode(true);
134
    li.id = radio_li_id(prefix, name);
135
    li.setAttribute("data-prefix", prefix);
136
    li.setAttribute("data-name", name);
137

    
138
    let radio = li.firstElementChild.firstElementChild;
139
    let span = radio.nextElementSibling;
140

    
141
    span.textContent = nice_name(prefix, name);
142

    
143
    radio_component_none_li.before(li);
144
    list_set_scrollbar(radio_components_ul);
145
}
146

    
147
/* Used to reset edited repo. */
148
function reset_work_repo_li(ul, item, _)
149
{
150
    ul.work_name_input.value = maybe_string(item);
151
}
152

    
153
/* Used to get repo data for saving */
154
function work_repo_li_data(ul)
155
{
156
    return [ul.work_name_input.value, {}];
157
}
158

    
159
const page_payload_span = by_id("page_payload");
160

    
161
function set_page_components(components)
162
{
163
    if (components === undefined) {
164
	page_payload_span.setAttribute("data-payload", "no");
165
	page_payload_span.textContent = "(None)";
166
    } else {
167
	page_payload_span.setAttribute("data-payload", "yes");
168
	let [prefix, name] = components;
169
	page_payload_span.setAttribute("data-prefix", prefix);
170
	page_payload_span.setAttribute("data-name", name);
171
	page_payload_span.textContent = nice_name(prefix, name);
172
    }
173
}
174

    
175
const page_allow_chbx = by_id("page_allow_chbx");
176

    
177
/* Used to reset edited page. */
178
function reset_work_page_li(ul, item, settings)
179
{
180
    ul.work_name_input.value = maybe_string(item);
181
    settings = settings || {allow: false, components: undefined};
182
    page_allow_chbx.checked = !!settings.allow;
183

    
184
    set_page_components(settings.components);
185
}
186

    
187
function work_page_li_components()
188
{
189
    if (page_payload_span.getAttribute("data-payload") === "no")
190
	return undefined;
191

    
192
    let prefix = page_payload_span.getAttribute("data-prefix");
193
    let name = page_payload_span.getAttribute("data-name");
194
    return [prefix, name];
195
}
196

    
197
/* Used to get edited page data for saving. */
198
function work_page_li_data(ul)
199
{
200
    let url = ul.work_name_input.value;
201
    let settings = {
202
	components : work_page_li_components(),
203
	allow : !!page_allow_chbx.checked
204
    };
205

    
206
    return [url, settings];
207
}
208

    
209
const empty_bag_component_li = by_id("empty_bag_component_li");
210
var bag_components_ul = by_id("bag_components_ul");
211

    
212
function remove_bag_component_entry(entry)
213
{
214
    const list = entry.parentElement;
215
    entry.remove();
216
    list_set_scrollbar(list);
217
}
218

    
219
/* Used to construct and update components list of edited bag. */
220
function add_bag_components(components)
221
{
222
    for (let component of components) {
223
	let [prefix, name] = component;
224
	let li = bag_component_li_template.cloneNode(true);
225
	li.setAttribute("data-prefix", prefix);
226
	li.setAttribute("data-name", name);
227

    
228
	let span = li.firstElementChild;
229
	span.textContent = nice_name(prefix, name);
230
	let remove_but = span.nextElementSibling;
231
	remove_but.addEventListener("click",
232
				    () => remove_bag_component_entry(li));
233
	bag_components_ul.appendChild(li);
234
    }
235

    
236
    bag_components_ul.appendChild(empty_bag_component_li);
237
    list_set_scrollbar(bag_components_ul);
238
}
239

    
240
/* Used to reset edited bag. */
241
function reset_work_bag_li(ul, item, components)
242
{
243
    components = components || [];
244

    
245
    ul.work_name_input.value = maybe_string(item);
246
    let old_components_ul = bag_components_ul;
247
    bag_components_ul = old_components_ul.cloneNode(false);
248

    
249
    old_components_ul.replaceWith(bag_components_ul);
250

    
251
    add_bag_components(components);
252
}
253

    
254
/* Used to get edited bag data for saving. */
255
function work_bag_li_data(ul)
256
{
257
    let component_li = bag_components_ul.firstElementChild;
258

    
259
    let components = [];
260

    
261
    /* Last list element is empty li with id set. */
262
    while (component_li.id === '') {
263
	components.push([component_li.getAttribute("data-prefix"),
264
			 component_li.getAttribute("data-name")]);
265
	component_li = component_li.nextElementSibling;
266
    }
267

    
268
    return [ul.work_name_input.value, components];
269
}
270

    
271
const script_url_input = by_id("script_url_field");
272
const script_sha256_input = by_id("script_sha256_field");
273
const script_contents_field = by_id("script_contents_field");
274

    
275
function maybe_string(maybe_defined)
276
{
277
    return maybe_defined === undefined ? "" : maybe_defined + "";
278
}
279

    
280
/* Used to reset edited script. */
281
function reset_work_script_li(ul, name, data)
282
{
283
    ul.work_name_input.value = maybe_string(name);
284
    if (data === undefined)
285
	data = {};
286
    script_url_input.value = maybe_string(data.url);
287
    script_sha256_input.value = maybe_string(data.hash);
288
    script_contents_field.value = maybe_string(data.text);
289
}
290

    
291
/* Used to get edited script data for saving. */
292
function work_script_li_data(ul)
293
{
294
    return [ul.work_name_input.value, {
295
	url : script_url_input.value,
296
	hash : script_sha256_input.value,
297
	text : script_contents_field.value
298
    }];
299
}
300

    
301
function cancel_work(prefix)
302
{
303
    let ul = ul_by_prefix[prefix];
304

    
305
    if (ul.state === UL_STATE.IDLE)
306
	return;
307

    
308
    if (ul.state === UL_STATE.EDITING_ENTRY) {
309
	add_li(prefix, ul.edited_item);
310
    }
311

    
312
    ul.work_li.classList.add("hide");
313
    ul.ul.append(ul.work_li);
314
    list_set_scrollbar(ul.ul);
315
    ul.state = UL_STATE.IDLE;
316
}
317

    
318
function save_work(prefix)
319
{
320
    let ul = ul_by_prefix[prefix];
321

    
322
    if (ul.state === UL_STATE.IDLE)
323
	return;
324

    
325
    let [item, data] = ul.get_work_li_data(ul);
326

    
327
    /* Here we fire promises and return without waiting. */
328

    
329
    if (ul.state === UL_STATE.EDITING_ENTRY)
330
	storage.replace(prefix, ul.edited_item, item, data);
331
    if (ul.state === UL_STATE.ADDING_ENTRY)
332
	storage.set(prefix, item, data);
333

    
334
    cancel_work(prefix);
335
}
336

    
337
function edit_item(prefix, item)
338
{
339
    cancel_work(prefix);
340

    
341
    let ul = ul_by_prefix[prefix];
342
    let li = by_id(item_li_id(prefix, item));
343

    
344
    if (li === null) {
345
	add_new_item(prefix, item);
346
	return;
347
    }
348

    
349
    ul.reset_work_li(ul, item, storage.get(prefix, item));
350
    ul.ul.insertBefore(ul.work_li, li);
351
    ul.ul.removeChild(li);
352
    ul.work_li.classList.remove("hide");
353
    list_set_scrollbar(ul.ul);
354

    
355
    ul.state = UL_STATE.EDITING_ENTRY;
356
    ul.edited_item = item;
357
}
358

    
359
const file_downloader = by_id("file_downloader");
360

    
361
function recursively_export_item(prefix, name, added_items, items_data)
362
{
363
    let key = prefix + name;
364

    
365
    if (added_items.has(key))
366
	return;
367

    
368
    let data = storage.get(prefix, name);
369
    if (data === undefined) {
370
	console.log(`${TYPE_NAME[prefix]} '${name}' for export not found`);
371
	return;
372
    }
373

    
374
    if (prefix !== TYPE_PREFIX.SCRIPT) {
375
	let components = prefix === TYPE_PREFIX.BAG ?
376
	    data : [data.components];
377

    
378
	for (let [comp_prefix, comp_name] of components) {
379
	    recursively_export_item(comp_prefix, comp_name,
380
				    added_items, items_data);
381
	}
382
    }
383

    
384
    items_data.push({[key]: data});
385
    added_items.add(key);
386
}
387

    
388
function export_item(prefix, name)
389
{
390
    let added_items = new Set();
391
    let items_data = [];
392
    recursively_export_item(prefix, name, added_items, items_data);
393
    let file = new Blob([JSON.stringify(items_data)],
394
			{type: "application/json"});
395
    let url = URL.createObjectURL(file);
396
    file_downloader.setAttribute("href", url);
397
    file_downloader.setAttribute("download", prefix + name + ".json");
398
    file_downloader.click();
399
    file_downloader.removeAttribute("href");
400
    URL.revokeObjectURL(url);
401
}
402

    
403
function add_new_item(prefix, name)
404
{
405
    cancel_work(prefix);
406

    
407
    let ul = ul_by_prefix[prefix];
408
    ul.reset_work_li(ul);
409
    ul.work_li.classList.remove("hide");
410
    ul.ul.appendChild(ul.work_li);
411
    list_set_scrollbar(ul.ul);
412

    
413
    if (name !== undefined)
414
	ul.work_name_input.value = name;
415
    ul.state = UL_STATE.ADDING_ENTRY;
416
}
417

    
418
const chbx_components_window = by_id("chbx_components_window");
419

    
420
function bag_components()
421
{
422
    chbx_components_window.classList.remove("hide");
423
    radio_components_window.classList.add("hide");
424

    
425
    for (let li of chbx_components_ul.children) {
426
	let chbx = li.firstElementChild.firstElementChild;
427
	chbx.checked = false;
428
    }
429
}
430

    
431
function commit_bag_components()
432
{
433
    let selected = [];
434

    
435
    for (let li of chbx_components_ul.children) {
436
	let chbx = li.firstElementChild.firstElementChild;
437
	if (!chbx.checked)
438
	    continue;
439

    
440
	selected.push([li.getAttribute("data-prefix"),
441
		       li.getAttribute("data-name")]);
442
    }
443

    
444
    add_bag_components(selected);
445
    cancel_components();
446
}
447

    
448
const radio_components_window = by_id("radio_components_window");
449
var radio_component_none_input = by_id("radio_component_none_input");
450

    
451
function page_components()
452
{
453
    radio_components_window.classList.remove("hide");
454
    chbx_components_window.classList.add("hide");
455

    
456
    radio_component_none_input.checked = true;
457

    
458
    let components = work_page_li_components();
459
    if (components === undefined)
460
	return;
461

    
462
    let [prefix, item] = components;
463
    let li = by_id(radio_li_id(prefix, item));
464

    
465
    if (li === null)
466
	radio_component_none_input.checked = false;
467
    else
468
	li.firstElementChild.firstElementChild.checked = true;
469
}
470

    
471
function commit_page_components()
472
{
473
    let components = null;
474

    
475
    for (let li of radio_components_ul.children) {
476
	let radio = li.firstElementChild.firstElementChild;
477
	if (!radio.checked)
478
	    continue;
479

    
480
	components = [li.getAttribute("data-prefix"),
481
		      li.getAttribute("data-name")];
482

    
483
	if (radio.id === "radio_component_none_input")
484
	    components = undefined;
485

    
486
	break;
487
    }
488

    
489
    if (components !== null)
490
	set_page_components(components);
491
    cancel_components();
492
}
493

    
494
function cancel_components()
495
{
496
    chbx_components_window.classList.add("hide");
497
    radio_components_window.classList.add("hide");
498
}
499

    
500
const UL_STATE = {
501
    EDITING_ENTRY : 0,
502
    ADDING_ENTRY : 1,
503
    IDLE : 2
504
};
505

    
506
const ul_by_prefix = {
507
    [TYPE_PREFIX.REPO] : {
508
	ul : by_id("repos_ul"),
509
	work_li : by_id("work_repo_li"),
510
	work_name_input : by_id("repo_url_field"),
511
	reset_work_li : reset_work_repo_li,
512
	get_work_li_data : work_repo_li_data,
513
	state : UL_STATE.IDLE,
514
	edited_item : undefined,
515
    },
516
    [TYPE_PREFIX.PAGE] : {
517
	ul : by_id("pages_ul"),
518
	work_li : by_id("work_page_li"),
519
	work_name_input : by_id("page_url_field"),
520
	reset_work_li : reset_work_page_li,
521
	get_work_li_data : work_page_li_data,
522
	select_components : page_components,
523
	commit_components : commit_page_components,
524
	state : UL_STATE.IDLE,
525
	edited_item : undefined,
526
    },
527
    [TYPE_PREFIX.BAG] : {
528
	ul : by_id("bags_ul"),
529
	work_li : by_id("work_bag_li"),
530
	work_name_input : by_id("bag_name_field"),
531
	reset_work_li : reset_work_bag_li,
532
	get_work_li_data : work_bag_li_data,
533
	select_components : bag_components,
534
	commit_components : commit_bag_components,
535
	state : UL_STATE.IDLE,
536
	edited_item : undefined,
537
    },
538
    [TYPE_PREFIX.SCRIPT] : {
539
	ul : by_id("scripts_ul"),
540
	work_li : by_id("work_script_li"),
541
	work_name_input : by_id("script_name_field"),
542
	reset_work_li : reset_work_script_li,
543
	get_work_li_data : work_script_li_data,
544
	state : UL_STATE.IDLE,
545
	edited_item : undefined,
546
    }
547
}
548

    
549
/*
550
 * Newer browsers could utilise `text' method of File objects.
551
 * Older ones require FileReader.
552
 */
553

    
554
function _read_file(file, resolve, reject)
555
{
556
    let reader = new FileReader();
557

    
558
    reader.onload = () => resolve(reader.result);
559
    reader.onerror = () => reject(reader.error);
560
    reader.readAsText(file);
561
}
562

    
563
function read_file(file)
564
{
565
    return new Promise((resolve, reject) =>
566
		       _read_file(file, resolve, reject));
567
}
568

    
569
const url_regex = /^[a-z0-9]+:\/\/[^/]+\.[^/]{2,}(\/[^?#]*)?$/;
570
const empty_regex = /^$/;
571

    
572
const settings_schema = [
573
    [{}, "matchentry", "minentries", 1,
574
     new RegExp(`^${TYPE_PREFIX.SCRIPT}`), {
575
	 /* script data */
576
	 "url":    ["optional", url_regex, "or", empty_regex],
577
	 "sha256": ["optional", matchers.sha256, "or", empty_regex],
578
	 "text":   ["optional", "string"]
579
     },
580
     new RegExp(`^${TYPE_PREFIX.BAG}`), [
581
	 "optional",
582
	 [matchers.component, "repeat"],
583
	 "default", undefined
584
     ],
585
     new RegExp(`^${TYPE_PREFIX.PAGE}`), {
586
	 /* page data */
587
	 "components": ["optional", matchers.component]
588
     }], "repeat"
589
];
590

    
591
const import_window = by_id("import_window");
592
let import_frame;
593

    
594
async function import_from_file(event)
595
{
596
    let files = event.target.files;
597
    if (files.length < 1)
598
	return;
599

    
600
    import_window.classList.remove("hide");
601
    import_frame.show_loading();
602

    
603
    try {
604
	const file = await read_file(files[0]);
605
	var result = parse_json_with_schema(settings_schema, file);
606
    } catch(e) {
607
	import_frame.show_error("Bad file :(", "" + e);
608
	return;
609
    }
610

    
611
    import_frame.show_selection(result);
612
}
613

    
614
const file_opener_form = by_id("file_opener_form");
615

    
616
function hide_import_window()
617
{
618
    import_window.classList.add("hide");
619

    
620
    /*
621
     * Reset file <input>. Without this, a second attempt to import the same
622
     * file would result in "change" event not happening on <input> element.
623
     */
624
    file_opener_form.reset();
625
}
626

    
627
async function initialize_import_facility()
628
{
629
    let import_but = by_id("import_but");
630
    let file_opener = by_id("file_opener");
631

    
632
    import_but.addEventListener("click", () => file_opener.click());
633
    file_opener.addEventListener("change", import_from_file);
634

    
635
    import_frame = await get_import_frame();
636
    import_frame.onclose = hide_import_window;
637
    import_frame.style_table("has_bottom_line", "always_scrollbar",
638
			     "has_upper_line", "tight_table");
639
}
640

    
641
/*
642
 * If url has a target appended, e.g.
643
 * chrome-extension://hnhmbnpohhlmhehionjgongbnfdnabdl/html/options.html#smyhax
644
 * that target will be split into prefix and item name (e.g. "s" and "myhax")
645
 * and editing of that respective item will be started.
646
 *
647
 * We don't need to worry about the state of the page (e.g. some editing being
648
 * in progress) in jump_to_item() - this function is called at the beginning,
649
 * together with callbacks being assigned to buttons, so it is safe to assume
650
 * lists are initialized with items and page is in its virgin state with regard
651
 * to everything else.
652
 */
653
function jump_to_item(url_with_item)
654
{
655
    const [dummy1, base_url, dummy2, target] =
656
	  /^([^#]*)(#(.*))?$/i.exec(url_with_item);
657
    if (target === undefined)
658
	return;
659

    
660
    const prefix = target.substring(0, 1);
661

    
662
    if (!list_prefixes.includes(prefix)) {
663
	history.replaceState(null, "", base_url);
664
	return;
665
    }
666

    
667
    by_id(`show_${TYPE_NAME[prefix]}s`).checked = true;
668
    edit_item(prefix, decodeURIComponent(target.substring(1)));
669
}
670

    
671
async function main()
672
{
673
    storage = await get_remote_storage();
674

    
675
    for (let prefix of list_prefixes) {
676
	for (let item of storage.get_all_names(prefix).sort()) {
677
	    add_li(prefix, item, true);
678
	    add_chbx_li(prefix, item);
679
	    add_radio_li(prefix, item);
680
	}
681

    
682
	let name = TYPE_NAME[prefix];
683

    
684
	let add_but = by_id(`add_${name}_but`);
685
	let discard_but = by_id(`discard_${name}_but`);
686
	let save_but = by_id(`save_${name}_but`);
687

    
688
	add_but.addEventListener("click", () => add_new_item(prefix));
689
	discard_but.addEventListener("click", () => cancel_work(prefix));
690
	save_but.addEventListener("click", () => save_work(prefix));
691

    
692
	if ([TYPE_PREFIX.REPO, TYPE_PREFIX.SCRIPT].includes(prefix))
693
	    continue;
694

    
695
	let ul = ul_by_prefix[prefix];
696

    
697
	let commit_components_but = by_id(`commit_${name}_components_but`);
698
	let cancel_components_but = by_id(`cancel_${name}_components_but`);
699
	let select_components_but = by_id(`select_${name}_components_but`);
700

    
701
	commit_components_but
702
	    .addEventListener("click", ul.commit_components);
703
	select_components_but
704
	    .addEventListener("click", ul.select_components);
705
	cancel_components_but.addEventListener("click", cancel_components);
706
    }
707

    
708
    jump_to_item(document.URL);
709

    
710
    storage.add_change_listener(handle_change);
711

    
712
    await initialize_import_facility();
713
}
714

    
715
function handle_change(change)
716
{
717
    if (change.old_val === undefined) {
718
	add_li(change.prefix, change.item);
719
	add_chbx_li(change.prefix, change.item);
720
	add_radio_li(change.prefix, change.item);
721

    
722
	return;
723
    }
724

    
725
    if (change.new_val !== undefined)
726
	return;
727

    
728
    let ul = ul_by_prefix[change.prefix];
729
    if (ul.state === UL_STATE.EDITING_ENTRY &&
730
	ul.edited_item === change.item) {
731
	ul.state = UL_STATE.ADDING_ENTRY;
732
	return;
733
    }
734

    
735
    let uls_creators = [[ul.ul, item_li_id]];
736

    
737
    if ([TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(change.prefix)) {
738
	uls_creators.push([chbx_components_ul, chbx_li_id]);
739
	uls_creators.push([radio_components_ul, radio_li_id]);
740
    }
741

    
742
    for (let [components_ul, id_creator] of uls_creators) {
743
	let li = by_id(id_creator(change.prefix, change.item));
744
	components_ul.removeChild(li);
745
	list_set_scrollbar(components_ul);
746
    }
747
}
748

    
749
main();
(9-9/11)