From 43fbbd3fc36af77a935c0c57eaf87892fd8d831a Mon Sep 17 00:00:00 2001 From: Christoph Fricke Date: Mon, 15 Aug 2022 12:53:36 +0200 Subject: [PATCH 1/7] feat(items): add action and delete interactions refs: #4 --- examples/random.html | 6 +++ src/components/item.ts | 3 ++ .../item-interactions-controller.ts | 41 +++++++++++++++++++ src/core/events.ts | 38 +++++++++++++++++ src/main.ts | 2 + 5 files changed, 90 insertions(+) create mode 100644 src/controllers/item-interactions-controller.ts create mode 100644 src/core/events.ts diff --git a/examples/random.html b/examples/random.html index d426319..02ded30 100644 --- a/examples/random.html +++ b/examples/random.html @@ -53,6 +53,10 @@ return `${100 * utilization}%`; } + function handleEvent(e) { + console.log(e); + } + const template = html` ${map( @@ -68,6 +72,8 @@ start=${item.startDate} end=${item.endDate} scale=${item.utilization} + @action=${handleEvent} + @delete=${handleEvent} > ${item.children} diff --git a/src/components/item.ts b/src/components/item.ts index a03a24b..a101e44 100644 --- a/src/components/item.ts +++ b/src/components/item.ts @@ -1,7 +1,9 @@ import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { cameraProp } from "../controllers/camera-controller.js"; +import { ItemInteractionsController } from "../controllers/item-interactions-controller.js"; import { MuttiDate } from "../core/date.js"; +import { ActionEvent } from "../core/events.js"; import { varX } from "../core/properties.js"; /** Custom CSS property names that are related to items. */ @@ -37,6 +39,7 @@ const styles = css` @customElement("mutti-item") export class MuttiItemElement extends LitElement { static override styles = styles; + private controller = new ItemInteractionsController(this); readonly role = "gridcell"; override slot = "item"; diff --git a/src/controllers/item-interactions-controller.ts b/src/controllers/item-interactions-controller.ts new file mode 100644 index 0000000..68a6e61 --- /dev/null +++ b/src/controllers/item-interactions-controller.ts @@ -0,0 +1,41 @@ +import type { LitElement, ReactiveController } from "lit"; +import { ActionEvent, DeleteEvent } from "../core/events.js"; + +export class ItemInteractionsController implements ReactiveController { + constructor(private readonly host: LitElement) { + host.addController(this); + } + + hostConnected() { + this.host.addEventListener("keydown", this.handleKeydown); + this.host.addEventListener("dblclick", this.handleDoubleClick); + } + + hostDisconnected() { + this.host.removeEventListener("keydown", this.handleKeydown); + this.host.removeEventListener("dblclick", this.handleDoubleClick); + } + + private handleKeydown = (e: KeyboardEvent) => { + switch (e.key) { + case "Enter": + if (!e.repeat) this.action(); + break; + case "Delete": + if (!e.repeat) this.delete(); + break; + } + }; + + private handleDoubleClick = () => { + this.action(); + }; + + private delete() { + this.host.dispatchEvent(new DeleteEvent()); + } + + private action() { + this.host.dispatchEvent(new ActionEvent()); + } +} diff --git a/src/core/events.ts b/src/core/events.ts new file mode 100644 index 0000000..9fa048d --- /dev/null +++ b/src/core/events.ts @@ -0,0 +1,38 @@ +abstract class MuttiEvent extends Event { + static match(_: Event): _ is MuttiEvent { + throw new TypeError( + "Static 'match' method must be implemented by each subclass." + ); + } +} + +declare global { + interface GlobalEventHandlersEventMap { + [ActionEvent.type]: ActionEvent; + [DeleteEvent.type]: DeleteEvent; + } +} + +export class ActionEvent extends MuttiEvent { + static type = "action" as const; + + constructor() { + super(ActionEvent.type); + } + + static override match(e: Event): e is ActionEvent { + return e instanceof ActionEvent; + } +} + +export class DeleteEvent extends MuttiEvent { + static type = "delete" as const; + + constructor() { + super(DeleteEvent.type); + } + + static override match(e: Event): e is DeleteEvent { + return e instanceof DeleteEvent; + } +} diff --git a/src/main.ts b/src/main.ts index 6ad7b94..d446236 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,3 +2,5 @@ export { MuttiItemElement } from "./components/item.js"; export { MuttiLabelElement } from "./components/label.js"; export { MuttiTimelineElement } from "./components/timeline.js"; export { MuttiTrackElement } from "./components/track.js"; + +export { ActionEvent, DeleteEvent } from "./core/events.js"; From 139df40796ba8db22314a577d72a809881ed1616 Mon Sep 17 00:00:00 2001 From: Christoph Fricke Date: Wed, 17 Aug 2022 15:27:41 +0200 Subject: [PATCH 2/7] feat(camera): add config change and move to range abilities refs: #4 --- src/core/camera.ts | 55 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/src/core/camera.ts b/src/core/camera.ts index 0aab917..090883d 100644 --- a/src/core/camera.ts +++ b/src/core/camera.ts @@ -9,29 +9,40 @@ export interface CameraConfig { initialZoom: number; minZoom: number; maxZoom: number; + viewportPadding: number; } +const defaultConfig: CameraConfig = { + initialDayWidth: 4, + initialDayOffset: 0, + initialZoom: 1, + minZoom: 0.1, + maxZoom: 2, + viewportPadding: 100, +}; + export class Camera { - private readonly config: CameraConfig; + private config: CameraConfig = {} as CameraConfig; private _offset: number; private _zoom: number; private _viewport: ViewPort; constructor(config?: Partial) { - this.config = { - initialDayWidth: config?.initialDayWidth ?? 4, - initialDayOffset: config?.initialDayOffset ?? 0, - initialZoom: config?.initialZoom ?? 1, - minZoom: config?.minZoom ?? 0.1, - maxZoom: config?.maxZoom ?? 2, - }; + this.updateConfig(config); - this._offset = this.config.initialDayWidth * this.config.initialDayOffset; + this._offset = this.initialOffset; this._zoom = this.config.initialZoom; this._viewport = { width: 0, height: 0 }; } + private get initialOffset() { + return ( + this.config.initialDayWidth * this.config.initialDayOffset + + this.config.viewportPadding + ); + } + get offset() { return this._offset; } @@ -48,10 +59,34 @@ export class Camera { return this._viewport; } + public updateConfig(config?: Partial): CameraConfig { + this.config = { + ...defaultConfig, + ...this.config, + ...config, + }; + return this.config; + } + public changeOffset(by: number) { this._offset += by; } + /** Changes the offset to display the given range in the viewport. */ + public moveRangeIntoViewport(start: number, end: number) { + const leftPadded = this.config.viewportPadding; + const rightPadded = this.viewport.width - this.config.viewportPadding; + + switch (true) { + case start >= leftPadded && end <= rightPadded: + return; + case start < leftPadded: + return this.changeOffset(leftPadded - start); + case true: + return this.changeOffset(rightPadded - end); + } + } + public changeZoom(by: number) { this._zoom = this.clamp( this.config.minZoom, @@ -66,7 +101,7 @@ export class Camera { public reset() { this._zoom = this.config.initialZoom; - this._offset = this.config.initialDayWidth * this.config.initialDayOffset; + this._offset = this.initialOffset; } private clamp(min: number, actual: number, max: number): number { From 2dbfbcba2b9b055c9c2fba3bffc01d2d0fa2990b Mon Sep 17 00:00:00 2001 From: Christoph Fricke Date: Wed, 17 Aug 2022 15:29:37 +0200 Subject: [PATCH 3/7] feat(item): dispatch events for focus changes refs: #4 --- src/components/item.ts | 9 ++++- .../item-interactions-controller.ts | 27 +++++++++++++- src/core/events.ts | 37 +++++++++++++++++++ src/main.ts | 7 +++- 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/components/item.ts b/src/components/item.ts index a101e44..169fa89 100644 --- a/src/components/item.ts +++ b/src/components/item.ts @@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators.js"; import { cameraProp } from "../controllers/camera-controller.js"; import { ItemInteractionsController } from "../controllers/item-interactions-controller.js"; import { MuttiDate } from "../core/date.js"; -import { ActionEvent } from "../core/events.js"; +import { ItemFocusEvent } from "../core/events.js"; import { varX } from "../core/properties.js"; /** Custom CSS property names that are related to items. */ @@ -53,6 +53,13 @@ export class MuttiItemElement extends LitElement { ); @property({ type: Number, attribute: false }) subTrack = 1; + override focus(): void { + const shouldContinue = this.dispatchEvent( + new ItemFocusEvent(this.start, this.end) + ); + if (shouldContinue) super.focus({ preventScroll: true }); + } + protected override willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has("subTrack")) { this.style.setProperty(itemProp.subTrack, this.subTrack.toString()); diff --git a/src/controllers/item-interactions-controller.ts b/src/controllers/item-interactions-controller.ts index 68a6e61..d08ca96 100644 --- a/src/controllers/item-interactions-controller.ts +++ b/src/controllers/item-interactions-controller.ts @@ -1,5 +1,10 @@ import type { LitElement, ReactiveController } from "lit"; -import { ActionEvent, DeleteEvent } from "../core/events.js"; +import { + ActionEvent, + DeleteEvent, + FocusChangeEvent, + FocusChangeLocation, +} from "../core/events.js"; export class ItemInteractionsController implements ReactiveController { constructor(private readonly host: LitElement) { @@ -24,6 +29,22 @@ export class ItemInteractionsController implements ReactiveController { case "Delete": if (!e.repeat) this.delete(); break; + case "ArrowLeft": + e.preventDefault(); + this.changeFocus("left"); + break; + case "ArrowRight": + e.preventDefault(); + this.changeFocus("right"); + break; + case "ArrowUp": + e.preventDefault(); + this.changeFocus("up"); + break; + case "ArrowDown": + e.preventDefault(); + this.changeFocus("down"); + break; } }; @@ -31,6 +52,10 @@ export class ItemInteractionsController implements ReactiveController { this.action(); }; + private changeFocus(where: FocusChangeLocation) { + this.host.dispatchEvent(new FocusChangeEvent(where)); + } + private delete() { this.host.dispatchEvent(new DeleteEvent()); } diff --git a/src/core/events.ts b/src/core/events.ts index 9fa048d..5d31ace 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -1,3 +1,5 @@ +import type { MuttiDate } from "./date.js"; + abstract class MuttiEvent extends Event { static match(_: Event): _ is MuttiEvent { throw new TypeError( @@ -10,6 +12,8 @@ declare global { interface GlobalEventHandlersEventMap { [ActionEvent.type]: ActionEvent; [DeleteEvent.type]: DeleteEvent; + [FocusChangeEvent.type]: FocusChangeEvent; + [ItemFocusEvent.type]: ItemFocusEvent; } } @@ -36,3 +40,36 @@ export class DeleteEvent extends MuttiEvent { return e instanceof DeleteEvent; } } + +export type FocusChangeLocation = "left" | "right" | "up" | "down"; +export class FocusChangeEvent extends MuttiEvent { + static type = "focuschange" as const; + + constructor(public readonly where: FocusChangeLocation) { + super(FocusChangeEvent.type, { bubbles: true }); + } + + static override match(e: Event): e is FocusChangeEvent { + return e instanceof FocusChangeEvent; + } +} + +/** + * This event is dispatched before an item is focused via the keyboard. + * If the event is cancelled, the item will not be focussed and will + * not be moved into view. + */ +export class ItemFocusEvent extends MuttiEvent { + static type = "itemfocus" as const; + + constructor( + public readonly start: MuttiDate, + public readonly end: MuttiDate + ) { + super(ItemFocusEvent.type, { bubbles: true, cancelable: true }); + } + + static override match(e: Event): e is ItemFocusEvent { + return e instanceof ItemFocusEvent; + } +} diff --git a/src/main.ts b/src/main.ts index d446236..453b1fe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,4 +3,9 @@ export { MuttiLabelElement } from "./components/label.js"; export { MuttiTimelineElement } from "./components/timeline.js"; export { MuttiTrackElement } from "./components/track.js"; -export { ActionEvent, DeleteEvent } from "./core/events.js"; +export { + ActionEvent, + DeleteEvent, + FocusChangeEvent, + ItemFocusEvent, +} from "./core/events.js"; From 8a0c93a9eddc21a60d32e30d22b39648fb250849 Mon Sep 17 00:00:00 2001 From: Christoph Fricke Date: Wed, 17 Aug 2022 15:31:05 +0200 Subject: [PATCH 4/7] feat: handle focus events for 2D timeline keyboard navigation refs: #4 --- examples/random.html | 4 +- src/components/timeline.ts | 41 +++++- src/components/track.ts | 179 ++++++++++++++++++++++----- src/controllers/camera-controller.ts | 19 +++ src/core/date.ts | 18 ++- 5 files changed, 216 insertions(+), 45 deletions(-) diff --git a/examples/random.html b/examples/random.html index 02ded30..98a61ed 100644 --- a/examples/random.html +++ b/examples/random.html @@ -45,7 +45,7 @@ import { buildTrack, listOf, seed } from "../dist/test-utils/builder.js"; const root = document.querySelector("#root"); const usedSeed = seed(); - const tracks = listOf(buildTrack, { min: 15, max: 100 }); + const tracks = listOf(buildTrack, { min: 15, max: 15 }); const itemSum = tracks.flatMap((track) => track.items).length; function getUtilizationPercentage(utilization) { @@ -58,7 +58,7 @@ } const template = html` - + ${map( tracks, (track) => html` diff --git a/src/components/timeline.ts b/src/components/timeline.ts index e04b6c0..862f482 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,10 +1,12 @@ -import { css, html, LitElement, TemplateResult } from "lit"; +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { CameraController, ZoomDetailLevel, } from "../controllers/camera-controller.js"; +import { FocusChangeEvent } from "../core/events.js"; import "./heading.js"; +import { MuttiTrackElement } from "./track.js"; const styles = css` :host { @@ -27,13 +29,44 @@ export class MuttiTimelineElement extends LitElement { static override styles = styles; readonly role = "grid"; + private cameraController: CameraController; @property() override lang = document.documentElement.lang || navigator.language; + @property({ type: Number }) viewportPadding = 100; - private cameraController = new CameraController(this, { - initialDayOffset: 100, - }); + constructor() { + super(); + this.cameraController = new CameraController(this, { + initialDayOffset: 100, + }); + this.addEventListener(FocusChangeEvent.type, this.handleFocusChange); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("viewportPadding")) { + this.cameraController.updateConfig({ + viewportPadding: this.viewportPadding, + }); + } + } + + private handleFocusChange = (e: FocusChangeEvent) => { + // The that first handled the event is not able to go further + // up or down, so the event should be passed to the previous/next track. + const track = e.composedPath().find(this.isMuttiTrack); + let next: Element | null | undefined; + do { + if (e.where === "up") next = (next ?? track)?.previousElementSibling; + else next = (next ?? track)?.nextElementSibling; + } while (next && !this.isMuttiTrack(next)); + + next?.focusOnRelevantSubTrack(e); + }; + + private isMuttiTrack(value: unknown): value is MuttiTrackElement { + return value instanceof MuttiTrackElement; + } protected override render(): TemplateResult { return html` diff --git a/src/components/track.ts b/src/components/track.ts index 678360f..4ff5a5b 100644 --- a/src/components/track.ts +++ b/src/components/track.ts @@ -1,6 +1,7 @@ import { css, html, LitElement, TemplateResult } from "lit"; import { customElement } from "lit/decorators.js"; import { cameraProp } from "../controllers/camera-controller.js"; +import { FocusChangeEvent } from "../core/events.js"; import { varX, themeProp } from "../core/properties.js"; import { MuttiItemElement } from "./item.js"; @@ -30,6 +31,10 @@ const styles = css` } `; +type SubTracks = MuttiItemElement[][]; +type ItemPosition = { subTrack: number; position: number }; +type ItemPositionMap = Map; + @customElement("mutti-track") export class MuttiTrackElement extends LitElement { static override styles = styles; @@ -37,54 +42,160 @@ export class MuttiTrackElement extends LitElement { readonly role = "row"; override slot = "track"; + private subTracks: SubTracks = []; + private itemPositionMap: ItemPositionMap = new Map(); + + constructor() { + super(); + this.addEventListener(FocusChangeEvent.type, this.handleFocusChange); + } + protected override firstUpdated(): void { const children = Array.from(this.children); - const items = children.filter( - (c): c is MuttiItemElement => c instanceof MuttiItemElement - ); - this.collisionAvoidance(items); + const items = children.filter(this.isMuttiItem); + this.subTracks = this.orderItemsIntoSubTracks(items); + this.applySubTrackInfoToElements(this.subTracks); + this.fillPositionMap(this.itemPositionMap, this.subTracks); } - private collisionAvoidance(items: MuttiItemElement[]) { - const subTracks: MuttiItemElement[][] = []; - for (const item of items) { - const added = this.addToFittingTrack(item, subTracks); - if (!added) { - subTracks.push([item]); + /** Called by the with delegated {@link FocusChangeEvent}s. */ + public focusOnRelevantSubTrack(e: FocusChangeEvent): void { + const item = e.target; + if (!this.isMuttiItem(item)) return; + + const track = + (e.where === "down" ? this.subTracks.at(0) : this.subTracks.at(-1)) ?? []; + const next = this.getClosestItemFromList(item, track); + if (!next) return; + + this.scrollIntoView({ block: "center" }); + next.focus(); + } + + private handleFocusChange = (e: FocusChangeEvent) => { + const item = e.target; + if (!this.isMuttiItem(item)) return; + + const position = this.itemPositionMap.get(item); + if (!position) { + throw new Error("Item has not been mapped into a position!"); + } + + switch (e.where) { + case "left": { + e.stopPropagation(); + const next = this.subTracks[position.subTrack]?.[position.position - 1]; + next?.focus(); + return; + } + case "right": { + e.stopPropagation(); + const next = this.subTracks[position.subTrack]?.[position.position + 1]; + next?.focus(); + return; + } + case "up": { + const previousTrack = this.subTracks[position.subTrack - 1]; + if (!previousTrack) return; // Event will be delegated to the previous track by the timeline + e.stopPropagation(); + const next = this.getClosestItemFromList(item, previousTrack); + return next?.focus(); + } + case "down": { + const nextTrack = this.subTracks[position.subTrack + 1]; + if (!nextTrack) return; // Event will be delegated to the next track by the timeline + e.stopPropagation(); + const next = this.getClosestItemFromList(item, nextTrack); + return next?.focus(); } } + }; - this.style.setProperty(trackProp.subTracks, `${subTracks.length}`); - subTracks.forEach((track, index) => { - for (const item of track) { - item.subTrack = index + 1; + private orderItemsIntoSubTracks(items: MuttiItemElement[]): SubTracks { + const subTracks: SubTracks = []; + for (const item of items) { + let fits = false; + for (const track of subTracks) { + fits = this.isItemFittingIntoTrack(item, track); + if (!fits) continue; + track.push(item); + break; } - }); + if (!fits) subTracks.push([item]); + } + + for (const track of subTracks) { + track.sort((a, b) => { + const lessThan = a.start.isEarlierThan(b.start); + const greaterThan = a.start.isLaterThan(b.start); + if (lessThan) return -1; + if (greaterThan) return 1; + return 0; + }); + } + return subTracks; } - private addToFittingTrack( + private isItemFittingIntoTrack( item: MuttiItemElement, - tracks: MuttiItemElement[][] - ): boolean { - for (const track of tracks) { - const isFitting = track.every((trackItem) => { - const itemWithinTrackItem = - item.start.isWithinDays(trackItem.start, trackItem.end) || - item.end.isWithinDays(trackItem.start, trackItem.end); - const trackItemWithinItem = - trackItem.start.isWithinDays(item.start, item.end) || - trackItem.end.isWithinDays(item.start, item.end); - - return !itemWithinTrackItem && !trackItemWithinItem; - }); - if (isFitting) { - track.push(item); - return true; + track: MuttiItemElement[] + ) { + return track.every((trackItem) => { + const itemWithinTrackItem = + item.start.isWithinDays(trackItem.start, trackItem.end) || + item.end.isWithinDays(trackItem.start, trackItem.end); + const trackItemWithinItem = + trackItem.start.isWithinDays(item.start, item.end) || + trackItem.end.isWithinDays(item.start, item.end); + + return !itemWithinTrackItem && !trackItemWithinItem; + }); + } + + private isMuttiItem(value: unknown): value is MuttiItemElement { + return value instanceof MuttiItemElement; + } + + private getClosestItemFromList( + ref: MuttiItemElement, + items: MuttiItemElement[] + ): MuttiItemElement | undefined { + if (items.length === 0) return; + + const scoring = items.map((item) => { + const startToStart = Math.abs(item.start.getDaysUntil(ref.start)); + const startToEnd = Math.abs(item.start.getDaysUntil(ref.end)); + const endToStart = Math.abs(item.end.getDaysUntil(ref.start)); + const endToEnd = Math.abs(item.end.getDaysUntil(ref.end)); + return ( + Math.min(startToStart, startToEnd) + Math.min(endToStart, endToEnd) + ); + }); + const minIndex = scoring.indexOf(Math.min(...scoring)); + return items[minIndex]; + } + + private applySubTrackInfoToElements(subTracks: SubTracks) { + this.style.setProperty(trackProp.subTracks, `${subTracks.length}`); + subTracks.forEach((track, index) => + track.forEach((item) => (item.subTrack = index + 1)) + ); + } + + private fillPositionMap(map: ItemPositionMap, subTracks: SubTracks) { + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + for (let subTrack = 0; subTrack < subTracks.length; subTrack++) { + for ( + let position = 0; + position < subTracks[subTrack]!.length; + position++ + ) { + const item = subTracks[subTrack]![position]!; + map.set(item, { subTrack, position }); } } - - return false; + /* eslint-enable @typescript-eslint/no-non-null-assertion */ } protected override render(): TemplateResult { diff --git a/src/controllers/camera-controller.ts b/src/controllers/camera-controller.ts index ad12720..a25f011 100644 --- a/src/controllers/camera-controller.ts +++ b/src/controllers/camera-controller.ts @@ -4,6 +4,7 @@ import { ResizeValueCallback, } from "@lit-labs/observers/resize_controller.js"; import { Camera, CameraConfig, ViewPort } from "../core/camera.js"; +import { ItemFocusEvent } from "../core/events.js"; export enum ZoomDetailLevel { Year = 0, @@ -55,8 +56,13 @@ export class CameraController implements ReactiveController { return this.camera.viewport; } + get updateConfig() { + return this.camera.updateConfig.bind(this.camera); + } + hostConnected() { document.addEventListener("keydown", this.handleKeydown); + this.host.addEventListener(ItemFocusEvent.type, this.handleItemFocus); this.host.addEventListener("pointerdown", this.handlePointerDown); this.host.addEventListener("pointermove", this.handlePointerMove); @@ -68,6 +74,7 @@ export class CameraController implements ReactiveController { hostDisconnected() { document.removeEventListener("keydown", this.handleKeydown); + this.host.removeEventListener(ItemFocusEvent.type, this.handleItemFocus); this.host.removeEventListener("pointerdown", this.handlePointerDown); this.host.removeEventListener("pointermove", this.handlePointerMove); @@ -102,6 +109,18 @@ export class CameraController implements ReactiveController { this.camera.changeViewport(contentSize.inlineSize, contentSize.blockSize); }; + private handleItemFocus = (e: ItemFocusEvent) => { + if (e.defaultPrevented) return; + + // Dates are plotted relative to today, which is positioned at the current offset. + // Therefore, they are converted to absolute positions on the timeline. + const start = this.camera.offset + e.start.getDaysFromNow() * this.dayWidth; + const end = this.camera.offset + e.end.getDaysFromNow() * this.dayWidth; + + this.camera.moveRangeIntoViewport(start, end); + this.setHostPropertiesAndUpdate(); + }; + private handleKeydown = (e: KeyboardEvent) => { switch (e.code) { case "KeyR": diff --git a/src/core/date.ts b/src/core/date.ts index cfa833b..be1ebdb 100644 --- a/src/core/date.ts +++ b/src/core/date.ts @@ -78,16 +78,24 @@ export class MuttiDate { return MuttiDate.now.getDaysUntil(this); } - public isLaterOrSameThan(date: MuttiDate): boolean { - return this.dateMS >= date.dateMS; + public isLaterThan(date: MuttiDate): boolean { + return this.dateMS > date.dateMS; } - public isEarlierOrSameThan(date: MuttiDate): boolean { - return this.dateMS <= date.dateMS; + public isEarlierThan(date: MuttiDate): boolean { + return this.dateMS < date.dateMS; + } + + public isSameDay(date: MuttiDate): boolean { + return this.dateMS === date.dateMS; } public isWithinDays(start: MuttiDate, end: MuttiDate): boolean { - return this.isLaterOrSameThan(start) && this.isEarlierOrSameThan(end); + return ( + (this.isLaterThan(start) && this.isEarlierThan(end)) || + this.isSameDay(start) || + this.isSameDay(end) + ); } public get isStartOfMonth(): boolean { From 883293d9baccd3fd133a25431df412285143b0e1 Mon Sep 17 00:00:00 2001 From: Christoph Fricke Date: Thu, 18 Aug 2022 10:25:03 +0200 Subject: [PATCH 5/7] fix(tracks): tracks with only static-items collapse --- src/components/track.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/track.ts b/src/components/track.ts index 7fead1f..45de395 100644 --- a/src/components/track.ts +++ b/src/components/track.ts @@ -193,6 +193,8 @@ export class MuttiTrackElement extends LitElement { } private applySubTrackInfoToElements(subTracks: SubTracks) { + if (subTracks.length === 0) return; + this.style.setProperty(trackProp.subTracks, `${subTracks.length}`); subTracks.forEach((track, index) => track.forEach((item) => (item.subTrack = index + 1)) From 605d328093ecc8b48ab6f36c431512a4a77eada9 Mon Sep 17 00:00:00 2001 From: Christoph Fricke Date: Thu, 18 Aug 2022 16:46:27 +0200 Subject: [PATCH 6/7] feat(item): add moving on same track refs: #4 --- src/components/item.ts | 76 ++++++++++++++++--- src/components/track.ts | 15 +++- .../item-interactions-controller.ts | 6 +- src/controllers/pointer-controller.ts | 76 +++++++++++++++++++ src/core/events.ts | 20 ++++- src/core/point.ts | 22 ++++++ 6 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 src/controllers/pointer-controller.ts create mode 100644 src/core/point.ts diff --git a/src/components/item.ts b/src/components/item.ts index 169fa89..d5d1fbd 100644 --- a/src/components/item.ts +++ b/src/components/item.ts @@ -1,9 +1,13 @@ import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { cameraProp } from "../controllers/camera-controller.js"; import { ItemInteractionsController } from "../controllers/item-interactions-controller.js"; +import { + PointerController, + PointerHandler, +} from "../controllers/pointer-controller.js"; import { MuttiDate } from "../core/date.js"; -import { ItemFocusEvent } from "../core/events.js"; +import { ItemChangeEvent, ItemFocusEvent } from "../core/events.js"; import { varX } from "../core/properties.js"; /** Custom CSS property names that are related to items. */ @@ -12,6 +16,7 @@ export const itemProp = { length: "--mutti-item-length", nowOffset: "--mutti-item-now-offset", subTrack: "--mutti-item-sub-track", + dragOffset: "--mutti-item-drag-offset", }; const styles = css` @@ -29,7 +34,10 @@ const styles = css` height: calc(100% * ${varX(itemProp.scale)}); width: calc(${varX(cameraProp.dayWidth)} * ${varX(itemProp.length)}); transform: translateX( - calc(${varX(cameraProp.dayWidth)} * ${varX(itemProp.nowOffset)}) + calc( + ${varX(itemProp.dragOffset)} + + (${varX(cameraProp.dayWidth)} * ${varX(itemProp.nowOffset)}) + ) ); grid-row-start: ${varX(itemProp.subTrack)}; grid-row-end: ${varX(itemProp.subTrack)}; @@ -39,20 +47,67 @@ const styles = css` @customElement("mutti-item") export class MuttiItemElement extends LitElement { static override styles = styles; - private controller = new ItemInteractionsController(this); + private interactionController: ItemInteractionsController; + private pointerController: PointerController; readonly role = "gridcell"; override slot = "item"; override tabIndex = 0; + @state() dragOffset = 0; + @property({ type: Number }) scale = 1; - @property({ converter: MuttiDate.converter }) start = MuttiDate.now; - @property({ converter: MuttiDate.converter }) end = MuttiDate.from( - this.start, - 7 - ); + @property({ converter: MuttiDate.converter, reflect: true }) start = + MuttiDate.now; + @property({ converter: MuttiDate.converter, reflect: true }) end = + MuttiDate.from(this.start, 7); @property({ type: Number, attribute: false }) subTrack = 1; + constructor() { + super(); + this.interactionController = new ItemInteractionsController(this); + this.pointerController = new PointerController(this, { + disabled: true, + stopPropagation: true, + moveHandler: this.handleMove, + doneHandler: this.handleMoveDone, + }); + + this.addEventListener( + "focus", + () => (this.pointerController.disabled = false) + ); + this.addEventListener( + "blur", + () => (this.pointerController.disabled = true) + ); + } + + private handleMove: PointerHandler = (_, delta) => { + this.dragOffset += delta.x; + }; + + private handleMoveDone: PointerHandler = () => { + // TODO: Parsing the value from CSS properties feels a bit "hacky" (but is quite fast). + // We should consider upgrading the backbone of the timeline with a lit context + // to pass global state around. However, it should not introduce additional + // lit renders when e.g. the offset updates. + const dayWidth = parseFloat( + getComputedStyle(this).getPropertyValue(cameraProp.dayWidth) + ); + const newStart = MuttiDate.from(this.start, this.dragOffset / dayWidth); + const newEnd = MuttiDate.from(this.end, this.dragOffset / dayWidth); + this.dragOffset = 0; + + const shouldContinue = this.dispatchEvent( + new ItemChangeEvent(newStart, newEnd) + ); + + if (!shouldContinue) return; + this.start = newStart; + this.end = newEnd; + }; + override focus(): void { const shouldContinue = this.dispatchEvent( new ItemFocusEvent(this.start, this.end) @@ -61,6 +116,9 @@ export class MuttiItemElement extends LitElement { } protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("dragOffset")) { + this.style.setProperty(itemProp.dragOffset, `${this.dragOffset}px`); + } if (changedProperties.has("subTrack")) { this.style.setProperty(itemProp.subTrack, this.subTrack.toString()); } diff --git a/src/components/track.ts b/src/components/track.ts index 45de395..dec5292 100644 --- a/src/components/track.ts +++ b/src/components/track.ts @@ -1,7 +1,7 @@ import { css, html, LitElement, TemplateResult } from "lit"; import { customElement } from "lit/decorators.js"; import { cameraProp } from "../controllers/camera-controller.js"; -import { FocusChangeEvent } from "../core/events.js"; +import { FocusChangeEvent, ItemChangeEvent } from "../core/events.js"; import { varX, themeProp } from "../core/properties.js"; import { MuttiItemElement } from "./item.js"; import { MuttiLabelElement } from "./label.js"; @@ -49,6 +49,7 @@ export class MuttiTrackElement extends LitElement { constructor() { super(); this.addEventListener(FocusChangeEvent.type, this.handleFocusChange); + this.addEventListener(ItemChangeEvent.type, this.handleItemChange); } protected override firstUpdated(): void { @@ -89,6 +90,17 @@ export class MuttiTrackElement extends LitElement { next.focus(); } + private handleItemChange = async (e: ItemChangeEvent) => { + const item = e.target; + if (e.defaultPrevented || !this.isMuttiItem(item)) return; + + await item.updateComplete; // Wait until item date updates are flushed. + const items = Array.from(this.children).filter(this.isMuttiItem); + this.subTracks = this.orderItemsIntoSubTracks(items); + this.applySubTrackInfoToElements(this.subTracks); + this.fillPositionMap(this.itemPositionMap, this.subTracks); + }; + private handleFocusChange = (e: FocusChangeEvent) => { const item = e.target; if (!this.isMuttiItem(item)) return; @@ -202,6 +214,7 @@ export class MuttiTrackElement extends LitElement { } private fillPositionMap(map: ItemPositionMap, subTracks: SubTracks) { + map.clear(); /* eslint-disable @typescript-eslint/no-non-null-assertion */ for (let subTrack = 0; subTrack < subTracks.length; subTrack++) { for ( diff --git a/src/controllers/item-interactions-controller.ts b/src/controllers/item-interactions-controller.ts index d08ca96..132e234 100644 --- a/src/controllers/item-interactions-controller.ts +++ b/src/controllers/item-interactions-controller.ts @@ -3,7 +3,7 @@ import { ActionEvent, DeleteEvent, FocusChangeEvent, - FocusChangeLocation, + Direction, } from "../core/events.js"; export class ItemInteractionsController implements ReactiveController { @@ -52,8 +52,8 @@ export class ItemInteractionsController implements ReactiveController { this.action(); }; - private changeFocus(where: FocusChangeLocation) { - this.host.dispatchEvent(new FocusChangeEvent(where)); + private changeFocus(dir: Direction) { + this.host.dispatchEvent(new FocusChangeEvent(dir)); } private delete() { diff --git a/src/controllers/pointer-controller.ts b/src/controllers/pointer-controller.ts new file mode 100644 index 0000000..b397885 --- /dev/null +++ b/src/controllers/pointer-controller.ts @@ -0,0 +1,76 @@ +import type { LitElement, ReactiveController } from "lit"; +import { delta, equal, fromEvent, Point, zero } from "../core/point.js"; + +export type PointerHandler = (e: Event, delta: Point) => void; + +export interface PointerControllerConfig { + disabled?: boolean; + stopPropagation?: boolean; + startHandler: PointerHandler; + moveHandler: PointerHandler; + doneHandler: PointerHandler; +} + +export class PointerController implements ReactiveController { + private isPointerDown = false; + private pointerPos = zero; + private startingPos = this.pointerPos; + + public stopPropagation: boolean; + public disabled: boolean; + + constructor( + private readonly host: LitElement, + private readonly config?: Partial + ) { + host.addController(this); + this.stopPropagation = config?.stopPropagation ?? false; + this.disabled = config?.disabled ?? false; + } + + hostConnected() { + this.host.addEventListener("pointerdown", this.handlePointerDown); + this.host.addEventListener("pointermove", this.handlePointerMove); + this.host.addEventListener("pointerup", this.handlePointerUp); + this.host.addEventListener("pointerleave", this.handlePointerUp); + } + + hostDisconnected() { + this.host.removeEventListener("pointerdown", this.handlePointerDown); + this.host.removeEventListener("pointermove", this.handlePointerMove); + this.host.removeEventListener("pointerup", this.handlePointerUp); + this.host.removeEventListener("pointerleave", this.handlePointerUp); + } + + private handlePointerDown = (e: PointerEvent) => { + if (this.isPointerDown || this.disabled) return; + if (this.stopPropagation) e.stopPropagation(); + + this.isPointerDown = true; + + this.startingPos = this.pointerPos = fromEvent(e); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + this.config?.startHandler?.(e, zero); + }; + + private handlePointerUp = (e: PointerEvent) => { + if (!this.isPointerDown || this.disabled) return; + if (this.stopPropagation) e.stopPropagation(); + + this.isPointerDown = false; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + + if (equal(fromEvent(e), this.startingPos)) return; + this.config?.doneHandler?.(e, zero); + }; + + private handlePointerMove = (e: PointerEvent) => { + if (!this.isPointerDown || this.disabled) return; + if (this.stopPropagation) e.stopPropagation(); + + const newP = fromEvent(e); + const deltaP: Point = delta(this.pointerPos, newP); + this.pointerPos = newP; + this.config?.moveHandler?.(e, deltaP); + }; +} diff --git a/src/core/events.ts b/src/core/events.ts index 5d31ace..08ba3df 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -14,6 +14,7 @@ declare global { [DeleteEvent.type]: DeleteEvent; [FocusChangeEvent.type]: FocusChangeEvent; [ItemFocusEvent.type]: ItemFocusEvent; + [ItemChangeEvent.type]: ItemFocusEvent; } } @@ -41,11 +42,11 @@ export class DeleteEvent extends MuttiEvent { } } -export type FocusChangeLocation = "left" | "right" | "up" | "down"; +export type Direction = "left" | "right" | "up" | "down"; export class FocusChangeEvent extends MuttiEvent { static type = "focuschange" as const; - constructor(public readonly where: FocusChangeLocation) { + constructor(public readonly where: Direction) { super(FocusChangeEvent.type, { bubbles: true }); } @@ -73,3 +74,18 @@ export class ItemFocusEvent extends MuttiEvent { return e instanceof ItemFocusEvent; } } + +export class ItemChangeEvent extends MuttiEvent { + static type = "itemchange" as const; + + constructor( + public readonly start: MuttiDate, + public readonly end: MuttiDate + ) { + super(ItemChangeEvent.type, { bubbles: true, cancelable: true }); + } + + static override match(e: Event): e is ItemChangeEvent { + return e instanceof ItemChangeEvent; + } +} diff --git a/src/core/point.ts b/src/core/point.ts new file mode 100644 index 0000000..76488a7 --- /dev/null +++ b/src/core/point.ts @@ -0,0 +1,22 @@ +export interface Point { + x: number; + y: number; +} + +export const zero: Point = { x: 0, y: 0 }; + +export function point(x: number, y: number) { + return { x, y }; +} + +export function fromEvent(e: PointerEvent): Point { + return point(e.pageX, e.pageY); +} + +export function delta(p1: Point, p2: Point): Point { + return point(p2.x - p1.x, p2.y - p1.y); +} + +export function equal(p1: Point, p2: Point): boolean { + return p1.x === p2.x && p1.y === p2.y; +} From 53a6bc19c09bf90a72dee7c4e28b0d44bfa26690 Mon Sep 17 00:00:00 2001 From: Christoph Fricke Date: Thu, 18 Aug 2022 16:51:46 +0200 Subject: [PATCH 7/7] refactor(camera): use pointer-controller for pointer events refs: #4 --- src/controllers/camera-controller.ts | 42 ++++++---------------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/src/controllers/camera-controller.ts b/src/controllers/camera-controller.ts index 7485afb..a330038 100644 --- a/src/controllers/camera-controller.ts +++ b/src/controllers/camera-controller.ts @@ -5,6 +5,7 @@ import { } from "@lit-labs/observers/resize_controller.js"; import { Camera, CameraConfig, ViewPort } from "../core/camera.js"; import { ItemFocusEvent } from "../core/events.js"; +import { PointerController } from "./pointer-controller.js"; export enum ZoomDetailLevel { Year = 0, @@ -22,9 +23,7 @@ export const cameraProp = { export class CameraController implements ReactiveController { private readonly camera: Camera; private readonly resizeController: ResizeController; - - private isPanning = false; - private mouseX = 0; + private readonly pointerController: PointerController; constructor( private readonly host: LitElement, @@ -35,6 +34,12 @@ export class CameraController implements ReactiveController { this.resizeController = new ResizeController(host, { callback: this.handleResize, }); + this.pointerController = new PointerController(host, { + moveHandler: (_, delta) => { + this.camera.changeOffset(delta.x); + this.setHostPropertiesAndUpdate(); + }, + }); this.setHostPropertiesAndUpdate(); } @@ -63,24 +68,12 @@ export class CameraController implements ReactiveController { hostConnected() { document.addEventListener("keydown", this.handleKeydown); this.host.addEventListener(ItemFocusEvent.type, this.handleItemFocus); - - this.host.addEventListener("pointerdown", this.handlePointerDown); - this.host.addEventListener("pointermove", this.handlePointerMove); - this.host.addEventListener("pointerup", this.handlePointerUp); - this.host.addEventListener("pointerleave", this.handlePointerUp); - this.host.addEventListener("wheel", this.handleWheel, { passive: false }); } hostDisconnected() { document.removeEventListener("keydown", this.handleKeydown); this.host.removeEventListener(ItemFocusEvent.type, this.handleItemFocus); - - this.host.removeEventListener("pointerdown", this.handlePointerDown); - this.host.removeEventListener("pointermove", this.handlePointerMove); - this.host.removeEventListener("pointerup", this.handlePointerUp); - this.host.removeEventListener("pointerleave", this.handlePointerUp); - this.host.removeEventListener("wheel", this.handleWheel); } @@ -131,25 +124,6 @@ export class CameraController implements ReactiveController { } }; - private handlePointerDown = (e: PointerEvent) => { - this.isPanning = true; - this.mouseX = e.pageX; - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }; - - private handlePointerUp = (e: PointerEvent) => { - this.isPanning = false; - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }; - - private handlePointerMove = (e: PointerEvent) => { - if (!this.isPanning) return; - - this.camera.changeOffset(e.pageX - this.mouseX); - this.setHostPropertiesAndUpdate(); - this.mouseX = e.pageX; - }; - private handleWheel = (e: WheelEvent) => { if (!e.ctrlKey) return; e.preventDefault();