mirror of
https://github.com/annimon/obsidian-timelive.git
synced 2026-07-22 05:45:09 +00:00
Time spans support (#2)
* Add spans support * Add unit tests * Update docs
This commit is contained in:
parent
92544845fe
commit
1a599f9555
8 changed files with 211 additions and 27 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
1
info.md
1
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
25
src/TimeEvent.ts
Normal file
25
src/TimeEvent.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
114
src/Timelive.ts
114
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, "<hr/>");
|
||||
merged[merged.length - 1].content += this.formatSingleEvent(event, "<hr/>");
|
||||
} else {
|
||||
lastPosition = event.position;
|
||||
merged.push(event);
|
||||
|
|
@ -106,13 +169,20 @@ export class Timelive {
|
|||
return merged;
|
||||
}
|
||||
|
||||
private formatEvent(event: TimeEvent, before = ""): string {
|
||||
private formatSingleEvent(event: SingleTimeEvent, before = ""): string {
|
||||
const date = this.transformer.formatter.formatDate(event.date);
|
||||
return before +
|
||||
`<h4 class="tlv-date-title">${date}</h4>` +
|
||||
event.content;
|
||||
}
|
||||
|
||||
private formatSpan(event: SpanTimeEvent, before = ""): string {
|
||||
const date = this.transformer.formatter.formatSpan(event.fromDate, event.toDate);
|
||||
return before +
|
||||
`<h4 class="tlv-date-title">${date}</h4>` +
|
||||
event.content;
|
||||
}
|
||||
|
||||
private calculatePosition(date: Moment): number {
|
||||
const deltaDays = date.diff(this.startDateTime, "days");
|
||||
const value = (deltaDays / this.totalDays) * 100;
|
||||
|
|
|
|||
19
styles.css
19
styles.css
|
|
@ -21,17 +21,28 @@
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
.tlv-marker {
|
||||
.tlv-marker,
|
||||
.tlv-span {
|
||||
position: absolute;
|
||||
background-color: var(--color-base-50);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tlv-marker {
|
||||
top: -5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--color-base-50);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
z-index: 3;
|
||||
}
|
||||
.tlv-span {
|
||||
top: -1px;
|
||||
height: 5px;
|
||||
border-radius: 6px;
|
||||
z-index: 2;
|
||||
background-color: var(--color-base-60);
|
||||
}
|
||||
.tlv-span h4.tlv-date-title,
|
||||
.tlv-marker h4.tlv-date-title {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,3 +61,66 @@ Deno.test("DateParser", async (test) => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("DateParser spans", async (test) => {
|
||||
// @ts-ignore: deno lack of type
|
||||
const expectedFrom = moment("2008-02-09").toDate();
|
||||
const expectedTo = moment("2012-09-12").toDate();
|
||||
|
||||
await test.step("should parse spans with dates in ymd format", () => {
|
||||
const dates = [
|
||||
"2008-02-09 - 2012-09-12",
|
||||
"2008/02/09 - 2012 09 12",
|
||||
"2008-2-9 - 2012 9 12",
|
||||
"08-2-9 - 12 9 12",
|
||||
"2008-02/09 - 2012/09-12",
|
||||
];
|
||||
const dateParser: DateParser = new TimeliveDateParser(
|
||||
MockTimeliveSettings.ofParseDateFormat(DateFormat.YMD),
|
||||
);
|
||||
for (const dateStr of dates) {
|
||||
const result = dateParser.parseSpan(dateStr);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result[0].toDate()).toEqual(expectedFrom);
|
||||
expect(result[1].toDate()).toEqual(expectedTo);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("should parse spans with dates in dmy format", () => {
|
||||
const dates = [
|
||||
"09-02-2008 - 12-09-2012",
|
||||
"09/02/2008 - 12 09 2012",
|
||||
"9-2-2008 - 12 9 2012",
|
||||
"9-2-08 - 12 9 12",
|
||||
"09/02-2008 - 12-09/2012",
|
||||
];
|
||||
const dateParser: DateParser = new TimeliveDateParser(
|
||||
MockTimeliveSettings.ofParseDateFormat(DateFormat.DMY),
|
||||
);
|
||||
for (const dateStr of dates) {
|
||||
const result = dateParser.parseSpan(dateStr);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result[0].toDate()).toEqual(expectedFrom);
|
||||
expect(result[1].toDate()).toEqual(expectedTo);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("should parse spans with dates in mdy format", () => {
|
||||
const dates = [
|
||||
"02-09-2008 - 09-12-2012",
|
||||
"02/09/2008 - 09 12 2012",
|
||||
"2-9-2008 - 9 12 2012",
|
||||
"2-9-08 - 9 12 12",
|
||||
"02/09-2008 - 09-12/2012",
|
||||
];
|
||||
const dateParser: DateParser = new TimeliveDateParser(
|
||||
MockTimeliveSettings.ofParseDateFormat(DateFormat.MDY),
|
||||
);
|
||||
for (const dateStr of dates) {
|
||||
const result = dateParser.parseSpan(dateStr);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result[0].toDate()).toEqual(expectedFrom);
|
||||
expect(result[1].toDate()).toEqual(expectedTo);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue