diff --git a/README.md b/README.md index 4798217..c8707d5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ Plugin for [Obsidian](https://obsidian.md) that turns a list of dates into a tim The key difference from other timeline plugins is that Timelive doesn't use a code block. It's much easier to format any date event, provide a link or block of code, add an image or a video, etc. -To build a timeline, you need to define a list of dates, enclosed by the `|` symbol. The date format is automatically detected, but in case of ambiguity, the preferred format can be selected in the preferences. +To build a timeline, you need to define a list of dates, enclosed by the `|` symbol. The date format is automatically detected, but in case of ambiguity, the preferred format can be selected in the preferences. + +There is also support for time spans. Just define two dates separated by ` - ` and they will be turned into a span. ``` ## Demo @@ -14,6 +16,7 @@ To build a timeline, you need to define a list of dates, enclosed by the `|` sym - |2021/04/28| Second event - |21 6 12| Third event It's also a valid date +- |2018-07-01 - 2021-11-14| Time span - |2025-02-05| **bold**, *italic*, ~~strikethrough~~, link to another note [[Welcome]] - |2025-02-06| Merged events Test links and multiline code: @@ -26,6 +29,7 @@ To build a timeline, you need to define a list of dates, enclosed by the `|` sym column-gap: 0.5rem; color: var(--text-normal); } + ``` - |now| Dynamic event, means that the timeline (e.g. your project) is **live** - |2056-05-24| Some future event ``` diff --git a/info.md b/info.md index 15c37e1..353b5a6 100644 --- a/info.md +++ b/info.md @@ -6,4 +6,5 @@ - |2025-02-24| Improved years line rendering - |2025-03-01| Added settings, pre-release - |2025-03-02| Switched to Deno, added an editor command +- |2025-03-28 - 2025-04-06| Implementing time spans - |present| Developing the `Timelive` plugin diff --git a/src/DateFormatter.ts b/src/DateFormatter.ts index 8806efd..3f40eb3 100644 --- a/src/DateFormatter.ts +++ b/src/DateFormatter.ts @@ -2,6 +2,7 @@ import type { Moment } from "moment"; import { TimeliveSettings } from "./TimeliveSettings.ts"; export interface DateFormatter { + formatSpan(fromDate: Moment, toDate: Moment): string; formatDate(date: Moment): string; } @@ -12,6 +13,10 @@ export class TimeliveDateFormatter implements DateFormatter { this.settings = settings; } + public formatSpan(fromDate: Moment, toDate: Moment): string { + return this.formatDate(fromDate) + " - " + this.formatDate(toDate); + } + public formatDate(date: Moment): string { return date.format(this.settings.previewTitleDateFormat); } diff --git a/src/DateParser.ts b/src/DateParser.ts index 378fab1..a54fb46 100644 --- a/src/DateParser.ts +++ b/src/DateParser.ts @@ -2,6 +2,7 @@ import type { Moment } from "moment"; import { TimeliveSettings } from "./TimeliveSettings.ts"; export interface DateParser { + parseSpan(datesString: string): Moment[]; parseDate(dateString: string): Moment; } @@ -41,6 +42,10 @@ export class TimeliveDateParser implements DateParser { this.settings = settings; } + public parseSpan(datesString: string): Moment[] { + return datesString.split(" - ").map((d) => this.parseDate(d.trim())); + } + public parseDate(dateString: string): Moment { // @ts-ignore: deno lack of type const moment: MomentCallable = globalThis.moment; diff --git a/src/TimeEvent.ts b/src/TimeEvent.ts new file mode 100644 index 0000000..7900eb2 --- /dev/null +++ b/src/TimeEvent.ts @@ -0,0 +1,25 @@ +import type { Moment } from "moment"; + +export enum TimeEventType { + Single = "single", + Span = "span", +} + +export interface TimeEvent { + content: string; + type: TimeEventType; +} + +export interface SingleTimeEvent extends TimeEvent { + date: Moment; + position: number; + type: TimeEventType.Single; +} + +export interface SpanTimeEvent extends TimeEvent { + fromDate: Moment; + toDate: Moment; + position: number; + width: number; + type: TimeEventType.Span; +} diff --git a/src/Timelive.ts b/src/Timelive.ts index d9b216a..ef64e9f 100644 --- a/src/Timelive.ts +++ b/src/Timelive.ts @@ -1,14 +1,9 @@ import type { Moment } from "moment"; import { DateTransformer } from "./DateTransformer.ts"; +import { SingleTimeEvent, SpanTimeEvent, TimeEvent, TimeEventType } from "./TimeEvent.ts"; const DAYS_IN_YEAR = 365.2422; -interface TimeEvent { - date: Moment; - content: string; - position: number; -} - export class Timelive { private root: HTMLElement; private transformer: DateTransformer; @@ -25,9 +20,45 @@ export class Timelive { } public addEvent(dateString: string, content: string) { - const date = this.transformer.parser.parseDate(dateString.trim().toLowerCase()); - this.events.push({ date, content, position: 0 }); - // Recalculate min/max dates + const dates = this.transformer.parser.parseSpan(dateString.trim().toLowerCase()); + switch (dates.length) { + case 0: + return; + case 1: + return this.addSingleEvent(dates[0], content); + default: + return this.addSpan(dates[0], dates[1], content); + } + } + + private addSingleEvent(date: Moment, content: string) { + this.events.push( + { + date, + content, + position: 0, + type: TimeEventType.Single, + } as SingleTimeEvent, + ); + this.recalculateMinMaxDates(date); + } + + private addSpan(fromDate: Moment, toDate: Moment, content: string) { + this.events.push( + { + fromDate, + toDate, + content, + position: 0, + width: 0, + type: TimeEventType.Span, + } as SpanTimeEvent, + ); + this.recalculateMinMaxDates(fromDate); + this.recalculateMinMaxDates(toDate); + } + + private recalculateMinMaxDates(date: Moment) { const time = date.valueOf(); if (!this.minDate || !this.maxDate) { this.minDate = date; @@ -74,30 +105,62 @@ export class Timelive { } this.mergeClosestEvents(this.events) .forEach((event) => { - const marker = timelineLine.createDiv({ cls: "tlv-marker" }); - marker.style.left = `${event.position}%`; - - const popup = marker.createDiv({ cls: "tlv-popup popover hover-popover" }); - popup.innerHTML = this.formatEvent(event); - marker.onmouseover = marker.ontouchstart = () => { - popup.style.display = "block"; - }; - marker.onmouseout = marker.ontouchend = () => { - popup.style.display = "none"; - }; + switch (event.type) { + case TimeEventType.Single: + this.createMarker(timelineLine, event as SingleTimeEvent); + break; + case TimeEventType.Span: + this.createSpan(timelineLine, event as SpanTimeEvent); + break; + } }); } + private createMarker(line: HTMLElement, event: SingleTimeEvent) { + const marker = line.createDiv({ cls: "tlv-marker" }); + marker.style.left = `${event.position}%`; + this.createPopover(marker, this.formatSingleEvent(event)); + } + + private createSpan(line: HTMLElement, event: SpanTimeEvent) { + const span = line.createDiv({ cls: "tlv-span" }); + span.style.left = `${event.position}%`; + span.style.width = `${event.width}%`; + this.createPopover(span, this.formatSpan(event)); + } + + private createPopover(marker: HTMLElement, html: string) { + const popup = marker.createDiv({ cls: "tlv-popup popover hover-popover" }); + popup.innerHTML = html; + marker.onmouseover = marker.ontouchstart = () => { + popup.style.display = "block"; + }; + marker.onmouseout = marker.ontouchend = () => { + popup.style.display = "none"; + }; + } + private mergeClosestEvents(events: TimeEvent[]): TimeEvent[] { const CLOSEST_DELTA = 1; // % const merged: TimeEvent[] = []; let lastPosition: number; events + .filter((event) => event.type === TimeEventType.Span) + .map((event) => event as SpanTimeEvent) + .sort((a, b) => a.fromDate.valueOf() - b.fromDate.valueOf()) + .forEach((event) => { + event.position = this.calculatePosition(event.fromDate); + event.width = this.calculatePosition(event.toDate) - event.position; + merged.push(event); + }); + events + .filter((event) => event.type === TimeEventType.Single) + .map((event) => event as SingleTimeEvent) .sort((a, b) => a.date.valueOf() - b.date.valueOf()) .forEach((event, i) => { event.position = this.calculatePosition(event.date); if (i > 0 && (event.position - lastPosition) < CLOSEST_DELTA) { - merged[merged.length - 1].content += this.formatEvent(event, "