fix codeblock
This commit is contained in:
535
main.ts
535
main.ts
@@ -1,5 +1,6 @@
|
|||||||
import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml, moment } from "obsidian";
|
import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml, moment } from "obsidian";
|
||||||
import { RangeSetBuilder } from "@codemirror/state";
|
import { RangeSetBuilder } from "@codemirror/state";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
import {
|
import {
|
||||||
Decoration,
|
Decoration,
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
@@ -9,43 +10,70 @@ import {
|
|||||||
WidgetType,
|
WidgetType,
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
Settings
|
Settings
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
interface DateCalcSettings {
|
interface DateCalcSettings {
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
hideResultWhileCursorInside: boolean;
|
hideResultWhileCursorInside: boolean;
|
||||||
verbose: boolean;
|
verbose: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: DateCalcSettings = {
|
const DEFAULT_SETTINGS: DateCalcSettings = {
|
||||||
debug: false,
|
debug: false,
|
||||||
hideResultWhileCursorInside: true,
|
hideResultWhileCursorInside: true,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Types & Interfaces
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
|
interface DateCalcResult {
|
||||||
|
text: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface DateCalcConfig {
|
||||||
|
type?: string;
|
||||||
|
birthday?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
date?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
until?: string;
|
||||||
|
since?: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
label?: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
Moment helpers
|
Moment helpers
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
type M = moment.Moment;
|
type M = moment.Moment;
|
||||||
|
|
||||||
|
|
||||||
function isLivePreviewEditor(cm: EditorView): boolean {
|
function isLivePreviewEditor(cm: EditorView): boolean {
|
||||||
const container = cm.dom.closest(".markdown-source-view");
|
const container = cm.dom.closest(".markdown-source-view");
|
||||||
return !!container?.classList.contains("is-live-preview");
|
return !!container?.classList.contains("is-live-preview");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse various inputs into a Moment.
|
|
||||||
* - "YYYY-MM-DD" => local startOf("day")
|
|
||||||
* - YAML Date objects that represent UTC midnight => keep same calendar date locally
|
|
||||||
* - Other date-time inputs => moment(...) as-is
|
|
||||||
*/
|
|
||||||
export function parseMoment(input: unknown): M | null {
|
export function parseMoment(input: unknown): M | null {
|
||||||
if (input == null || input === "") return null;
|
if (input == null || input === "") return null;
|
||||||
|
|
||||||
// YAML can produce Date objects (often representing UTC midnight for date-only scalars)
|
|
||||||
if (input instanceof Date) {
|
if (input instanceof Date) {
|
||||||
if (Number.isNaN(input.getTime())) return null;
|
if (Number.isNaN(input.getTime())) return null;
|
||||||
|
|
||||||
@@ -67,24 +95,19 @@ export function parseMoment(input: unknown): M | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const s = String(input).trim();
|
const s = String(input).trim();
|
||||||
|
|
||||||
// Strict date-only => local midnight
|
|
||||||
const dateOnly = moment(s, "YYYY-MM-DD", true);
|
const dateOnly = moment(s, "YYYY-MM-DD", true);
|
||||||
if (dateOnly.isValid()) return dateOnly.startOf("day");
|
if (dateOnly.isValid()) return dateOnly.startOf("day");
|
||||||
|
|
||||||
// Fallback: let Moment interpret other formats (ISO with time/offset, etc.)
|
|
||||||
const any = moment(s);
|
const any = moment(s);
|
||||||
return any.isValid() ? any : null;
|
return any.isValid() ? any : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatNiceDate(m: M): string {
|
function formatNiceDate(m: M): string {
|
||||||
return m.format("MMMM Do, YYYY");
|
return m.format("MMMM Do, YYYY");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calendar-aware breakdown (years, months, days, hours) with remainder behavior.
|
|
||||||
* Returns a formatted string (verbose or concise).
|
|
||||||
*/
|
|
||||||
function formatSpan(from: M, to: M, verbose: boolean): { text: string; isNegative: boolean } {
|
function formatSpan(from: M, to: M, verbose: boolean): { text: string; isNegative: boolean } {
|
||||||
let a = from.clone();
|
let a = from.clone();
|
||||||
let b = to.clone();
|
let b = to.clone();
|
||||||
@@ -114,10 +137,12 @@ function formatSpan(from: M, to: M, verbose: boolean): { text: string; isNegativ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
Inline args parsing
|
Config parsing & normalization
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
function parseKv(params: string): Record<string, string> {
|
function parseKv(params: string): Record<string, string> {
|
||||||
const cfg: Record<string, string> = {};
|
const cfg: Record<string, string> = {};
|
||||||
const rx = /(\w+)=("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+)/g;
|
const rx = /(\w+)=("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+)/g;
|
||||||
@@ -137,27 +162,26 @@ function parseKv(params: string): Record<string, string> {
|
|||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInlineArgs(params: string): any {
|
|
||||||
|
function parseConfig(params: string): DateCalcConfig | string {
|
||||||
if (!params) return {};
|
if (!params) return {};
|
||||||
|
|
||||||
// Prefer key=value (fast, predictable)
|
|
||||||
if (params.includes("=")) return parseKv(params);
|
if (params.includes("=")) return parseKv(params);
|
||||||
|
|
||||||
// Otherwise attempt YAML (supports `{ type: diff, from: ..., to: ... }`)
|
|
||||||
try {
|
try {
|
||||||
const v = parseYaml(params);
|
const v = parseYaml(params);
|
||||||
return v ?? {};
|
return v ?? {};
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to raw string type alias like: `date-calc: birthday`
|
|
||||||
return params.trim();
|
return params.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeType(paramsRaw: string, cfg: any): { type: string; cfg: any } {
|
|
||||||
|
|
||||||
|
function normalizeConfig(paramsRaw: string, cfg: any): { type: string; cfg: DateCalcConfig } {
|
||||||
const params = paramsRaw.trim();
|
const params = paramsRaw.trim();
|
||||||
let type = (cfg?.type ?? "").toString().toLowerCase().trim();
|
let type = (cfg?.type ?? "").toString().toLowerCase().trim();
|
||||||
|
|
||||||
// `date-calc: birthday`
|
|
||||||
if (!type && typeof cfg === "string" && /^[A-Za-z][A-Za-z-]*$/.test(cfg.trim())) {
|
if (!type && typeof cfg === "string" && /^[A-Za-z][A-Za-z-]*$/.test(cfg.trim())) {
|
||||||
type = cfg.trim().toLowerCase();
|
type = cfg.trim().toLowerCase();
|
||||||
cfg = {};
|
cfg = {};
|
||||||
@@ -167,7 +191,6 @@ function normalizeType(paramsRaw: string, cfg: any): { type: string; cfg: any }
|
|||||||
cfg = {};
|
cfg = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infer from fields
|
|
||||||
if (!type && cfg && typeof cfg === "object") {
|
if (!type && cfg && typeof cfg === "object") {
|
||||||
if (cfg.birthday || cfg.birthdate) type = "birthday";
|
if (cfg.birthday || cfg.birthdate) type = "birthday";
|
||||||
else if (cfg.to || cfg.until) type = "countdown";
|
else if (cfg.to || cfg.until) type = "countdown";
|
||||||
@@ -175,7 +198,6 @@ function normalizeType(paramsRaw: string, cfg: any): { type: string; cfg: any }
|
|||||||
else if (cfg.from && cfg.to) type = "diff";
|
else if (cfg.from && cfg.to) type = "diff";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aliases
|
|
||||||
if (type === "bday") type = "birthday";
|
if (type === "bday") type = "birthday";
|
||||||
if (type === "until") type = "countdown";
|
if (type === "until") type = "countdown";
|
||||||
if (type === "difference") type = "diff";
|
if (type === "difference") type = "diff";
|
||||||
@@ -183,115 +205,32 @@ function normalizeType(paramsRaw: string, cfg: any): { type: string; cfg: any }
|
|||||||
return { type, cfg };
|
return { type, cfg };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
Inline processor
|
Core date calculation logic
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
// raw should be WITHOUT backticks: "date-calc: birthday=1992-08-16"
|
|
||||||
function processInlineDateCalc(
|
function calculateDateResult(
|
||||||
raw: string,
|
type: string,
|
||||||
|
cfg: DateCalcConfig,
|
||||||
app: App,
|
app: App,
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
settings: DateCalcSettings
|
verbose: boolean
|
||||||
): { text: string; tooltip?: string } {
|
): DateCalcResult {
|
||||||
if (!/^date-calc\s*:/.test(raw)) return { text: "" };
|
|
||||||
|
|
||||||
const paramsRaw = raw.replace(/^date-calc\s*:/, "").trim();
|
const useVerbose = cfg.verbose !== undefined ? cfg.verbose : verbose;
|
||||||
let cfg: any = parseInlineArgs(paramsRaw);
|
|
||||||
const norm = normalizeType(paramsRaw, cfg);
|
|
||||||
const type = norm.type;
|
|
||||||
cfg = norm.cfg;
|
|
||||||
|
|
||||||
if (!type) return { text: "" };
|
|
||||||
|
|
||||||
const verbose = settings.verbose;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "birthday": {
|
case "birthday":
|
||||||
const fileCache = app.metadataCache.getCache(sourcePath);
|
return calculateBirthday(cfg, app, sourcePath, useVerbose);
|
||||||
const fm = (fileCache?.frontmatter ?? {}) as any;
|
case "countdown":
|
||||||
|
return calculateCountdown(cfg, useVerbose);
|
||||||
const bstr =
|
case "diff":
|
||||||
cfg.birthday ||
|
return calculateDiff(cfg, useVerbose);
|
||||||
cfg.birthdate ||
|
case "since":
|
||||||
cfg.date ||
|
return calculateSince(cfg, useVerbose);
|
||||||
fm?.birthday ||
|
|
||||||
fm?.birthdate;
|
|
||||||
|
|
||||||
const bd = parseMoment(bstr)?.startOf("day");
|
|
||||||
if (!bd) return { text: `date-calc: Missing or invalid "birthday" date.` };
|
|
||||||
|
|
||||||
const today = moment().startOf("day");
|
|
||||||
|
|
||||||
const age = today.diff(bd, "years");
|
|
||||||
|
|
||||||
const next = bd.clone().year(today.year());
|
|
||||||
if (next.isBefore(today, "day")) next.add(1, "year");
|
|
||||||
|
|
||||||
const daysUntil = next.diff(today, "days");
|
|
||||||
|
|
||||||
let msg: string;
|
|
||||||
if (daysUntil === 0) msg = verbose ? "Wish them Happy Birthday!" : "Birthday!";
|
|
||||||
else if (daysUntil === 1) msg = verbose ? "Their birthday is tomorrow!" : "Tomorrow!";
|
|
||||||
else if (daysUntil > 31) {
|
|
||||||
const monthsUntil = next.diff(today, "months");
|
|
||||||
msg = verbose ? `Next birthday in ${monthsUntil} months.` : `Next in ${monthsUntil}mo`;
|
|
||||||
} else {
|
|
||||||
msg = verbose ? `Next birthday in ${daysUntil} days.` : `Next in ${daysUntil}d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageStr = verbose ? `${age} years` : `${age}y`;
|
|
||||||
return {
|
|
||||||
text: `Age: ${ageStr}. ${msg}`,
|
|
||||||
tooltip: formatNiceDate(bd),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case "countdown": {
|
|
||||||
const to = parseMoment(cfg.to || cfg.date || cfg.until);
|
|
||||||
if (!to) return { text: `date-calc: Missing or invalid "to" date.` };
|
|
||||||
|
|
||||||
const from = parseMoment(cfg.from) ?? moment();
|
|
||||||
const label = cfg.label ? `${cfg.label}: ` : "";
|
|
||||||
|
|
||||||
const span = formatSpan(from, to, verbose);
|
|
||||||
|
|
||||||
const text = !span.isNegative
|
|
||||||
? `${label}${verbose ? "Countdown: " : ""}${span.text}`
|
|
||||||
: `${label}${verbose ? "Event passed " : ""}${span.text}${verbose ? " ago" : ""}`;
|
|
||||||
|
|
||||||
return { text };
|
|
||||||
}
|
|
||||||
|
|
||||||
case "diff": {
|
|
||||||
const from = parseMoment(cfg.from || cfg.start);
|
|
||||||
const to = parseMoment(cfg.to || cfg.end);
|
|
||||||
if (!from || !to) return { text: `date-calc: Provide valid "from" and "to" dates.` };
|
|
||||||
|
|
||||||
const span = formatSpan(from, to, verbose);
|
|
||||||
|
|
||||||
const text = verbose
|
|
||||||
? `Difference: ${span.text}${span.isNegative ? " (to is before from)" : ""}`
|
|
||||||
: `Diff: ${span.text}${span.isNegative ? " (reverse)" : ""}`;
|
|
||||||
|
|
||||||
return { text };
|
|
||||||
}
|
|
||||||
|
|
||||||
case "since": {
|
|
||||||
const since = parseMoment(cfg.since || cfg.from || cfg.date);
|
|
||||||
if (!since) return { text: `date-calc: Missing or invalid "since" date.` };
|
|
||||||
|
|
||||||
const now = moment();
|
|
||||||
const span = formatSpan(since, now, verbose);
|
|
||||||
|
|
||||||
const text = !span.isNegative
|
|
||||||
? `Since: ${span.text}${verbose ? " ago" : ""}`
|
|
||||||
: `In: ${span.text}`;
|
|
||||||
|
|
||||||
return { text };
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { text: "date-calc: Unknown type. Supported: birthday, countdown, diff, since" };
|
return { text: "date-calc: Unknown type. Supported: birthday, countdown, diff, since" };
|
||||||
}
|
}
|
||||||
@@ -300,28 +239,160 @@ function processInlineDateCalc(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function calculateBirthday(
|
||||||
|
cfg: DateCalcConfig,
|
||||||
|
app: App,
|
||||||
|
sourcePath: string,
|
||||||
|
verbose: boolean
|
||||||
|
): DateCalcResult {
|
||||||
|
const fileCache = app.metadataCache.getCache(sourcePath);
|
||||||
|
const fm = (fileCache?.frontmatter ?? {}) as any;
|
||||||
|
|
||||||
|
const bstr = cfg.birthday || cfg.birthdate || cfg.date || fm?.birthday || fm?.birthdate;
|
||||||
|
const bd = parseMoment(bstr)?.startOf("day");
|
||||||
|
if (!bd) return { text: `date-calc: Missing or invalid "birthday" date.` };
|
||||||
|
|
||||||
|
const today = moment().startOf("day");
|
||||||
|
const age = today.diff(bd, "years");
|
||||||
|
|
||||||
|
const next = bd.clone().year(today.year());
|
||||||
|
if (next.isBefore(today, "day")) next.add(1, "year");
|
||||||
|
const daysUntil = next.diff(today, "days");
|
||||||
|
|
||||||
|
let msg: string;
|
||||||
|
if (daysUntil === 0) {
|
||||||
|
msg = verbose ? "Wish them Happy Birthday!" : "Happy bday!";
|
||||||
|
} else if (daysUntil === 1) {
|
||||||
|
msg = verbose ? "Their birthday is tomorrow!" : "Bday's tomorrow!";
|
||||||
|
} else if (daysUntil > 31) {
|
||||||
|
const monthsUntil = next.diff(today, "months");
|
||||||
|
msg = verbose ? `Next birthday in ${monthsUntil} months.` : `Next bday in ${monthsUntil}mo`;
|
||||||
|
} else {
|
||||||
|
msg = verbose ? `Next birthday in ${daysUntil} days.` : `Next bday in ${daysUntil}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageStr = verbose ? `${age} years old` : `${age}y`;
|
||||||
|
return { text: `${ageStr}. ${msg}`, tooltip: formatNiceDate(bd) };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function calculateCountdown(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
|
||||||
|
const to = parseMoment(cfg.to || cfg.date || cfg.until);
|
||||||
|
if (!to) return { text: `date-calc: Missing or invalid "to" date.` };
|
||||||
|
|
||||||
|
const from = parseMoment(cfg.from) ?? moment();
|
||||||
|
const label = cfg.label ? `${cfg.label}: ` : "";
|
||||||
|
const span = formatSpan(from, to, verbose);
|
||||||
|
|
||||||
|
const text = !span.isNegative
|
||||||
|
? `${label}${verbose ? "Countdown: " : ""}${span.text}`
|
||||||
|
: `${label}${verbose ? "Event passed " : ""}${span.text}${verbose ? " ago" : ""}`;
|
||||||
|
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function calculateDiff(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
|
||||||
|
const from = parseMoment(cfg.from || cfg.start);
|
||||||
|
const to = parseMoment(cfg.to || cfg.end);
|
||||||
|
if (!from || !to) return { text: `date-calc: Provide valid "from" and "to" dates.` };
|
||||||
|
|
||||||
|
const span = formatSpan(from, to, verbose);
|
||||||
|
|
||||||
|
const text = verbose
|
||||||
|
? `Difference: ${span.text}${span.isNegative ? " (to is before from)" : ""}`
|
||||||
|
: `Diff: ${span.text}${span.isNegative ? " (reverse)" : ""}`;
|
||||||
|
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function calculateSince(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
|
||||||
|
const since = parseMoment(cfg.since || cfg.from || cfg.date);
|
||||||
|
if (!since) return { text: `date-calc: Missing or invalid "since" date.` };
|
||||||
|
|
||||||
|
const now = moment();
|
||||||
|
const span = formatSpan(since, now, verbose);
|
||||||
|
|
||||||
|
const text = !span.isNegative
|
||||||
|
? `Since: ${span.text}${verbose ? " ago" : ""}`
|
||||||
|
: `In: ${span.text}`;
|
||||||
|
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
Live Preview decorations
|
Processing entry points
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
class DateCalcWidget extends WidgetType {
|
|
||||||
|
function processInlineCode(
|
||||||
|
raw: string,
|
||||||
|
app: App,
|
||||||
|
sourcePath: string,
|
||||||
|
settings: DateCalcSettings
|
||||||
|
): DateCalcResult {
|
||||||
|
if (!/^date-calc\s*:/.test(raw)) return { text: "" };
|
||||||
|
|
||||||
|
const paramsRaw = raw.replace(/^date-calc\s*:/, "").trim();
|
||||||
|
const cfg: any = parseConfig(paramsRaw);
|
||||||
|
const norm = normalizeConfig(paramsRaw, cfg);
|
||||||
|
|
||||||
|
if (!norm.type) return { text: "" };
|
||||||
|
|
||||||
|
return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function processFencedBlock(
|
||||||
|
source: string,
|
||||||
|
app: App,
|
||||||
|
sourcePath: string,
|
||||||
|
settings: DateCalcSettings
|
||||||
|
): DateCalcResult {
|
||||||
|
let cfg: any = {};
|
||||||
|
try {
|
||||||
|
cfg = parseYaml(source) ?? {};
|
||||||
|
} catch {
|
||||||
|
cfg = parseConfig(source.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = normalizeConfig("", cfg);
|
||||||
|
if (!norm.type) {
|
||||||
|
return { text: 'date-calc: Missing "type" (birthday/countdown/diff/since).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Widgets
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
|
class DateCalcInlineWidget extends WidgetType {
|
||||||
constructor(
|
constructor(
|
||||||
private text: string,
|
private text: string,
|
||||||
private tooltip: string | undefined,
|
private tooltip: string | undefined,
|
||||||
private from: number,
|
private from: number,
|
||||||
private to: number
|
private to: number
|
||||||
) { super(); }
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
eq(other: DateCalcWidget) {
|
eq(other: DateCalcInlineWidget) {
|
||||||
return other.text === this.text && other.tooltip === this.tooltip;
|
return other.text === this.text && other.tooltip === this.tooltip;
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM() {
|
toDOM() {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "date-calc-inline";
|
span.className = "date-calc-inline";
|
||||||
span.textContent = this.text;
|
span.textContent = this.text;
|
||||||
span.setAttribute("contenteditable", "false");
|
span.setAttribute("contenteditable", "false");
|
||||||
|
|
||||||
// store source range for click-to-edit
|
|
||||||
span.dataset.dcFrom = String(this.from);
|
span.dataset.dcFrom = String(this.from);
|
||||||
span.dataset.dcTo = String(this.to);
|
span.dataset.dcTo = String(this.to);
|
||||||
|
|
||||||
@@ -332,13 +403,63 @@ class DateCalcWidget extends WidgetType {
|
|||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
ignoreEvent() { return false; } // important for interaction [web:64]
|
ignoreEvent() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSettings) {
|
|
||||||
const re = /`date-calc:[^`]*`/g;
|
|
||||||
|
|
||||||
// Scan past viewport boundaries so matches don't flicker/miss near edges
|
class DateCalcBlockWidget extends WidgetType {
|
||||||
|
constructor(private text: string, private tooltip?: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: DateCalcBlockWidget) {
|
||||||
|
return other.text === this.text && other.tooltip === this.tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM() {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "date-calc-block";
|
||||||
|
div.textContent = this.text;
|
||||||
|
div.setAttribute("contenteditable", "false");
|
||||||
|
if (this.tooltip) div.setAttribute("aria-label", this.tooltip);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Helpers for fenced code
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
|
function parseFencedCode(text: string): { lang: string; body: string } | null {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
if (lines.length < 2) return null;
|
||||||
|
|
||||||
|
const m = lines[0].match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
const fence = m[2];
|
||||||
|
const info = (m[3] ?? "").trim();
|
||||||
|
const lang = (info.split(/\s+/)[0] ?? "").toLowerCase();
|
||||||
|
|
||||||
|
const last = lines[lines.length - 1].trim();
|
||||||
|
if (!last.startsWith(fence)) return null;
|
||||||
|
|
||||||
|
const body = lines.slice(1, -1).join("\n");
|
||||||
|
return { lang, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Live Preview decorations
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
|
export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSettings) {
|
||||||
|
const inlineRe = /`date-calc:[^`]*`/g;
|
||||||
const margin = 2000;
|
const margin = 2000;
|
||||||
|
|
||||||
return ViewPlugin.fromClass(
|
return ViewPlugin.fromClass(
|
||||||
@@ -356,101 +477,133 @@ export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSetting
|
|||||||
}
|
}
|
||||||
|
|
||||||
build(view: EditorView): DecorationSet {
|
build(view: EditorView): DecorationSet {
|
||||||
// Do nothing in Source mode so raw markdown stays visible
|
|
||||||
if (!isLivePreviewEditor(view)) return Decoration.none;
|
if (!isLivePreviewEditor(view)) return Decoration.none;
|
||||||
|
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const sourcePath = app.workspace.getActiveFile()?.path ?? "";
|
const sourcePath = app.workspace.getActiveFile()?.path ?? "";
|
||||||
const head = view.state.selection.main.head;
|
const sel = view.state.selection.main;
|
||||||
|
|
||||||
const b = new RangeSetBuilder<Decoration>();
|
const b = new RangeSetBuilder<Decoration>();
|
||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
|
|
||||||
let matchesSeen = 0;
|
// Process inline code
|
||||||
let replaced = 0;
|
|
||||||
|
|
||||||
for (const r of view.visibleRanges) {
|
for (const r of view.visibleRanges) {
|
||||||
const scanFrom = Math.max(0, r.from - margin);
|
const scanFrom = Math.max(0, r.from - margin);
|
||||||
const scanTo = Math.min(view.state.doc.length, r.to + margin);
|
const scanTo = Math.min(view.state.doc.length, r.to + margin);
|
||||||
|
|
||||||
const slice = view.state.doc.sliceString(scanFrom, scanTo);
|
const slice = view.state.doc.sliceString(scanFrom, scanTo);
|
||||||
re.lastIndex = 0;
|
|
||||||
|
|
||||||
|
inlineRe.lastIndex = 0;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(slice)) !== null) {
|
while ((m = inlineRe.exec(slice)) !== null) {
|
||||||
matchesSeen++;
|
|
||||||
|
|
||||||
const absStart = scanFrom + m.index;
|
const absStart = scanFrom + m.index;
|
||||||
const absEnd = absStart + m[0].length;
|
const absEnd = absStart + m[0].length;
|
||||||
|
const key = `inline:${absStart}:${absEnd}`;
|
||||||
// De-dupe overlaps from overlapping scan windows
|
|
||||||
const key = `${absStart}:${absEnd}`;
|
|
||||||
if (used.has(key)) continue;
|
if (used.has(key)) continue;
|
||||||
used.add(key);
|
used.add(key);
|
||||||
|
|
||||||
// Optional: keep raw inline code visible while editing it
|
const intersects = sel.from <= absEnd && sel.to >= absStart;
|
||||||
const sel = view.state.selection.main;
|
if (settings.hideResultWhileCursorInside && intersects) continue;
|
||||||
const intersects = sel.from <= absEnd && sel.to >= absStart;
|
|
||||||
|
|
||||||
if (settings.hideResultWhileCursorInside && intersects) continue;
|
const inner = m[0].slice(1, -1).trim();
|
||||||
|
const result = processInlineCode(inner, app, sourcePath, settings);
|
||||||
|
|
||||||
const inner = m[0].slice(1, -1).trim(); // remove backticks
|
|
||||||
const result = processInlineDateCalc(inner, app, sourcePath, settings);
|
|
||||||
if (!result.text) continue;
|
if (!result.text) continue;
|
||||||
|
|
||||||
b.add(
|
b.add(
|
||||||
absStart,
|
absStart,
|
||||||
absEnd,
|
absEnd,
|
||||||
Decoration.replace({
|
Decoration.replace({
|
||||||
widget: new DateCalcWidget(result.text, result.tooltip, absStart, absEnd),
|
widget: new DateCalcInlineWidget(result.text, result.tooltip, absStart, absEnd),
|
||||||
inclusive: false,
|
inclusive: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
replaced++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process fenced code blocks
|
||||||
|
for (const r of view.visibleRanges) {
|
||||||
|
const scanFrom = Math.max(0, r.from - margin);
|
||||||
|
const scanTo = Math.min(view.state.doc.length, r.to + margin);
|
||||||
|
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from: scanFrom,
|
||||||
|
to: scanTo,
|
||||||
|
enter: (node) => {
|
||||||
|
if (node.name !== "FencedCode") return;
|
||||||
|
|
||||||
|
const absStart = node.from;
|
||||||
|
const absEnd = node.to;
|
||||||
|
const key = `block:${absStart}:${absEnd}`;
|
||||||
|
if (used.has(key)) return;
|
||||||
|
used.add(key);
|
||||||
|
|
||||||
|
const intersects = sel.from <= absEnd && sel.to >= absStart;
|
||||||
|
if (settings.hideResultWhileCursorInside && intersects) return;
|
||||||
|
|
||||||
|
const blockText = view.state.doc.sliceString(absStart, absEnd);
|
||||||
|
const parsed = parseFencedCode(blockText);
|
||||||
|
if (!parsed || parsed.lang !== "date-calc") return;
|
||||||
|
|
||||||
|
const result = processFencedBlock(parsed.body, app, sourcePath, settings);
|
||||||
|
if (!result.text) return;
|
||||||
|
|
||||||
|
b.add(
|
||||||
|
absEnd,
|
||||||
|
absEnd,
|
||||||
|
Decoration.widget({
|
||||||
|
widget: new DateCalcBlockWidget(result.text, result.tooltip),
|
||||||
|
side: 1,
|
||||||
|
block: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.debug) {
|
if (settings.debug) {
|
||||||
console.log("[date-calc] replace scan", { matchesSeen, replaced, sourcePath });
|
console.log("[date-calc] decorations built", { sourcePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.finish();
|
return b.finish();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decorations: (v) => v.decorations,
|
decorations: (v) => v.decorations,
|
||||||
|
|
||||||
eventHandlers: {
|
eventHandlers: {
|
||||||
mousedown: (e, view) => {
|
mousedown: (e, view) => {
|
||||||
const el = (e.target as HTMLElement | null)?.closest?.(".date-calc-inline") as HTMLElement | null;
|
const el = (e.target as HTMLElement | null)?.closest?.(
|
||||||
if (!el) return false;
|
".date-calc-inline"
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
const from = Number(el.dataset.dcFrom);
|
const from = Number(el.dataset.dcFrom);
|
||||||
const to = Number(el.dataset.dcTo);
|
const to = Number(el.dataset.dcTo);
|
||||||
if (!Number.isFinite(from) || !Number.isFinite(to)) return false;
|
if (!Number.isFinite(from) || !Number.isFinite(to)) return false;
|
||||||
|
|
||||||
// Select the whole backticked expression so it immediately “reveals” for editing
|
view.dispatch({
|
||||||
view.dispatch({
|
selection: { anchor: from, head: to },
|
||||||
selection: { anchor: from, head: to },
|
scrollIntoView: true,
|
||||||
scrollIntoView: true,
|
});
|
||||||
});
|
view.focus();
|
||||||
view.focus();
|
|
||||||
|
|
||||||
return true; // CodeMirror will preventDefault when handler returns true [web:64]
|
return true;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
|
|
||||||
provide: (plugin) =>
|
provide: (plugin) =>
|
||||||
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
|
EditorView.atomicRanges.of(
|
||||||
}
|
(view) => view.plugin(plugin)?.decorations ?? Decoration.none
|
||||||
|
),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
Plugin
|
Plugin
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
export default class DateCalcPlugin extends Plugin {
|
export default class DateCalcPlugin extends Plugin {
|
||||||
settings: DateCalcSettings;
|
settings: DateCalcSettings;
|
||||||
|
|
||||||
@@ -458,10 +611,22 @@ export default class DateCalcPlugin extends Plugin {
|
|||||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||||
|
|
||||||
if (this.settings.debug) console.log("[date-calc] loaded");
|
if (this.settings.debug) console.log("[date-calc] loaded");
|
||||||
new Notice("Date Calc loaded (Live Preview)");
|
new Notice("Date Calc loaded (Live Preview + Reading)");
|
||||||
|
|
||||||
|
// Register Live Preview editor extension
|
||||||
this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings));
|
this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings));
|
||||||
|
|
||||||
|
// Register Reading mode fenced code block processor
|
||||||
|
this.registerMarkdownCodeBlockProcessor("date-calc", (source, el, ctx) => {
|
||||||
|
const result = processFencedBlock(source, this.app, ctx.sourcePath, this.settings);
|
||||||
|
el.empty();
|
||||||
|
el.createDiv({ cls: "date-calc-block", text: result.text });
|
||||||
|
if (result.tooltip) {
|
||||||
|
el.setAttribute("aria-label", result.tooltip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Commands
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "date-calc-toggle-debug",
|
id: "date-calc-toggle-debug",
|
||||||
name: "Date Calc: Toggle debug",
|
name: "Date Calc: Toggle debug",
|
||||||
@@ -486,10 +651,12 @@ export default class DateCalcPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
Settings tab
|
Settings tab
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
|
|
||||||
class DateCalcSettingTab extends PluginSettingTab {
|
class DateCalcSettingTab extends PluginSettingTab {
|
||||||
constructor(app: App, private plugin: DateCalcPlugin) {
|
constructor(app: App, private plugin: DateCalcPlugin) {
|
||||||
super(app, plugin);
|
super(app, plugin);
|
||||||
@@ -503,7 +670,7 @@ class DateCalcSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Debug logging")
|
.setName("Debug logging")
|
||||||
.setDesc("Logs how many `date-calc:` inline-code matches were seen and decorated.")
|
.setDesc("Logs decoration activity to the console.")
|
||||||
.addToggle((t) =>
|
.addToggle((t) =>
|
||||||
t.setValue(this.plugin.settings.debug).onChange(async (v) => {
|
t.setValue(this.plugin.settings.debug).onChange(async (v) => {
|
||||||
this.plugin.settings.debug = v;
|
this.plugin.settings.debug = v;
|
||||||
@@ -512,8 +679,8 @@ class DateCalcSettingTab extends PluginSettingTab {
|
|||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Hide result while cursor inside inline code")
|
.setName("Hide result while cursor inside")
|
||||||
.setDesc("Prevents the widget from showing while you edit the inline backticks.")
|
.setDesc("Prevents the widget from showing while you edit inline/fenced code.")
|
||||||
.addToggle((t) =>
|
.addToggle((t) =>
|
||||||
t.setValue(this.plugin.settings.hideResultWhileCursorInside).onChange(async (v) => {
|
t.setValue(this.plugin.settings.hideResultWhileCursorInside).onChange(async (v) => {
|
||||||
this.plugin.settings.hideResultWhileCursorInside = v;
|
this.plugin.settings.hideResultWhileCursorInside = v;
|
||||||
@@ -531,6 +698,8 @@ class DateCalcSettingTab extends PluginSettingTab {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
containerEl.createEl("p", { text: 'Example: `date-calc: birthday=1992-08-16`' });
|
containerEl.createEl("h3", { text: "Usage Examples" });
|
||||||
|
containerEl.createEl("p", { text: "Inline: `date-calc: birthday=1992-08-16`" });
|
||||||
|
containerEl.createEl("p", { text: "Block: ```date-calc with YAML inside" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user