mirror of
https://github.com/annimon/obsidian-timelive.git
synced 2026-07-22 05:45:09 +00:00
Initial commit
This commit is contained in:
commit
2c036048ef
14 changed files with 1759 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.vscode
|
||||
node_modules
|
||||
main.js
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 aNNiMON
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4
README.md
Normal file
4
README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Obsidian Timelive Plugin
|
||||
|
||||
Plugin for [Obsidian](https://obsidian.md) that turns a list of dates into a timeline.
|
||||
|
||||
39
esbuild.config.mjs
Normal file
39
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
6
info.md
Normal file
6
info.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
## Timelive plugin
|
||||
- |2025-02-05| Came up with the idea
|
||||
- |2025-02-15| Started learning Obsidian plugin development
|
||||
- |2025-02-19| First working prototype
|
||||
- |2025-02-21| Dealing with styles so it looks better on both light/dark themes
|
||||
- |present| Developing the `Timelive` plugin
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "timelive",
|
||||
"name": "Timelive",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Turn your dates list into a timeline.",
|
||||
"author": "aNNiMON",
|
||||
"authorUrl": "https://projects.annimon.com/",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "obsidian-timelive-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Turn your dates list into a timeline",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
1429
pnpm-lock.yaml
Normal file
1429
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
116
src/Timelive.ts
Normal file
116
src/Timelive.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
const DAYS_IN_YEAR = 365.2422;
|
||||
const MILLIS_IN_DAY = 1000 * 3600 * 24;
|
||||
const MILLIS_IN_YEAR = MILLIS_IN_DAY * DAYS_IN_YEAR;
|
||||
const MIN_YEAR_WIDTH_IN_PX = 120;
|
||||
|
||||
interface TimeEvent {
|
||||
date: Date;
|
||||
content: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export class Timelive {
|
||||
private root: HTMLElement;
|
||||
private events: TimeEvent[] = [];
|
||||
// For timeline computation
|
||||
private minDate?: Date;
|
||||
private maxDate?: Date;
|
||||
private startDateTime: number = 0;
|
||||
private totalDays: number = 0;
|
||||
|
||||
constructor(root: HTMLElement) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
public addEvent(dateString: string, content: string) {
|
||||
const date = this.parseDate(dateString.toLowerCase());
|
||||
this.events.push({ date, content, position: 0 });
|
||||
// Recalculate min/max dates
|
||||
const time = date.getTime();
|
||||
if (!this.minDate) {
|
||||
this.minDate = date;
|
||||
this.maxDate = date;
|
||||
this.startDateTime = time;
|
||||
} else {
|
||||
if (time < this.minDate.getTime()) {
|
||||
this.minDate = date;
|
||||
this.startDateTime = time;
|
||||
} else if (time > this.maxDate!.getDate()) {
|
||||
this.maxDate = date;
|
||||
} else return; // no updates to min/max dates -> skip
|
||||
}
|
||||
// Recalculate total days
|
||||
const deltaYears = 1 + this.maxDate!.getFullYear() - this.minDate.getFullYear();
|
||||
this.totalDays = deltaYears * DAYS_IN_YEAR;
|
||||
}
|
||||
|
||||
public render() {
|
||||
this.root.innerHTML = "";
|
||||
this.root.style.minWidth = "100%";
|
||||
this.renderYears();
|
||||
this.renderLine();
|
||||
}
|
||||
|
||||
private renderYears() {
|
||||
const yearsContainer = this.root.createDiv({ cls: "tlv-years" });
|
||||
const fromYear = this.minDate?.getFullYear() ?? (new Date().getFullYear());
|
||||
const toYear = 1 + (this.maxDate?.getFullYear() ?? fromYear);
|
||||
for (let year = fromYear; year <= toYear; year++) {
|
||||
yearsContainer.createSpan({ text: `${year}` });
|
||||
}
|
||||
}
|
||||
|
||||
private renderLine() {
|
||||
const timelineLine = this.root.createDiv({ cls: "tlv-timeline" });
|
||||
if (this.minDate) {
|
||||
const highlight = timelineLine.createDiv({ cls: "tlv-timeline-highlight" });
|
||||
const start = this.calculatePosition(this.minDate);
|
||||
const width = this.calculatePosition(this.maxDate!) - start;
|
||||
highlight.style.left = `${start}%`;
|
||||
highlight.style.width = `${width}%`;
|
||||
const totalYearsWidth = MIN_YEAR_WIDTH_IN_PX * this.totalDays / DAYS_IN_YEAR;
|
||||
if (this.root.innerWidth < totalYearsWidth) {
|
||||
this.root.style.width = `${Math.floor(totalYearsWidth)}px`;
|
||||
}
|
||||
}
|
||||
this.events
|
||||
.map((e) => this.updatePosition(e))
|
||||
.sort((a, b) => a.position - b.position)
|
||||
// TODO: merge closest 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" });
|
||||
const date = event.date.toLocaleDateString();
|
||||
popup.innerHTML = `<h4 class="tlv-date-title">${date}</h4>` + event.content;
|
||||
marker.onmouseover = marker.ontouchstart = () => {
|
||||
popup.style.display = "block";
|
||||
};
|
||||
marker.onmouseout = marker.ontouchend = () => {
|
||||
popup.style.display = "none";
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private updatePosition(event: TimeEvent): TimeEvent {
|
||||
event.position = this.calculatePosition(event.date);
|
||||
return event;
|
||||
}
|
||||
|
||||
private calculatePosition(date: Date): number {
|
||||
const start = Math.floor(this.startDateTime / MILLIS_IN_YEAR) * MILLIS_IN_YEAR;
|
||||
const deltaDays = (date.getTime() - start) / MILLIS_IN_DAY;
|
||||
const value = (deltaDays / this.totalDays) * 100;
|
||||
// Round to 3 decimal points
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
private parseDate(dateString: string): Date {
|
||||
// TODO: better date parsing
|
||||
if (/now|today|present/.test(dateString)) {
|
||||
return new Date();
|
||||
}
|
||||
return new Date(dateString);
|
||||
}
|
||||
}
|
||||
27
src/main.ts
Normal file
27
src/main.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Plugin } from "obsidian";
|
||||
import { Timelive } from "./Timelive";
|
||||
|
||||
const REGEX_COMMON: RegExp = /^\|(.{3,30}?)\|/;
|
||||
const REGEX_COMMON_REPLACE: RegExp = /^\s*<span.*?\/span>\|(.{3,30}?)\|\s*/gm;
|
||||
|
||||
export default class TimelivePlugin extends Plugin {
|
||||
override onload() {
|
||||
this.registerMarkdownPostProcessor((element, _context) => {
|
||||
const ul = element.find("ul");
|
||||
if (!ul) return;
|
||||
|
||||
const timelive = new Timelive(ul);
|
||||
for (const li of ul.findAll("li")) {
|
||||
// All list items must match a pattern
|
||||
const m = li.innerText.match(REGEX_COMMON);
|
||||
if (!m) return;
|
||||
|
||||
timelive.addEvent(
|
||||
m[1],
|
||||
li.innerHTML.replace(REGEX_COMMON_REPLACE, ""),
|
||||
);
|
||||
}
|
||||
timelive.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
43
styles.css
Normal file
43
styles.css
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
.tlv-years {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
column-gap: 0.5rem;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.tlv-timeline {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
margin: 0.8rem 0 0.6rem 0;
|
||||
background-color: var(--color-base-30);
|
||||
}
|
||||
.tlv-timeline-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: var(--color-accent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tlv-marker {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--color-base-50);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
}
|
||||
.tlv-marker h4.tlv-date-title {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.tlv-popup.popover.hover-popover {
|
||||
display: none;
|
||||
top: 1.8rem;
|
||||
padding: var(--size-4-3);
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue