From f2bef14f203ff07218caa3cf82f9681180f8446e Mon Sep 17 00:00:00 2001 From: Jareika Date: Fri, 2 Jan 2026 18:11:41 +0100 Subject: [PATCH] New timeline design Horizontal timeline added with bases support. --- README.md | 180 +++-- main.js | 1300 +++++++++++++++++++++++----------- manifest.json | 2 +- package.json | 2 +- src/main.ts | 1887 +++++++++++++++++++++++++++++++++---------------- styles.css | 145 +++- versions.json | 3 +- 7 files changed, 2419 insertions(+), 1100 deletions(-) diff --git a/README.md b/README.md index 3993336..5f0d349 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,128 @@ -# TTRPG Tools: Timeline +# Simple Timeline (Obsidian Plugin) -Simple Timeline is an Obsidian plugin that renders visual timelines from note frontmatter. +Simple Timeline renders timelines from note frontmatter. It can be used in: -It reads `fc-date` / `fc-end` fields (compatible with [Calendarium]-style frontmatter) and shows each note as a card: image on the left, a callout‑like box on the right, with native Obsidian popovers on hover. +- Markdown code blocks (`timeline-cal`, `timeline-h`) +- Bases views (optional integration) -## Features +It is designed to be theme-friendly and configurable. -- Uses `fc-date` / `fc-end` from note frontmatter. -- “Cross” layout: - - Left: fixed image with rounded corners. - - Right: fixed‑height card using your theme’s H1/H4 styles. -- Native Obsidian page preview: - - Hover over image or card to open the note preview. - - Works without holding Ctrl. -- Responsive layout: - - Wide screens: image left, card right. - - Narrow screens: image stacked on top of the card, behaving like a single combined “card”. -- Summary handling: - - Optional `tl-summary` frontmatter (multi‑line YAML `|` / `|-` supported). - - If missing, the plugin uses the first meaningful paragraph from the note body. - - Summary is clamped to a configurable number of lines with a native multi‑line ellipsis. -- Colors and sizing: - - Global defaults for image size, card height, paddings and colors. - - Per‑timeline overrides (including title/date color and hover background). -- Month names: - - English by default (`January`…`December`). - - Custom month names per timeline (useful for fantasy calendars). -- No runtime dependency on Calendarium: - - Just reuses its `fc-date` / `fc-end` convention. - -## Installation - -Until it is available in the community plugin browser, you can install it manually: - -1. Build the plugin (or download a release ZIP, if available). -2. Copy the contents (`main.js`, `manifest.json`, `styles.css` and `versions.json`) into a folder named `simple-timeline` inside your vault’s `.obsidian/plugins` directory. -3. Enable **Simple Timeline** in *Settings → Community plugins*. -4. Custom month names: In your timeline settings you can write down your own fantasy or whatever month names, just separate them with commas (,). - -Minimum Obsidian version: `1.6.7`. - -## Usage - -### 1. Frontmatter in your notes - -Add at least `fc-date` to any note you want to see on a timeline: - -```yaml --- -fc-date: 1165-04-01 # required -fc-end: 1165-04-03 # optional (end of range) -timelines: [Travel] # one or more timeline names -tl-title: Arrival in Hollowhome 2 # optional display title -tl-summary: |- # optional custom summary - Late in the afternoon we reached the old cemetery... - The fence was swallowed by vines... -tl-image: assets/hollowhome.jpg # optional image override ---- -``` -### 2. YAML Codeblock to display the timeline -```yaml +## Install / Enable + +1. Install the plugin (manual or via BRAT / community store if available). +2. Enable it in **Settings → Community plugins**. +3. Enable **Page Preview** (recommended) to use hover popovers. + +--- + +## Required Frontmatter + +A note becomes a timeline entry when it has `fc-date`. + +### Minimal +fc-date: 1165-03-01 +fc-end: 1165-03-03 +timelines: [Travelbook 1] +tl-title: Arrival in New York +tl-summary: |- + We arrived after two days of travel. + Weather: rain and wind. +tl-image: assets/my-image.png + +Image can be: +- an external URL (https://...) +- an internal link/path to a vault file + +Image selection priority +The plugin tries to find an image in this order: +- tl-image (frontmatter) +- first internal embed image (![[...]]) +- first markdown image link (![](…)) +- first image file in the note folder + +## Markdown Code Blocks + +A) Vertical “Cross” layout (default renderer) +~~~ ```timeline-cal -name/names: YourTimeline, YourSecondTimelineIfYouLikeToShowTwo...orMore -``` \ No newline at end of file +names: Travelbook 1, Travelbook 2 +jumpToToday: true +``` +~~~ + +Shows one entry per row (image + callout box). + +B) Horizontal timeline +~~~ +```timeline-h +names: Reisebuch 1, Reisebuch 2 +mode: stacked # stacked | mixed +jumpToToday: false +``` +~~~ + +Modes: +- mixed: all entries from all timelines in one horizontal row (chronological) +- stacked: one horizontal row per timeline; aligned by the union of existing dates (gaps appear only when a timeline has no entry for a date that another timeline has) + +## Settings (Global + Per Timeline) +You can configure: +- image width / height +- box height +- inner left/right padding +- max summary lines (multi-line ellipsis) +- colors (background, border, hover, title, date) +- per-timeline month names (manual) (name, name,...) +Per-timeline configuration is stored under a timeline key (the same name you use in timelines:). + +## Calendarium “Today” (optional) +If the Calendarium plugin is installed, the Timeline UI can jump to “today”: + +In Markdown blocks: jumpToToday: true +In Bases views: option jumpToToday: "true" +If Calendarium is not installed, the button will show a notice. + +## Bases Integration (optional) +Enable it in plugin settings: + +Settings → Simple Timeline → Bases integration +After enabling, restart Obsidian or reload the plugin (required for view registration). + +A) Bases view: Timeline (Cross) +View type: +simple-timeline-cross +B) Bases view: Timeline (Horizontal) +View type: +simple-timeline-horizontal + +Common options (property mapping): +- timelineConfig (optional, forces one style config for all cards) +- timelineProperty (used when timelineConfig is empty) +- startProperty, endProperty +- titleProperty, summaryProperty, imageProperty +- orderMode: bases | start-asc | start-desc +- jumpToToday: "true" | "false" + +Horizontal-only option: +- mode: stacked | mixed + + +Example YAML for Bases: +~~~ +```base +views: + - type: simple-timeline-horizontal # simple-timeline-cross + name: Timeline (mixed) + timelineConfig: "" + startProperty: note.fc-date + endProperty: note.fc-end + titleProperty: note.tl-title + summaryProperty: note.tl-summary + imageProperty: note.tl-image + orderMode: start-asc +``` +~~~ + + diff --git a/main.js b/main.js index dd34205..e44dc54 100644 --- a/main.js +++ b/main.js @@ -81,8 +81,111 @@ function toCommaListString(v) { } return void 0; } +function normalizeStringArray(v) { + if (typeof v === "string") { + return v.split(",").map((s) => s.trim()).filter(Boolean); + } + if (Array.isArray(v)) { + return v.map((x) => primitiveToString(x)).filter((x) => typeof x === "string").map((s) => s.trim()).filter(Boolean); + } + return []; +} +function splitTimelineList(raw) { + const cleaned = raw.replace(/[\]["]/g, ""); + return cleaned.split(/[,;\n]+/g).map((s) => s.trim()).filter(Boolean); +} +function ymdSortKey(v) { + return v.y * 1e4 + v.m * 100 + v.d; +} +function parseBasesOrderMode(raw) { + const t = raw.trim().toLowerCase(); + if (t === "start-desc" || t === "desc") return "start-desc"; + if (t === "start-asc" || t === "asc") return "start-asc"; + return "bases"; +} +function parseHorizontalMode(v) { + const s = primitiveToString(v)?.trim().toLowerCase(); + if (s === "mixed" || s === "stacked") return s; + return void 0; +} var BASES_VIEW_TYPE_CROSS = "simple-timeline-cross"; +var BASES_VIEW_TYPE_HORIZONTAL = "simple-timeline-horizontal"; var SimpleTimeline = class extends import_obsidian.Plugin { + getCalendariumCurrentYmd() { + const plugins = this.app.plugins; + if (!isRecord(plugins)) return null; + const getPlugin = plugins["getPlugin"]; + if (!isFunction(getPlugin)) return null; + const calendarium = getPlugin.call(plugins, "calendarium"); + if (!isRecord(calendarium)) return null; + const apiRoot = calendarium["api"]; + if (!isRecord(apiRoot)) return null; + const directGetCurrentDate = apiRoot["getCurrentDate"]; + if (isFunction(directGetCurrentDate)) { + const raw2 = directGetCurrentDate.call(apiRoot); + if (isRecord(raw2)) { + const y2 = Number(raw2["year"]); + const monthZeroBased2 = Number(raw2["month"]); + const d2 = Number(raw2["day"]); + if (Number.isFinite(y2) && Number.isFinite(monthZeroBased2) && Number.isFinite(d2) && y2 !== 0) { + return { y: y2, m: monthZeroBased2 + 1, d: d2 }; + } + } + } + const getAPI = apiRoot["getAPI"]; + if (!isFunction(getAPI)) return null; + let calendarApi; + try { + calendarApi = getAPI.call(apiRoot); + } catch { + return null; + } + if (!isRecord(calendarApi)) return null; + const getCurrentDate = calendarApi["getCurrentDate"]; + if (!isFunction(getCurrentDate)) return null; + const raw = getCurrentDate.call(calendarApi); + if (!isRecord(raw)) return null; + const y = Number(raw["year"]); + const monthZeroBased = Number(raw["month"]); + const d = Number(raw["day"]); + if (Number.isFinite(y) && Number.isFinite(monthZeroBased) && Number.isFinite(d) && y !== 0) { + return { y, m: monthZeroBased + 1, d }; + } + return null; + } + jumpContainerToYmd(containerEl, ymd, selector = ".tl-row") { + const targetKey = ymdSortKey(ymd); + const rows = Array.from(containerEl.querySelectorAll(selector)); + let exact = null; + let nextAfter = null; + let lastBefore = null; + for (const row of rows) { + const startKeyRaw = row.dataset.tlStartKey; + if (!startKeyRaw) continue; + const startKey = Number(startKeyRaw); + if (!Number.isFinite(startKey)) continue; + const endKeyRaw = row.dataset.tlEndKey; + const endKey = endKeyRaw ? Number(endKeyRaw) : startKey; + const endKeyOk = Number.isFinite(endKey) ? endKey : startKey; + if (targetKey >= startKey && targetKey <= endKeyOk) { + exact = row; + break; + } + if (startKey >= targetKey) { + if (!nextAfter || startKey < nextAfter.key) nextAfter = { key: startKey, el: row }; + } else { + if (!lastBefore || startKey > lastBefore.key) lastBefore = { key: startKey, el: row }; + } + } + const target = exact ?? nextAfter?.el ?? lastBefore?.el; + if (!target) return false; + try { + target.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" }); + } catch { + target.scrollIntoView(); + } + return true; + } async onload() { await this.loadSettings(); await this.migrateLegacyToTimelineConfigs(); @@ -91,6 +194,10 @@ var SimpleTimeline = class extends import_obsidian.Plugin { "timeline-cal", (src, el, ctx) => this.renderTimeline(src, el, ctx) ); + this.registerMarkdownCodeBlockProcessor( + "timeline-h", + (src, el, ctx) => this.renderTimelineHorizontal(src, el, ctx) + ); this.addCommand({ id: "set-cal-date", name: "Timeline set date", @@ -169,21 +276,18 @@ var SimpleTimeline = class extends import_obsidian.Plugin { title: "Set fc-end (optional)", placeholder: "leave empty for no end" }) : null; - await this.app.fileManager.processFrontMatter( - file, - (fm) => { - try { - fm["fc-date"] = this.tryParseYamlOrString(start); - if (range && end) { - fm["fc-end"] = this.tryParseYamlOrString(end); - } else if (!range) { - delete fm["fc-end"]; - } - } catch { - new import_obsidian.Notice("Invalid date."); + await this.app.fileManager.processFrontMatter(file, (fm) => { + try { + fm["fc-date"] = this.tryParseYamlOrString(start); + if (range && end) { + fm["fc-end"] = this.tryParseYamlOrString(end); + } else if (!range) { + delete fm["fc-end"]; } + } catch { + new import_obsidian.Notice("Invalid date."); } - ); + }); } async promptEditTimelines(file) { const curVal = this.getFrontmatterValue(file, "timelines"); @@ -195,12 +299,9 @@ var SimpleTimeline = class extends import_obsidian.Plugin { }); if (val == null) return; const arr = String(val).split(",").map((s) => s.trim()).filter(Boolean); - await this.app.fileManager.processFrontMatter( - file, - (fm) => { - fm["timelines"] = arr; - } - ); + await this.app.fileManager.processFrontMatter(file, (fm) => { + fm["timelines"] = arr; + }); } async promptSetSummary(file) { const curVal = this.getFrontmatterValue(file, "tl-summary"); @@ -211,12 +312,9 @@ var SimpleTimeline = class extends import_obsidian.Plugin { placeholder: "Multi-line allowed (YAML | or |- in frontmatter)" }); if (val == null) return; - await this.app.fileManager.processFrontMatter( - file, - (fm) => { - fm["tl-summary"] = String(val); - } - ); + await this.app.fileManager.processFrontMatter(file, (fm) => { + fm["tl-summary"] = String(val); + }); } async adoptFirstImage(file) { const link = this.findImageForFile(file); @@ -224,12 +322,9 @@ var SimpleTimeline = class extends import_obsidian.Plugin { new import_obsidian.Notice("No image found."); return; } - await this.app.fileManager.processFrontMatter( - file, - (fm) => { - fm["tl-image"] = link; - } - ); + await this.app.fileManager.processFrontMatter(file, (fm) => { + fm["tl-image"] = link; + }); new import_obsidian.Notice("Timeline image set from first image."); } tryParseYamlOrString(input) { @@ -265,10 +360,10 @@ var SimpleTimeline = class extends import_obsidian.Plugin { return; } const BasesViewCtor = maybeBasesView; - class TimelineBasesCrossView extends BasesViewCtor { + class TimelineBasesBaseView extends BasesViewCtor { constructor(controller, parentEl, plugin) { super(controller); - this.type = BASES_VIEW_TYPE_CROSS; + this.renderToken = 0; this.plugin = plugin; this.hostEl = parentEl.createDiv({ cls: "tl-bases-host" }); setCssProps(this.hostEl, { @@ -282,74 +377,32 @@ var SimpleTimeline = class extends import_obsidian.Plugin { return typeof v === "string" ? v : fallback; } getGroupKeyText(v) { - return primitiveToString(v) ?? ""; - } - onDataUpdated() { - this.hostEl.empty(); - const wrapper = this.hostEl.createDiv({ - cls: "tl-wrapper tl-cross-mode" - }); - const timelineConfigNameRaw = this.getOptionString("timelineConfig", "").trim(); - const timelineConfigName = timelineConfigNameRaw || void 0; - const startProp = this.getOptionString("startProperty", "note.fc-date"); - const endProp = this.getOptionString("endProperty", "note.fc-end"); - const titleProp = this.getOptionString("titleProperty", "note.tl-title"); - const summaryProp = this.getOptionString( - "summaryProperty", - "note.tl-summary" - ); - const imageProp = this.getOptionString("imageProperty", "note.tl-image"); - const cfg = this.plugin.getConfigFor(timelineConfigName); - const months = this.plugin.getMonths(timelineConfigName); - const groups = this.data?.groupedData ?? []; - for (const group of groups) { - const keyText = this.getGroupKeyText(group.key); - if (keyText && keyText !== "null") { - const h = wrapper.createEl("h3", { text: keyText }); - h.addClass("tl-bases-group-title"); - } - const entries = group.entries ?? []; - for (const entry of entries) { - const maybeFile = entry.file; - if (!(maybeFile instanceof import_obsidian.TFile)) continue; - const file = maybeFile; - const startValue = entry.getValue?.(startProp); - const start = this.valueToYmd(startValue); - if (!start) continue; - const endValue = endProp ? entry.getValue?.(endProp) : null; - const end = this.valueToYmd(endValue) ?? void 0; - const titleValue = titleProp ? entry.getValue?.(titleProp) : null; - const title = titleValue && !titleValue.isEmpty?.() ? String(titleValue.toString()) : file.basename; - let summary; - const summaryValue = summaryProp ? entry.getValue?.(summaryProp) : null; - if (summaryValue && !summaryValue.isEmpty?.()) { - summary = String(summaryValue.toString()); + const prim = primitiveToString(v); + if (prim != null) return prim; + if (isRecord(v)) { + const ts = v["toString"]; + if (isFunction(ts)) { + try { + const out = ts.call(v); + if (typeof out === "string" && out !== "[object Object]") return out; + } catch { } - const buildRow = async () => { - if (!summary) { - summary = await this.plugin.extractFirstParagraph(file); - } - const imgSrc = this.resolveImageFromEntryValue( - entry, - imageProp, - file.path - ); - const mNameStart = months[(start.m - 1 + months.length) % months.length] ?? String(start.m); - const mNameEnd = end ? months[(end.m - 1 + months.length) % months.length] ?? String(end.m) : void 0; - const card = { - file, - title, - summary, - start: { ...start, mName: mNameStart }, - end: end ? { ...end, mName: mNameEnd } : void 0, - imgSrc, - primaryTl: timelineConfigName - }; - this.renderCrossRow(wrapper, card, cfg); - }; - void buildRow(); } } + return ""; + } + getTimelineKeyFromEntry(entry, timelineProperty) { + if (!timelineProperty) return void 0; + const v = entry.getValue?.(timelineProperty); + if (!v || v.isEmpty?.()) return void 0; + const raw = String(v.toString?.() ?? "").trim(); + if (!raw) return void 0; + const candidates = splitTimelineList(raw); + if (!candidates.length) return void 0; + for (const c of candidates) { + if (this.plugin.settings.timelineConfigs[c]) return c; + } + return candidates[0]; } getControllerFilePath() { const c = this.controller; @@ -362,8 +415,92 @@ var SimpleTimeline = class extends import_obsidian.Plugin { } return void 0; } - renderCrossRow(wrapper, c, cfg) { + resolveImageFromEntryValue(entry, imageProp, sourcePath) { + if (!imageProp) return void 0; + const v = entry.getValue?.(imageProp); + if (!v || v.isEmpty?.()) return void 0; + try { + if (typeof v.renderTo === "function") { + const tmp = document.createElement("div"); + try { + const renderContext = this.app.renderContext; + v.renderTo(tmp, renderContext ?? this.app); + } catch { + v.renderTo(tmp); + } + const img = tmp.querySelector("img"); + const src = img?.getAttribute("src") ?? void 0; + if (src) return src; + } + } catch { + } + const s = String(v.toString?.() ?? "").trim(); + if (!s) return void 0; + return this.plugin.resolveLinkToSrc(s, sourcePath); + } + valueToYmd(value) { + if (!value) return null; + if (typeof value.isEmpty === "function" && value.isEmpty()) return null; + const yRaw = value.year; + const mRaw = value.month; + const dRaw = value.day; + const y = Number(yRaw); + const m = Number(mRaw); + const d = Number(dRaw); + if (Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d) && y !== 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31) { + return { y, m, d }; + } + const raw = String(value.toString?.() ?? value).trim(); + const match = raw.match(/^(\d{1,6})-(\d{1,2})-(\d{1,2})/); + if (match) { + return { y: Number(match[1]), m: Number(match[2]), d: Number(match[3]) }; + } + const asNum = Number(raw); + if (Number.isFinite(asNum) && asNum > 0) { + const dt = new Date(asNum); + if (!Number.isNaN(dt.getTime())) { + return { y: dt.getFullYear(), m: dt.getMonth() + 1, d: dt.getDate() }; + } + } + return null; + } + buildItemFromEntry(entry, startProp, endProp, titleProp, summaryProp, imageProp, pos) { + const maybeFile = entry.file; + if (!(maybeFile instanceof import_obsidian.TFile)) return null; + const file = maybeFile; + const startValue = entry.getValue?.(startProp); + const start = this.valueToYmd(startValue); + if (!start) return null; + const endValue = endProp ? entry.getValue?.(endProp) : null; + const end = this.valueToYmd(endValue) ?? void 0; + const titleValue = titleProp ? entry.getValue?.(titleProp) : null; + const title = titleValue && !titleValue.isEmpty?.() ? String(titleValue.toString()) : file.basename; + let summary; + const summaryValue = summaryProp ? entry.getValue?.(summaryProp) : null; + if (summaryValue && !summaryValue.isEmpty?.()) { + summary = String(summaryValue.toString()); + } + const imgSrc = this.resolveImageFromEntryValue(entry, imageProp, file.path); + return { + entry, + file, + start, + end, + title, + summary, + imgSrc, + sortKey: ymdSortKey(start), + pos + }; + } + renderCrossCard(wrapper, c, cfg) { const row = wrapper.createDiv({ cls: "tl-row" }); + row.dataset.tlStartKey = String(ymdSortKey({ y: c.start.y, m: c.start.m, d: c.start.d })); + if (c.end) { + row.dataset.tlEndKey = String(ymdSortKey({ y: c.end.y, m: c.end.m, d: c.end.d })); + } else { + delete row.dataset.tlEndKey; + } const align = cfg.align ?? "left"; if (align === "right") row.addClass("tl-align-right"); const W = cfg.cardWidth; @@ -413,10 +550,7 @@ var SimpleTimeline = class extends import_obsidian.Plugin { "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" }); - const titleEl = box.createEl("h1", { - cls: "tl-title", - text: c.title - }); + const titleEl = box.createEl("h1", { cls: "tl-title", text: c.title }); const dateEl = box.createEl("h4", { cls: "tl-date", text: this.plugin.formatRange(c.start, c.end) @@ -441,12 +575,7 @@ var SimpleTimeline = class extends import_obsidian.Plugin { display: "block", pointerEvents: "auto" }); - this.plugin.attachHoverForAnchor( - aImg, - media, - c.file.path, - basesSourcePath - ); + this.plugin.attachHoverForAnchor(aImg, media, c.file.path, basesSourcePath); } const aBox = box.createEl("a", { cls: "internal-link tl-hover-anchor", @@ -462,59 +591,355 @@ var SimpleTimeline = class extends import_obsidian.Plugin { }); this.plugin.attachHoverForAnchor(aBox, box, c.file.path, basesSourcePath); this.plugin.applyFixedLineClamp(sum, cfg.maxSummaryLines); + return row; } - resolveImageFromEntryValue(entry, imageProp, sourcePath) { - if (!imageProp) return void 0; - const v = entry.getValue?.(imageProp); - if (!v || v.isEmpty?.()) return void 0; - try { - if (typeof v.renderTo === "function") { - const tmp = document.createElement("div"); - try { - const renderContext = this.app.renderContext; - v.renderTo(tmp, renderContext ?? this.app); - } catch { - v.renderTo(tmp); + getHorizontalEdges(c, cfg) { + const hasMedia = !!c.imgSrc; + const align = cfg.align ?? "left"; + if (!hasMedia) return { left: "box", right: "box" }; + if (align === "right") return { left: "box", right: "media" }; + return { left: "media", right: "box" }; + } + applyHorizontalJoin(a, b) { + if (a.right === "box") a.el.classList.add("tl-h-join-right-box"); + if (b.left === "box") b.el.classList.add("tl-h-join-left-box"); + } + async buildCardData(it, tlKey, months) { + let summary = it.summary; + if (!summary) { + summary = await this.plugin.extractFirstParagraph(it.file); + } + const mNameStart = months[(it.start.m - 1 + months.length) % months.length] ?? String(it.start.m); + const mNameEnd = it.end ? months[(it.end.m - 1 + months.length) % months.length] ?? String(it.end.m) : void 0; + return { + file: it.file, + title: it.title, + summary, + start: { ...it.start, mName: mNameStart }, + end: it.end ? { ...it.end, mName: mNameEnd } : void 0, + imgSrc: it.imgSrc, + primaryTl: tlKey + }; + } + } + class TimelineBasesCrossView extends TimelineBasesBaseView { + constructor() { + super(...arguments); + this.type = BASES_VIEW_TYPE_CROSS; + } + onDataUpdated() { + this.hostEl.empty(); + const controls = this.hostEl.createDiv({ cls: "tl-controls" }); + const todayBtn = controls.createEl("button", { text: "Today" }); + const wrapper = this.hostEl.createDiv({ cls: "tl-wrapper tl-cross-mode" }); + const timelineConfigNameRaw = this.getOptionString("timelineConfig", "").trim(); + const timelineConfigName = timelineConfigNameRaw || void 0; + const timelineProperty = this.getOptionString("timelineProperty", "note.timelines").trim(); + const startProp = this.getOptionString("startProperty", "note.fc-date"); + const endProp = this.getOptionString("endProperty", "note.fc-end"); + const titleProp = this.getOptionString("titleProperty", "note.tl-title"); + const summaryProp = this.getOptionString("summaryProperty", "note.tl-summary"); + const imageProp = this.getOptionString("imageProperty", "note.tl-image"); + const jumpToToday = this.getOptionString("jumpToToday", "false").trim().toLowerCase() === "true"; + todayBtn.addEventListener("click", () => { + const today = this.plugin.getCalendariumCurrentYmd(); + if (!today) { + new import_obsidian.Notice("Calendarium is not installed"); + return; + } + const ok = this.plugin.jumpContainerToYmd(wrapper, today); + if (!ok) new import_obsidian.Notice("No timeline entry found for today"); + }); + const orderMode = parseBasesOrderMode(this.getOptionString("orderMode", "bases")); + const token = ++this.renderToken; + const groups = this.data?.groupedData ?? []; + const hasMeaningfulGroups = groups.some((g) => { + const t = this.getGroupKeyText(g.key); + return !!t && t !== "null"; + }); + const renderItems = async (items2) => { + const list = [...items2]; + if (orderMode === "start-asc" || orderMode === "start-desc") { + const dir = orderMode === "start-desc" ? -1 : 1; + list.sort((a, b) => { + if (a.sortKey !== b.sortKey) return dir * (a.sortKey - b.sortKey); + return a.pos - b.pos; + }); + } + for (const it of list) { + if (token !== this.renderToken) return; + const tlKey = timelineConfigName ?? this.getTimelineKeyFromEntry(it.entry, timelineProperty); + const cfg = this.plugin.getConfigFor(tlKey); + const months = this.plugin.getMonths(tlKey); + const card = await this.buildCardData(it, tlKey, months); + if (token !== this.renderToken) return; + this.renderCrossCard(wrapper, card, cfg); + } + }; + if (hasMeaningfulGroups) { + void (async () => { + let pos2 = 0; + for (const group of groups) { + if (token !== this.renderToken) return; + const keyText = this.getGroupKeyText(group.key); + if (keyText && keyText !== "null") { + const h = wrapper.createEl("h3", { text: keyText }); + h.addClass("tl-bases-group-title"); + } + const groupItems = []; + for (const entry of group.entries ?? []) { + const it = this.buildItemFromEntry( + entry, + startProp, + endProp, + titleProp, + summaryProp, + imageProp, + pos2++ + ); + if (it) groupItems.push(it); + } + await renderItems(groupItems); } - const img = tmp.querySelector("img"); - const src = img?.getAttribute("src") ?? void 0; - if (src) return src; - } - } catch { + if (token !== this.renderToken) return; + if (jumpToToday) { + const today = this.plugin.getCalendariumCurrentYmd(); + if (today) this.plugin.jumpContainerToYmd(wrapper, today); + } + })(); + return; } - const s = String(v.toString?.() ?? "").trim(); - if (!s) return void 0; - return this.plugin.resolveLinkToSrc(s, sourcePath); + const items = []; + let pos = 0; + for (const group of groups) { + for (const entry of group.entries ?? []) { + const it = this.buildItemFromEntry( + entry, + startProp, + endProp, + titleProp, + summaryProp, + imageProp, + pos++ + ); + if (it) items.push(it); + } + } + void (async () => { + await renderItems(items); + if (token !== this.renderToken) return; + if (jumpToToday) { + const today = this.plugin.getCalendariumCurrentYmd(); + if (today) this.plugin.jumpContainerToYmd(wrapper, today); + } + })(); } - valueToYmd(value) { - if (!value) return null; - if (typeof value.isEmpty === "function" && value.isEmpty()) return null; - const yRaw = value.year; - const mRaw = value.month; - const dRaw = value.day; - const y = Number(yRaw); - const m = Number(mRaw); - const d = Number(dRaw); - if (Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d) && y !== 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31) { - return { y, m, d }; + } + class TimelineBasesHorizontalView extends TimelineBasesBaseView { + constructor() { + super(...arguments); + this.type = BASES_VIEW_TYPE_HORIZONTAL; + } + onDataUpdated() { + this.hostEl.empty(); + const controls = this.hostEl.createDiv({ cls: "tl-controls" }); + const todayBtn = controls.createEl("button", { text: "Today" }); + const scroller = this.hostEl.createDiv({ cls: "tl-h-scroller" }); + const timelineConfigNameRaw = this.getOptionString("timelineConfig", "").trim(); + const timelineConfigName = timelineConfigNameRaw || void 0; + const timelineProperty = this.getOptionString("timelineProperty", "note.timelines").trim(); + const startProp = this.getOptionString("startProperty", "note.fc-date"); + const endProp = this.getOptionString("endProperty", "note.fc-end"); + const titleProp = this.getOptionString("titleProperty", "note.tl-title"); + const summaryProp = this.getOptionString("summaryProperty", "note.tl-summary"); + const imageProp = this.getOptionString("imageProperty", "note.tl-image"); + const jumpToToday = this.getOptionString("jumpToToday", "false").trim().toLowerCase() === "true"; + const orderMode = parseBasesOrderMode(this.getOptionString("orderMode", "bases")); + const mode = parseHorizontalMode(this.getOptionString("mode", "stacked")) ?? "stacked"; + todayBtn.addEventListener("click", () => { + const today = this.plugin.getCalendariumCurrentYmd(); + if (!today) { + new import_obsidian.Notice("Calendarium is not installed"); + return; + } + const ok = this.plugin.jumpContainerToYmd(scroller, today, ".tl-h-item"); + if (!ok) new import_obsidian.Notice("No timeline entry found for today"); + }); + const token = ++this.renderToken; + const groups = this.data?.groupedData ?? []; + const hasMeaningfulGroups = groups.some((g) => { + const t = this.getGroupKeyText(g.key); + return !!t && t !== "null"; + }); + const renderHorizontalItems = async (items2, host) => { + const wrapper = host.createDiv({ + cls: mode === "stacked" ? "tl-h-content tl-horizontal tl-h-stacked" : "tl-h-content tl-horizontal tl-h-mixed" + }); + const list = [...items2]; + if (orderMode === "start-asc" || orderMode === "start-desc") { + const dir = orderMode === "start-desc" ? -1 : 1; + list.sort((a, b) => { + if (a.sortKey !== b.sortKey) return dir * (a.sortKey - b.sortKey); + return a.pos - b.pos; + }); + } + if (mode === "mixed") { + const rendered = []; + for (const it of list) { + if (token !== this.renderToken) return; + const tlKey = timelineConfigName ?? this.getTimelineKeyFromEntry(it.entry, timelineProperty); + const cfg = this.plugin.getConfigFor(tlKey); + const months = this.plugin.getMonths(tlKey); + const card = await this.buildCardData(it, tlKey, months); + if (token !== this.renderToken) return; + const rowEl = this.renderCrossCard(wrapper, card, cfg); + rowEl.addClass("tl-h-item"); + const edges = this.getHorizontalEdges(card, cfg); + rendered.push({ el: rowEl, ...edges }); + } + for (let i = 0; i < rendered.length - 1; i++) { + this.applyHorizontalJoin( + { el: rendered[i].el, right: rendered[i].right }, + { el: rendered[i + 1].el, left: rendered[i + 1].left } + ); + } + return; + } + const axisKeys = []; + const seen = /* @__PURE__ */ new Set(); + for (const it of list) { + const k = ymdSortKey(it.start); + if (!seen.has(k)) { + seen.add(k); + axisKeys.push(k); + } + } + if (orderMode === "start-asc") axisKeys.sort((a, b) => a - b); + if (orderMode === "start-desc") axisKeys.sort((a, b) => b - a); + const colByKey = /* @__PURE__ */ new Map(); + for (let i = 0; i < axisKeys.length; i++) colByKey.set(axisKeys[i], i + 1); + setCssProps(wrapper, { "--tl-h-cols": String(axisKeys.length) }); + const byTl = /* @__PURE__ */ new Map(); + for (const it of list) { + const tlKey = timelineConfigName ?? this.getTimelineKeyFromEntry(it.entry, timelineProperty); + const k = tlKey ?? "default"; + const arr = byTl.get(k); + if (arr) arr.push(it); + else byTl.set(k, [it]); + } + const tlKeys = orderMode === "bases" ? Array.from(byTl.keys()) : Array.from(byTl.keys()).sort(); + for (const tlKey of tlKeys) { + if (token !== this.renderToken) return; + const rowItems = byTl.get(tlKey) ?? []; + const cfg = this.plugin.getConfigFor(tlKey); + const months = this.plugin.getMonths(tlKey); + const rowWrap = wrapper.createDiv({ cls: "tl-h-timeline" }); + const rowGrid = rowWrap.createDiv({ cls: "tl-h-row" }); + setCssProps(rowGrid, { "--tl-h-cols": String(axisKeys.length) }); + const byDate = /* @__PURE__ */ new Map(); + for (const it of rowItems) { + const k = ymdSortKey(it.start); + const arr = byDate.get(k); + if (arr) arr.push(it); + else byDate.set(k, [it]); + } + const dateKeysSorted = Array.from(byDate.keys()).sort((a, b) => { + const ca = colByKey.get(a) ?? 0; + const cb = colByKey.get(b) ?? 0; + return ca - cb; + }); + const renderedSlots = []; + for (const dateKey of dateKeysSorted) { + if (token !== this.renderToken) return; + const col = colByKey.get(dateKey); + if (!col) continue; + const slot = rowGrid.createDiv({ cls: "tl-h-slot" }); + setCssProps(slot, { "--tl-h-col": String(col) }); + const cardsAtDate = byDate.get(dateKey) ?? []; + let stored = false; + for (const it of cardsAtDate) { + const card = await this.buildCardData(it, tlKey, months); + if (token !== this.renderToken) return; + const rowEl = this.renderCrossCard(slot, card, cfg); + rowEl.addClass("tl-h-item"); + if (!stored) { + const edges = this.getHorizontalEdges(card, cfg); + renderedSlots.push({ col, el: rowEl, ...edges }); + stored = true; + } + } + } + renderedSlots.sort((a, b) => a.col - b.col); + for (let i = 0; i < renderedSlots.length - 1; i++) { + const a = renderedSlots[i]; + const b = renderedSlots[i + 1]; + if (b.col === a.col + 1) { + this.applyHorizontalJoin({ el: a.el, right: a.right }, { el: b.el, left: b.left }); + } + } + } + }; + const renderGroup = async (groupItems, groupTitle) => { + if (groupTitle) { + const h = scroller.createDiv({ cls: "tl-bases-group-title" }); + h.createEl("h3", { text: groupTitle }); + } + const groupHost = scroller.createDiv({ cls: "tl-h-group" }); + await renderHorizontalItems(groupItems, groupHost); + }; + if (hasMeaningfulGroups) { + void (async () => { + let pos2 = 0; + for (const group of groups) { + if (token !== this.renderToken) return; + const groupItems = []; + for (const entry of group.entries ?? []) { + const it = this.buildItemFromEntry( + entry, + startProp, + endProp, + titleProp, + summaryProp, + imageProp, + pos2++ + ); + if (it) groupItems.push(it); + } + const title = this.getGroupKeyText(group.key); + await renderGroup(groupItems, title && title !== "null" ? title : void 0); + } + if (token !== this.renderToken) return; + if (jumpToToday) { + const today = this.plugin.getCalendariumCurrentYmd(); + if (today) this.plugin.jumpContainerToYmd(scroller, today, ".tl-h-item"); + } + })(); + return; } - const raw = String(value.toString?.() ?? value).trim(); - const match = raw.match(/^(\d{1,6})-(\d{1,2})-(\d{1,2})/); - if (match) { - return { - y: Number(match[1]), - m: Number(match[2]), - d: Number(match[3]) - }; - } - const asNum = Number(raw); - if (Number.isFinite(asNum) && asNum > 0) { - const dt = new Date(asNum); - if (!Number.isNaN(dt.getTime())) { - return { y: dt.getFullYear(), m: dt.getMonth() + 1, d: dt.getDate() }; + const items = []; + let pos = 0; + for (const group of groups) { + for (const entry of group.entries ?? []) { + const it = this.buildItemFromEntry( + entry, + startProp, + endProp, + titleProp, + summaryProp, + imageProp, + pos++ + ); + if (it) items.push(it); } } - return null; + void (async () => { + await renderHorizontalItems(items, scroller); + if (token !== this.renderToken) return; + if (jumpToToday) { + const today = this.plugin.getCalendariumCurrentYmd(); + if (today) this.plugin.jumpContainerToYmd(scroller, today, ".tl-h-item"); + } + })(); } } const registerBasesView = maybeRegister; @@ -525,54 +950,89 @@ var SimpleTimeline = class extends import_obsidian.Plugin { options: () => [ { type: "text", - displayName: "Timeline config name (optional)", + displayName: "Timeline config name (optional, forces one config)", key: "timelineConfig", default: "" }, { type: "text", - displayName: "Start date property", - key: "startProperty", - default: "note.fc-date" + displayName: "Timeline property (used if timelineConfig is empty; can be multi-value)", + key: "timelineProperty", + default: "note.timelines" + }, + { type: "text", displayName: "Start date property", key: "startProperty", default: "note.fc-date" }, + { type: "text", displayName: "Jump to Calendarium 'today' on refresh (true|false)", key: "jumpToToday", default: "false" }, + { type: "text", displayName: "Order mode (bases|start-asc|start-desc). Default: bases", key: "orderMode", default: "bases" }, + { type: "text", displayName: "End date property (optional)", key: "endProperty", default: "note.fc-end" }, + { type: "text", displayName: "Title property", key: "titleProperty", default: "note.tl-title" }, + { type: "text", displayName: "Summary property", key: "summaryProperty", default: "note.tl-summary" }, + { type: "text", displayName: "Image property", key: "imageProperty", default: "note.tl-image" } + ] + }); + registerBasesView.call(this, BASES_VIEW_TYPE_HORIZONTAL, { + name: "Timeline (Horizontal)", + icon: "lucide-arrow-left-right", + factory: (controller, containerEl) => new TimelineBasesHorizontalView(controller, containerEl, this), + options: () => [ + { + type: "text", + displayName: "Mode (stacked|mixed). Default: stacked", + key: "mode", + default: "stacked" }, { type: "text", - displayName: "End date property (optional)", - key: "endProperty", - default: "note.fc-end" + displayName: "Timeline config name (optional, forces one config)", + key: "timelineConfig", + default: "" }, { type: "text", - displayName: "Title property", - key: "titleProperty", - default: "note.tl-title" + displayName: "Timeline property (used if timelineConfig is empty; can be multi-value)", + key: "timelineProperty", + default: "note.timelines" }, - { - type: "text", - displayName: "Summary property", - key: "summaryProperty", - default: "note.tl-summary" - }, - { - type: "text", - displayName: "Image property", - key: "imageProperty", - default: "note.tl-image" - } + { type: "text", displayName: "Start date property", key: "startProperty", default: "note.fc-date" }, + { type: "text", displayName: "Jump to Calendarium 'today' on refresh (true|false)", key: "jumpToToday", default: "false" }, + { type: "text", displayName: "Order mode (bases|start-asc|start-desc). Default: bases", key: "orderMode", default: "bases" }, + { type: "text", displayName: "End date property (optional)", key: "endProperty", default: "note.fc-end" }, + { type: "text", displayName: "Title property", key: "titleProperty", default: "note.tl-title" }, + { type: "text", displayName: "Summary property", key: "summaryProperty", default: "note.tl-summary" }, + { type: "text", displayName: "Image property", key: "imageProperty", default: "note.tl-image" } ] }); } - // ---------- Renderer (timeline-cal) ---------- - async renderTimeline(src, el, ctx) { - let opts = {}; + // ---------- Markdown renderers ---------- + parseBlockOptionsObject(src) { + if (!src.trim()) return {}; try { - opts = (0, import_obsidian.parseYaml)(src) ?? {}; + const raw = (0, import_obsidian.parseYaml)(src); + return isRecord(raw) ? raw : {}; } catch (e) { console.debug("simple-timeline: invalid block options", e); + return {}; } - const namesValue = opts.names ?? opts.name; - const namesRaw = typeof namesValue === "string" ? namesValue.split(",").map((s) => s.trim()).filter(Boolean) : Array.isArray(namesValue) ? namesValue.map((s) => String(s).trim()).filter(Boolean) : []; - const filterNames = namesRaw.length ? namesRaw : []; + } + parseNamesFromOptions(opts) { + const namesValue = opts["names"] ?? opts["name"]; + return normalizeStringArray(namesValue); + } + parseJumpToTodayFromOptions(opts) { + const v = opts["jumpToToday"]; + return v === true; + } + getHorizontalEdges(c, cfg) { + const hasMedia = !!c.imgSrc; + const align = cfg.align ?? "left"; + if (!hasMedia) return { left: "box", right: "box" }; + if (align === "right") return { left: "box", right: "media" }; + return { left: "media", right: "box" }; + } + applyHorizontalJoin(a, b) { + if (a.right === "box") a.el.classList.add("tl-h-join-right-box"); + if (b.left === "box") b.el.classList.add("tl-h-join-left-box"); + } + async collectCards(filterNames, ctx) { const files = this.app.vault.getMarkdownFiles(); const cards = []; for (const f of files) { @@ -580,10 +1040,13 @@ var SimpleTimeline = class extends import_obsidian.Plugin { if (!fm) continue; if (!Object.prototype.hasOwnProperty.call(fm, "fc-date")) continue; const timelinesVal = fm["timelines"]; - const tlList = Array.isArray(timelinesVal) ? timelinesVal.map((s) => String(s)) : []; - if (filterNames.length && !tlList.some((t) => filterNames.includes(t))) { - continue; + let tlList = []; + if (Array.isArray(timelinesVal)) { + tlList = timelinesVal.map((x) => primitiveToString(x)).filter((x) => typeof x === "string").map((s) => s.trim()).filter(Boolean); + } else if (typeof timelinesVal === "string") { + tlList = splitTimelineList(timelinesVal); } + if (filterNames.length && !tlList.some((t) => filterNames.includes(t))) continue; const start = this.parseFcDate(fm["fc-date"]); if (!start) continue; const end = fm["fc-end"] ? this.parseFcDate(fm["fc-end"]) : void 0; @@ -595,20 +1058,16 @@ var SimpleTimeline = class extends import_obsidian.Plugin { const title = primitiveToString(tlTitleVal) ?? f.basename; const rawSummary = fm["tl-summary"]; let summary; - if (typeof rawSummary === "string") { - summary = rawSummary; - } else if (typeof rawSummary === "number" || typeof rawSummary === "boolean") { - summary = String(rawSummary); - } else if (rawSummary != null) { + if (typeof rawSummary === "string") summary = rawSummary; + else if (typeof rawSummary === "number" || typeof rawSummary === "boolean") summary = String(rawSummary); + else if (rawSummary != null) { try { summary = JSON.stringify(rawSummary); } catch { summary = void 0; } } - if (!summary) { - summary = await this.extractFirstParagraph(f); - } + if (!summary) summary = await this.extractFirstParagraph(f); const imgSrc = this.resolveImageSrc(f, fm, ctx.sourcePath ?? f.path); cards.push({ file: f, @@ -620,100 +1079,224 @@ var SimpleTimeline = class extends import_obsidian.Plugin { primaryTl }); } - cards.sort( - (a, b) => a.start.y * 1e4 + a.start.m * 100 + a.start.d - (b.start.y * 1e4 + b.start.m * 100 + b.start.d) - ); - const wrapper = el.createDiv({ cls: "tl-wrapper tl-cross-mode" }); - for (const c of cards) { - const row = wrapper.createDiv({ cls: "tl-row" }); - const cfg = this.getConfigFor(c.primaryTl); - const align = cfg.align ?? "left"; - if (align === "right") row.addClass("tl-align-right"); - const W = cfg.cardWidth; - const H = cfg.cardHeight; - const BH = cfg.boxHeight; - setCssProps(row, { - paddingLeft: `${cfg.sideGapLeft}px`, - paddingRight: `${cfg.sideGapRight}px`, - "--tl-bg": cfg.colors.bg || "var(--background-primary)", - "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", - "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" + cards.sort((a, b) => ymdSortKey(a.start) - ymdSortKey(b.start)); + return cards; + } + renderCrossCardRow(wrapper, c, cfg, sourcePathForHover, extraRowClasses = []) { + const row = wrapper.createDiv({ cls: ["tl-row", ...extraRowClasses].join(" ") }); + row.dataset.tlStartKey = String(ymdSortKey({ y: c.start.y, m: c.start.m, d: c.start.d })); + if (c.end) { + row.dataset.tlEndKey = String(ymdSortKey({ y: c.end.y, m: c.end.m, d: c.end.d })); + } else { + delete row.dataset.tlEndKey; + } + const align = cfg.align ?? "left"; + if (align === "right") row.addClass("tl-align-right"); + const W = cfg.cardWidth; + const H = cfg.cardHeight; + const BH = cfg.boxHeight; + setCssProps(row, { + paddingLeft: `${cfg.sideGapLeft}px`, + paddingRight: `${cfg.sideGapRight}px`, + "--tl-bg": cfg.colors.bg || "var(--background-primary)", + "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", + "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" + }); + const grid = row.createDiv({ cls: "tl-grid" }); + const hasMedia = !!c.imgSrc; + grid.addClass(hasMedia ? "has-media" : "no-media"); + setCssProps(grid, { + display: "grid", + alignItems: "center", + columnGap: "0", + "--tl-media-w": `${W}px` + }); + let media = null; + if (hasMedia && c.imgSrc) { + media = grid.createDiv({ cls: "tl-media" }); + setCssProps(media, { + width: `${W}px`, + height: `${H}px`, + position: "relative" }); - const grid = row.createDiv({ cls: "tl-grid" }); - const hasMedia = !!c.imgSrc; - grid.addClass(hasMedia ? "has-media" : "no-media"); - setCssProps(grid, { - display: "grid", - alignItems: "center", - columnGap: "0", - "--tl-media-w": `${W}px` + const img = media.createEl("img", { + attr: { src: c.imgSrc, alt: c.title, loading: "lazy" } }); - let media = null; - if (hasMedia && c.imgSrc) { - media = grid.createDiv({ cls: "tl-media" }); - setCssProps(media, { - width: `${W}px`, - height: `${H}px`, - position: "relative" - }); - const img = media.createEl("img", { - attr: { src: c.imgSrc, alt: c.title, loading: "lazy" } - }); - setCssProps(img, { - width: "100%", - height: "100%", - objectFit: "cover", - display: "block" - }); - } - const box = grid.createDiv({ - cls: `tl-box callout ${hasMedia ? "has-media" : "no-media"}` + setCssProps(img, { + width: "100%", + height: "100%", + objectFit: "cover", + display: "block" }); - setCssProps(box, { - height: `${BH}px`, - boxSizing: "border-box", - "--tl-bg": cfg.colors.bg || "var(--background-primary)", - "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", - "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" - }); - const titleEl = box.createEl("h1", { - cls: "tl-title", - text: c.title - }); - const dateEl = box.createEl("h4", { - cls: "tl-date", - text: this.formatRange(c.start, c.end) - }); - const sum = box.createDiv({ cls: "tl-summary" }); - titleEl.classList.add("tl-title-colored"); - dateEl.classList.add("tl-date-colored"); - if (cfg.colors.title) { - titleEl.style.color = cfg.colors.title; - } - if (cfg.colors.date) { - dateEl.style.color = cfg.colors.date; - } - if (c.summary) { - sum.setText(c.summary); - } - if (media) { - const aImg = media.createEl("a", { - cls: "internal-link tl-hover-anchor", - href: c.file.path, - attr: { "data-href": c.file.path, "aria-label": c.title } - }); - this.attachHoverForAnchor(aImg, media, c.file.path, ctx.sourcePath); - } - const aBox = box.createEl("a", { + } + const box = grid.createDiv({ + cls: `tl-box callout ${hasMedia ? "has-media" : "no-media"}` + }); + setCssProps(box, { + height: `${BH}px`, + boxSizing: "border-box", + "--tl-bg": cfg.colors.bg || "var(--background-primary)", + "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", + "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" + }); + const titleEl = box.createEl("h1", { cls: "tl-title", text: c.title }); + const dateEl = box.createEl("h4", { cls: "tl-date", text: this.formatRange(c.start, c.end) }); + const sum = box.createDiv({ cls: "tl-summary" }); + titleEl.classList.add("tl-title-colored"); + dateEl.classList.add("tl-date-colored"); + if (cfg.colors.title) titleEl.style.color = cfg.colors.title; + if (cfg.colors.date) dateEl.style.color = cfg.colors.date; + if (c.summary) sum.setText(c.summary); + if (media) { + const aImg = media.createEl("a", { cls: "internal-link tl-hover-anchor", href: c.file.path, attr: { "data-href": c.file.path, "aria-label": c.title } }); - this.attachHoverForAnchor(aBox, box, c.file.path, ctx.sourcePath); - this.applyFixedLineClamp(sum, cfg.maxSummaryLines); + this.attachHoverForAnchor(aImg, media, c.file.path, sourcePathForHover); + } + const aBox = box.createEl("a", { + cls: "internal-link tl-hover-anchor", + href: c.file.path, + attr: { "data-href": c.file.path, "aria-label": c.title } + }); + this.attachHoverForAnchor(aBox, box, c.file.path, sourcePathForHover); + this.applyFixedLineClamp(sum, cfg.maxSummaryLines); + return row; + } + // ---------- Renderer (timeline-cal) ---------- + async renderTimeline(src, el, ctx) { + const opts = this.parseBlockOptionsObject(src); + const filterNames = this.parseNamesFromOptions(opts); + const jumpToToday = this.parseJumpToTodayFromOptions(opts); + const cards = await this.collectCards(filterNames, ctx); + const controls = el.createDiv({ cls: "tl-controls" }); + const todayBtn = controls.createEl("button", { text: "Today" }); + const wrapper = el.createDiv({ cls: "tl-wrapper tl-cross-mode" }); + todayBtn.addEventListener("click", () => { + const today = this.getCalendariumCurrentYmd(); + if (!today) { + new import_obsidian.Notice("Calendarium is not installed."); + return; + } + const ok = this.jumpContainerToYmd(wrapper, today); + if (!ok) new import_obsidian.Notice("No timeline entry for today found."); + }); + for (const c of cards) { + const cfg = this.getConfigFor(c.primaryTl); + this.renderCrossCardRow(wrapper, c, cfg, ctx.sourcePath); + } + if (jumpToToday) { + const today = this.getCalendariumCurrentYmd(); + if (today) this.jumpContainerToYmd(wrapper, today); } } - // Line clamp helper (made public for Bases view integration) + // ---------- Renderer (timeline-h) ---------- + async renderTimelineHorizontal(src, el, ctx) { + const opts = this.parseBlockOptionsObject(src); + const filterNames = this.parseNamesFromOptions(opts); + const jumpToToday = this.parseJumpToTodayFromOptions(opts); + const mode = parseHorizontalMode(opts["mode"]) ?? "mixed"; + const cards = await this.collectCards(filterNames, ctx); + const controls = el.createDiv({ cls: "tl-controls" }); + const todayBtn = controls.createEl("button", { text: "Today" }); + const scroller = el.createDiv({ cls: "tl-h-scroller" }); + const wrapper = scroller.createDiv({ + cls: mode === "stacked" ? "tl-h-content tl-horizontal tl-h-stacked" : "tl-h-content tl-horizontal tl-h-mixed" + }); + todayBtn.addEventListener("click", () => { + const today = this.getCalendariumCurrentYmd(); + if (!today) { + new import_obsidian.Notice("Calendarium is not installed."); + return; + } + const ok = this.jumpContainerToYmd(scroller, today, ".tl-h-item"); + if (!ok) new import_obsidian.Notice("No timeline entry for today found."); + }); + if (mode === "mixed") { + const rendered = []; + for (const c of cards) { + const cfg = this.getConfigFor(c.primaryTl); + const rowEl = this.renderCrossCardRow(wrapper, c, cfg, ctx.sourcePath, ["tl-h-item"]); + const edges = this.getHorizontalEdges(c, cfg); + rendered.push({ el: rowEl, ...edges }); + } + for (let i = 0; i < rendered.length - 1; i++) { + this.applyHorizontalJoin( + { el: rendered[i].el, right: rendered[i].right }, + { el: rendered[i + 1].el, left: rendered[i + 1].left } + ); + } + if (jumpToToday) { + const today = this.getCalendariumCurrentYmd(); + if (today) this.jumpContainerToYmd(scroller, today, ".tl-h-item"); + } + return; + } + const axisKeys = Array.from(new Set(cards.map((c) => ymdSortKey(c.start)))).sort((a, b) => a - b); + const colByKey = /* @__PURE__ */ new Map(); + for (let i = 0; i < axisKeys.length; i++) colByKey.set(axisKeys[i], i + 1); + setCssProps(wrapper, { "--tl-h-cols": String(axisKeys.length) }); + const byTl = /* @__PURE__ */ new Map(); + for (const c of cards) { + const key = c.primaryTl ?? "default"; + const list = byTl.get(key); + if (list) list.push(c); + else byTl.set(key, [c]); + } + const orderedTlKeys = filterNames.length > 0 ? filterNames.filter((k) => byTl.has(k)) : Array.from(byTl.keys()).sort((a, b) => a.localeCompare(b)); + for (const tlKey of orderedTlKeys) { + const list = byTl.get(tlKey) ?? []; + list.sort((a, b) => ymdSortKey(a.start) - ymdSortKey(b.start)); + const cfg = this.getConfigFor(tlKey); + const tlRowWrap = wrapper.createDiv({ cls: "tl-h-timeline" }); + const rowGrid = tlRowWrap.createDiv({ cls: "tl-h-row" }); + setCssProps(rowGrid, { "--tl-h-cols": String(axisKeys.length) }); + const byDate = /* @__PURE__ */ new Map(); + for (const c of list) { + const k = ymdSortKey(c.start); + const arr = byDate.get(k); + if (arr) arr.push(c); + else byDate.set(k, [c]); + } + const sortedDateKeys = Array.from(byDate.keys()).sort((a, b) => { + const ca = colByKey.get(a) ?? 0; + const cb = colByKey.get(b) ?? 0; + return ca - cb; + }); + const renderedSlots = []; + for (const dateKey of sortedDateKeys) { + const col = colByKey.get(dateKey); + if (!col) continue; + const cardsAtDate = byDate.get(dateKey) ?? []; + if (!cardsAtDate.length) continue; + const slot = rowGrid.createDiv({ cls: "tl-h-slot" }); + setCssProps(slot, { "--tl-h-col": String(col) }); + let stored = false; + for (const c of cardsAtDate) { + const rowEl = this.renderCrossCardRow(slot, c, cfg, ctx.sourcePath, ["tl-h-item"]); + if (!stored) { + const edges = this.getHorizontalEdges(c, cfg); + renderedSlots.push({ col, el: rowEl, ...edges }); + stored = true; + } + } + } + renderedSlots.sort((a, b) => a.col - b.col); + for (let i = 0; i < renderedSlots.length - 1; i++) { + const a = renderedSlots[i]; + const b = renderedSlots[i + 1]; + if (b.col === a.col + 1) { + this.applyHorizontalJoin({ el: a.el, right: a.right }, { el: b.el, left: b.left }); + } + } + } + if (jumpToToday) { + const today = this.getCalendariumCurrentYmd(); + if (today) this.jumpContainerToYmd(scroller, today, ".tl-h-item"); + } + } + // Line clamp helper applyFixedLineClamp(summaryEl, lines) { const n = Math.max(1, Math.floor(lines || this.settings.maxSummaryLines)); summaryEl.classList.add("tl-clamp"); @@ -722,7 +1305,6 @@ var SimpleTimeline = class extends import_obsidian.Plugin { "--tl-summary-lh": "1.4" }); } - // made public for Bases view integration formatRange(a, b) { const f = (x) => `${x.d} ${x.mName ?? x.m} ${x.y}`; if (!b) return f(a); @@ -745,14 +1327,11 @@ var SimpleTimeline = class extends import_obsidian.Plugin { mNum = mRaw; } else { const months = this.getMonths(); - const idx = months.findIndex( - (x) => x.toLowerCase() === String(mRaw).toLowerCase() - ); + const idx = months.findIndex((x) => x.toLowerCase() === String(mRaw).toLowerCase()); mNum = idx >= 0 ? idx + 1 : Number(mRaw) || 1; } return { y, m: mNum, d }; } - // made public for Bases view integration getMonths(calKey) { if (calKey) { const tl = this.settings.timelineConfigs[calKey]; @@ -783,7 +1362,6 @@ var SimpleTimeline = class extends import_obsidian.Plugin { "December" ]; } - // made public for Bases view integration getConfigFor(name) { const base = { maxSummaryLines: this.settings.maxSummaryLines, @@ -793,9 +1371,7 @@ var SimpleTimeline = class extends import_obsidian.Plugin { sideGapLeft: this.settings.sideGapLeft, sideGapRight: this.settings.sideGapRight, align: "left", - colors: { - ...this.settings.defaultColors || {} - }, + colors: { ...this.settings.defaultColors || {} }, months: void 0 }; if (!name) return base; @@ -823,7 +1399,6 @@ var SimpleTimeline = class extends import_obsidian.Plugin { base.months = tl.months ?? (!this.settings.migratedLegacy ? this.settings.monthOverrides[name] : void 0); return base; } - // made public for Bases view integration resolveLinkToSrc(link, sourcePath) { if (/^https?:\/\//i.test(link)) return link; const dest = this.app.metadataCache.getFirstLinkpathDest(link, sourcePath); @@ -851,9 +1426,7 @@ var SimpleTimeline = class extends import_obsidian.Plugin { if (src) return src; } } - const parent = this.app.vault.getAbstractFileByPath( - file.parent?.path ?? "" - ); + const parent = this.app.vault.getAbstractFileByPath(file.parent?.path ?? ""); if (parent instanceof import_obsidian.TFolder) { for (const ch of parent.children) { if (ch instanceof import_obsidian.TFile && /\.(png|jpe?g|webp|gif|avif)$/i.test(ch.name)) { @@ -866,18 +1439,12 @@ var SimpleTimeline = class extends import_obsidian.Plugin { findImageForFile(file) { const cache = this.app.metadataCache.getFileCache(file); for (const e of cache?.embeds ?? []) { - if (/\.(png|jpe?g|webp|gif|avif|bmp|svg)$/i.test(e.link)) { - return e.link; - } + if (/\.(png|jpe?g|webp|gif|avif|bmp|svg)$/i.test(e.link)) return e.link; } for (const l of cache?.links ?? []) { - if (/\.(png|jpe?g|webp|gif|avif)$/i.test(l.link)) { - return l.link; - } + if (/\.(png|jpe?g|webp|gif|avif)$/i.test(l.link)) return l.link; } - const parent = this.app.vault.getAbstractFileByPath( - file.parent?.path ?? "" - ); + const parent = this.app.vault.getAbstractFileByPath(file.parent?.path ?? ""); if (parent instanceof import_obsidian.TFolder) { for (const ch of parent.children) { if (ch instanceof import_obsidian.TFile && /\.(png|jpe?g|webp|gif|avif)$/i.test(ch.name)) { @@ -887,12 +1454,9 @@ var SimpleTimeline = class extends import_obsidian.Plugin { } return void 0; } - // made public for Bases view integration attachHoverForAnchor(anchorEl, hoverParent, filePath, sourcePath) { const makeForcedHoverEvent = (evt) => { - if (evt && typeof TouchEvent !== "undefined" && evt instanceof TouchEvent) { - return evt; - } + if (evt && typeof TouchEvent !== "undefined" && evt instanceof TouchEvent) return evt; const m = evt && evt instanceof MouseEvent ? evt : void 0; const clientX = m?.clientX ?? 0; const clientY = m?.clientY ?? 0; @@ -941,7 +1505,6 @@ var SimpleTimeline = class extends import_obsidian.Plugin { (ev) => anchorEl.addEventListener(ev, clear, { passive: true }) ); } - // made public for Bases view integration async extractFirstParagraph(file) { try { const raw = await this.app.vault.read(file); @@ -978,9 +1541,7 @@ var SimpleTimeline = class extends import_obsidian.Plugin { ]); for (const k of keys) { if (!k) continue; - if (!this.settings.timelineConfigs[k]) { - this.settings.timelineConfigs[k] = {}; - } + if (!this.settings.timelineConfigs[k]) this.settings.timelineConfigs[k] = {}; const tl = this.settings.timelineConfigs[k]; const so = this.settings.styleOverrides[k]; if (so) { @@ -1009,9 +1570,7 @@ var InputModal = class extends import_obsidian.Modal { onOpen() { const { contentEl } = this; contentEl.empty(); - if (this.titleText) { - contentEl.createEl("h3", { text: this.titleText }); - } + if (this.titleText) contentEl.createEl("h3", { text: this.titleText }); const input = contentEl.createEl("input", { type: "text" }); setCssProps(input, { width: "100%" }); input.placeholder = this.placeholder ?? ""; @@ -1033,8 +1592,7 @@ var InputModal = class extends import_obsidian.Modal { }, 50); } onClose() { - const { contentEl } = this; - contentEl.empty(); + this.contentEl.empty(); } waitForClose() { return new Promise((res) => { @@ -1057,9 +1615,7 @@ var TimelineConfigModal = class extends import_obsidian.Modal { onOpen() { const { contentEl } = this; contentEl.empty(); - contentEl.createEl("h3", { - text: this.initialKey ? "Edit timeline" : "Create timeline" - }); + contentEl.createEl("h3", { text: this.initialKey ? "Edit timeline" : "Create timeline" }); let key = this.initialKey ?? ""; const cfg = this.initialCfg ?? {}; const addNum = (name, prop, ph) => new import_obsidian.Setting(contentEl).setName(name).setDesc("Empty = use defaults").addText((t) => { @@ -1071,9 +1627,7 @@ var TimelineConfigModal = class extends import_obsidian.Modal { delete cfg[prop]; } else { const n = Number(vv); - if (Number.isFinite(n)) { - cfg[prop] = Math.floor(n); - } + if (Number.isFinite(n)) cfg[prop] = Math.floor(n); } }); }); @@ -1088,11 +1642,8 @@ var TimelineConfigModal = class extends import_obsidian.Modal { d.addOption("right", "Right (image right)"); d.setValue(cur); d.onChange((v) => { - if (v === "right") { - cfg.align = "right"; - } else { - delete cfg.align; - } + if (v === "right") cfg.align = "right"; + else delete cfg.align; }); }); addNum("Max. summary lines", "maxSummaryLines", "e.g. 7"); @@ -1104,41 +1655,29 @@ var TimelineConfigModal = class extends import_obsidian.Modal { cfg.colors || (cfg.colors = {}); new import_obsidian.Setting(contentEl).setName("Box background").setDesc("Empty = default/theme color").addColorPicker((cp) => { cp.setValue(cfg.colors.bg || ""); - cp.onChange((v) => { - cfg.colors.bg = v || void 0; - }); + cp.onChange((v) => cfg.colors.bg = v || void 0); }); new import_obsidian.Setting(contentEl).setName("Box border").setDesc("Empty = default/theme color").addColorPicker((cp) => { cp.setValue(cfg.colors.accent || ""); - cp.onChange((v) => { - cfg.colors.accent = v || void 0; - }); + cp.onChange((v) => cfg.colors.accent = v || void 0); }); new import_obsidian.Setting(contentEl).setName("Hover background").setDesc("Empty = default/theme color").addColorPicker((cp) => { cp.setValue(cfg.colors.hover || ""); - cp.onChange((v) => { - cfg.colors.hover = v || void 0; - }); + cp.onChange((v) => cfg.colors.hover = v || void 0); }); new import_obsidian.Setting(contentEl).setName("Title color").setDesc("Empty = default/theme color").addColorPicker((cp) => { cp.setValue(cfg.colors.title || ""); - cp.onChange((v) => { - cfg.colors.title = v || void 0; - }); + cp.onChange((v) => cfg.colors.title = v || void 0); }); new import_obsidian.Setting(contentEl).setName("Date color").setDesc("Empty = default/theme color").addColorPicker((cp) => { cp.setValue(cfg.colors.date || ""); - cp.onChange((v) => { - cfg.colors.date = v || void 0; - }); + cp.onChange((v) => cfg.colors.date = v || void 0); }); let monthsText = Array.isArray(cfg.months) && cfg.months.length > 0 ? cfg.months.join(", ") : cfg.months ?? ""; new import_obsidian.Setting(contentEl).setName("Month names").setDesc("Set own month names. Separate them with comma.").addTextArea((ta) => { ta.inputEl.rows = 3; ta.setValue(monthsText); - ta.onChange((v) => { - monthsText = v; - }); + ta.onChange((v) => monthsText = v); }); const btns = contentEl.createDiv({ cls: "modal-button-container" }); const saveBtn = btns.createEl("button", { text: "Save" }); @@ -1163,9 +1702,7 @@ var TimelineConfigModal = class extends import_obsidian.Modal { this.contentEl.empty(); } waitForClose() { - return new Promise((res) => { - this.resolve = res; - }); + return new Promise((res) => this.resolve = res); } }; function parseMonths(text) { @@ -1173,9 +1710,7 @@ function parseMonths(text) { try { if (text.includes("\n") && /(\n-|\n\s*-)/.test(text)) { const y = (0, import_obsidian.parseYaml)(text); - if (Array.isArray(y)) { - return y.map((v) => String(v)).filter(Boolean); - } + if (Array.isArray(y)) return y.map((v) => String(v)).filter(Boolean); } } catch (e) { console.debug("simple-timeline: invalid months YAML", e); @@ -1208,21 +1743,13 @@ var DefaultsModal = class extends import_obsidian.Modal { t.setValue(String(this.draft[key])); t.onChange((v) => { const n = Number(v); - if (Number.isFinite(n)) { - if (key === "maxSummaryLines") { - this.draft.maxSummaryLines = Math.floor(n); - } else if (key === "cardWidth") { - this.draft.cardWidth = Math.floor(n); - } else if (key === "cardHeight") { - this.draft.cardHeight = Math.floor(n); - } else if (key === "boxHeight") { - this.draft.boxHeight = Math.floor(n); - } else if (key === "sideGapLeft") { - this.draft.sideGapLeft = Math.floor(n); - } else if (key === "sideGapRight") { - this.draft.sideGapRight = Math.floor(n); - } - } + if (!Number.isFinite(n)) return; + if (key === "maxSummaryLines") this.draft.maxSummaryLines = Math.floor(n); + else if (key === "cardWidth") this.draft.cardWidth = Math.floor(n); + else if (key === "cardHeight") this.draft.cardHeight = Math.floor(n); + else if (key === "boxHeight") this.draft.boxHeight = Math.floor(n); + else if (key === "sideGapLeft") this.draft.sideGapLeft = Math.floor(n); + else if (key === "sideGapRight") this.draft.sideGapRight = Math.floor(n); }); }); addNum("Image width", "cardWidth", "200"); @@ -1233,33 +1760,23 @@ var DefaultsModal = class extends import_obsidian.Modal { addNum("Max. summary lines", "maxSummaryLines", "7"); new import_obsidian.Setting(contentEl).setName("Box background (default)").setDesc("Empty = theme color").addColorPicker((cp) => { cp.setValue(this.draft.colors.bg || ""); - cp.onChange((v) => { - this.draft.colors.bg = v || void 0; - }); + cp.onChange((v) => this.draft.colors.bg = v || void 0); }); new import_obsidian.Setting(contentEl).setName("Box border (default)").setDesc("Empty = theme color").addColorPicker((cp) => { cp.setValue(this.draft.colors.accent || ""); - cp.onChange((v) => { - this.draft.colors.accent = v || void 0; - }); + cp.onChange((v) => this.draft.colors.accent = v || void 0); }); new import_obsidian.Setting(contentEl).setName("Hover background (default)").setDesc("Empty = var(--interactive-accent)").addColorPicker((cp) => { cp.setValue(this.draft.colors.hover || ""); - cp.onChange((v) => { - this.draft.colors.hover = v || void 0; - }); + cp.onChange((v) => this.draft.colors.hover = v || void 0); }); new import_obsidian.Setting(contentEl).setName("Title color (default)").setDesc("Empty = theme color").addColorPicker((cp) => { cp.setValue(this.draft.colors.title || ""); - cp.onChange((v) => { - this.draft.colors.title = v || void 0; - }); + cp.onChange((v) => this.draft.colors.title = v || void 0); }); new import_obsidian.Setting(contentEl).setName("Date color (default)").setDesc("Empty = theme color").addColorPicker((cp) => { cp.setValue(this.draft.colors.date || ""); - cp.onChange((v) => { - this.draft.colors.date = v || void 0; - }); + cp.onChange((v) => this.draft.colors.date = v || void 0); }); const btns = contentEl.createDiv({ cls: "modal-button-container" }); const saveBtn = btns.createEl("button", { text: "Save" }); @@ -1285,9 +1802,7 @@ var DefaultsModal = class extends import_obsidian.Modal { this.contentEl.empty(); } waitForClose() { - return new Promise((res) => { - this.resolve = res; - }); + return new Promise((res) => this.resolve = res); } }; var SimpleTimelineSettingsTab = class extends import_obsidian.PluginSettingTab { @@ -1299,7 +1814,7 @@ var SimpleTimelineSettingsTab = class extends import_obsidian.PluginSettingTab { const { containerEl } = this; containerEl.empty(); new import_obsidian.Setting(containerEl).setName("Bases integration (optional)").setDesc( - "Registers a custom bases view type timeline cross. Requires Obsidian with bases support. Toggle needs a plugin reload to take effect." + "Registers custom bases view types (cross + horizontal). Requires Obsidian with bases support. Toggle needs a plugin reload to take effect." ).addToggle( (t) => t.setValue(this.plugin.settings.enableBasesIntegration).onChange(async (v) => { this.plugin.settings.enableBasesIntegration = v; @@ -1309,9 +1824,7 @@ var SimpleTimelineSettingsTab = class extends import_obsidian.PluginSettingTab { ); }) ); - new import_obsidian.Setting(containerEl).setName("Global defaults").setDesc( - "Used by all timelines that do not define their own values (including default colors)." - ).addButton( + new import_obsidian.Setting(containerEl).setName("Global defaults").setDesc("Used by all timelines that do not define their own values (including default colors).").addButton( (b) => b.setButtonText("Edit").onClick(async () => { const saved = await openDefaultsWizard(this.app, this.plugin); if (saved) { @@ -1330,9 +1843,7 @@ var SimpleTimelineSettingsTab = class extends import_obsidian.PluginSettingTab { } }) ); - const keys = Object.keys(this.plugin.settings.timelineConfigs).sort( - (a, b) => a.localeCompare(b) - ); + const keys = Object.keys(this.plugin.settings.timelineConfigs).sort((a, b) => a.localeCompare(b)); for (const key of keys) { const row = new import_obsidian.Setting(containerEl).setName(key); row.addButton( @@ -1353,18 +1864,13 @@ var SimpleTimelineSettingsTab = class extends import_obsidian.PluginSettingTab { }) ); } - const hint = containerEl.createDiv({ - cls: "setting-item-description" - }); + const hint = containerEl.createDiv({ cls: "setting-item-description" }); hint.textContent = "Note: older \u201Cstyles per timeline\u201D / \u201Cmonth overrides\u201D were migrated once and will not be imported again."; } }; async function openTimelineWizard(app, plugin, existingKey) { const initCfg = existingKey ? plugin.settings.timelineConfigs[existingKey] : void 0; - const modal = new TimelineConfigModal(app, plugin, { - key: existingKey, - cfg: initCfg - }); + const modal = new TimelineConfigModal(app, plugin, { key: existingKey, cfg: initCfg }); modal.open(); const res = await modal.waitForClose(); if (!res) return null; diff --git a/manifest.json b/manifest.json index cf937ad..7652a0e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "simple-timeline", "name": "TTRPG Tools: Timeline", - "version": "0.3.3", + "version": "0.4.0", "minAppVersion": "1.6.7", "description": "Timeline-Renderer für Calendarium-Frontmatter (fc-date/fc-end). 200×315 Bild, Callout-Stil, native Popover, mobilfreundlich.", "author": "Jareika", diff --git a/package.json b/package.json index a21c71b..9d9e688 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-simple-timeline", - "version": "0.3.3", + "version": "0.4.0", "license": "MIT", "private": true, "scripts": { diff --git a/src/main.ts b/src/main.ts index 9fc207f..5f68ab6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,8 @@ import { } from "obsidian"; type TimelineAlign = "left" | "right"; +type HorizontalMode = "mixed" | "stacked"; +type HorizontalEdge = "media" | "box"; /* ========================================= Settings model @@ -112,13 +114,26 @@ type CardData = { primaryTl?: string; }; -interface TimelineBlockOptions { - name?: string | string[]; - names?: string | string[]; -} - type FrontmatterLike = Record; +type ResolvedTimelineRenderConfig = { + maxSummaryLines: number; + cardWidth: number; + cardHeight: number; + boxHeight: number; + sideGapLeft: number; + sideGapRight: number; + align: TimelineAlign; + colors: { + bg?: string; + accent?: string; + hover?: string; + title?: string; + date?: string; + }; + months?: string[] | string; +}; + /* numeric settings we edit via the wizards */ type TimelineNumericKey = | "maxSummaryLines" @@ -184,11 +199,60 @@ function toCommaListString(v: unknown): string | undefined { return undefined; } +function normalizeStringArray(v: unknown): string[] { + if (typeof v === "string") { + return v + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + if (Array.isArray(v)) { + return v + .map((x) => primitiveToString(x)) + .filter((x): x is string => typeof x === "string") + .map((s) => s.trim()) + .filter(Boolean); + } + + return []; +} + +function splitTimelineList(raw: string): string[] { + const cleaned = raw.replace(/[\]["]/g, ""); + return cleaned + .split(/[,;\n]+/g) + .map((s) => s.trim()) + .filter(Boolean); +} + +function ymdSortKey(v: { y: number; m: number; d: number }): number { + return v.y * 10000 + v.m * 100 + v.d; +} + +type Ymd = { y: number; m: number; d: number }; + +type BasesOrderMode = "bases" | "start-asc" | "start-desc"; + +function parseBasesOrderMode(raw: string): BasesOrderMode { + const t = raw.trim().toLowerCase(); + if (t === "start-desc" || t === "desc") return "start-desc"; + if (t === "start-asc" || t === "asc") return "start-asc"; + return "bases"; +} + +function parseHorizontalMode(v: unknown): HorizontalMode | undefined { + const s = primitiveToString(v)?.trim().toLowerCase(); + if (s === "mixed" || s === "stacked") return s; + return undefined; +} + /* ========================================= Bases integration constants + types ========================================= */ const BASES_VIEW_TYPE_CROSS = "simple-timeline-cross"; +const BASES_VIEW_TYPE_HORIZONTAL = "simple-timeline-horizontal"; /** Minimal shape of Bases view options used by registerBasesView(). */ interface BasesViewOption { @@ -246,6 +310,18 @@ interface BasesViewRuntime { type BasesViewConstructor = new (controller: unknown) => BasesViewRuntime; +type BasesTimelineItem = { + entry: BasesEntryLike; + file: TFile; + start: { y: number; m: number; d: number }; + end?: { y: number; m: number; d: number }; + title: string; + summary?: string; + imgSrc?: string; + sortKey: number; + pos: number; +}; + /* ========================================= Main plugin ========================================= */ @@ -253,6 +329,117 @@ type BasesViewConstructor = new (controller: unknown) => BasesViewRuntime; export default class SimpleTimeline extends Plugin { settings: SimpleTimelineSettings; + public getCalendariumCurrentYmd(): Ymd | null { + // Soft integration: Calendarium may not be installed / api may not exist. + const plugins = (this.app as unknown as { plugins?: unknown }).plugins; + if (!isRecord(plugins)) return null; + + const getPlugin = plugins["getPlugin"]; + if (!isFunction(getPlugin)) return null; + + const calendarium = getPlugin.call(plugins, "calendarium"); + if (!isRecord(calendarium)) return null; + + const apiRoot = calendarium["api"]; + if (!isRecord(apiRoot)) return null; + + const directGetCurrentDate = apiRoot["getCurrentDate"]; + if (isFunction(directGetCurrentDate)) { + const raw = directGetCurrentDate.call(apiRoot); + if (isRecord(raw)) { + const y = Number(raw["year"]); + const monthZeroBased = Number(raw["month"]); + const d = Number(raw["day"]); + if ( + Number.isFinite(y) && + Number.isFinite(monthZeroBased) && + Number.isFinite(d) && + y !== 0 + ) { + return { y, m: monthZeroBased + 1, d }; + } + } + } + + const getAPI = apiRoot["getAPI"]; + if (!isFunction(getAPI)) return null; + + let calendarApi: unknown; + try { + calendarApi = getAPI.call(apiRoot); + } catch { + return null; + } + if (!isRecord(calendarApi)) return null; + + const getCurrentDate = calendarApi["getCurrentDate"]; + if (!isFunction(getCurrentDate)) return null; + + const raw = getCurrentDate.call(calendarApi); + if (!isRecord(raw)) return null; + + const y = Number(raw["year"]); + const monthZeroBased = Number(raw["month"]); + const d = Number(raw["day"]); + + if ( + Number.isFinite(y) && + Number.isFinite(monthZeroBased) && + Number.isFinite(d) && + y !== 0 + ) { + return { y, m: monthZeroBased + 1, d }; + } + + return null; + } + + public jumpContainerToYmd( + containerEl: HTMLElement, + ymd: Ymd, + selector = ".tl-row" + ): boolean { + const targetKey = ymdSortKey(ymd); + const rows = Array.from(containerEl.querySelectorAll(selector)); + + let exact: HTMLElement | null = null; + let nextAfter: { key: number; el: HTMLElement } | null = null; + let lastBefore: { key: number; el: HTMLElement } | null = null; + + for (const row of rows) { + const startKeyRaw = row.dataset.tlStartKey; + if (!startKeyRaw) continue; + const startKey = Number(startKeyRaw); + if (!Number.isFinite(startKey)) continue; + + const endKeyRaw = row.dataset.tlEndKey; + const endKey = endKeyRaw ? Number(endKeyRaw) : startKey; + const endKeyOk = Number.isFinite(endKey) ? endKey : startKey; + + // Range match: start <= today <= end + if (targetKey >= startKey && targetKey <= endKeyOk) { + exact = row; + break; + } + + if (startKey >= targetKey) { + if (!nextAfter || startKey < nextAfter.key) nextAfter = { key: startKey, el: row }; + } else { + if (!lastBefore || startKey > lastBefore.key) lastBefore = { key: startKey, el: row }; + } + } + + const target = exact ?? nextAfter?.el ?? lastBefore?.el; + if (!target) return false; + + try { + target.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" }); + } catch { + target.scrollIntoView(); + } + return true; + } + async onload() { await this.loadSettings(); await this.migrateLegacyToTimelineConfigs(); @@ -264,6 +451,10 @@ export default class SimpleTimeline extends Plugin { this.renderTimeline(src, el, ctx) ); + this.registerMarkdownCodeBlockProcessor("timeline-h", (src, el, ctx) => + this.renderTimelineHorizontal(src, el, ctx) + ); + this.addCommand({ id: "set-cal-date", name: "Timeline set date", @@ -274,6 +465,7 @@ export default class SimpleTimeline extends Plugin { return true; } }); + this.addCommand({ id: "set-cal-range", name: "Timeline set date range", @@ -284,6 +476,7 @@ export default class SimpleTimeline extends Plugin { return true; } }); + this.addCommand({ id: "edit-timelines", name: "Timeline edit timelines", @@ -294,6 +487,7 @@ export default class SimpleTimeline extends Plugin { return true; } }); + this.addCommand({ id: "set-summary", name: "Timeline set summary", @@ -304,6 +498,7 @@ export default class SimpleTimeline extends Plugin { return true; } }); + this.addCommand({ id: "adopt-first-image", name: "Timeline use first image as tl image", @@ -354,21 +549,18 @@ export default class SimpleTimeline extends Plugin { }) : null; - await this.app.fileManager.processFrontMatter( - file, - (fm: FrontmatterLike) => { - try { - fm["fc-date"] = this.tryParseYamlOrString(start); - if (range && end) { - fm["fc-end"] = this.tryParseYamlOrString(end); - } else if (!range) { - delete fm["fc-end"]; - } - } catch { - new Notice("Invalid date."); + await this.app.fileManager.processFrontMatter(file, (fm: FrontmatterLike) => { + try { + fm["fc-date"] = this.tryParseYamlOrString(start); + if (range && end) { + fm["fc-end"] = this.tryParseYamlOrString(end); + } else if (!range) { + delete fm["fc-end"]; } + } catch { + new Notice("Invalid date."); } - ); + }); } private async promptEditTimelines(file: TFile) { @@ -385,12 +577,9 @@ export default class SimpleTimeline extends Plugin { .split(",") .map((s) => s.trim()) .filter(Boolean); - await this.app.fileManager.processFrontMatter( - file, - (fm: FrontmatterLike) => { - fm["timelines"] = arr; - } - ); + await this.app.fileManager.processFrontMatter(file, (fm: FrontmatterLike) => { + fm["timelines"] = arr; + }); } private async promptSetSummary(file: TFile) { @@ -403,12 +592,9 @@ export default class SimpleTimeline extends Plugin { placeholder: "Multi-line allowed (YAML | or |- in frontmatter)" }); if (val == null) return; - await this.app.fileManager.processFrontMatter( - file, - (fm: FrontmatterLike) => { - fm["tl-summary"] = String(val); - } - ); + await this.app.fileManager.processFrontMatter(file, (fm: FrontmatterLike) => { + fm["tl-summary"] = String(val); + }); } private async adoptFirstImage(file: TFile) { @@ -417,21 +603,15 @@ export default class SimpleTimeline extends Plugin { new Notice("No image found."); return; } - await this.app.fileManager.processFrontMatter( - file, - (fm: FrontmatterLike) => { - fm["tl-image"] = link; - } - ); + await this.app.fileManager.processFrontMatter(file, (fm: FrontmatterLike) => { + fm["tl-image"] = link; + }); new Notice("Timeline image set from first image."); } private tryParseYamlOrString(input: string): unknown { const trimmed = String(input).trim(); - if ( - (trimmed.startsWith("{") && trimmed.endsWith("}")) || - trimmed.includes(":") - ) { + if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || trimmed.includes(":")) { return parseYaml(trimmed); } return trimmed; @@ -470,22 +650,16 @@ export default class SimpleTimeline extends Plugin { const BasesViewCtor = maybeBasesView as unknown as BasesViewConstructor; - class TimelineBasesCrossView extends BasesViewCtor { - readonly type = BASES_VIEW_TYPE_CROSS; + abstract class TimelineBasesBaseView extends BasesViewCtor { + protected hostEl: HTMLElement; + protected plugin: SimpleTimeline; + protected renderToken = 0; - private hostEl: HTMLElement; - private plugin: SimpleTimeline; - - constructor( - controller: unknown, - parentEl: HTMLElement, - plugin: SimpleTimeline - ) { + constructor(controller: unknown, parentEl: HTMLElement, plugin: SimpleTimeline) { super(controller); this.plugin = plugin; this.hostEl = parentEl.createDiv({ cls: "tl-bases-host" }); - setCssProps(this.hostEl, { boxSizing: "border-box", paddingLeft: "var(--file-margins, 24px)", @@ -493,115 +667,50 @@ export default class SimpleTimeline extends Plugin { }); } - private getOptionString(key: string, fallback: string): string { + protected getOptionString(key: string, fallback: string): string { const v = this.config.get(key); return typeof v === "string" ? v : fallback; } - private getGroupKeyText(v: unknown): string { - // For safety: only stringify primitives. Avoid "[object Object]". - return primitiveToString(v) ?? ""; - } + protected getGroupKeyText(v: unknown): string { + const prim = primitiveToString(v); + if (prim != null) return prim; - public onDataUpdated(): void { - this.hostEl.empty(); - - const wrapper = this.hostEl.createDiv({ - cls: "tl-wrapper tl-cross-mode" - }); - - // View options - const timelineConfigNameRaw = this.getOptionString("timelineConfig", "") - .trim(); - const timelineConfigName = timelineConfigNameRaw || undefined; - - const startProp = this.getOptionString("startProperty", "note.fc-date"); - const endProp = this.getOptionString("endProperty", "note.fc-end"); - const titleProp = this.getOptionString("titleProperty", "note.tl-title"); - const summaryProp = this.getOptionString( - "summaryProperty", - "note.tl-summary" - ); - const imageProp = this.getOptionString("imageProperty", "note.tl-image"); - - const cfg = this.plugin.getConfigFor(timelineConfigName); - const months: string[] = this.plugin.getMonths(timelineConfigName); - - const groups = this.data?.groupedData ?? []; - for (const group of groups) { - // Optional group header (if groupBy is set in Bases) - const keyText = this.getGroupKeyText(group.key); - if (keyText && keyText !== "null") { - const h = wrapper.createEl("h3", { text: keyText }); - h.addClass("tl-bases-group-title"); - } - - const entries = group.entries ?? []; - for (const entry of entries) { - const maybeFile = entry.file; - if (!(maybeFile instanceof TFile)) continue; - const file = maybeFile; - - const startValue = entry.getValue?.(startProp); - const start = this.valueToYmd(startValue); - if (!start) continue; - - const endValue = endProp ? entry.getValue?.(endProp) : null; - const end = this.valueToYmd(endValue) ?? undefined; - - const titleValue = titleProp ? entry.getValue?.(titleProp) : null; - const title = - titleValue && !titleValue.isEmpty?.() - ? String(titleValue.toString()) - : file.basename; - - let summary: string | undefined; - const summaryValue = summaryProp - ? entry.getValue?.(summaryProp) - : null; - if (summaryValue && !summaryValue.isEmpty?.()) { - summary = String(summaryValue.toString()); + if (isRecord(v)) { + const ts = v["toString"]; + if (isFunction(ts)) { + try { + const out = ts.call(v); + if (typeof out === "string" && out !== "[object Object]") return out; + } catch { + // ignore } - - const buildRow = async () => { - if (!summary) { - summary = await this.plugin.extractFirstParagraph(file); - } - - const imgSrc = this.resolveImageFromEntryValue( - entry, - imageProp, - file.path - ); - - // month names - const mNameStart = - months[(start.m - 1 + months.length) % months.length] ?? - String(start.m); - const mNameEnd = end - ? months[(end.m - 1 + months.length) % months.length] ?? - String(end.m) - : undefined; - - const card: CardData = { - file, - title, - summary, - start: { ...start, mName: mNameStart }, - end: end ? { ...end, mName: mNameEnd } : undefined, - imgSrc, - primaryTl: timelineConfigName - }; - - this.renderCrossRow(wrapper, card, cfg); - }; - - void buildRow(); } } + return ""; } - private getControllerFilePath(): string | undefined { + protected getTimelineKeyFromEntry( + entry: BasesEntryLike, + timelineProperty: string + ): string | undefined { + if (!timelineProperty) return undefined; + const v = entry.getValue?.(timelineProperty); + if (!v || v.isEmpty?.()) return undefined; + + const raw = String(v.toString?.() ?? "").trim(); + if (!raw) return undefined; + + const candidates = splitTimelineList(raw); + if (!candidates.length) return undefined; + + for (const c of candidates) { + if (this.plugin.settings.timelineConfigs[c]) return c; + } + return candidates[0]; + } + + protected getControllerFilePath(): string | undefined { const c = this.controller; if (!c || !isRecord(c)) return undefined; @@ -615,29 +724,150 @@ export default class SimpleTimeline extends Plugin { return undefined; } - private renderCrossRow( + protected resolveImageFromEntryValue( + entry: BasesEntryLike, + imageProp: string, + sourcePath: string + ): string | undefined { + if (!imageProp) return undefined; + + const v = entry.getValue?.(imageProp); + if (!v || v.isEmpty?.()) return undefined; + + // Try to let Bases render the value into HTML, then extract + try { + if (typeof v.renderTo === "function") { + const tmp = document.createElement("div"); + try { + const renderContext = (this.app as unknown as { renderContext?: unknown }) + .renderContext; + v.renderTo(tmp, renderContext ?? this.app); + } catch { + v.renderTo(tmp); + } + const img = tmp.querySelector("img"); + const src = img?.getAttribute("src") ?? undefined; + if (src) return src; + } + } catch { + // ignore and fall back to string parsing + } + + const s = String(v.toString?.() ?? "").trim(); + if (!s) return undefined; + + return this.plugin.resolveLinkToSrc(s, sourcePath); + } + + protected valueToYmd( + value: BasesValueLike | null | undefined + ): { y: number; m: number; d: number } | null { + if (!value) return null; + if (typeof value.isEmpty === "function" && value.isEmpty()) return null; + + // 1) Direct fields (best case) + const yRaw = value.year; + const mRaw = value.month; + const dRaw = value.day; + + const y = Number(yRaw); + const m = Number(mRaw); + const d = Number(dRaw); + + if ( + Number.isFinite(y) && + Number.isFinite(m) && + Number.isFinite(d) && + y !== 0 && + m >= 1 && + m <= 12 && + d >= 1 && + d <= 31 + ) { + return { y, m, d }; + } + + // 2) String parse (YYYY-MM-DD...) + const raw = String(value.toString?.() ?? value).trim(); + const match = raw.match(/^(\d{1,6})-(\d{1,2})-(\d{1,2})/); + if (match) { + return { y: Number(match[1]), m: Number(match[2]), d: Number(match[3]) }; + } + + // 3) Numeric epoch (ms) + const asNum = Number(raw); + if (Number.isFinite(asNum) && asNum > 0) { + const dt = new Date(asNum); + if (!Number.isNaN(dt.getTime())) { + return { y: dt.getFullYear(), m: dt.getMonth() + 1, d: dt.getDate() }; + } + } + + return null; + } + + protected buildItemFromEntry( + entry: BasesEntryLike, + startProp: string, + endProp: string, + titleProp: string, + summaryProp: string, + imageProp: string, + pos: number + ): BasesTimelineItem | null { + const maybeFile = entry.file; + if (!(maybeFile instanceof TFile)) return null; + const file = maybeFile; + + const startValue = entry.getValue?.(startProp); + const start = this.valueToYmd(startValue); + if (!start) return null; + + const endValue = endProp ? entry.getValue?.(endProp) : null; + const end = this.valueToYmd(endValue) ?? undefined; + + const titleValue = titleProp ? entry.getValue?.(titleProp) : null; + const title = + titleValue && !titleValue.isEmpty?.() + ? String(titleValue.toString()) + : file.basename; + + let summary: string | undefined; + const summaryValue = summaryProp ? entry.getValue?.(summaryProp) : null; + if (summaryValue && !summaryValue.isEmpty?.()) { + summary = String(summaryValue.toString()); + } + + const imgSrc = this.resolveImageFromEntryValue(entry, imageProp, file.path); + + return { + entry, + file, + start, + end, + title, + summary, + imgSrc, + sortKey: ymdSortKey(start), + pos + }; + } + + protected renderCrossCard( wrapper: HTMLElement, c: CardData, - cfg: { - maxSummaryLines: number; - cardWidth: number; - cardHeight: number; - boxHeight: number; - sideGapLeft: number; - sideGapRight: number; - align: TimelineAlign; - colors: { - bg?: string; - accent?: string; - hover?: string; - title?: string; - date?: string; - }; - months?: string[] | string; - } - ) { + cfg: ResolvedTimelineRenderConfig + ): HTMLElement { const row = wrapper.createDiv({ cls: "tl-row" }); + // used for jump-to-today + row.dataset.tlStartKey = String(ymdSortKey({ y: c.start.y, m: c.start.m, d: c.start.d })); + if (c.end) { + row.dataset.tlEndKey = String(ymdSortKey({ y: c.end.y, m: c.end.m, d: c.end.d })); + } else { + delete row.dataset.tlEndKey; + } + const align: TimelineAlign = cfg.align ?? "left"; if (align === "right") row.addClass("tl-align-right"); @@ -649,8 +879,7 @@ export default class SimpleTimeline extends Plugin { paddingLeft: `${cfg.sideGapLeft}px`, paddingRight: `${cfg.sideGapRight}px`, "--tl-bg": cfg.colors.bg || "var(--background-primary)", - "--tl-accent": - cfg.colors.accent || "var(--background-modifier-border)", + "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" }); @@ -692,15 +921,11 @@ export default class SimpleTimeline extends Plugin { height: `${BH}px`, boxSizing: "border-box", "--tl-bg": cfg.colors.bg || "var(--background-primary)", - "--tl-accent": - cfg.colors.accent || "var(--background-modifier-border)", + "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" }); - const titleEl = box.createEl("h1", { - cls: "tl-title", - text: c.title - }); + const titleEl = box.createEl("h1", { cls: "tl-title", text: c.title }); const dateEl = box.createEl("h4", { cls: "tl-date", text: this.plugin.formatRange(c.start, c.end) @@ -734,12 +959,7 @@ export default class SimpleTimeline extends Plugin { pointerEvents: "auto" }); - this.plugin.attachHoverForAnchor( - aImg, - media, - c.file.path, - basesSourcePath - ); + this.plugin.attachHoverForAnchor(aImg, media, c.file.path, basesSourcePath); } const aBox = box.createEl("a", { @@ -757,101 +977,477 @@ export default class SimpleTimeline extends Plugin { }); this.plugin.attachHoverForAnchor(aBox, box, c.file.path, basesSourcePath); - this.plugin.applyFixedLineClamp(sum, cfg.maxSummaryLines); + + return row; } - private resolveImageFromEntryValue( - entry: BasesEntryLike, - imageProp: string, - sourcePath: string - ): string | undefined { - if (!imageProp) return undefined; + protected getHorizontalEdges( + c: CardData, + cfg: ResolvedTimelineRenderConfig + ): { left: HorizontalEdge; right: HorizontalEdge } { + const hasMedia = !!c.imgSrc; + const align: TimelineAlign = cfg.align ?? "left"; + if (!hasMedia) return { left: "box", right: "box" }; + if (align === "right") return { left: "box", right: "media" }; + return { left: "media", right: "box" }; + } - const v = entry.getValue?.(imageProp); - if (!v || v.isEmpty?.()) return undefined; + protected applyHorizontalJoin( + a: { el: HTMLElement; right: HorizontalEdge }, + b: { el: HTMLElement; left: HorizontalEdge } + ) { + // Only "glue" boxes. Images keep their rounded corners and size. + if (a.right === "box") a.el.classList.add("tl-h-join-right-box"); + if (b.left === "box") b.el.classList.add("tl-h-join-left-box"); + } - // Try to let Bases render the value into HTML, then extract - try { - if (typeof v.renderTo === "function") { - const tmp = document.createElement("div"); - try { - const renderContext = ( - this.app as unknown as { renderContext?: unknown } - ).renderContext; - v.renderTo(tmp, renderContext ?? this.app); - } catch { - v.renderTo(tmp); + protected async buildCardData( + it: BasesTimelineItem, + tlKey: string | undefined, + months: string[] + ): Promise { + let summary = it.summary; + if (!summary) { + summary = await this.plugin.extractFirstParagraph(it.file); + } + + const mNameStart = + months[(it.start.m - 1 + months.length) % months.length] ?? String(it.start.m); + const mNameEnd = it.end + ? months[(it.end.m - 1 + months.length) % months.length] ?? String(it.end.m) + : undefined; + + return { + file: it.file, + title: it.title, + summary, + start: { ...it.start, mName: mNameStart }, + end: it.end ? { ...it.end, mName: mNameEnd } : undefined, + imgSrc: it.imgSrc, + primaryTl: tlKey + }; + } + } + + class TimelineBasesCrossView extends TimelineBasesBaseView { + readonly type = BASES_VIEW_TYPE_CROSS; + + public onDataUpdated(): void { + this.hostEl.empty(); + + const controls = this.hostEl.createDiv({ cls: "tl-controls" }); + const todayBtn = controls.createEl("button", { text: "Today" }); + + const wrapper = this.hostEl.createDiv({ cls: "tl-wrapper tl-cross-mode" }); + + const timelineConfigNameRaw = this.getOptionString("timelineConfig", "").trim(); + const timelineConfigName = timelineConfigNameRaw || undefined; + + const timelineProperty = this.getOptionString("timelineProperty", "note.timelines").trim(); + + const startProp = this.getOptionString("startProperty", "note.fc-date"); + const endProp = this.getOptionString("endProperty", "note.fc-end"); + const titleProp = this.getOptionString("titleProperty", "note.tl-title"); + const summaryProp = this.getOptionString("summaryProperty", "note.tl-summary"); + const imageProp = this.getOptionString("imageProperty", "note.tl-image"); + const jumpToToday = + this.getOptionString("jumpToToday", "false").trim().toLowerCase() === "true"; + + todayBtn.addEventListener("click", () => { + const today = this.plugin.getCalendariumCurrentYmd(); + if (!today) { + new Notice("Calendarium is not installed"); + return; + } + const ok = this.plugin.jumpContainerToYmd(wrapper, today); + if (!ok) new Notice("No timeline entry found for today"); + }); + + const orderMode = parseBasesOrderMode(this.getOptionString("orderMode", "bases")); + + const token = ++this.renderToken; + const groups = this.data?.groupedData ?? []; + + const hasMeaningfulGroups = groups.some((g) => { + const t = this.getGroupKeyText(g.key); + return !!t && t !== "null"; + }); + + const renderItems = async (items: BasesTimelineItem[]) => { + const list = [...items]; + if (orderMode === "start-asc" || orderMode === "start-desc") { + const dir = orderMode === "start-desc" ? -1 : 1; + list.sort((a, b) => { + if (a.sortKey !== b.sortKey) return dir * (a.sortKey - b.sortKey); + return a.pos - b.pos; + }); + } + + for (const it of list) { + if (token !== this.renderToken) return; + + const tlKey = + timelineConfigName ?? this.getTimelineKeyFromEntry(it.entry, timelineProperty); + + const cfg = this.plugin.getConfigFor(tlKey); + const months: string[] = this.plugin.getMonths(tlKey); + + const card = await this.buildCardData(it, tlKey, months); + if (token !== this.renderToken) return; + + this.renderCrossCard(wrapper, card, cfg); + } + }; + + if (hasMeaningfulGroups) { + void (async () => { + let pos = 0; + for (const group of groups) { + if (token !== this.renderToken) return; + + const keyText = this.getGroupKeyText(group.key); + if (keyText && keyText !== "null") { + const h = wrapper.createEl("h3", { text: keyText }); + h.addClass("tl-bases-group-title"); + } + + const groupItems: BasesTimelineItem[] = []; + for (const entry of group.entries ?? []) { + const it = this.buildItemFromEntry( + entry, + startProp, + endProp, + titleProp, + summaryProp, + imageProp, + pos++ + ); + if (it) groupItems.push(it); + } + + await renderItems(groupItems); } - const img = tmp.querySelector("img"); - const src = img?.getAttribute("src") ?? undefined; - if (src) return src; - } - } catch { - // ignore and fall back to string parsing + + if (token !== this.renderToken) return; + if (jumpToToday) { + const today = this.plugin.getCalendariumCurrentYmd(); + if (today) this.plugin.jumpContainerToYmd(wrapper, today); + } + })(); + return; } - const s = String(v.toString?.() ?? "").trim(); - if (!s) return undefined; + const items: BasesTimelineItem[] = []; + let pos = 0; + for (const group of groups) { + for (const entry of group.entries ?? []) { + const it = this.buildItemFromEntry( + entry, + startProp, + endProp, + titleProp, + summaryProp, + imageProp, + pos++ + ); + if (it) items.push(it); + } + } - return this.plugin.resolveLinkToSrc(s, sourcePath); + void (async () => { + await renderItems(items); + + if (token !== this.renderToken) return; + if (jumpToToday) { + const today = this.plugin.getCalendariumCurrentYmd(); + if (today) this.plugin.jumpContainerToYmd(wrapper, today); + } + })(); } + } - private valueToYmd( - value: BasesValueLike | null | undefined - ): { y: number; m: number; d: number } | null { - if (!value) return null; - if (typeof value.isEmpty === "function" && value.isEmpty()) return null; + class TimelineBasesHorizontalView extends TimelineBasesBaseView { + readonly type = BASES_VIEW_TYPE_HORIZONTAL; - // 1) Direct fields (best case) - const yRaw = value.year; - const mRaw = value.month; - const dRaw = value.day; + public onDataUpdated(): void { + this.hostEl.empty(); - const y = Number(yRaw); - const m = Number(mRaw); - const d = Number(dRaw); + const controls = this.hostEl.createDiv({ cls: "tl-controls" }); + const todayBtn = controls.createEl("button", { text: "Today" }); - if ( - Number.isFinite(y) && - Number.isFinite(m) && - Number.isFinite(d) && - y !== 0 && - m >= 1 && - m <= 12 && - d >= 1 && - d <= 31 - ) { - return { y, m, d }; + // One shared scroller (like markdown timeline-h) + const scroller = this.hostEl.createDiv({ cls: "tl-h-scroller" }); + + const timelineConfigNameRaw = this.getOptionString("timelineConfig", "").trim(); + const timelineConfigName = timelineConfigNameRaw || undefined; + + const timelineProperty = this.getOptionString("timelineProperty", "note.timelines").trim(); + + const startProp = this.getOptionString("startProperty", "note.fc-date"); + const endProp = this.getOptionString("endProperty", "note.fc-end"); + const titleProp = this.getOptionString("titleProperty", "note.tl-title"); + const summaryProp = this.getOptionString("summaryProperty", "note.tl-summary"); + const imageProp = this.getOptionString("imageProperty", "note.tl-image"); + + const jumpToToday = + this.getOptionString("jumpToToday", "false").trim().toLowerCase() === "true"; + + const orderMode = parseBasesOrderMode(this.getOptionString("orderMode", "bases")); + const mode: HorizontalMode = + parseHorizontalMode(this.getOptionString("mode", "stacked")) ?? "stacked"; + + todayBtn.addEventListener("click", () => { + const today = this.plugin.getCalendariumCurrentYmd(); + if (!today) { + new Notice("Calendarium is not installed"); + return; + } + const ok = this.plugin.jumpContainerToYmd(scroller, today, ".tl-h-item"); + if (!ok) new Notice("No timeline entry found for today"); + }); + + const token = ++this.renderToken; + const groups = this.data?.groupedData ?? []; + + const hasMeaningfulGroups = groups.some((g) => { + const t = this.getGroupKeyText(g.key); + return !!t && t !== "null"; + }); + + const renderHorizontalItems = async (items: BasesTimelineItem[], host: HTMLElement) => { + const wrapper = host.createDiv({ + cls: + mode === "stacked" + ? "tl-h-content tl-horizontal tl-h-stacked" + : "tl-h-content tl-horizontal tl-h-mixed" + }); + + const list = [...items]; + if (orderMode === "start-asc" || orderMode === "start-desc") { + const dir = orderMode === "start-desc" ? -1 : 1; + list.sort((a, b) => { + if (a.sortKey !== b.sortKey) return dir * (a.sortKey - b.sortKey); + return a.pos - b.pos; + }); + } + + if (mode === "mixed") { + const rendered: Array<{ el: HTMLElement; left: HorizontalEdge; right: HorizontalEdge }> = + []; + + for (const it of list) { + if (token !== this.renderToken) return; + + const tlKey = + timelineConfigName ?? this.getTimelineKeyFromEntry(it.entry, timelineProperty); + + const cfg = this.plugin.getConfigFor(tlKey); + const months: string[] = this.plugin.getMonths(tlKey); + + const card = await this.buildCardData(it, tlKey, months); + if (token !== this.renderToken) return; + + const rowEl = this.renderCrossCard(wrapper, card, cfg); + rowEl.addClass("tl-h-item"); + + const edges = this.getHorizontalEdges(card, cfg); + rendered.push({ el: rowEl, ...edges }); + } + + for (let i = 0; i < rendered.length - 1; i++) { + this.applyHorizontalJoin( + { el: rendered[i].el, right: rendered[i].right }, + { el: rendered[i + 1].el, left: rendered[i + 1].left } + ); + } + + return; + } + + // stacked: union-axis (only existing dates become columns) + // axis order depends on orderMode: + // - bases: order of first appearance + // - start-asc/desc: date order + const axisKeys: number[] = []; + const seen = new Set(); + for (const it of list) { + const k = ymdSortKey(it.start); + if (!seen.has(k)) { + seen.add(k); + axisKeys.push(k); + } + } + if (orderMode === "start-asc") axisKeys.sort((a, b) => a - b); + if (orderMode === "start-desc") axisKeys.sort((a, b) => b - a); + + const colByKey = new Map(); + for (let i = 0; i < axisKeys.length; i++) colByKey.set(axisKeys[i], i + 1); + + setCssProps(wrapper, { "--tl-h-cols": String(axisKeys.length) }); + + // group by timeline key + const byTl = new Map(); + for (const it of list) { + const tlKey = + timelineConfigName ?? this.getTimelineKeyFromEntry(it.entry, timelineProperty); + const k = tlKey ?? "default"; + + const arr = byTl.get(k); + if (arr) arr.push(it); + else byTl.set(k, [it]); + } + + // Keep Bases order for timeline groups when orderMode=bases (stable), + // otherwise alphabetical (predictable). + const tlKeys: string[] = + orderMode === "bases" ? Array.from(byTl.keys()) : Array.from(byTl.keys()).sort(); + + for (const tlKey of tlKeys) { + if (token !== this.renderToken) return; + + const rowItems = byTl.get(tlKey) ?? []; + const cfg = this.plugin.getConfigFor(tlKey); + const months: string[] = this.plugin.getMonths(tlKey); + + const rowWrap = wrapper.createDiv({ cls: "tl-h-timeline" }); + const rowGrid = rowWrap.createDiv({ cls: "tl-h-row" }); + setCssProps(rowGrid, { "--tl-h-cols": String(axisKeys.length) }); + + // by dateKey, keep stable order + const byDate = new Map(); + for (const it of rowItems) { + const k = ymdSortKey(it.start); + const arr = byDate.get(k); + if (arr) arr.push(it); + else byDate.set(k, [it]); + } + + const dateKeysSorted = Array.from(byDate.keys()).sort((a, b) => { + const ca = colByKey.get(a) ?? 0; + const cb = colByKey.get(b) ?? 0; + return ca - cb; + }); + + const renderedSlots: Array<{ + col: number; + el: HTMLElement; + left: HorizontalEdge; + right: HorizontalEdge; + }> = []; + + for (const dateKey of dateKeysSorted) { + if (token !== this.renderToken) return; + + const col = colByKey.get(dateKey); + if (!col) continue; + + const slot = rowGrid.createDiv({ cls: "tl-h-slot" }); + setCssProps(slot, { "--tl-h-col": String(col) }); + + const cardsAtDate = byDate.get(dateKey) ?? []; + let stored = false; + + for (const it of cardsAtDate) { + const card = await this.buildCardData(it, tlKey, months); + if (token !== this.renderToken) return; + + const rowEl = this.renderCrossCard(slot, card, cfg); + rowEl.addClass("tl-h-item"); + + if (!stored) { + const edges = this.getHorizontalEdges(card, cfg); + renderedSlots.push({ col, el: rowEl, ...edges }); + stored = true; + } + } + } + + renderedSlots.sort((a, b) => a.col - b.col); + for (let i = 0; i < renderedSlots.length - 1; i++) { + const a = renderedSlots[i]; + const b = renderedSlots[i + 1]; + if (b.col === a.col + 1) { + this.applyHorizontalJoin({ el: a.el, right: a.right }, { el: b.el, left: b.left }); + } + } + } + }; + + const renderGroup = async (groupItems: BasesTimelineItem[], groupTitle?: string) => { + if (groupTitle) { + const h = scroller.createDiv({ cls: "tl-bases-group-title" }); + h.createEl("h3", { text: groupTitle }); + } + const groupHost = scroller.createDiv({ cls: "tl-h-group" }); + await renderHorizontalItems(groupItems, groupHost); + }; + + if (hasMeaningfulGroups) { + void (async () => { + let pos = 0; + for (const group of groups) { + if (token !== this.renderToken) return; + + const groupItems: BasesTimelineItem[] = []; + for (const entry of group.entries ?? []) { + const it = this.buildItemFromEntry( + entry, + startProp, + endProp, + titleProp, + summaryProp, + imageProp, + pos++ + ); + if (it) groupItems.push(it); + } + + const title = this.getGroupKeyText(group.key); + await renderGroup(groupItems, title && title !== "null" ? title : undefined); + } + + if (token !== this.renderToken) return; + if (jumpToToday) { + const today = this.plugin.getCalendariumCurrentYmd(); + if (today) this.plugin.jumpContainerToYmd(scroller, today, ".tl-h-item"); + } + })(); + return; } - // 2) String parse (YYYY-MM-DD...) - const raw = String(value.toString?.() ?? value).trim(); - const match = raw.match(/^(\d{1,6})-(\d{1,2})-(\d{1,2})/); - if (match) { - return { - y: Number(match[1]), - m: Number(match[2]), - d: Number(match[3]) - }; - } - - // 3) Numeric epoch (ms) - const asNum = Number(raw); - if (Number.isFinite(asNum) && asNum > 0) { - const dt = new Date(asNum); - if (!Number.isNaN(dt.getTime())) { - return { y: dt.getFullYear(), m: dt.getMonth() + 1, d: dt.getDate() }; + const items: BasesTimelineItem[] = []; + let pos = 0; + for (const group of groups) { + for (const entry of group.entries ?? []) { + const it = this.buildItemFromEntry( + entry, + startProp, + endProp, + titleProp, + summaryProp, + imageProp, + pos++ + ); + if (it) items.push(it); } } - return null; + void (async () => { + await renderHorizontalItems(items, scroller); + + if (token !== this.renderToken) return; + if (jumpToToday) { + const today = this.plugin.getCalendariumCurrentYmd(); + if (today) this.plugin.jumpContainerToYmd(scroller, today, ".tl-h-item"); + } + })(); } } const registerBasesView = maybeRegister as unknown as RegisterBasesViewFn; - // Register the Bases view type + // Cross (existing) registerBasesView.call(this, BASES_VIEW_TYPE_CROSS, { name: "Timeline (Cross)", icon: "lucide-calendar-days", @@ -860,92 +1456,130 @@ export default class SimpleTimeline extends Plugin { options: () => [ { type: "text", - displayName: "Timeline config name (optional)", + displayName: "Timeline config name (optional, forces one config)", key: "timelineConfig", default: "" }, { type: "text", - displayName: "Start date property", - key: "startProperty", - default: "note.fc-date" + displayName: + "Timeline property (used if timelineConfig is empty; can be multi-value)", + key: "timelineProperty", + default: "note.timelines" + }, + { type: "text", displayName: "Start date property", key: "startProperty", default: "note.fc-date" }, + { type: "text", displayName: "Jump to Calendarium 'today' on refresh (true|false)", key: "jumpToToday", default: "false" }, + { type: "text", displayName: "Order mode (bases|start-asc|start-desc). Default: bases", key: "orderMode", default: "bases" }, + { type: "text", displayName: "End date property (optional)", key: "endProperty", default: "note.fc-end" }, + { type: "text", displayName: "Title property", key: "titleProperty", default: "note.tl-title" }, + { type: "text", displayName: "Summary property", key: "summaryProperty", default: "note.tl-summary" }, + { type: "text", displayName: "Image property", key: "imageProperty", default: "note.tl-image" } + ] + }); + + // NEW: Horizontal (Bases) + registerBasesView.call(this, BASES_VIEW_TYPE_HORIZONTAL, { + name: "Timeline (Horizontal)", + icon: "lucide-arrow-left-right", + factory: (controller: unknown, containerEl: HTMLElement) => + new TimelineBasesHorizontalView(controller, containerEl, this), + options: () => [ + { + type: "text", + displayName: "Mode (stacked|mixed). Default: stacked", + key: "mode", + default: "stacked" }, { type: "text", - displayName: "End date property (optional)", - key: "endProperty", - default: "note.fc-end" + displayName: "Timeline config name (optional, forces one config)", + key: "timelineConfig", + default: "" }, { type: "text", - displayName: "Title property", - key: "titleProperty", - default: "note.tl-title" + displayName: + "Timeline property (used if timelineConfig is empty; can be multi-value)", + key: "timelineProperty", + default: "note.timelines" }, - { - type: "text", - displayName: "Summary property", - key: "summaryProperty", - default: "note.tl-summary" - }, - { - type: "text", - displayName: "Image property", - key: "imageProperty", - default: "note.tl-image" - } + { type: "text", displayName: "Start date property", key: "startProperty", default: "note.fc-date" }, + { type: "text", displayName: "Jump to Calendarium 'today' on refresh (true|false)", key: "jumpToToday", default: "false" }, + { type: "text", displayName: "Order mode (bases|start-asc|start-desc). Default: bases", key: "orderMode", default: "bases" }, + { type: "text", displayName: "End date property (optional)", key: "endProperty", default: "note.fc-end" }, + { type: "text", displayName: "Title property", key: "titleProperty", default: "note.tl-title" }, + { type: "text", displayName: "Summary property", key: "summaryProperty", default: "note.tl-summary" }, + { type: "text", displayName: "Image property", key: "imageProperty", default: "note.tl-image" } ] }); } - // ---------- Renderer (timeline-cal) ---------- + // ---------- Markdown renderers ---------- - private async renderTimeline( - src: string, - el: HTMLElement, - ctx: MarkdownPostProcessorContext - ) { - // Code block options - let opts: TimelineBlockOptions = {}; + private parseBlockOptionsObject(src: string): UnknownRecord { + if (!src.trim()) return {}; try { - opts = (parseYaml(src) as TimelineBlockOptions) ?? {}; + const raw = parseYaml(src) as unknown; + return isRecord(raw) ? raw : {}; } catch (e) { - // ignore invalid options YAML console.debug("simple-timeline: invalid block options", e); + return {}; } + } - const namesValue = opts.names ?? opts.name; - const namesRaw: string[] = - typeof namesValue === "string" - ? namesValue - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - : Array.isArray(namesValue) - ? namesValue.map((s) => String(s).trim()).filter(Boolean) - : []; + private parseNamesFromOptions(opts: UnknownRecord): string[] { + const namesValue = opts["names"] ?? opts["name"]; + return normalizeStringArray(namesValue); + } - const filterNames = namesRaw.length ? namesRaw : []; + private parseJumpToTodayFromOptions(opts: UnknownRecord): boolean { + const v = opts["jumpToToday"]; + return v === true; + } - // Collect card data + private getHorizontalEdges(c: CardData, cfg: ResolvedTimelineRenderConfig): { left: HorizontalEdge; right: HorizontalEdge } { + const hasMedia = !!c.imgSrc; + const align: TimelineAlign = cfg.align ?? "left"; + if (!hasMedia) return { left: "box", right: "box" }; + if (align === "right") return { left: "box", right: "media" }; + return { left: "media", right: "box" }; + } + + private applyHorizontalJoin( + a: { el: HTMLElement; right: HorizontalEdge }, + b: { el: HTMLElement; left: HorizontalEdge } + ) { + // Only "glue" boxes. Images keep their rounded corners and size. + if (a.right === "box") a.el.classList.add("tl-h-join-right-box"); + if (b.left === "box") b.el.classList.add("tl-h-join-left-box"); + } + + private async collectCards(filterNames: string[], ctx: MarkdownPostProcessorContext): Promise { const files = this.app.vault.getMarkdownFiles(); const cards: CardData[] = []; + for (const f of files) { const fm = this.getFrontmatter(f); if (!fm) continue; if (!Object.prototype.hasOwnProperty.call(fm, "fc-date")) continue; const timelinesVal = fm["timelines"]; - const tlList: string[] = Array.isArray(timelinesVal) - ? timelinesVal.map((s) => String(s)) - : []; - - if (filterNames.length && !tlList.some((t) => filterNames.includes(t))) { - continue; + let tlList: string[] = []; + if (Array.isArray(timelinesVal)) { + tlList = timelinesVal + .map((x) => primitiveToString(x)) + .filter((x): x is string => typeof x === "string") + .map((s) => s.trim()) + .filter(Boolean); + } else if (typeof timelinesVal === "string") { + tlList = splitTimelineList(timelinesVal); } + if (filterNames.length && !tlList.some((t) => filterNames.includes(t))) continue; + const start = this.parseFcDate(fm["fc-date"] as FCDate | undefined); if (!start) continue; + const end = fm["fc-end"] ? this.parseFcDate(fm["fc-end"] as FCDate | undefined) : undefined; @@ -966,15 +1600,9 @@ export default class SimpleTimeline extends Plugin { const rawSummary = fm["tl-summary"]; let summary: string | undefined; - if (typeof rawSummary === "string") { - summary = rawSummary; - } else if ( - typeof rawSummary === "number" || - typeof rawSummary === "boolean" - ) { - summary = String(rawSummary); - } else if (rawSummary != null) { - // Fallback for unexpected structures: serialize instead of "[object Object]" + if (typeof rawSummary === "string") summary = rawSummary; + else if (typeof rawSummary === "number" || typeof rawSummary === "boolean") summary = String(rawSummary); + else if (rawSummary != null) { try { summary = JSON.stringify(rawSummary); } catch { @@ -982,9 +1610,7 @@ export default class SimpleTimeline extends Plugin { } } - if (!summary) { - summary = await this.extractFirstParagraph(f); - } + if (!summary) summary = await this.extractFirstParagraph(f); const imgSrc = this.resolveImageSrc(f, fm, ctx.sourcePath ?? f.path); @@ -999,125 +1625,291 @@ export default class SimpleTimeline extends Plugin { }); } - // Sort by start date - cards.sort( - (a, b) => - a.start.y * 10000 + - a.start.m * 100 + - a.start.d - - (b.start.y * 10000 + b.start.m * 100 + b.start.d) - ); + cards.sort((a, b) => ymdSortKey(a.start) - ymdSortKey(b.start)); + return cards; + } - // Render - const wrapper = el.createDiv({ cls: "tl-wrapper tl-cross-mode" }); + private renderCrossCardRow( + wrapper: HTMLElement, + c: CardData, + cfg: ResolvedTimelineRenderConfig, + sourcePathForHover: string, + extraRowClasses: string[] = [] + ): HTMLElement { + const row = wrapper.createDiv({ cls: ["tl-row", ...extraRowClasses].join(" ") }); - for (const c of cards) { - const row = wrapper.createDiv({ cls: "tl-row" }); + row.dataset.tlStartKey = String(ymdSortKey({ y: c.start.y, m: c.start.m, d: c.start.d })); + if (c.end) { + row.dataset.tlEndKey = String(ymdSortKey({ y: c.end.y, m: c.end.m, d: c.end.d })); + } else { + delete row.dataset.tlEndKey; + } - // Config for this timeline - const cfg = this.getConfigFor(c.primaryTl); - const align: TimelineAlign = cfg.align ?? "left"; - if (align === "right") row.addClass("tl-align-right"); + const align: TimelineAlign = cfg.align ?? "left"; + if (align === "right") row.addClass("tl-align-right"); - const W = cfg.cardWidth; - const H = cfg.cardHeight; - const BH = cfg.boxHeight; + const W = cfg.cardWidth; + const H = cfg.cardHeight; + const BH = cfg.boxHeight; - setCssProps(row, { - paddingLeft: `${cfg.sideGapLeft}px`, - paddingRight: `${cfg.sideGapRight}px`, - "--tl-bg": cfg.colors.bg || "var(--background-primary)", - "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", - "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" + setCssProps(row, { + paddingLeft: `${cfg.sideGapLeft}px`, + paddingRight: `${cfg.sideGapRight}px`, + "--tl-bg": cfg.colors.bg || "var(--background-primary)", + "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", + "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" + }); + + const grid = row.createDiv({ cls: "tl-grid" }); + const hasMedia = !!c.imgSrc; + grid.addClass(hasMedia ? "has-media" : "no-media"); + + setCssProps(grid, { + display: "grid", + alignItems: "center", + columnGap: "0", + "--tl-media-w": `${W}px` + }); + + let media: HTMLDivElement | null = null; + if (hasMedia && c.imgSrc) { + media = grid.createDiv({ cls: "tl-media" }); + setCssProps(media, { + width: `${W}px`, + height: `${H}px`, + position: "relative" }); - const grid = row.createDiv({ cls: "tl-grid" }); - const hasMedia = !!c.imgSrc; - grid.addClass(hasMedia ? "has-media" : "no-media"); - - setCssProps(grid, { - display: "grid", - alignItems: "center", - columnGap: "0", - "--tl-media-w": `${W}px` + const img = media.createEl("img", { + attr: { src: c.imgSrc, alt: c.title, loading: "lazy" } }); - - let media: HTMLDivElement | null = null; - if (hasMedia && c.imgSrc) { - media = grid.createDiv({ cls: "tl-media" }); - setCssProps(media, { - width: `${W}px`, - height: `${H}px`, - position: "relative" - }); - - const img = media.createEl("img", { - attr: { src: c.imgSrc, alt: c.title, loading: "lazy" } - }); - setCssProps(img, { - width: "100%", - height: "100%", - objectFit: "cover", - display: "block" - }); - } - - const box = grid.createDiv({ - cls: `tl-box callout ${hasMedia ? "has-media" : "no-media"}` - }); - setCssProps(box, { - height: `${BH}px`, - boxSizing: "border-box", - "--tl-bg": cfg.colors.bg || "var(--background-primary)", - "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", - "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" + setCssProps(img, { + width: "100%", + height: "100%", + objectFit: "cover", + display: "block" }); + } - const titleEl = box.createEl("h1", { - cls: "tl-title", - text: c.title - }); - const dateEl = box.createEl("h4", { - cls: "tl-date", - text: this.formatRange(c.start, c.end) - }); - const sum = box.createDiv({ cls: "tl-summary" }); + const box = grid.createDiv({ + cls: `tl-box callout ${hasMedia ? "has-media" : "no-media"}` + }); + setCssProps(box, { + height: `${BH}px`, + boxSizing: "border-box", + "--tl-bg": cfg.colors.bg || "var(--background-primary)", + "--tl-accent": cfg.colors.accent || "var(--background-modifier-border)", + "--tl-hover": cfg.colors.hover || "var(--interactive-accent)" + }); - titleEl.classList.add("tl-title-colored"); - dateEl.classList.add("tl-date-colored"); + const titleEl = box.createEl("h1", { cls: "tl-title", text: c.title }); + const dateEl = box.createEl("h4", { cls: "tl-date", text: this.formatRange(c.start, c.end) }); + const sum = box.createDiv({ cls: "tl-summary" }); - if (cfg.colors.title) { - titleEl.style.color = cfg.colors.title; - } - if (cfg.colors.date) { - dateEl.style.color = cfg.colors.date; - } + titleEl.classList.add("tl-title-colored"); + dateEl.classList.add("tl-date-colored"); - if (c.summary) { - sum.setText(c.summary); - } + if (cfg.colors.title) titleEl.style.color = cfg.colors.title; + if (cfg.colors.date) dateEl.style.color = cfg.colors.date; - // Popover: only image (if present) and box - if (media) { - const aImg = media.createEl("a", { - cls: "internal-link tl-hover-anchor", - href: c.file.path, - attr: { "data-href": c.file.path, "aria-label": c.title } - }); - this.attachHoverForAnchor(aImg, media, c.file.path, ctx.sourcePath); - } - const aBox = box.createEl("a", { + if (c.summary) sum.setText(c.summary); + + // Popover: only image (if present) and box + if (media) { + const aImg = media.createEl("a", { cls: "internal-link tl-hover-anchor", href: c.file.path, attr: { "data-href": c.file.path, "aria-label": c.title } }); - this.attachHoverForAnchor(aBox, box, c.file.path, ctx.sourcePath); + this.attachHoverForAnchor(aImg, media, c.file.path, sourcePathForHover); + } + const aBox = box.createEl("a", { + cls: "internal-link tl-hover-anchor", + href: c.file.path, + attr: { "data-href": c.file.path, "aria-label": c.title } + }); + this.attachHoverForAnchor(aBox, box, c.file.path, sourcePathForHover); - this.applyFixedLineClamp(sum, cfg.maxSummaryLines); + this.applyFixedLineClamp(sum, cfg.maxSummaryLines); + + return row; + } + + // ---------- Renderer (timeline-cal) ---------- + + private async renderTimeline(src: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) { + const opts = this.parseBlockOptionsObject(src); + const filterNames = this.parseNamesFromOptions(opts); + const jumpToToday = this.parseJumpToTodayFromOptions(opts); + + const cards = await this.collectCards(filterNames, ctx); + + const controls = el.createDiv({ cls: "tl-controls" }); + const todayBtn = controls.createEl("button", { text: "Today" }); + + const wrapper = el.createDiv({ cls: "tl-wrapper tl-cross-mode" }); + + todayBtn.addEventListener("click", () => { + const today = this.getCalendariumCurrentYmd(); + if (!today) { + new Notice("Calendarium is not installed."); + return; + } + const ok = this.jumpContainerToYmd(wrapper, today); + if (!ok) new Notice("No timeline entry for today found."); + }); + + for (const c of cards) { + const cfg = this.getConfigFor(c.primaryTl); + this.renderCrossCardRow(wrapper, c, cfg, ctx.sourcePath); + } + + if (jumpToToday) { + const today = this.getCalendariumCurrentYmd(); + if (today) this.jumpContainerToYmd(wrapper, today); } } - // Line clamp helper (made public for Bases view integration) + // ---------- Renderer (timeline-h) ---------- + + private async renderTimelineHorizontal(src: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) { + const opts = this.parseBlockOptionsObject(src); + const filterNames = this.parseNamesFromOptions(opts); + const jumpToToday = this.parseJumpToTodayFromOptions(opts); + const mode: HorizontalMode = parseHorizontalMode(opts["mode"]) ?? "mixed"; + + const cards = await this.collectCards(filterNames, ctx); + + const controls = el.createDiv({ cls: "tl-controls" }); + const todayBtn = controls.createEl("button", { text: "Today" }); + + const scroller = el.createDiv({ cls: "tl-h-scroller" }); + + const wrapper = scroller.createDiv({ + cls: + mode === "stacked" + ? "tl-h-content tl-horizontal tl-h-stacked" + : "tl-h-content tl-horizontal tl-h-mixed" + }); + + todayBtn.addEventListener("click", () => { + const today = this.getCalendariumCurrentYmd(); + if (!today) { + new Notice("Calendarium is not installed."); + return; + } + const ok = this.jumpContainerToYmd(scroller, today, ".tl-h-item"); + if (!ok) new Notice("No timeline entry for today found."); + }); + + if (mode === "mixed") { + const rendered: Array<{ el: HTMLElement; left: HorizontalEdge; right: HorizontalEdge }> = []; + + for (const c of cards) { + const cfg = this.getConfigFor(c.primaryTl); + const rowEl = this.renderCrossCardRow(wrapper, c, cfg, ctx.sourcePath, ["tl-h-item"]); + const edges = this.getHorizontalEdges(c, cfg); + rendered.push({ el: rowEl, ...edges }); + } + + for (let i = 0; i < rendered.length - 1; i++) { + this.applyHorizontalJoin( + { el: rendered[i].el, right: rendered[i].right }, + { el: rendered[i + 1].el, left: rendered[i + 1].left } + ); + } + + if (jumpToToday) { + const today = this.getCalendariumCurrentYmd(); + if (today) this.jumpContainerToYmd(scroller, today, ".tl-h-item"); + } + return; + } + + // stacked: union-axis (only existing dates become columns) + const axisKeys = Array.from(new Set(cards.map((c) => ymdSortKey(c.start)))).sort((a, b) => a - b); + const colByKey = new Map(); + for (let i = 0; i < axisKeys.length; i++) colByKey.set(axisKeys[i], i + 1); + + setCssProps(wrapper, { "--tl-h-cols": String(axisKeys.length) }); + + const byTl = new Map(); + for (const c of cards) { + const key = c.primaryTl ?? "default"; + const list = byTl.get(key); + if (list) list.push(c); + else byTl.set(key, [c]); + } + + const orderedTlKeys = + filterNames.length > 0 + ? filterNames.filter((k) => byTl.has(k)) + : Array.from(byTl.keys()).sort((a, b) => a.localeCompare(b)); + + for (const tlKey of orderedTlKeys) { + const list = byTl.get(tlKey) ?? []; + list.sort((a, b) => ymdSortKey(a.start) - ymdSortKey(b.start)); + + const cfg = this.getConfigFor(tlKey); + + const tlRowWrap = wrapper.createDiv({ cls: "tl-h-timeline" }); + const rowGrid = tlRowWrap.createDiv({ cls: "tl-h-row" }); + setCssProps(rowGrid, { "--tl-h-cols": String(axisKeys.length) }); + + const byDate = new Map(); + for (const c of list) { + const k = ymdSortKey(c.start); + const arr = byDate.get(k); + if (arr) arr.push(c); + else byDate.set(k, [c]); + } + + const sortedDateKeys = Array.from(byDate.keys()).sort((a, b) => { + const ca = colByKey.get(a) ?? 0; + const cb = colByKey.get(b) ?? 0; + return ca - cb; + }); + + const renderedSlots: Array<{ col: number; el: HTMLElement; left: HorizontalEdge; right: HorizontalEdge }> = []; + + for (const dateKey of sortedDateKeys) { + const col = colByKey.get(dateKey); + if (!col) continue; + + const cardsAtDate = byDate.get(dateKey) ?? []; + if (!cardsAtDate.length) continue; + + const slot = rowGrid.createDiv({ cls: "tl-h-slot" }); + setCssProps(slot, { "--tl-h-col": String(col) }); + + let stored = false; + for (const c of cardsAtDate) { + const rowEl = this.renderCrossCardRow(slot, c, cfg, ctx.sourcePath, ["tl-h-item"]); + if (!stored) { + const edges = this.getHorizontalEdges(c, cfg); + renderedSlots.push({ col, el: rowEl, ...edges }); + stored = true; + } + } + } + + renderedSlots.sort((a, b) => a.col - b.col); + for (let i = 0; i < renderedSlots.length - 1; i++) { + const a = renderedSlots[i]; + const b = renderedSlots[i + 1]; + if (b.col === a.col + 1) { + this.applyHorizontalJoin({ el: a.el, right: a.right }, { el: b.el, left: b.left }); + } + } + } + + if (jumpToToday) { + const today = this.getCalendariumCurrentYmd(); + if (today) this.jumpContainerToYmd(scroller, today, ".tl-h-item"); + } + } + + // Line clamp helper public applyFixedLineClamp(summaryEl: HTMLElement, lines: number) { const n = Math.max(1, Math.floor(lines || this.settings.maxSummaryLines)); summaryEl.classList.add("tl-clamp"); @@ -1127,7 +1919,6 @@ export default class SimpleTimeline extends Plugin { }); } - // made public for Bases view integration public formatRange( a: { y: number; m: number; d: number; mName?: string }, b?: { y: number; m: number; d: number; mName?: string } @@ -1140,40 +1931,32 @@ export default class SimpleTimeline extends Plugin { if (sameDay) return f(a); const sameMY = a.y === b.y && a.m === b.m; - return sameMY - ? `${a.d}–${b.d} ${a.mName ?? a.m} ${a.y}` - : `${f(a)} – ${f(b)}`; + return sameMY ? `${a.d}–${b.d} ${a.mName ?? a.m} ${a.y}` : `${f(a)} – ${f(b)}`; } - private parseFcDate( - val: FCDate | undefined - ): { y: number; m: number; d: number } | null { + private parseFcDate(val: FCDate | undefined): { y: number; m: number; d: number } | null { if (!val) return null; if (typeof val === "string") { const m = val.trim().match(/^(\d{1,6})-(\d{1,2})-(\d{1,2})/); - return m - ? { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) } - : null; + return m ? { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) } : null; } const y = Number(val.year); const d = Number(val.day); const mRaw = val.month; let mNum: number; + if (typeof mRaw === "number") { mNum = mRaw; } else { const months = this.getMonths(); - const idx = months.findIndex( - (x) => x.toLowerCase() === String(mRaw).toLowerCase() - ); + const idx = months.findIndex((x) => x.toLowerCase() === String(mRaw).toLowerCase()); mNum = idx >= 0 ? idx + 1 : Number(mRaw) || 1; } return { y, m: mNum, d }; } - // made public for Bases view integration public getMonths(calKey?: string): string[] { if (calKey) { const tl = this.settings.timelineConfigs[calKey]; @@ -1216,25 +1999,8 @@ export default class SimpleTimeline extends Plugin { ]; } - // made public for Bases view integration - public getConfigFor(name?: string): { - maxSummaryLines: number; - cardWidth: number; - cardHeight: number; - boxHeight: number; - sideGapLeft: number; - sideGapRight: number; - align: TimelineAlign; - colors: { - bg?: string; - accent?: string; - hover?: string; - title?: string; - date?: string; - }; - months?: string[] | string; - } { - const base = { + public getConfigFor(name?: string): ResolvedTimelineRenderConfig { + const base: ResolvedTimelineRenderConfig = { maxSummaryLines: this.settings.maxSummaryLines, cardWidth: this.settings.cardWidth, cardHeight: this.settings.cardHeight, @@ -1242,16 +2008,8 @@ export default class SimpleTimeline extends Plugin { sideGapLeft: this.settings.sideGapLeft, sideGapRight: this.settings.sideGapRight, align: "left" as TimelineAlign, - colors: { - ...(this.settings.defaultColors || {}) - } as { - bg?: string; - accent?: string; - hover?: string; - title?: string; - date?: string; - }, - months: undefined as string[] | string | undefined + colors: { ...(this.settings.defaultColors || {}) }, + months: undefined }; if (!name) return base; @@ -1280,13 +2038,11 @@ export default class SimpleTimeline extends Plugin { base.months = tl.months ?? - (!this.settings.migratedLegacy - ? this.settings.monthOverrides[name] - : undefined); + (!this.settings.migratedLegacy ? this.settings.monthOverrides[name] : undefined); + return base; } - // made public for Bases view integration public resolveLinkToSrc(link: string, sourcePath: string): string | undefined { if (/^https?:\/\//i.test(link)) return link; const dest = this.app.metadataCache.getFirstLinkpathDest(link, sourcePath); @@ -1296,11 +2052,7 @@ export default class SimpleTimeline extends Plugin { return undefined; } - private resolveImageSrc( - file: TFile, - fm: FrontmatterLike, - sourcePath: string - ): string | undefined { + private resolveImageSrc(file: TFile, fm: FrontmatterLike, sourcePath: string): string | undefined { const fmImage = fm["tl-image"]; if (typeof fmImage === "string") { const src = this.resolveLinkToSrc(fmImage, sourcePath); @@ -1322,15 +2074,10 @@ export default class SimpleTimeline extends Plugin { } } - const parent = this.app.vault.getAbstractFileByPath( - file.parent?.path ?? "" - ); + const parent = this.app.vault.getAbstractFileByPath(file.parent?.path ?? ""); if (parent instanceof TFolder) { for (const ch of parent.children) { - if ( - ch instanceof TFile && - /\.(png|jpe?g|webp|gif|avif)$/i.test(ch.name) - ) { + if (ch instanceof TFile && /\.(png|jpe?g|webp|gif|avif)$/i.test(ch.name)) { return this.app.vault.getResourcePath(ch); } } @@ -1341,24 +2088,15 @@ export default class SimpleTimeline extends Plugin { private findImageForFile(file: TFile): string | undefined { const cache = this.app.metadataCache.getFileCache(file); for (const e of cache?.embeds ?? []) { - if (/\.(png|jpe?g|webp|gif|avif|bmp|svg)$/i.test(e.link)) { - return e.link; - } + if (/\.(png|jpe?g|webp|gif|avif|bmp|svg)$/i.test(e.link)) return e.link; } for (const l of cache?.links ?? []) { - if (/\.(png|jpe?g|webp|gif|avif)$/i.test(l.link)) { - return l.link; - } + if (/\.(png|jpe?g|webp|gif|avif)$/i.test(l.link)) return l.link; } - const parent = this.app.vault.getAbstractFileByPath( - file.parent?.path ?? "" - ); + const parent = this.app.vault.getAbstractFileByPath(file.parent?.path ?? ""); if (parent instanceof TFolder) { for (const ch of parent.children) { - if ( - ch instanceof TFile && - /\.(png|jpe?g|webp|gif|avif)$/i.test(ch.name) - ) { + if (ch instanceof TFile && /\.(png|jpe?g|webp|gif|avif)$/i.test(ch.name)) { return ch.path; } } @@ -1366,23 +2104,17 @@ export default class SimpleTimeline extends Plugin { return undefined; } - // made public for Bases view integration public attachHoverForAnchor( anchorEl: HTMLElement, hoverParent: HTMLElement, filePath: string, sourcePath: string ) { - const makeForcedHoverEvent = ( - evt?: MouseEvent | TouchEvent - ): MouseEvent | TouchEvent => { + const makeForcedHoverEvent = (evt?: MouseEvent | TouchEvent): MouseEvent | TouchEvent => { // Touch: keep original (no ctrl concept) - if (evt && typeof TouchEvent !== "undefined" && evt instanceof TouchEvent) { - return evt; - } + if (evt && typeof TouchEvent !== "undefined" && evt instanceof TouchEvent) return evt; - const m: MouseEvent | undefined = - evt && evt instanceof MouseEvent ? evt : undefined; + const m: MouseEvent | undefined = evt && evt instanceof MouseEvent ? evt : undefined; const clientX = m?.clientX ?? 0; const clientY = m?.clientY ?? 0; @@ -1451,10 +2183,7 @@ export default class SimpleTimeline extends Plugin { ); } - // made public for Bases view integration - public async extractFirstParagraph( - file: TFile - ): Promise { + public async extractFirstParagraph(file: TFile): Promise { try { const raw = await this.app.vault.read(file); const text = raw.replace(/^---[\s\S]*?---\s*/m, ""); @@ -1462,6 +2191,7 @@ export default class SimpleTimeline extends Plugin { .split(/\r?\n\s*\r?\n/) .map((p) => p.trim()) .filter(Boolean); + for (const p of paras) { if (/^(#{1,6}\s|>\s|[-*+]\s|\d+\.\s)/.test(p)) continue; let clean = p @@ -1471,6 +2201,7 @@ export default class SimpleTimeline extends Plugin { .replace(/[*_~]/g, "") .replace(/\s+/g, " ") .trim(); + if (clean) { if (clean.length > 400) clean = `${clean.slice(0, 397)}…`; return clean; @@ -1496,15 +2227,16 @@ export default class SimpleTimeline extends Plugin { private async migrateLegacyToTimelineConfigs() { if (this.settings.migratedLegacy) return; + const keys = new Set([ ...Object.keys(this.settings.styleOverrides || {}), ...Object.keys(this.settings.monthOverrides || {}) ]); + for (const k of keys) { if (!k) continue; - if (!this.settings.timelineConfigs[k]) { - this.settings.timelineConfigs[k] = {}; - } + if (!this.settings.timelineConfigs[k]) this.settings.timelineConfigs[k] = {}; + const tl = this.settings.timelineConfigs[k]; const so = this.settings.styleOverrides[k]; if (so) { @@ -1514,9 +2246,11 @@ export default class SimpleTimeline extends Plugin { accent: tl.colors?.accent ?? so.accent }; } + const mo = this.settings.monthOverrides[k]; if (mo && !tl.months) tl.months = mo; } + this.settings.migratedLegacy = true; this.settings.styleOverrides = {}; this.settings.monthOverrides = {}; @@ -1534,10 +2268,7 @@ class InputModal extends Modal { private placeholder?: string; private titleText?: string; - constructor( - app: App, - opts: { title?: string; value?: string; placeholder?: string } - ) { + constructor(app: App, opts: { title?: string; value?: string; placeholder?: string }) { super(app); this.valueInit = opts.value; this.placeholder = opts.placeholder; @@ -1547,9 +2278,7 @@ class InputModal extends Modal { onOpen() { const { contentEl } = this; contentEl.empty(); - if (this.titleText) { - contentEl.createEl("h3", { text: this.titleText }); - } + if (this.titleText) contentEl.createEl("h3", { text: this.titleText }); const input = contentEl.createEl("input", { type: "text" }); setCssProps(input, { width: "100%" }); @@ -1576,8 +2305,7 @@ class InputModal extends Modal { } onClose() { - const { contentEl } = this; - contentEl.empty(); + this.contentEl.empty(); } waitForClose(): Promise { @@ -1587,10 +2315,7 @@ class InputModal extends Modal { } } -async function promptModal( - app: App, - opts: { title?: string; value?: string; placeholder?: string } -) { +async function promptModal(app: App, opts: { title?: string; value?: string; placeholder?: string }) { const m = new InputModal(app, opts); m.open(); return m.waitForClose(); @@ -1610,17 +2335,13 @@ class TimelineConfigModal extends Modal { super(app); this.plugin = plugin; this.initialKey = params?.key; - this.initialCfg = params?.cfg - ? (JSON.parse(JSON.stringify(params.cfg)) as TimelineConfig) - : {}; + this.initialCfg = params?.cfg ? (JSON.parse(JSON.stringify(params.cfg)) as TimelineConfig) : {}; } onOpen() { const { contentEl } = this; contentEl.empty(); - contentEl.createEl("h3", { - text: this.initialKey ? "Edit timeline" : "Create timeline" - }); + contentEl.createEl("h3", { text: this.initialKey ? "Edit timeline" : "Create timeline" }); let key = this.initialKey ?? ""; const cfg: TimelineConfig = this.initialCfg ?? {}; @@ -1638,9 +2359,7 @@ class TimelineConfigModal extends Modal { delete cfg[prop]; } else { const n = Number(vv); - if (Number.isFinite(n)) { - cfg[prop] = Math.floor(n); - } + if (Number.isFinite(n)) cfg[prop] = Math.floor(n); } }); }); @@ -1663,11 +2382,8 @@ class TimelineConfigModal extends Modal { d.addOption("right", "Right (image right)"); d.setValue(cur); d.onChange((v) => { - if (v === "right") { - cfg.align = "right"; - } else { - delete cfg.align; // keep config clean: left is the default - } + if (v === "right") cfg.align = "right"; + else delete cfg.align; }); }); @@ -1684,9 +2400,7 @@ class TimelineConfigModal extends Modal { .setDesc("Empty = default/theme color") .addColorPicker((cp) => { cp.setValue(cfg.colors!.bg || ""); - cp.onChange((v) => { - cfg.colors!.bg = v || undefined; - }); + cp.onChange((v) => (cfg.colors!.bg = v || undefined)); }); new Setting(contentEl) @@ -1694,9 +2408,7 @@ class TimelineConfigModal extends Modal { .setDesc("Empty = default/theme color") .addColorPicker((cp) => { cp.setValue(cfg.colors!.accent || ""); - cp.onChange((v) => { - cfg.colors!.accent = v || undefined; - }); + cp.onChange((v) => (cfg.colors!.accent = v || undefined)); }); new Setting(contentEl) @@ -1704,9 +2416,7 @@ class TimelineConfigModal extends Modal { .setDesc("Empty = default/theme color") .addColorPicker((cp) => { cp.setValue(cfg.colors!.hover || ""); - cp.onChange((v) => { - cfg.colors!.hover = v || undefined; - }); + cp.onChange((v) => (cfg.colors!.hover = v || undefined)); }); new Setting(contentEl) @@ -1714,9 +2424,7 @@ class TimelineConfigModal extends Modal { .setDesc("Empty = default/theme color") .addColorPicker((cp) => { cp.setValue(cfg.colors!.title || ""); - cp.onChange((v) => { - cfg.colors!.title = v || undefined; - }); + cp.onChange((v) => (cfg.colors!.title = v || undefined)); }); new Setting(contentEl) @@ -1724,9 +2432,7 @@ class TimelineConfigModal extends Modal { .setDesc("Empty = default/theme color") .addColorPicker((cp) => { cp.setValue(cfg.colors!.date || ""); - cp.onChange((v) => { - cfg.colors!.date = v || undefined; - }); + cp.onChange((v) => (cfg.colors!.date = v || undefined)); }); let monthsText = @@ -1740,9 +2446,7 @@ class TimelineConfigModal extends Modal { .addTextArea((ta) => { ta.inputEl.rows = 3; ta.setValue(monthsText); - ta.onChange((v) => { - monthsText = v; - }); + ta.onChange((v) => (monthsText = v)); }); const btns = contentEl.createDiv({ cls: "modal-button-container" }); @@ -1761,6 +2465,7 @@ class TimelineConfigModal extends Modal { this.close(); this.resolve({ key: k, cfg }); }); + cancelBtn.addEventListener("click", () => { this.close(); this.resolve(null); @@ -1772,9 +2477,7 @@ class TimelineConfigModal extends Modal { } waitForClose(): Promise<{ key: string; cfg: TimelineConfig } | null> { - return new Promise((res) => { - this.resolve = res; - }); + return new Promise((res) => (this.resolve = res)); } } @@ -1783,9 +2486,7 @@ function parseMonths(text: string): string[] | string | undefined { try { if (text.includes("\n") && /(\n-|\n\s*-)/.test(text)) { const y = parseYaml(text) as unknown; - if (Array.isArray(y)) { - return y.map((v) => String(v)).filter(Boolean); - } + if (Array.isArray(y)) return y.map((v) => String(v)).filter(Boolean); } } catch (e) { console.debug("simple-timeline: invalid months YAML", e); @@ -1840,11 +2541,7 @@ class DefaultsModal extends Modal { contentEl.empty(); contentEl.createEl("h3", { text: "Edit defaults" }); - const addNum = ( - name: string, - key: DefaultsNumericKey, - placeholder: string - ) => + const addNum = (name: string, key: DefaultsNumericKey, placeholder: string) => new Setting(contentEl) .setName(name) .addText((t) => { @@ -1852,21 +2549,14 @@ class DefaultsModal extends Modal { t.setValue(String(this.draft[key])); t.onChange((v) => { const n = Number(v); - if (Number.isFinite(n)) { - if (key === "maxSummaryLines") { - this.draft.maxSummaryLines = Math.floor(n); - } else if (key === "cardWidth") { - this.draft.cardWidth = Math.floor(n); - } else if (key === "cardHeight") { - this.draft.cardHeight = Math.floor(n); - } else if (key === "boxHeight") { - this.draft.boxHeight = Math.floor(n); - } else if (key === "sideGapLeft") { - this.draft.sideGapLeft = Math.floor(n); - } else if (key === "sideGapRight") { - this.draft.sideGapRight = Math.floor(n); - } - } + if (!Number.isFinite(n)) return; + + if (key === "maxSummaryLines") this.draft.maxSummaryLines = Math.floor(n); + else if (key === "cardWidth") this.draft.cardWidth = Math.floor(n); + else if (key === "cardHeight") this.draft.cardHeight = Math.floor(n); + else if (key === "boxHeight") this.draft.boxHeight = Math.floor(n); + else if (key === "sideGapLeft") this.draft.sideGapLeft = Math.floor(n); + else if (key === "sideGapRight") this.draft.sideGapRight = Math.floor(n); }); }); @@ -1882,9 +2572,7 @@ class DefaultsModal extends Modal { .setDesc("Empty = theme color") .addColorPicker((cp) => { cp.setValue(this.draft.colors.bg || ""); - cp.onChange((v) => { - this.draft.colors.bg = v || undefined; - }); + cp.onChange((v) => (this.draft.colors.bg = v || undefined)); }); new Setting(contentEl) @@ -1892,9 +2580,7 @@ class DefaultsModal extends Modal { .setDesc("Empty = theme color") .addColorPicker((cp) => { cp.setValue(this.draft.colors.accent || ""); - cp.onChange((v) => { - this.draft.colors.accent = v || undefined; - }); + cp.onChange((v) => (this.draft.colors.accent = v || undefined)); }); new Setting(contentEl) @@ -1902,9 +2588,7 @@ class DefaultsModal extends Modal { .setDesc("Empty = var(--interactive-accent)") .addColorPicker((cp) => { cp.setValue(this.draft.colors.hover || ""); - cp.onChange((v) => { - this.draft.colors.hover = v || undefined; - }); + cp.onChange((v) => (this.draft.colors.hover = v || undefined)); }); new Setting(contentEl) @@ -1912,9 +2596,7 @@ class DefaultsModal extends Modal { .setDesc("Empty = theme color") .addColorPicker((cp) => { cp.setValue(this.draft.colors.title || ""); - cp.onChange((v) => { - this.draft.colors.title = v || undefined; - }); + cp.onChange((v) => (this.draft.colors.title = v || undefined)); }); new Setting(contentEl) @@ -1922,9 +2604,7 @@ class DefaultsModal extends Modal { .setDesc("Empty = theme color") .addColorPicker((cp) => { cp.setValue(this.draft.colors.date || ""); - cp.onChange((v) => { - this.draft.colors.date = v || undefined; - }); + cp.onChange((v) => (this.draft.colors.date = v || undefined)); }); const btns = contentEl.createDiv({ cls: "modal-button-container" }); @@ -1956,9 +2636,7 @@ class DefaultsModal extends Modal { } waitForClose(): Promise { - return new Promise((res) => { - this.resolve = res; - }); + return new Promise((res) => (this.resolve = res)); } } @@ -1980,7 +2658,7 @@ class SimpleTimelineSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName("Bases integration (optional)") .setDesc( - "Registers a custom bases view type timeline cross. Requires Obsidian with bases support. Toggle needs a plugin reload to take effect." + "Registers custom bases view types (cross + horizontal). Requires Obsidian with bases support. Toggle needs a plugin reload to take effect." ) .addToggle((t) => t @@ -1996,9 +2674,7 @@ class SimpleTimelineSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName("Global defaults") - .setDesc( - "Used by all timelines that do not define their own values (including default colors)." - ) + .setDesc("Used by all timelines that do not define their own values (including default colors).") .addButton((b) => b.setButtonText("Edit").onClick(async () => { const saved = await openDefaultsWizard(this.app, this.plugin); @@ -2023,9 +2699,7 @@ class SimpleTimelineSettingsTab extends PluginSettingTab { }) ); - const keys = Object.keys(this.plugin.settings.timelineConfigs).sort((a, b) => - a.localeCompare(b) - ); + const keys = Object.keys(this.plugin.settings.timelineConfigs).sort((a, b) => a.localeCompare(b)); for (const key of keys) { const row = new Setting(containerEl).setName(key); row.addButton((b) => @@ -2050,9 +2724,7 @@ class SimpleTimelineSettingsTab extends PluginSettingTab { ); } - const hint = containerEl.createDiv({ - cls: "setting-item-description" - }); + const hint = containerEl.createDiv({ cls: "setting-item-description" }); hint.textContent = "Note: older “styles per timeline” / “month overrides” were migrated once and will not be imported again."; } @@ -2060,18 +2732,9 @@ class SimpleTimelineSettingsTab extends PluginSettingTab { /* Timeline wizard */ -async function openTimelineWizard( - app: App, - plugin: SimpleTimeline, - existingKey?: string -) { - const initCfg = existingKey - ? plugin.settings.timelineConfigs[existingKey] - : undefined; - const modal = new TimelineConfigModal(app, plugin, { - key: existingKey, - cfg: initCfg - }); +async function openTimelineWizard(app: App, plugin: SimpleTimeline, existingKey?: string) { + const initCfg = existingKey ? plugin.settings.timelineConfigs[existingKey] : undefined; + const modal = new TimelineConfigModal(app, plugin, { key: existingKey, cfg: initCfg }); modal.open(); const res = await modal.waitForClose(); if (!res) return null; diff --git a/styles.css b/styles.css index 7c2e716..7d73ede 100644 --- a/styles.css +++ b/styles.css @@ -94,15 +94,15 @@ .tl-box .tl-title.tl-title-colored, .tl-box .tl-date.tl-date-colored { position: relative; - display: inline-block; /* isoliert den Unterstrich auf Textbreite */ - border: 0 !important; /* Theme-Borders aus */ + display: inline-block; + border: 0 !important; border-bottom: 0 !important; - box-shadow: none !important; /* Theme-Schatten aus */ - background: none !important; /* Theme-Hintergründe aus */ + box-shadow: none !important; + background: none !important; background-image: none !important; } -/* H1 line: slightly thicker, full width, close to the text */ +/* H1 line */ .tl-box .tl-title.tl-title-colored::after { content: ""; pointer-events: none; @@ -120,7 +120,7 @@ opacity: 0.95; } -/* H4 / date line: shorter and visually distinct*/ +/* H4 / date line */ .tl-box .tl-date.tl-date-colored::after { content: ""; pointer-events: none; @@ -138,7 +138,7 @@ opacity: 0.95; } -/* Summary: native multi‑line ellipsis; line count is set from JS */ +/* Summary */ .tl-box .tl-summary { position: relative; flex: 1 1 auto; @@ -152,13 +152,12 @@ word-break: break-word; } -/* Extra safety against overflow (no visual gradient, just clipping) */ .tl-box .tl-summary.tl-clamp { max-height: calc(var(--tl-summary-lines, 7) * var(--tl-summary-lh, 1.4) * 1em); overflow: hidden; } -/* Hover: background changes, border keeps the accent color */ +/* Hover */ .tl-media:hover + .tl-box.callout.has-media, .tl-box.callout:hover { background: var( @@ -180,53 +179,145 @@ cursor: pointer; } +/* ========================================= + Horizontal timeline (timeline-h) + ========================================= */ + +.tl-h-scroller { + --tl-h-col-w: 600px; + --tl-h-gap: 0px; /* cards touch */ + --tl-h-stack-gap: 26px; + + overflow-x: auto; + overflow-y: visible; + -webkit-overflow-scrolling: touch; + padding-bottom: 8px; +} + +.tl-h-content.tl-horizontal.tl-h-mixed { + display: flex; + flex-direction: row; + align-items: stretch; + gap: var(--tl-h-gap, 0px); +} + +.tl-h-content.tl-horizontal.tl-h-stacked { + display: flex; + flex-direction: column; + gap: var(--tl-h-stack-gap, 26px); +} + +.tl-h-item { + flex: 0 0 auto; + width: var(--tl-h-col-w, 600px); + padding-left: 0 !important; + padding-right: 0 !important; +} + +.tl-horizontal .tl-row { + width: auto !important; +} + +/* Ensure fixed card width */ +.tl-h-item .tl-grid.no-media { + grid-template-columns: var(--tl-h-col-w, 600px); +} + +.tl-h-item .tl-grid.has-media { + grid-template-columns: + var(--tl-media-w, 200px) + max(160px, calc(var(--tl-h-col-w, 600px) - var(--tl-media-w, 200px))); +} + +.tl-h-item.tl-align-right .tl-grid.has-media { + grid-template-columns: + max(160px, calc(var(--tl-h-col-w, 600px) - var(--tl-media-w, 200px))) + var(--tl-media-w, 200px); +} + +/* Join styling (ONLY when JS adds join-classes) */ +.tl-h-item.tl-h-join-left-box .tl-box.callout { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.tl-h-item.tl-h-join-right-box .tl-box.callout { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +/* stacked grid per timeline row */ +.tl-h-timeline { + display: block; +} + +.tl-h-row { + display: grid; + grid-template-columns: repeat(var(--tl-h-cols, 1), var(--tl-h-col-w, 600px)); + column-gap: var(--tl-h-gap, 0px); + row-gap: 12px; + align-items: start; +} + +.tl-h-slot { + grid-column-start: var(--tl-h-col); + display: flex; + flex-direction: column; + gap: 12px; +} + /* ========================================= Responsive behaviour (tablet / mobile) ========================================= */ @media (max-width: 900px) { + .tl-horizontal { + --tl-h-col-w: 420px; + --tl-h-gap: 0px; + } + .tl-wrapper.tl-cross-mode { gap: 16px; } - .tl-row { + .tl-wrapper.tl-cross-mode .tl-row { padding-left: 16px !important; padding-right: 16px !important; display: flex; justify-content: center; } - .tl-grid { - grid-template-columns: 1fr !important; /* image above card */ + .tl-wrapper.tl-cross-mode .tl-grid { + grid-template-columns: 1fr !important; justify-items: stretch; width: 100%; max-width: 560px; row-gap: 0; } - /* IMPORTANT: override desktop grid-areas for single-column layout */ - .tl-grid.has-media { + .tl-wrapper.tl-cross-mode .tl-grid.has-media { grid-template-areas: "media" "box" !important; } - .tl-grid.no-media { + .tl-wrapper.tl-cross-mode .tl-grid.no-media { grid-template-areas: "box" !important; } - /* Image on top: visually part of the same “card”. */ - .tl-media { + .tl-wrapper.tl-cross-mode .tl-media { width: 100% !important; max-width: 560px; height: auto !important; border-radius: 10px 10px 0 0; border: none; - margin-bottom: -6px; /* lets the image slightly overlap the box */ + margin-bottom: -6px; background: transparent; } - .tl-media img { + .tl-wrapper.tl-cross-mode .tl-media img { width: 100%; height: auto !important; object-fit: cover; @@ -234,31 +325,27 @@ border-radius: 10px 10px 0 0; } - /* Box below the image: together they form a single card */ - .tl-box.callout { + .tl-wrapper.tl-cross-mode .tl-box.callout { width: 100%; max-width: 560px; } - /* Mobile: stacked -> box always below image. Alignment doesn't matter here. */ - .tl-box.callout.has-media { + .tl-wrapper.tl-cross-mode .tl-box.callout.has-media { height: auto !important; border-radius: 0 0 10px 10px; border: 1px solid var(--tl-accent, var(--background-modifier-border)); - border-top: none; /* no double border directly under the image */ + border-top: none; margin-top: 0; } - /* Override desktop cut-edges for both alignments */ - .tl-row.tl-align-right .tl-box.callout.has-media, - .tl-row:not(.tl-align-right) .tl-box.callout.has-media { + .tl-wrapper.tl-cross-mode .tl-row.tl-align-right .tl-box.callout.has-media, + .tl-wrapper.tl-cross-mode .tl-row:not(.tl-align-right) .tl-box.callout.has-media { border: 1px solid var(--tl-accent, var(--background-modifier-border)); border-top: none; border-radius: 0 0 10px 10px; } - /* Without image: card stands on its own, full width, auto height */ - .tl-box.callout.no-media { + .tl-wrapper.tl-cross-mode .tl-box.callout.no-media { width: 100%; max-width: 560px; height: auto !important; diff --git a/versions.json b/versions.json index 6370eca..a01eb73 100644 --- a/versions.json +++ b/versions.json @@ -2,5 +2,6 @@ "0.2.9": "1.6.7", "0.3.1": "1.6.7", "0.3.2": "1.6.7", - "0.3.3": "1.6.7" + "0.3.3": "1.6.7", + "0.4.0": "1.6.7" } \ No newline at end of file