From 410404cfdd54c083b6609fd52334e02d320145d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=B6rnquist?= Date: Wed, 10 Nov 2021 01:40:22 +0100 Subject: Re-modularize javascript. This moves almost everything out of globals.ts, into sepparate files. Things are still slightly to tightly coupled. But that is worked on. --- static/components/date-time-input.ts | 77 ++++++++++++++++++ static/components/popup-element.ts | 100 +++++++++++++++++++++++ static/components/tab-element.ts | 40 ++++++++++ static/components/vevent-block.ts | 63 +++++++++++++++ static/components/vevent-description.ts | 12 +++ static/components/vevent-dl.ts | 24 ++++++ static/components/vevent-edit.ts | 137 ++++++++++++++++++++++++++++++++ static/components/vevent.ts | 71 +++++++++++++++++ 8 files changed, 524 insertions(+) create mode 100644 static/components/date-time-input.ts create mode 100644 static/components/popup-element.ts create mode 100644 static/components/tab-element.ts create mode 100644 static/components/vevent-block.ts create mode 100644 static/components/vevent-description.ts create mode 100644 static/components/vevent-dl.ts create mode 100644 static/components/vevent-edit.ts create mode 100644 static/components/vevent.ts (limited to 'static/components') diff --git a/static/components/date-time-input.ts b/static/components/date-time-input.ts new file mode 100644 index 00000000..1f54b15e --- /dev/null +++ b/static/components/date-time-input.ts @@ -0,0 +1,77 @@ +export { DateTimeInput } + +import { to_boolean, parseDate } from '../lib' + +/* '' */ +class DateTimeInput extends /* HTMLInputElement */ HTMLElement { + connectedCallback() { + /* This can be in the constructor for chromium, but NOT firefox... + Vivaldi 4.3.2439.63 stable + Mozilla Firefox 94.0.1 + */ + this.innerHTML = '' + // console.log('constructing datetime input') + } + + static get observedAttributes() { + return ['dateonly'] + } + + attributeChangedCallback(name: string, _: any, to: any): void { + // console.log(this, name, to_boolean(from), to_boolean(to)); + switch (name) { + case 'dateonly': + (this.querySelector('input[type="time"]') as HTMLInputElement) + .disabled = to_boolean(to) + break; + } + } + + get dateonly(): boolean { + return to_boolean(this.getAttribute('dateonly')); + } + + set dateonly(bool: boolean) { + this.setAttribute('dateonly', "" + bool); + } + + get valueAsDate(): Date { + let dt; + let date = (this.querySelector("input[type='date']") as HTMLInputElement).value; + if (to_boolean(this.getAttribute('dateonly'))) { + dt = parseDate(date); + dt.type = 'date'; + } else { + let time = (this.querySelector("input[type='time']") as HTMLInputElement).value; + dt = parseDate(date + 'T' + time) + dt.type = 'date-time'; + } + return dt; + } + + get value(): string { + return this.valueAsDate.format("~Y-~m-~dT~H:~M:~S") + } + + set value(new_value: Date | string) { + // console.log('Setting date'); + let date, time; + if (new_value instanceof Date) { + date = new_value.format("~L~Y-~m-~d"); + time = new_value.format("~L~H:~M:~S"); + } else { + [date, time] = new_value.split('T') + } + (this.querySelector("input[type='date']") as HTMLInputElement).value = date; + (this.querySelector("input[type='time']") as HTMLInputElement).value = time; + } + + addEventListener(type: string, proc: ((e: Event) => void)) { + if (type != 'input') throw "Only input supported"; + + (this.querySelector("input[type='date']") as HTMLInputElement) + .addEventListener(type, proc); + (this.querySelector("input[type='time']") as HTMLInputElement) + .addEventListener(type, proc); + } +} diff --git a/static/components/popup-element.ts b/static/components/popup-element.ts new file mode 100644 index 00000000..3225fa52 --- /dev/null +++ b/static/components/popup-element.ts @@ -0,0 +1,100 @@ +export { PopupElement } + +import { gensym } from '../lib' +import { VEvent } from '../vevent' +import { bind_popup_control } from '../dragable' +import { close_popup } from '../popup' + +import { ComponentVEvent } from './vevent' +import { TabElement } from './tab-element' + +/* */ +class PopupElement extends ComponentVEvent { + + tabgroup_id: string + tabcount: number + + constructor(uid?: string) { + super(uid); + + /* TODO populate remaining */ + // this.id = 'popup' + this.dataset.uid + this.tabgroup_id = gensym(); + this.tabcount = 0 + } + + redraw(data: VEvent) { + // console.warn('IMPLEMENT ME'); + + if (data._calendar) { + this.dataset.calendar = data._calendar; + } + + /* TODO is there any case where we want to propagate the draw to any of + our tabs? or are all our tabs independent? */ + } + + connectedCallback() { + let template: HTMLTemplateElement = document.getElementById('popup-template') as HTMLTemplateElement + let body = (template.content.cloneNode(true) as DocumentFragment).firstElementChild!; + + let uid = this.uid; + // console.log(uid); + + body.getElementsByClassName('populate-with-uid') + .forEach((e) => e.setAttribute('data-uid', uid)); + + /* tabs */ + // for (let tab of body.querySelectorAll(".tabgroup .tab")) { + // } + window.setTimeout(() => { + // let tabs = this.querySelector('tab-element')! + // .shadowRoot! + // .querySelectorAll('label') + // console.log(tabs); + // console.log(this.getElementsByTagName('tab-element')) + for (let tab of this.getElementsByTagName('tab-element')) { + // console.log(tab_container); + // let tab = tab_container.shadowRoot!; + // tab.documentElement.style.setProperty('--i', i); + popuplateTab(tab as TabElement, this.tabgroup_id, this.tabcount) + this.tabcount += 1 + } + (this.querySelector('tab-element label') as HTMLInputElement).click() + }); + /* end tabs */ + + /* nav bar */ + let nav = body.getElementsByClassName("popup-control")[0] as HTMLElement; + bind_popup_control(nav); + + let btn = body.querySelector('.popup-control .close-tooltip') as HTMLButtonElement + btn.addEventListener('click', () => close_popup(this)); + /* end nav bar */ + + this.replaceChildren(body); + } + + addTab(tab: TabElement) { + let tabgroup = this.getElementsByClassName('tabgroup')![0]! + tabgroup.append(tab); + popuplateTab(tab, this.tabgroup_id, this.tabcount) + this.tabcount += 1 + } +} + +function popuplateTab(tab: HTMLElement, tabgroup: string, index: number) { + // console.log(tab); + let new_id = gensym(); + let input = tab.querySelector('input[type="radio"]') as HTMLInputElement; + let label = tab.querySelector("label")! + tab.style.setProperty('--tab-index', '' + index); + /* TODO this throws a number of errors, but somehow still works...? */ + if (input !== null) { + input.name = tabgroup + input.id = new_id; + } + if (label !== null) { + label.setAttribute('for', new_id); + } +} diff --git a/static/components/tab-element.ts b/static/components/tab-element.ts new file mode 100644 index 00000000..9403a737 --- /dev/null +++ b/static/components/tab-element.ts @@ -0,0 +1,40 @@ +export { TabElement } + +/* */ +class TabElement extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + // this.replaceChildren(template.cloneNode(true)); + let template + = (document.getElementById('tab-template') as HTMLTemplateElement) + .content + // const shadowRoot = this.attachShadow({ mode: 'open' }) + // .appendChild(template.cloneNode(true)); + // console.log(this); + let label = this.querySelector('[slot="label"]') + let content = this.querySelector('[slot="content"]') + if (!verifySlot(label)) throw "Bad label"; + if (!verifySlot(content)) throw "Bad content"; + + /* TODO set label hover title somewhere around here */ + + this.replaceChildren(template.cloneNode(true)); + this.querySelector('slot[name="label"]')!.replaceWith(label); + this.querySelector('slot[name="content"]')!.replaceWith(content); + } +} + +function verifySlot(el: Node | null): el is HTMLElement { + if (el === null) { + console.error("Element is null"); + return false; + } + if (!(el instanceof HTMLElement)) { + console.error("Node is not an HTMLElement", el); + return false; + } + return true +} diff --git a/static/components/vevent-block.ts b/static/components/vevent-block.ts new file mode 100644 index 00000000..439ba20e --- /dev/null +++ b/static/components/vevent-block.ts @@ -0,0 +1,63 @@ +export { ComponentBlock } + +import { ComponentVEvent } from './vevent' +import { VEvent } from '../vevent' +import { toggle_popup, find_popup } from '../popup' +import { parseDate, to_local } from '../lib' + + +/* + + A grahpical block in the week view. +*/ +class ComponentBlock extends ComponentVEvent { + constructor(uid?: string) { + super(uid); + + this.addEventListener('click', () => { + let uid = this.uid + let popup = find_popup(uid); + if (popup === null) throw new Error('no popup for uid ' + uid); + toggle_popup(popup); + }); + } + + redraw(data: VEvent) { + super.redraw(data); + + let p; + if ((p = data.getProperty('dtstart'))) { + let c = this.closest('.event-container') as HTMLElement + let start = parseDate(c.dataset.start!).getTime() + let end = parseDate(c.dataset.end!).getTime(); + // console.log(p); + let pp = to_local(p).getTime() + let result = 100 * (Math.min(end, Math.max(start, pp)) - start) / (end - start) + "%" + if (c.classList.contains('longevents')) { + this.style.left = result + } else { + this.style.top = result + } + // console.log('dtstart', p); + } + if ((p = data.getProperty('dtend'))) { + // console.log('dtend', p); + let c = this.closest('.event-container') as HTMLElement + let start = parseDate(c.dataset.start!).getTime() + let end = parseDate(c.dataset.end!).getTime(); + let pp = to_local(p).getTime() + let result = 100 - (100 * (Math.min(end, Math.max(start, pp)) - start) / (end - start)) + "%" + if (c.classList.contains('longevents')) { + this.style.width = 'unset'; + this.style.right = result; + } else { + this.style.height = 'unset'; + this.style.bottom = result; + } + } + + if (data._calendar) { + this.dataset.calendar = data._calendar; + } + } +} diff --git a/static/components/vevent-description.ts b/static/components/vevent-description.ts new file mode 100644 index 00000000..f97b60e1 --- /dev/null +++ b/static/components/vevent-description.ts @@ -0,0 +1,12 @@ +export { ComponentDescription } + +import { ComponentVEvent } from './vevent' + +/* + +*/ +class ComponentDescription extends ComponentVEvent { + constructor() { + super(); + } +} diff --git a/static/components/vevent-dl.ts b/static/components/vevent-dl.ts new file mode 100644 index 00000000..a9e60d81 --- /dev/null +++ b/static/components/vevent-dl.ts @@ -0,0 +1,24 @@ +export { VEventDL } + +import { ComponentVEvent } from './vevent' +import { VEvent } from '../vevent' +import { makeElement } from '../lib' + +/* */ +class VEventDL extends ComponentVEvent { + redraw(obj: VEvent) { + let dl = buildDescriptionList( + Array.from(obj.boundProperties) + .map(key => [key, obj.getProperty(key)])) + this.replaceChildren(dl); + } +} + +function buildDescriptionList(data: [string, any][]): HTMLElement { + let dl = document.createElement('dl'); + for (let [key, val] of data) { + dl.appendChild(makeElement('dt', { innerText: key })) + dl.appendChild(makeElement('dd', { innerText: val })) + } + return dl; +} diff --git a/static/components/vevent-edit.ts b/static/components/vevent-edit.ts new file mode 100644 index 00000000..602e1872 --- /dev/null +++ b/static/components/vevent-edit.ts @@ -0,0 +1,137 @@ +export { ComponentEdit } + +import { ComponentVEvent } from './vevent' +import { DateTimeInput } from './date-time-input' + +import { vcal_objects, event_calendar_mapping } from '../globals' +import { VEvent } from '../vevent' +import { create_event } from '../server_connect' + +/* + Edit form for a given VEvent. Used as the edit tab of popups. +*/ +class ComponentEdit extends ComponentVEvent { + + firstTime: boolean + + constructor() { + super(); + + this.firstTime = true; + } + + connectedCallback() { + + /* Edit tab is rendered here. It's left blank server-side, since + it only makes sense to have something here if we have javascript */ + + let data = vcal_objects.get(this.uid) + + if (!data) { + throw `Data missing for uid ${this.dataset.uid}.` + } + + + // return; + + /* Handle calendar dropdown */ + for (let el of this.getElementsByClassName('calendar-selection')) { + for (let opt of el.getElementsByTagName('option')) { + opt.selected = false; + if (opt.value == event_calendar_mapping.get(this.uid)) { + data.setCalendar(opt.value); + opt.selected = true; + /* No break since we want to set the remainders 'selected' to false */ + } + } + + el.addEventListener('change', (e) => { + let v = (e.target as HTMLSelectElement).selectedOptions[0].value + // e.selectedOptions[0].innerText + + let obj = vcal_objects.get(this.uid)! + obj.setCalendar(v); + }); + } + + this.redraw(data); + + for (let el of this.getElementsByClassName("interactive")) { + // console.log(el); + el.addEventListener('input', () => { + let obj = vcal_objects.get(this.uid) + if (obj === undefined) { + throw 'No object with uid ' + this.uid + } + if (!(el instanceof HTMLInputElement + || el instanceof DateTimeInput)) { + console.log(el, 'not an HTMLInputElement'); + return; + } + obj.setProperty( + el.dataset.property!, + el.value) + }); + } + + let submit = this.querySelector('form') as HTMLFormElement + submit.addEventListener('submit', (e) => { + console.log(submit, e); + create_event(vcal_objects.get(this.uid)!); + + e.preventDefault(); + return false; + }); + } + + redraw(data: VEvent) { + // update ourselves from template + + if (!this.template) { + throw "Something"; + } + + let body; + if (this.firstTime) { + body = (this.template.content.cloneNode(true) as DocumentFragment).firstElementChild!; + } else { + body = this; + } + + for (let el of body.getElementsByClassName("interactive")) { + if (!(el instanceof HTMLElement)) continue; + let p = el.dataset.property!; + let d: any; + if ((d = data.getProperty(p))) { + /* + https://stackoverflow.com/questions/57157830/how-can-i-specify-the-sequence-of-running-nested-web-components-constructors + */ + window.setTimeout(() => { + /* NOTE Some specific types might require special formatting + here. But due to my custom components implementing custom + `.value' procedures, we might not need any special cases + here */ + /* Technically we just want to cast to HTMLElement with + value field here, but multiple types implement it + sepparately, and no common interface exist */ + (el as HTMLInputElement).value = d; + }); + } + } + + for (let el of body.getElementsByTagName('calendar-selection')) { + for (let opt of el.getElementsByTagName('option')) { + opt.selected = false; + if (opt.value == data._calendar) { + opt.selected = true; + } + } + } + + if (this.firstTime) { + this.replaceChildren(body); + this.firstTime = false; + } + } + +} diff --git a/static/components/vevent.ts b/static/components/vevent.ts new file mode 100644 index 00000000..de232794 --- /dev/null +++ b/static/components/vevent.ts @@ -0,0 +1,71 @@ +export { ComponentVEvent } + +import { vcal_objects } from '../globals' +import { VEvent } from '../vevent' + +/* Root component for all events which content is closely linked to a +@code{VEvent} object + +Lacks an accompaning tag, and shouldn't be directly instanciated. +*/ +class ComponentVEvent extends HTMLElement { + + template: HTMLTemplateElement + uid: string + + constructor(uid?: string) { + super(); + this.template = document.getElementById(this.tagName) as HTMLTemplateElement; + + let real_uid; + if (this.dataset.uid) uid = this.dataset.uid; + if (uid) real_uid = uid; + + if (!real_uid) { + throw `UID required` + } + + this.uid = real_uid; + + vcal_objects.get(this.uid)?.register(this); + + /* We DON'T have a redraw here in the general case, since the + HTML rendered server-side should be fine enough for us. + Those that need a direct rerendering (such as the edit tabs) + should take care of that some other way */ + } + + connectedCallback() { + let uid, v; + if ((uid = this.dataset.uid)) { + v = vcal_objects.get(uid) + if (v) this.redraw(v); + } + } + + redraw(data: VEvent) { + // update ourselves from template + + if (!this.template) { + throw "Something"; + } + + let body = (this.template.content.cloneNode(true) as DocumentFragment).firstElementChild!; + + for (let el of body.getElementsByClassName("bind")) { + if (!(el instanceof HTMLElement)) continue; + let p = el.dataset.property!; + let d, fmt; + if ((d = data.getProperty(p))) { + if ((fmt = el.dataset.fmt)) { + el.innerHTML = d.format(fmt); + } else { + el.innerHTML = d; + } + } + } + + this.replaceChildren(body); + } + +} -- cgit v1.2.3