Skip to content

Commit

Permalink
feat(item): add moving on same track
Browse files Browse the repository at this point in the history
refs: #4
  • Loading branch information
christoph-fricke committed Aug 18, 2022
1 parent 883293d commit 605d328
Show file tree
Hide file tree
Showing 6 changed files with 200 additions and 15 deletions.
76 changes: 67 additions & 9 deletions src/components/item.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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`
Expand All @@ -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)};
Expand All @@ -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)
Expand All @@ -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());
}
Expand Down
15 changes: 14 additions & 1 deletion src/components/track.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down
6 changes: 3 additions & 3 deletions src/controllers/item-interactions-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
ActionEvent,
DeleteEvent,
FocusChangeEvent,
FocusChangeLocation,
Direction,
} from "../core/events.js";

export class ItemInteractionsController implements ReactiveController {
Expand Down Expand Up @@ -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() {
Expand Down
76 changes: 76 additions & 0 deletions src/controllers/pointer-controller.ts
Original file line number Diff line number Diff line change
@@ -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<PointerControllerConfig>
) {
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);
};
}
20 changes: 18 additions & 2 deletions src/core/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ declare global {
[DeleteEvent.type]: DeleteEvent;
[FocusChangeEvent.type]: FocusChangeEvent;
[ItemFocusEvent.type]: ItemFocusEvent;
[ItemChangeEvent.type]: ItemFocusEvent;
}
}

Expand Down Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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;
}
}
22 changes: 22 additions & 0 deletions src/core/point.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit 605d328

Please sign in to comment.