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; +}