Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b617c275eb | |||
| bb842b55b4 | |||
| 0eb529e604 | |||
| ece066caf8 | |||
| b064a64142 | |||
| ec3ad7d384 | |||
| d22b53253e | |||
| 61c191505e | |||
| c5282dbdef | |||
| dc9e4c3200 | |||
| 6a1a044566 | |||
| 3c7b6741f5 | |||
| abf4d6ccf7 | |||
| 141121878f | |||
| 6a89660cf4 |
+64
-43
@@ -10,18 +10,19 @@
|
||||
src/
|
||||
├── main.ts Plugin entry — lifecycle, commands, events, data persistence
|
||||
├── settings.ts Interface definitions + DEFAULT_SETTINGS constant
|
||||
├── settings-tab.ts PluginSettingTab UI (calendar, periodic paths, recent files)
|
||||
├── settings-tab.ts PluginSettingTab UI — tabs: calendar, periodic, recent, display, about
|
||||
├── models/
|
||||
│ └── bookmark.ts BookmarkItem + WaypointData interfaces
|
||||
├── utils/
|
||||
│ └── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
|
||||
│ ├── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
|
||||
│ └── path-utils.ts Pure path helper: rename remapping (no `obsidian` import, unit-testable)
|
||||
└── views/
|
||||
└── waypoint-view.ts ItemView subclass — full sidebar rendering + interaction
|
||||
```
|
||||
|
||||
**Build:** esbuild bundles `src/main.ts` → `main.js`. TypeScript strict, ESNext target.
|
||||
**Build:** `npm run build` runs `tsc --noEmit` first, then esbuild bundles `src/main.ts` → `main.js`. The typecheck is a real gate — esbuild strips types without checking them, so without it type errors would ship. `npm run typecheck` runs the check alone; `npm run dev` (watch) stays ungated for speed. TypeScript strict-null + `noImplicitAny`, ESNext modules, ES6 target, `lib` floor at ES2017.
|
||||
|
||||
**Data persistence:** Single `data.json` via Obsidian `Plugin.loadData()/saveData()`. Top-level keys: `settings` (merged with `DEFAULT_SETTINGS`), `waypointData` (bookmark tree). Recent files are in-memory only (lost on vault close).
|
||||
**Data persistence:** Single `data.json` via Obsidian `Plugin.loadData()/saveData()`. Top-level keys: `settings` (merged with `DEFAULT_SETTINGS`), `waypointData` (bookmark tree **and** recent files). Recent files are persisted under `waypointData.recentFiles` and reloaded on startup; writes are debounced 300ms so rapid file opens collapse into one save.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,7 +34,7 @@ src/
|
||||
2. **Register view** — `WAYPOINT_VIEW_TYPE = "waypoint-view"` maps to `WaypointView` factory `(leaf) => new WaypointView(leaf, this)`.
|
||||
3. **Settings tab** — `WaypointSettingTab` receives the live `settings` object + `() => this.redrawAll()` callback for live preview.
|
||||
4. **Register commands** (see Commands section below).
|
||||
5. **Register events** — `file-open`, `vault:create`, `vault:delete`, `vault:rename`.
|
||||
5. **Register events** — `file-open`, `vault:create`, `vault:delete`, `vault:rename`, `vault:modify`.
|
||||
6. **Auto-open** — On `onLayoutReady`, if no existing leaves of the view type, opens one in the left sidebar.
|
||||
7. **Midnight refresh** — 10-minute `setInterval` checks if `new Date().toDateString()` changed; if so, calls `redrawAll()` (calendar day indicators refresh).
|
||||
|
||||
@@ -55,7 +56,8 @@ interface WaypointSettings {
|
||||
monthly: PeriodNoteSettings;
|
||||
quarterly: PeriodNoteSettings;
|
||||
yearly: PeriodNoteSettings;
|
||||
recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[]
|
||||
recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[], filterTags[]
|
||||
display: DisplaySettings; // px sizing for bookmark rows, fonts, icons, calendar cells
|
||||
}
|
||||
|
||||
interface PeriodNoteSettings {
|
||||
@@ -74,7 +76,17 @@ interface RecentFilesSettings {
|
||||
maxItems: number; // default 50
|
||||
updateOn: 'file-open' | 'file-edit'; // trigger mode
|
||||
omittedPaths: string[]; // regex patterns (one per line)
|
||||
omittedTags: string[]; // regex patterns for frontmatter tags
|
||||
omittedTags: string[]; // regex patterns for frontmatter/inline tags
|
||||
filterTags: string[]; // tags shown as filter pills (empty = auto-detect from frontmatter)
|
||||
}
|
||||
|
||||
interface DisplaySettings {
|
||||
rowSize: number; // px, base height of bookmark items (18–40)
|
||||
rowSpacing: number; // px, gap between items (0–12)
|
||||
indentSize: number; // px, indent per depth level (8–32)
|
||||
fontSize: number; // px, font size for bookmark labels (10–18)
|
||||
iconSize: number; // px, icon size (12–24)
|
||||
calendarCellSize: number; // px, calendar day cell height (20–48)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -94,10 +106,11 @@ interface BookmarkItem {
|
||||
|
||||
interface WaypointData {
|
||||
bookmarks: BookmarkItem[];
|
||||
recentFiles: { path: string; basename: string }[];
|
||||
}
|
||||
```
|
||||
|
||||
The bookmark tree is stored flat in `data.json` under `waypointData.bookmarks` but rendered recursively. Groups contain children; files/separators/spacers are leaf nodes.
|
||||
The bookmark tree is stored under `waypointData.bookmarks` and rendered recursively. Groups contain children; files/separators/spacers are leaf nodes. `waypointData.recentFiles` holds the persisted recent-files list (mirrored into the live `plugin.recentFiles` array on load).
|
||||
|
||||
---
|
||||
|
||||
@@ -121,15 +134,9 @@ All call `openPeriodNote(period, moment())` — opens or creates the current per
|
||||
|
||||
These call `navigatePeriodNote(direction)` which:
|
||||
1. Gets the active file's basename.
|
||||
2. Calls `detectPeriodType(basename)` — regex matching:
|
||||
- `^\d{4}-\d{2}-\d{2}$` → daily
|
||||
- `^\d{4}-W\d{2}$` → weekly
|
||||
- `^\d{4}-\d{2}$` → monthly
|
||||
- `^\d{4}-Q[1-4]$` → quarterly
|
||||
- `^\d{4}$` → yearly
|
||||
3. Parses date via `moment(basename, format)`.
|
||||
4. Adds ±1 period (quarters add ±3 months).
|
||||
5. Calls `openPeriodNote` for the new date.
|
||||
2. Calls `detectPeriodType(basename)` — **settings-driven**, not hardcoded regexes. It reads `settings.{daily,weekly,monthly,quarterly,yearly}.nameFormat` and tries `moment(basename, nameFormat, true).isValid()` (strict parsing) in order day → week → month → quarter → year, returning the first match as `{ period, date }`. Periods with an empty `nameFormat` are skipped; returns `null` if nothing matches. Changing a name format in settings therefore keeps next/prev navigation working.
|
||||
3. Adds ±1 period (quarters add ±3 months).
|
||||
4. Calls `openPeriodNote` for the new date.
|
||||
|
||||
### Other commands
|
||||
|
||||
@@ -142,7 +149,15 @@ These call `navigatePeriodNote(direction)` which:
|
||||
|
||||
## Period Note Creation (`openPeriodNote`)
|
||||
|
||||
For a given period + moment date:
|
||||
```typescript
|
||||
openPeriodNote(
|
||||
period: 'day' | 'week' | 'month' | 'quarter' | 'year',
|
||||
date: moment.Moment,
|
||||
leaf?: WorkspaceLeaf,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
Single unified opener — there is no separate `openPeriodNoteInLeaf`. For a given period + moment date:
|
||||
|
||||
1. **Build filename:** `date.format(periodSettings.nameFormat) + ".md"`.
|
||||
2. **Build full path:** If `periodSettings.folder` is set, prepend it; otherwise root.
|
||||
@@ -151,7 +166,9 @@ For a given period + moment date:
|
||||
- Try to read template at `periodSettings.templateFile + ".md"`.
|
||||
- If template exists → `vault.create(fullPath, templateContent)`.
|
||||
- If no template → `vault.create(fullPath, minimalFrontmatter)` where frontmatter is `type: {typeProperty}` + `date: YYYY-MM-DD`.
|
||||
5. **Open in new leaf:** `workspace.getLeaf(false).openFile(file)`.
|
||||
5. **Open:** in `leaf` when one is supplied, otherwise in `workspace.getLeaf(false)`.
|
||||
|
||||
Middle-click handlers in the view pass an explicit tab leaf: `openPeriodNote(period, date, this.app.workspace.getLeaf('tab'))`.
|
||||
|
||||
---
|
||||
|
||||
@@ -159,32 +176,42 @@ For a given period + moment date:
|
||||
|
||||
### `file-open`
|
||||
|
||||
If `settings.recentFiles.updateOn === 'file-open'` → calls `addToRecentFiles(file)`.
|
||||
Tracks the opened file into recent files **only** when `settings.recentFiles.updateOn === 'file-open'`.
|
||||
|
||||
If `updateOn === 'file-edit'` → currently skipped (placeholder for future edit-detection).
|
||||
### `vault:modify`
|
||||
|
||||
Tracks the modified file (guarded to `TFile`) into recent files **only** when `updateOn === 'file-edit'`. Exactly one of the two modes is active at a time, so `'file-edit'` is a working mode rather than a no-op.
|
||||
|
||||
### `vault:create` / `vault:delete`
|
||||
|
||||
Both trigger `broadcastRedraw()` — tells all WaypointView instances to re-render (needed for calendar note indicators and stale recent file references).
|
||||
Both update the markdown-basename set used for calendar note indicators, then trigger `broadcastRedraw()` — telling all WaypointView instances to re-render (needed for the indicators and for stale recent file references).
|
||||
|
||||
### `vault:rename`
|
||||
|
||||
1. Updates `recentFiles` entry if path matches `oldPath`.
|
||||
2. Calls `updateBookmarkPath(oldPath, newPath)` — recursively scans `waypointData.bookmarks` and updates any `filePath` matching `oldPath`.
|
||||
1. Updates the markdown-basename set (old basename out, new basename in).
|
||||
2. Remaps the `recentFiles` entry through `remapRenamedPath(entry.path, oldPath, newPath)`.
|
||||
3. Remaps every bookmark `filePath` through the same helper.
|
||||
|
||||
`remapRenamedPath(path, oldPath, newPath)` (in `src/utils/path-utils.ts`) returns the updated path when `path` **is** `oldPath` (a plain file rename) *or* is nested under it (`path.startsWith(oldPath + '/')`, i.e. a folder rename/move), and `null` when the path is unaffected. This is why moving a folder no longer orphans the bookmarks inside it. The helper deliberately imports nothing from `'obsidian'` so it stays pure and unit-testable in plain node.
|
||||
|
||||
---
|
||||
|
||||
## Recent Files (in-memory)
|
||||
## Recent Files (persisted)
|
||||
|
||||
```typescript
|
||||
recentFiles: { path: string; basename: string }[]
|
||||
```
|
||||
|
||||
**Update flow:**
|
||||
1. `addToRecentFiles(file)` — dedupes (removes existing entry), prepends to front, truncates to `maxItems`.
|
||||
2. Calls `broadcastRedraw()`.
|
||||
Backed by `waypointData.recentFiles` in `data.json`, so the list survives vault reload. `loadWaypointData()` restores it and re-applies the current `maxItems` limit (in case the setting shrank since the last save).
|
||||
|
||||
**Omitted paths/tags:** Settings store regex patterns but the current `addToRecentFiles` does **not** filter by them — these are only exposed in the settings UI. The filtering is not yet wired up in the add logic.
|
||||
**Update flow:**
|
||||
1. `addToRecentFiles(file)` — omission check, then dedupes (removes existing entry), prepends to front, truncates to `maxItems`.
|
||||
2. `persistRecentFiles()` — mirrors the array into `waypointData.recentFiles` and schedules a debounced (300ms) `saveWaypointData()`.
|
||||
3. Calls `broadcastRedraw()`.
|
||||
|
||||
**Omitted paths/tags:** Both filters are applied in `addToRecentFiles`. `omittedPaths` entries are treated as regexes tested against `file.path`; `omittedTags` entries are regexes tested against the file's tags, read via `getAllTags(metadataCache.getFileCache(file))` with the leading `#` stripped. An invalid regex is skipped rather than throwing.
|
||||
|
||||
**`enforceRecentFilesLimit()`:** Called from the settings-change callback — trims the list when `maxItems` is lowered and persists the result.
|
||||
|
||||
**On file not found:** When clicking a recent file that no longer exists, `focusFile()` shows a Notice and removes the stale entry from `recentFiles`.
|
||||
|
||||
@@ -222,8 +249,8 @@ The last section (`waypoint-section:last-child`) gets `margin-top: auto`, pushin
|
||||
|
||||
- **Breadcrumb:** Q-label, month name, year — each clickable to open that period note.
|
||||
- **Nav buttons:** ◀/▶ shift month ±1. "Today" resets to current month.
|
||||
- **Week number column:** Clicking a week number opens the weekly note for that week's Monday.
|
||||
- **Day cells:** Click opens daily note. `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator (checked via `vault.getFiles().some(f => f.basename === dateStr)`).
|
||||
- **Week number column:** Clicking a week number opens the weekly note for that week's Monday; middle-clicking opens it in a new tab.
|
||||
- **Day cells:** Click opens daily note, middle-click opens it in a new tab. `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator, decided by the synchronous O(1) `plugin.hasNoteForDate(dateStr)` (a `Set` lookup — no per-cell vault scan).
|
||||
- **Grid generation:** `getMonthGrid(year, month, firstDayOfWeek)` in `date-utils.ts` produces up to 6 weeks, each with 7 `CalendarDay` objects containing `moment`, `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`.
|
||||
|
||||
### Recent Files Panel
|
||||
@@ -232,7 +259,7 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi
|
||||
|
||||
**Per-file features:**
|
||||
- **Active indicator:** `.is-active` class if path matches `workspace.getActiveFile()`.
|
||||
- **Remove button:** × icon, appears on hover (`.waypoint-recent-remove`), removes entry from array + saves settings + redraws.
|
||||
- **Remove button:** × icon, appears on hover (`.waypoint-recent-remove`), drops the entry from `plugin.recentFiles`, calls `persistRecentFiles()`, then redraws.
|
||||
- **Drag:** Uses `app.dragManager.dragFile()` for native Obsidian drag.
|
||||
- **Hover preview:** Triggers `hover-link` event for Obsidian's page preview popup.
|
||||
- **Context menu:** "Open in new tab" + Obsidian's native `file-menu` event.
|
||||
@@ -280,7 +307,7 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi
|
||||
**Icon picker (`IconSuggestModal`):** Modal with:
|
||||
- Live preview of selected icon.
|
||||
- Search input with 60ms debounce.
|
||||
- Grid of matching icons (max 80 shown), loaded from `https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json` with fallback to `https://lucide.dev/api/tags` and a hardcoded `FALLBACK_ICONS` object (~300 icons).
|
||||
- Grid of matching icons (max 80 shown), loaded from `https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json` with fallback to `https://lucide.dev/api/tags` and a hardcoded `FALLBACK_ICONS` object (~300 icons). The fetch result is cached for the session, so at most one network round-trip happens no matter how often the picker is opened.
|
||||
- Click to select, "No icon" link to clear, Save/Cancel buttons.
|
||||
|
||||
---
|
||||
@@ -345,7 +372,7 @@ Settings and waypoint data share a single `data.json` via Obsidian's `Plugin.loa
|
||||
}
|
||||
```
|
||||
|
||||
Both `loadSettings()` and `loadWaypointData()` read from the same file, merging partials over defaults. `saveSettings()` and `saveWaypointData()` each re-read the full data, update their key, and write back. This means they must not run concurrently (no locking — relies on Obsidian's synchronous save behavior).
|
||||
Both `loadSettings()` and `loadWaypointData()` read from the same file, merging partials over defaults. `saveSettings()` and `saveWaypointData()` each re-read the full data, update their key, and write back — so they are serialized through a single-writer save queue: each call chains onto the previous one's promise instead of racing it. Recent-file writes additionally go through the 300ms debounce in `persistRecentFiles()`.
|
||||
|
||||
---
|
||||
|
||||
@@ -353,7 +380,7 @@ Both `loadSettings()` and `loadWaypointData()` read from the same file, merging
|
||||
|
||||
`broadcastRedraw()` iterates all leaves of `WAYPOINT_VIEW_TYPE` and calls `view.redraw()` on each. `redraw()` is a full DOM rebuild (no virtual DOM, no diffing). This is triggered by:
|
||||
|
||||
- File open/create/delete/rename events
|
||||
- File open/create/delete/rename/modify events
|
||||
- Bookmark add/remove/update
|
||||
- Period navigation
|
||||
- Settings changes (via `onSettingsChange` callback)
|
||||
@@ -365,11 +392,5 @@ The sidebar is re-rendered from scratch on every change. For a small sidebar thi
|
||||
|
||||
## Known Limitations / Gaps
|
||||
|
||||
1. **Recent files omittedPaths/omittedTags** — Settings UI exposes these regex filters, but `addToRecentFiles()` does not apply them. Files are never filtered out.
|
||||
2. **`updateOn: 'file-edit'`** — The `file-open` handler returns early for this mode, but no edit-detection event is registered. Recent files never update in edit mode.
|
||||
3. **No virtual DOM** — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar.
|
||||
4. **Concurrent saves** — `saveSettings()` and `saveWaypointData()` both re-read then write `data.json`. Rapid sequential calls could race.
|
||||
5. **Recent files not persisted** — Cleared on vault reload. Only bookmarks survive restarts.
|
||||
6. **Note indicators scan all vault files** — `hasNoteForDate()` iterates `vault.getFiles()` on every calendar render (called per day cell). For large vaults, this is O(days × files). Not cached.
|
||||
7. **`detectPeriodType` uses hardcoded formats** — The regex patterns in `main.ts` don't read from `settings.*.nameFormat`. If a user changes the name format to something non-standard, next/prev navigation breaks.
|
||||
8. **IconSuggestModal fetches from CDN** — Network dependency on first icon picker open. Falls back to hardcoded list on failure.
|
||||
1. **No virtual DOM** — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker.
|
||||
2. **IconSuggestModal fetches from CDN** — Network dependency for the Lucide tag list. The fetch happens at most once per session (the result is cached) and falls back to the hardcoded `FALLBACK_ICONS` list on failure, but a first-open with no network still degrades to that reduced list.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Olivier
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -40,7 +40,7 @@
|
||||
- [ ] Type "arrow" → should find all arrow icons
|
||||
- [ ] Type a tag keyword like "fitness" → should find `activity`, `dumbbell`, etc.
|
||||
- [ ] Clear search (empty input) → shows first 80 icons
|
||||
- [ ] Click "No icon" link → clears the icon
|
||||
- [ ] Set an icon on a bookmark, reopen the picker, click "No icon" → the previously-set icon is cleared
|
||||
- [ ] Click an icon → preview updates, grid highlights selection
|
||||
- [ ] Click Save → icon applied, click Cancel → no change
|
||||
- [ ] Test with **network offline** → falls back to FALLBACK_ICONS list
|
||||
@@ -56,12 +56,14 @@
|
||||
- [ ] Hover preview (page preview popup) still works on file bookmarks
|
||||
- [ ] Right-click context menu on a file bookmark still shows native Obsidian file-menu items
|
||||
- [ ] Bookmarks persist across vault reload
|
||||
- [ ] Move a folder containing bookmarked files to a new location → those bookmarks survive and still open the moved files
|
||||
|
||||
## Calendar (Regression)
|
||||
|
||||
- [ ] Calendar renders with correct month grid
|
||||
- [ ] Click on a day → opens/creates daily note
|
||||
- [ ] Click on week number → opens/creates weekly note
|
||||
- [ ] Middle-click on a week number → opens/creates the weekly note in a new tab, with no console error
|
||||
- [ ] Click Q/M/Y breadcrumb → opens respective period note
|
||||
- [ ] ◀/▶ navigation shifts month
|
||||
- [ ] "Today" button returns to current month
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "waypoint-sidebar",
|
||||
"name": "Waypoint Sidebar",
|
||||
"version": "1.4.0",
|
||||
"minAppVersion": "0.16.3",
|
||||
"version": "1.5.2",
|
||||
"minAppVersion": "1.4.4",
|
||||
"description": "Calendar, recent files, and custom bookmarks sidebar.",
|
||||
"author": "Olivier",
|
||||
"isDesktopOnly": false
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "waypoint",
|
||||
"version": "1.0.0",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "waypoint",
|
||||
"version": "1.0.0",
|
||||
"version": "1.5.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"builtin-modules": "4.0.0",
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "waypoint",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.2",
|
||||
"description": "Calendar, recent files, and custom bookmarks sidebar.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "node esbuild.config.mjs production"
|
||||
"typecheck": "node node_modules/typescript/bin/tsc --noEmit",
|
||||
"build": "npm run typecheck && node esbuild.config.mjs production"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Olivier",
|
||||
|
||||
+355
-180
@@ -3,34 +3,38 @@
|
||||
import {
|
||||
Plugin,
|
||||
WorkspaceLeaf,
|
||||
ItemView,
|
||||
Notice,
|
||||
TFile,
|
||||
TFolder,
|
||||
TAbstractFile,
|
||||
getAllTags,
|
||||
moment,
|
||||
} from 'obsidian';
|
||||
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
|
||||
import { WaypointSettingTab } from 'src/settings-tab';
|
||||
import { WaypointView, WAYPOINT_VIEW_TYPE } from 'src/views/waypoint-view';
|
||||
import { BookmarkItem, WaypointData } from 'src/models/bookmark';
|
||||
import { remapRenamedPath } from 'src/utils/path-utils';
|
||||
|
||||
const DEFAULT_DATA: WaypointData = {
|
||||
bookmarks: [],
|
||||
recentFiles: [],
|
||||
};
|
||||
export type PeriodKey = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
export default class WaypointPlugin extends Plugin {
|
||||
public settings: WaypointSettings;
|
||||
public waypointData: WaypointData;
|
||||
public recentFiles: { path: string; basename: string }[] = [];
|
||||
private recentFilesSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private recentFilesSaveTimer: number | undefined;
|
||||
/** Serializes writes to data.json so overlapping saves cannot lose updates. */
|
||||
private savePromise: Promise<void> = Promise.resolve();
|
||||
/** Basenames of every markdown file in the vault, for O(1) calendar lookups. */
|
||||
private markdownBasenames: Set<string> = new Set();
|
||||
|
||||
async onload(): Promise<void> {
|
||||
console.debug('Waypoint: loading plugin v' + this.manifest.version);
|
||||
|
||||
// Load persisted data
|
||||
await this.loadSettings();
|
||||
await this.loadWaypointData();
|
||||
// Load persisted data — data.json is read exactly once here.
|
||||
const saved = await this.loadData() as Record<string, unknown> | null;
|
||||
this.applySettings(saved);
|
||||
this.applyWaypointData(saved);
|
||||
|
||||
// Register the sidebar view
|
||||
this.registerView(
|
||||
@@ -156,23 +160,31 @@ export default class WaypointPlugin extends Plugin {
|
||||
);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on('create', () => this.onVaultChange()),
|
||||
this.app.vault.on('create', (file: TAbstractFile) => this.onVaultCreate(file)),
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', () => this.onVaultChange()),
|
||||
this.app.vault.on('delete', (file: TAbstractFile) => this.onVaultDelete(file)),
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on('rename', (file, oldPath) => this.onRename(file, oldPath)),
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on('modify', (file: TAbstractFile) => this.onFileModify(file)),
|
||||
);
|
||||
|
||||
// Auto-open view on first load
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.buildMarkdownIndex();
|
||||
|
||||
const leaves = this.app.workspace.getLeavesOfType(WAYPOINT_VIEW_TYPE);
|
||||
if (leaves.length === 0) {
|
||||
const leaf = this.app.workspace.getLeftLeaf(false);
|
||||
if (leaf) {
|
||||
leaf.setViewState({ type: WAYPOINT_VIEW_TYPE });
|
||||
}
|
||||
} else {
|
||||
// A restored view may have rendered before the index existed.
|
||||
this.broadcastRedraw();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,27 +205,33 @@ export default class WaypointPlugin extends Plugin {
|
||||
this.app.workspace.detachLeavesOfType(WAYPOINT_VIEW_TYPE);
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
// ── Settings & persistence ──
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
const saved = await this.loadData() as Record<string, unknown> | null;
|
||||
/**
|
||||
* Merge persisted settings over the defaults. Nested objects are merged
|
||||
* individually so existing configs keep their values while picking up
|
||||
* fields added in newer versions.
|
||||
*/
|
||||
private applySettings(saved: Record<string, unknown> | null): void {
|
||||
const s = (saved?.settings || {}) as Partial<WaypointSettings>;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, s);
|
||||
this.settings.recentFiles = Object.assign({}, DEFAULT_SETTINGS.recentFiles, s.recentFiles || {});
|
||||
this.settings.calendar = Object.assign({}, DEFAULT_SETTINGS.calendar, s.calendar || {});
|
||||
this.settings.display = Object.assign({}, DEFAULT_SETTINGS.display, s.display || {});
|
||||
for (const key of PERIOD_SETTING_KEYS) {
|
||||
this.settings[key] = Object.assign({}, DEFAULT_SETTINGS[key], s[key] || {});
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
const all = (await this.loadData()) as Record<string, unknown> || {};
|
||||
all.settings = this.settings;
|
||||
await this.saveData(all);
|
||||
}
|
||||
|
||||
async loadWaypointData(): Promise<void> {
|
||||
const saved = await this.loadData() as Record<string, unknown> | null;
|
||||
private applyWaypointData(saved: Record<string, unknown> | null): void {
|
||||
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
|
||||
this.waypointData = Object.assign({}, DEFAULT_DATA, d);
|
||||
this.waypointData = {
|
||||
bookmarks: Array.isArray(d.bookmarks) ? d.bookmarks : [],
|
||||
recentFiles: Array.isArray(d.recentFiles) ? d.recentFiles : [],
|
||||
};
|
||||
|
||||
// Load persisted recent files
|
||||
this.recentFiles = this.waypointData.recentFiles || [];
|
||||
this.recentFiles = this.waypointData.recentFiles;
|
||||
|
||||
// Apply current limit (in case maxItems was reduced since last save)
|
||||
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
|
||||
@@ -222,6 +240,37 @@ export default class WaypointPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write both top-level keys of data.json from memory.
|
||||
*
|
||||
* Saves never re-read from disk: `settings` and `waypointData` are the only
|
||||
* keys and both are held in memory, so a read-modify-write would only give
|
||||
* two concurrent saves a stale snapshot of the other's key. Writes are
|
||||
* chained onto `savePromise` so they cannot interleave.
|
||||
*/
|
||||
private persistAll(): Promise<void> {
|
||||
const write = this.savePromise.then(() => {
|
||||
// Sync recentFiles into waypointData before writing
|
||||
this.waypointData.recentFiles = this.recentFiles;
|
||||
return this.saveData({
|
||||
settings: this.settings,
|
||||
waypointData: this.waypointData,
|
||||
});
|
||||
});
|
||||
// Keep the queue usable after a failed write without leaving an
|
||||
// unhandled rejection behind; callers still see `write` reject.
|
||||
this.savePromise = write.catch(() => undefined);
|
||||
return write;
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.persistAll();
|
||||
}
|
||||
|
||||
async saveWaypointData(): Promise<void> {
|
||||
await this.persistAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim recent files to the current maxItems limit and persist.
|
||||
* Called when the maxItems setting changes.
|
||||
@@ -233,21 +282,13 @@ export default class WaypointPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
async saveWaypointData(): Promise<void> {
|
||||
// Sync recentFiles into waypointData before saving
|
||||
this.waypointData.recentFiles = this.recentFiles;
|
||||
const all = (await this.loadData()) as Record<string, unknown> || {};
|
||||
all.waypointData = this.waypointData;
|
||||
await this.saveData(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist recent files to disk (debounced to avoid excessive writes on rapid opens).
|
||||
*/
|
||||
persistRecentFiles(): void {
|
||||
this.waypointData.recentFiles = this.recentFiles;
|
||||
if (this.recentFilesSaveTimer) clearTimeout(this.recentFilesSaveTimer);
|
||||
this.recentFilesSaveTimer = setTimeout(() => {
|
||||
window.clearTimeout(this.recentFilesSaveTimer);
|
||||
this.recentFilesSaveTimer = window.setTimeout(() => {
|
||||
this.saveWaypointData();
|
||||
}, 300);
|
||||
}
|
||||
@@ -255,24 +296,20 @@ export default class WaypointPlugin extends Plugin {
|
||||
// ── Recent Files ──
|
||||
|
||||
private onFileOpen(file: TFile): void {
|
||||
if (this.settings.recentFiles.updateOn === 'file-edit') {
|
||||
// We'll handle this via quick-preview in a future refinement
|
||||
return;
|
||||
if (this.settings.recentFiles.updateOn !== 'file-open') return;
|
||||
this.addToRecentFiles(file);
|
||||
}
|
||||
|
||||
private onFileModify(file: TAbstractFile): void {
|
||||
if (this.settings.recentFiles.updateOn !== 'file-edit') return;
|
||||
if (!(file instanceof TFile)) return;
|
||||
// Already the most recent entry: nothing to reorder or redraw.
|
||||
if (this.recentFiles.length > 0 && this.recentFiles[0].path === file.path) return;
|
||||
this.addToRecentFiles(file);
|
||||
}
|
||||
|
||||
addToRecentFiles(file: TFile): void {
|
||||
// Apply omitted paths filter
|
||||
if (this.settings.recentFiles.omittedPaths.length > 0) {
|
||||
for (const pattern of this.settings.recentFiles.omittedPaths) {
|
||||
try {
|
||||
if (new RegExp(pattern).test(file.path)) return;
|
||||
} catch {
|
||||
// Invalid regex, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.isOmittedFromRecentFiles(file)) return;
|
||||
|
||||
this.recentFiles = this.recentFiles.filter(f => f.path !== file.path);
|
||||
this.recentFiles.unshift({ path: file.path, basename: file.basename });
|
||||
@@ -286,21 +323,135 @@ export default class WaypointPlugin extends Plugin {
|
||||
this.broadcastRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the omittedPaths / omittedTags filters. Each entry is treated as a
|
||||
* regex; an invalid pattern is skipped rather than throwing.
|
||||
*/
|
||||
private isOmittedFromRecentFiles(file: TFile): boolean {
|
||||
for (const pattern of this.settings.recentFiles.omittedPaths) {
|
||||
try {
|
||||
if (new RegExp(pattern).test(file.path)) return true;
|
||||
} catch {
|
||||
// Invalid regex, skip
|
||||
}
|
||||
}
|
||||
|
||||
const omittedTags = this.settings.recentFiles.omittedTags;
|
||||
if (omittedTags.length === 0) return false;
|
||||
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const tags = (cache ? getAllTags(cache) : null) || [];
|
||||
if (tags.length === 0) return false;
|
||||
|
||||
const bareTags = tags.map(tag => tag.replace(/^#/, ''));
|
||||
for (const pattern of omittedTags) {
|
||||
try {
|
||||
const regex = new RegExp(pattern);
|
||||
if (bareTags.some(tag => regex.test(tag))) return true;
|
||||
} catch {
|
||||
// Invalid regex, skip
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian fires `rename` for folders too, so a stored path may need
|
||||
* remapping either because it is the renamed item or because it sits
|
||||
* inside a renamed folder.
|
||||
*/
|
||||
private onRename(file: TAbstractFile, oldPath: string): void {
|
||||
const entry = this.recentFiles.find(f => f.path === oldPath);
|
||||
if (entry) {
|
||||
entry.path = file.path;
|
||||
entry.basename = (file as TFile).basename || file.name.replace(/\.[^/.]+$/, '');
|
||||
this.persistRecentFiles();
|
||||
const indexChanged = this.syncIndexForRename(file, oldPath);
|
||||
let dataChanged = false;
|
||||
|
||||
for (const entry of this.recentFiles) {
|
||||
const remapped = remapRenamedPath(entry.path, oldPath, file.path);
|
||||
if (remapped === null) continue;
|
||||
entry.path = remapped;
|
||||
entry.basename = basenameFromPath(remapped);
|
||||
dataChanged = true;
|
||||
}
|
||||
|
||||
const remapBookmarks = (items: BookmarkItem[]): void => {
|
||||
for (const item of items) {
|
||||
if (item.filePath) {
|
||||
const remapped = remapRenamedPath(item.filePath, oldPath, file.path);
|
||||
if (remapped !== null) {
|
||||
item.filePath = remapped;
|
||||
dataChanged = true;
|
||||
}
|
||||
}
|
||||
if (item.children) remapBookmarks(item.children);
|
||||
}
|
||||
};
|
||||
remapBookmarks(this.waypointData.bookmarks);
|
||||
|
||||
if (dataChanged) {
|
||||
this.waypointData.recentFiles = this.recentFiles;
|
||||
this.persistAll();
|
||||
}
|
||||
if (dataChanged || indexChanged) this.broadcastRedraw();
|
||||
}
|
||||
|
||||
private onVaultCreate(file: TAbstractFile): void {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.markdownBasenames.add(file.basename);
|
||||
}
|
||||
this.broadcastRedraw();
|
||||
}
|
||||
|
||||
// Update bookmark file paths
|
||||
this.updateBookmarkPath(oldPath, file.path);
|
||||
private onVaultDelete(file: TAbstractFile): void {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.removeFromMarkdownIndex(file.basename, file.path);
|
||||
}
|
||||
this.broadcastRedraw();
|
||||
}
|
||||
|
||||
private onVaultChange(): void {
|
||||
this.broadcastRedraw();
|
||||
// ── Markdown basename index (calendar note indicators) ──
|
||||
|
||||
private buildMarkdownIndex(): void {
|
||||
this.markdownBasenames.clear();
|
||||
for (const file of this.app.vault.getMarkdownFiles()) {
|
||||
this.markdownBasenames.add(file.basename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a basename from the index, unless another markdown file still
|
||||
* carries it. `path` is excluded from that check because the vault may not
|
||||
* have dropped the file yet when the event fires.
|
||||
*/
|
||||
private removeFromMarkdownIndex(basename: string, path: string): boolean {
|
||||
if (!this.markdownBasenames.has(basename)) return false;
|
||||
const stillExists = this.app.vault.getMarkdownFiles()
|
||||
.some(f => f.basename === basename && f.path !== path);
|
||||
if (stillExists) return false;
|
||||
this.markdownBasenames.delete(basename);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns true when the index changed. Folder renames never change basenames. */
|
||||
private syncIndexForRename(file: TAbstractFile, oldPath: string): boolean {
|
||||
if (!(file instanceof TFile)) return false;
|
||||
|
||||
let changed = false;
|
||||
const oldBasename = basenameFromPath(oldPath);
|
||||
if (oldPath.toLowerCase().endsWith('.md') && (oldBasename !== file.basename || file.extension !== 'md')) {
|
||||
changed = this.removeFromMarkdownIndex(oldBasename, oldPath);
|
||||
}
|
||||
if (file.extension === 'md' && !this.markdownBasenames.has(file.basename)) {
|
||||
this.markdownBasenames.add(file.basename);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any markdown file in the vault is named exactly `dateStr`.
|
||||
* Synchronous and O(1) — the calendar calls this once per day cell.
|
||||
*/
|
||||
hasNoteForDate(dateStr: string): boolean {
|
||||
return this.markdownBasenames.has(dateStr);
|
||||
}
|
||||
|
||||
// ── Bookmarks ──
|
||||
@@ -359,137 +510,146 @@ export default class WaypointPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
private updateBookmarkPath(oldPath: string, newPath: string): void {
|
||||
const updateRecursive = (items: BookmarkItem[]) => {
|
||||
for (const item of items) {
|
||||
if (item.filePath === oldPath) {
|
||||
item.filePath = newPath;
|
||||
}
|
||||
if (item.children) updateRecursive(item.children);
|
||||
}
|
||||
};
|
||||
updateRecursive(this.waypointData.bookmarks);
|
||||
}
|
||||
|
||||
// ── Period note creation/opening ──
|
||||
|
||||
async openPeriodNote(period: 'day' | 'week' | 'month' | 'quarter' | 'year', date: moment.Moment): Promise<void> {
|
||||
// Map period to settings and date format
|
||||
type PeriodConfig = {
|
||||
settings: PeriodNoteSettings;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const configs: Record<string, PeriodConfig> = {
|
||||
day: { settings: this.settings.daily, label: 'Daily' },
|
||||
week: { settings: this.settings.weekly, label: 'Weekly' },
|
||||
month: { settings: this.settings.monthly, label: 'Monthly' },
|
||||
quarter: { settings: this.settings.quarterly, label: 'Quarterly' },
|
||||
year: { settings: this.settings.yearly, label: 'Yearly' },
|
||||
};
|
||||
|
||||
const config = configs[period];
|
||||
if (!config) return;
|
||||
|
||||
const { settings: periodSettings } = config;
|
||||
/**
|
||||
* Open (creating if needed) the period note for `date`.
|
||||
* Opens in `leaf` when given, otherwise in the active leaf.
|
||||
*/
|
||||
async openPeriodNote(period: PeriodKey, date: moment.Moment, leaf?: WorkspaceLeaf): Promise<void> {
|
||||
const config = PERIOD_CONFIGS[period];
|
||||
const periodSettings = this.settings[config.key];
|
||||
const filename = date.format(periodSettings.nameFormat) + '.md';
|
||||
const fullPath = periodSettings.folder
|
||||
? `${periodSettings.folder}/${filename}`
|
||||
: filename;
|
||||
|
||||
// Check if exists
|
||||
let file = this.app.vault.getFileByPath(fullPath);
|
||||
if (!file) {
|
||||
// Create it from template
|
||||
file = await this.createPeriodNote(fullPath, periodSettings, date, config.label);
|
||||
if (!file) return;
|
||||
}
|
||||
|
||||
const target = leaf || this.app.workspace.getLeaf(false);
|
||||
await target.openFile(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a period note from its template, or from minimal frontmatter.
|
||||
*
|
||||
* Every failure path names the offending path and the reason: the usual
|
||||
* cause is a configured folder that does not exist yet, which `vault.create`
|
||||
* refuses outright rather than creating.
|
||||
*/
|
||||
private async createPeriodNote(
|
||||
fullPath: string,
|
||||
periodSettings: PeriodNoteSettings,
|
||||
date: moment.Moment,
|
||||
label: string,
|
||||
): Promise<TFile | null> {
|
||||
const noun = label.toLowerCase();
|
||||
const slash = fullPath.lastIndexOf('/');
|
||||
const folder = slash < 0 ? '' : fullPath.slice(0, slash);
|
||||
|
||||
try {
|
||||
// Try to find template file
|
||||
const templatePath = periodSettings.templateFile + '.md';
|
||||
const templateFile = this.app.vault.getFileByPath(templatePath);
|
||||
await this.ensureFolderExists(folder);
|
||||
} catch (err: unknown) {
|
||||
new Notice(
|
||||
`Waypoint: could not create the folder "${folder}" for the ${noun} note.\n`
|
||||
+ `${describeError(err)}\n`
|
||||
+ 'Check Settings → Waypoint Sidebar → Periodic Notes.',
|
||||
DIAGNOSTIC_NOTICE_MS,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const configuredTemplate = periodSettings.templateFile;
|
||||
const templateFile = this.resolveTemplateFile(configuredTemplate);
|
||||
|
||||
let content: string;
|
||||
if (templateFile) {
|
||||
const templateContent = await this.app.vault.read(templateFile);
|
||||
file = await this.app.vault.create(fullPath, templateContent);
|
||||
} else {
|
||||
// Fallback: create with minimal frontmatter
|
||||
const content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
|
||||
file = await this.app.vault.create(fullPath, content);
|
||||
}
|
||||
} catch (err) {
|
||||
new Notice(`Failed to create ${config.label.toLowerCase()} note: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
new Notice(`Created ${config.label.toLowerCase()} note: ${filename}`);
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const leaf = this.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Open period note in a specific leaf (for middle-click) ──
|
||||
async openPeriodNoteInLeaf(period: 'day' | 'week' | 'month' | 'quarter' | 'year', date: moment.Moment, leaf: any): Promise<void> {
|
||||
type PeriodConfig = { settings: PeriodNoteSettings; label: string };
|
||||
const configs: Record<string, PeriodConfig> = {
|
||||
day: { settings: this.settings.daily, label: 'Daily' },
|
||||
week: { settings: this.settings.weekly, label: 'Weekly' },
|
||||
month: { settings: this.settings.monthly, label: 'Monthly' },
|
||||
quarter: { settings: this.settings.quarterly, label: 'Quarterly' },
|
||||
year: { settings: this.settings.yearly, label: 'Yearly' },
|
||||
};
|
||||
const config = configs[period];
|
||||
if (!config) return;
|
||||
const { settings: periodSettings } = config;
|
||||
const filename = date.format(periodSettings.nameFormat) + '.md';
|
||||
const fullPath = periodSettings.folder ? `${periodSettings.folder}/${filename}` : filename;
|
||||
let file = this.app.vault.getFileByPath(fullPath);
|
||||
if (!file) {
|
||||
try {
|
||||
const templatePath = periodSettings.templateFile + '.md';
|
||||
const templateFile = this.app.vault.getFileByPath(templatePath);
|
||||
if (templateFile) {
|
||||
const templateContent = await this.app.vault.read(templateFile);
|
||||
file = await this.app.vault.create(fullPath, templateContent);
|
||||
content = await this.app.vault.read(templateFile);
|
||||
} catch (err: unknown) {
|
||||
new Notice(
|
||||
`Waypoint: could not read the template "${templateFile.path}" for the ${noun} note.\n`
|
||||
+ describeError(err),
|
||||
DIAGNOSTIC_NOTICE_MS,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
|
||||
content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
|
||||
}
|
||||
|
||||
let file: TFile;
|
||||
try {
|
||||
file = await this.app.vault.create(fullPath, content);
|
||||
} catch (err: unknown) {
|
||||
new Notice(
|
||||
`Waypoint: could not create the ${noun} note at "${fullPath}".\n${describeError(err)}`,
|
||||
DIAGNOSTIC_NOTICE_MS,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
new Notice(`Failed to create ${config.label.toLowerCase()} note: ${err.message}`);
|
||||
return;
|
||||
|
||||
// A configured-but-missing template otherwise fails silently: the note
|
||||
// just appears with the fallback frontmatter and no explanation.
|
||||
if (configuredTemplate && !templateFile) {
|
||||
new Notice(
|
||||
`Created ${noun} note: ${file.basename}\n`
|
||||
+ `Template "${configuredTemplate}" was not found, so a basic note was created instead.`,
|
||||
DIAGNOSTIC_NOTICE_MS,
|
||||
);
|
||||
} else {
|
||||
new Notice(`Created ${noun} note: ${file.basename}`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create `folder` and any missing ancestors.
|
||||
* `vault.create` throws when the parent folder is absent, so this runs first.
|
||||
*/
|
||||
private async ensureFolderExists(folder: string): Promise<void> {
|
||||
if (!folder) return;
|
||||
if (this.app.vault.getAbstractFileByPath(folder) instanceof TFolder) return;
|
||||
|
||||
let path = '';
|
||||
for (const segment of folder.split('/')) {
|
||||
if (!segment) continue;
|
||||
path = path ? `${path}/${segment}` : segment;
|
||||
if (this.app.vault.getAbstractFileByPath(path) instanceof TFolder) continue;
|
||||
try {
|
||||
await this.app.vault.createFolder(path);
|
||||
} catch (err: unknown) {
|
||||
// Someone else may have created it between the check and the call.
|
||||
if (!(this.app.vault.getAbstractFileByPath(path) instanceof TFolder)) throw err;
|
||||
}
|
||||
}
|
||||
if (file) {
|
||||
await leaf.openFile(file);
|
||||
}
|
||||
|
||||
/** The setting may or may not already carry the .md extension. */
|
||||
private resolveTemplateFile(templateFile: string): TFile | null {
|
||||
if (!templateFile) return null;
|
||||
const path = templateFile.toLowerCase().endsWith('.md') ? templateFile : `${templateFile}.md`;
|
||||
return this.app.vault.getFileByPath(path);
|
||||
}
|
||||
|
||||
// ── Period note navigation (next/prev from current file) ──
|
||||
|
||||
/**
|
||||
* Detect the period type from a filename's basename.
|
||||
* Detect the period type from a filename's basename using the configured
|
||||
* name formats, so custom formats keep working. Parsing is strict, which
|
||||
* stops a loose format (e.g. YYYY) from swallowing a longer basename.
|
||||
* Returns the period key and the parsed moment, or null if not a period note.
|
||||
*/
|
||||
private detectPeriodType(basename: string): { period: 'day' | 'week' | 'month' | 'quarter' | 'year'; date: moment.Moment } | null {
|
||||
// YYYY-MM-DD → daily
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(basename)) {
|
||||
return { period: 'day', date: moment(basename, 'YYYY-MM-DD') };
|
||||
}
|
||||
// GGGG-WWW → weekly (e.g. 2026-W24)
|
||||
if (/^\d{4}-W\d{2}$/.test(basename)) {
|
||||
return { period: 'week', date: moment(basename, 'GGGG-[W]WW') };
|
||||
}
|
||||
// YYYY-MM → monthly
|
||||
if (/^\d{4}-\d{2}$/.test(basename)) {
|
||||
return { period: 'month', date: moment(basename, 'YYYY-MM') };
|
||||
}
|
||||
// YYYY-Q# → quarterly
|
||||
if (/^\d{4}-Q[1-4]$/.test(basename)) {
|
||||
return { period: 'quarter', date: moment(basename, 'YYYY-[Q]Q') };
|
||||
}
|
||||
// YYYY → yearly
|
||||
if (/^\d{4}$/.test(basename)) {
|
||||
return { period: 'year', date: moment(basename, 'YYYY') };
|
||||
private detectPeriodType(basename: string): { period: PeriodKey; date: moment.Moment } | null {
|
||||
for (const period of PERIOD_DETECTION_ORDER) {
|
||||
const format = this.settings[PERIOD_CONFIGS[period].key].nameFormat;
|
||||
if (!format) continue;
|
||||
const date = moment(basename, format, true);
|
||||
if (date.isValid()) return { period, date };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -506,17 +666,19 @@ export default class WaypointPlugin extends Plugin {
|
||||
|
||||
const detected = this.detectPeriodType(file.basename);
|
||||
if (!detected) {
|
||||
new Notice('Current file is not a periodic note (daily/weekly/monthly/quarterly/yearly)');
|
||||
const formats = PERIOD_DETECTION_ORDER
|
||||
.map(p => `${PERIOD_CONFIGS[p].label.toLowerCase()} "${this.settings[PERIOD_CONFIGS[p].key].nameFormat}"`)
|
||||
.join(', ');
|
||||
new Notice(
|
||||
`Waypoint: "${file.basename}" does not match any configured periodic note format.\n`
|
||||
+ `Expected one of: ${formats}.`,
|
||||
DIAGNOSTIC_NOTICE_MS,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { period, date } = detected;
|
||||
|
||||
if (!date.isValid()) {
|
||||
new Notice(`Could not parse date from filename: ${file.basename}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = direction === 'next' ? 1 : -1;
|
||||
|
||||
// Map period to moment duration unit
|
||||
@@ -541,20 +703,33 @@ export default class WaypointPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all markdown files that exist on a specific date.
|
||||
* Used to show note indicators on the calendar.
|
||||
*/
|
||||
async getNotesForDate(dateStr: string): Promise<TFile[]> {
|
||||
return this.app.vault.getFiles().filter(f =>
|
||||
f.extension === 'md' && f.basename === dateStr,
|
||||
);
|
||||
}
|
||||
|
||||
async hasNoteForDate(dateStr: string): Promise<boolean> {
|
||||
return this.app.vault.getFiles().some(f =>
|
||||
f.extension === 'md' && f.basename === dateStr,
|
||||
);
|
||||
// ── Module helpers ──
|
||||
|
||||
type PeriodSettingKey = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
||||
|
||||
const PERIOD_SETTING_KEYS: PeriodSettingKey[] = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'];
|
||||
|
||||
const PERIOD_CONFIGS: Record<PeriodKey, { key: PeriodSettingKey; label: string }> = {
|
||||
day: { key: 'daily', label: 'Daily' },
|
||||
week: { key: 'weekly', label: 'Weekly' },
|
||||
month: { key: 'monthly', label: 'Monthly' },
|
||||
quarter: { key: 'quarterly', label: 'Quarterly' },
|
||||
year: { key: 'yearly', label: 'Yearly' },
|
||||
};
|
||||
|
||||
/** Checked day → year so a loose format (YYYY) cannot claim a longer basename. */
|
||||
const PERIOD_DETECTION_ORDER: PeriodKey[] = ['day', 'week', 'month', 'quarter', 'year'];
|
||||
|
||||
function basenameFromPath(path: string): string {
|
||||
const name = path.slice(path.lastIndexOf('/') + 1);
|
||||
return name.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
/** Notices that carry a diagnosis need longer on screen than the default. */
|
||||
const DIAGNOSTIC_NOTICE_MS = 10000;
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
+76
-8
@@ -1,13 +1,14 @@
|
||||
import { Setting, PluginSettingTab, App, Plugin } from 'obsidian';
|
||||
import { Setting, PluginSettingTab, App, setIcon } from 'obsidian';
|
||||
import type WaypointPlugin from 'src/main';
|
||||
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
|
||||
|
||||
export class WaypointSettingTab extends PluginSettingTab {
|
||||
private plugin: Plugin;
|
||||
private plugin: WaypointPlugin;
|
||||
private settings: WaypointSettings;
|
||||
private onSettingsChange: () => void;
|
||||
private activeTab: 'calendar' | 'periodic' | 'recent' | 'display' = 'calendar';
|
||||
private activeTab: 'calendar' | 'periodic' | 'recent' | 'display' | 'about' = 'calendar';
|
||||
|
||||
constructor(app: App, plugin: Plugin, settings: WaypointSettings, onSettingsChange: () => void) {
|
||||
constructor(app: App, plugin: WaypointPlugin, settings: WaypointSettings, onSettingsChange: () => void) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
this.settings = settings;
|
||||
@@ -25,6 +26,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
{ key: 'periodic' as const, label: 'Periodic Notes' },
|
||||
{ key: 'recent' as const, label: 'Recent Files' },
|
||||
{ key: 'display' as const, label: 'Display' },
|
||||
{ key: 'about' as const, label: 'About' },
|
||||
];
|
||||
|
||||
for (const tab of tabs) {
|
||||
@@ -53,6 +55,9 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
case 'display':
|
||||
this.renderDisplayTab(tabContent);
|
||||
break;
|
||||
case 'about':
|
||||
this.renderAboutTab(tabContent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +221,21 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
this.saveAndRefresh();
|
||||
};
|
||||
});
|
||||
|
||||
const filterDesc = new DocumentFragment();
|
||||
filterDesc.appendText('Tags to show as filter pills above the file list. One per line. Leave empty to auto-detect from frontmatter `type` property.');
|
||||
new Setting(container)
|
||||
.setName('Filter tags')
|
||||
.setDesc(filterDesc)
|
||||
.addTextArea((text) => {
|
||||
text.inputEl.setAttr('rows', 4);
|
||||
text.setPlaceholder('meeting\nperson\nproject');
|
||||
text.setValue(this.settings.recentFiles.filterTags.join('\n'));
|
||||
text.inputEl.onblur = () => {
|
||||
this.settings.recentFiles.filterTags = text.getValue().split('\n').filter(t => t.trim());
|
||||
this.saveAndRefresh();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════
|
||||
@@ -265,12 +285,12 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
);
|
||||
}
|
||||
|
||||
private addSliderSetting(
|
||||
private addSliderSetting<K extends string>(
|
||||
container: HTMLElement,
|
||||
name: string,
|
||||
desc: string,
|
||||
obj: any,
|
||||
key: string,
|
||||
obj: Record<K, number>,
|
||||
key: K,
|
||||
min: number,
|
||||
max: number,
|
||||
step: number,
|
||||
@@ -294,7 +314,55 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
}
|
||||
|
||||
private async saveAndRefresh(): Promise<void> {
|
||||
await (this.plugin as any).saveSettings();
|
||||
await this.plugin.saveSettings();
|
||||
this.onSettingsChange();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════
|
||||
// About tab
|
||||
// ═══════════════════════════════
|
||||
|
||||
private renderAboutTab(container: HTMLElement): void {
|
||||
const version = this.plugin.manifest.version;
|
||||
|
||||
const header = container.createDiv();
|
||||
header.style.display = 'flex';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.gap = '12px';
|
||||
header.style.marginBottom = '16px';
|
||||
|
||||
const icon = header.createDiv();
|
||||
icon.style.display = 'flex';
|
||||
icon.style.alignItems = 'center';
|
||||
icon.style.justifyContent = 'center';
|
||||
icon.style.width = '48px';
|
||||
icon.style.height = '48px';
|
||||
icon.style.borderRadius = '12px';
|
||||
icon.style.background = 'var(--interactive-accent)';
|
||||
icon.style.color = 'var(--text-on-accent)';
|
||||
icon.style.fontSize = '24px';
|
||||
setIcon(icon, 'compass');
|
||||
|
||||
const titleGroup = header.createDiv();
|
||||
const title = titleGroup.createEl('h2', { text: 'Waypoint Sidebar' });
|
||||
title.style.margin = '0';
|
||||
title.style.lineHeight = '1.2';
|
||||
const subtitle = titleGroup.createDiv({ text: `v${version}` });
|
||||
subtitle.style.color = 'var(--text-muted)';
|
||||
subtitle.style.fontSize = 'var(--font-ui-small)';
|
||||
|
||||
const desc = container.createDiv();
|
||||
desc.style.marginBottom = '20px';
|
||||
desc.style.lineHeight = '1.6';
|
||||
desc.style.color = 'var(--text-normal)';
|
||||
desc.innerHTML = [
|
||||
`<p><strong>Waypoint</strong> is a sidebar plugin that brings three essential panels into one view:</p>`,
|
||||
`<ul style="padding-left: 20px; margin: 8px 0;">`,
|
||||
`<li><strong>Calendar</strong> — a monthly grid for your periodic notes (daily, weekly, monthly, quarterly, yearly). Click any day, week, or month to open or create the corresponding note.</li>`,
|
||||
`<li><strong>Recent Files</strong> — a list of recently opened files with type filtering, drag-and-drop, and right-click actions.</li>`,
|
||||
`<li><strong>Bookmarks</strong> — custom bookmarks with icons, groups, nesting, and drag-and-drop reordering. Separate from Obsidian's native bookmarks.</li>`,
|
||||
`</ul>`,
|
||||
`<p style="color: var(--text-muted); font-size: var(--font-ui-small);">Made by Olivier. Licensed under MIT.</p>`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface RecentFilesSettings {
|
||||
updateOn: 'file-open' | 'file-edit';
|
||||
omittedPaths: string[];
|
||||
omittedTags: string[];
|
||||
filterTags: string[]; // tags to show as filter pills (empty = auto-detect from frontmatter)
|
||||
}
|
||||
|
||||
export interface DisplaySettings {
|
||||
@@ -79,6 +80,7 @@ export const DEFAULT_SETTINGS: WaypointSettings = {
|
||||
updateOn: 'file-open',
|
||||
omittedPaths: [],
|
||||
omittedTags: [],
|
||||
filterTags: [],
|
||||
},
|
||||
display: {
|
||||
rowSize: 26,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// ── Path helpers ──
|
||||
// Pure functions only: no 'obsidian' imports, so this stays unit-testable in plain node.
|
||||
|
||||
/**
|
||||
* Remap a stored vault path after a rename.
|
||||
*
|
||||
* Obsidian's `vault.on('rename')` fires for folders as well as files, so a
|
||||
* stored path can be affected either because it *is* the renamed item or
|
||||
* because it lives inside a renamed folder.
|
||||
*
|
||||
* The nested check requires a `/` boundary, so renaming `notes/foo` leaves
|
||||
* `notes/foobar.md` untouched.
|
||||
*
|
||||
* @returns the updated path, or `null` when `path` is unaffected.
|
||||
*/
|
||||
export function remapRenamedPath(path: string, oldPath: string, newPath: string): string | null {
|
||||
if (!path || !oldPath) return null;
|
||||
if (path === oldPath) return newPath;
|
||||
if (path.startsWith(oldPath + '/')) return newPath + path.slice(oldPath.length);
|
||||
return null;
|
||||
}
|
||||
+196
-225
@@ -12,6 +12,7 @@ import {
|
||||
Notice,
|
||||
TFile,
|
||||
moment,
|
||||
type PaneType,
|
||||
} from 'obsidian';
|
||||
import type WaypointPlugin from 'src/main';
|
||||
import { getMonthGrid } from 'src/utils/date-utils';
|
||||
@@ -19,6 +20,18 @@ import { BookmarkItem } from 'src/models/bookmark';
|
||||
|
||||
export const WAYPOINT_VIEW_TYPE = 'waypoint-view';
|
||||
|
||||
/** Obsidian's internal drag manager — undocumented, so it has no public typings. */
|
||||
interface DragManager {
|
||||
dragFile(event: DragEvent, file: TFile): unknown;
|
||||
onDragStart(event: DragEvent, draggable: unknown): void;
|
||||
}
|
||||
|
||||
function getDragManager(app: App): DragManager {
|
||||
// `dragManager` is an internal Obsidian API, absent from the public typings.
|
||||
const internal = app as unknown as { dragManager: DragManager };
|
||||
return internal.dragManager;
|
||||
}
|
||||
|
||||
export class WaypointView extends ItemView {
|
||||
private plugin: WaypointPlugin;
|
||||
|
||||
@@ -72,6 +85,7 @@ export class WaypointView extends ItemView {
|
||||
private currentDisplayMonth: number = moment().month(); // 0-indexed
|
||||
private currentDisplayYear: number = moment().year();
|
||||
private dragId: string | null = null;
|
||||
private dropZones = new WeakMap<HTMLElement, { above: boolean; into: boolean }>();
|
||||
private recentFilesFilter: string | null = null; // null = show all, else filter by type
|
||||
|
||||
private renderCalendar(): void {
|
||||
@@ -97,7 +111,7 @@ export class WaypointView extends ItemView {
|
||||
qEl.addEventListener('mousedown', (event: MouseEvent) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
this.plugin.openPeriodNoteInLeaf('quarter', displayDate, this.app.workspace.getLeaf('tab'));
|
||||
this.plugin.openPeriodNote('quarter', displayDate, this.app.workspace.getLeaf('tab'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,7 +123,7 @@ export class WaypointView extends ItemView {
|
||||
mEl.addEventListener('mousedown', (event: MouseEvent) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
this.plugin.openPeriodNoteInLeaf('month', displayDate, this.app.workspace.getLeaf('tab'));
|
||||
this.plugin.openPeriodNote('month', displayDate, this.app.workspace.getLeaf('tab'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -121,7 +135,7 @@ export class WaypointView extends ItemView {
|
||||
yEl.addEventListener('mousedown', (event: MouseEvent) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
this.plugin.openPeriodNoteInLeaf('year', displayDate, this.app.workspace.getLeaf('tab'));
|
||||
this.plugin.openPeriodNote('year', displayDate, this.app.workspace.getLeaf('tab'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -172,15 +186,14 @@ export class WaypointView extends ItemView {
|
||||
// Week number cell
|
||||
const wnCell = row.createEl('td', { cls: 'waypoint-weeknum' });
|
||||
wnCell.setText(String(week.weekNumber));
|
||||
const weekStart = week.days[0].date;
|
||||
wnCell.addEventListener('click', () => {
|
||||
const monday = week.days[0].date;
|
||||
this.plugin.openPeriodNote('week', monday);
|
||||
this.plugin.openPeriodNote('week', weekStart);
|
||||
});
|
||||
wnCell.addEventListener('mousedown', (event: MouseEvent) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
const file = this.app.workspace.getLeaf('tab');
|
||||
this.plugin.openPeriodNoteInLeaf('week', monday, file);
|
||||
this.plugin.openPeriodNote('week', weekStart, this.app.workspace.getLeaf('tab'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -197,10 +210,7 @@ export class WaypointView extends ItemView {
|
||||
|
||||
if (this.plugin.settings.calendar.showNoteIndicators) {
|
||||
const dateStr = day.date.format('YYYY-MM-DD');
|
||||
const hasNote = this.plugin.app.vault.getFiles().some(
|
||||
f => f.extension === 'md' && f.basename === dateStr,
|
||||
);
|
||||
if (hasNote) {
|
||||
if (this.plugin.hasNoteForDate(dateStr)) {
|
||||
cell.addClass('has-note');
|
||||
}
|
||||
}
|
||||
@@ -211,8 +221,7 @@ export class WaypointView extends ItemView {
|
||||
cell.addEventListener('mousedown', (event: MouseEvent) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
const file = this.app.workspace.getLeaf('tab');
|
||||
this.plugin.openPeriodNoteInLeaf('day', day.date, file);
|
||||
this.plugin.openPeriodNote('day', day.date, this.app.workspace.getLeaf('tab'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -241,7 +250,26 @@ export class WaypointView extends ItemView {
|
||||
}
|
||||
|
||||
// ── Type filter bar ──
|
||||
const typeCounts: Record<string, number> = {};
|
||||
const configuredTags = this.plugin.settings.recentFiles.filterTags || [];
|
||||
let typeCounts: Record<string, number> = {};
|
||||
|
||||
if (configuredTags.length > 0) {
|
||||
// Use configured tags — count occurrences in recent files
|
||||
for (const tag of configuredTags) {
|
||||
typeCounts[tag] = 0;
|
||||
}
|
||||
for (const file of this.plugin.recentFiles) {
|
||||
const tfile = this.app.vault.getAbstractFileByPath(file.path);
|
||||
if (tfile instanceof TFile) {
|
||||
const cache = this.app.metadataCache.getFileCache(tfile);
|
||||
const type = cache?.frontmatter?.type;
|
||||
if (type && typeof type === 'string' && typeCounts.hasOwnProperty(type)) {
|
||||
typeCounts[type]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auto-detect from frontmatter `type` property
|
||||
for (const file of this.plugin.recentFiles) {
|
||||
const tfile = this.app.vault.getAbstractFileByPath(file.path);
|
||||
if (tfile instanceof TFile) {
|
||||
@@ -252,6 +280,7 @@ export class WaypointView extends ItemView {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(typeCounts).length > 0) {
|
||||
const filterBar = section.createDiv({ cls: 'waypoint-recent-filter' });
|
||||
@@ -262,7 +291,9 @@ export class WaypointView extends ItemView {
|
||||
this.redraw();
|
||||
});
|
||||
// Type pills
|
||||
const sortedTypes = Object.entries(typeCounts).sort(([,a], [,b]) => b - a);
|
||||
const sortedTypes = configuredTags.length > 0
|
||||
? Object.entries(typeCounts) // preserve configured order
|
||||
: Object.entries(typeCounts).sort((a, b) => b[1] - a[1]); // sort by count
|
||||
for (const [type, count] of sortedTypes) {
|
||||
const pill = filterBar.createSpan({ cls: `waypoint-recent-pill${this.recentFilesFilter === type ? ' is-active' : ''}` });
|
||||
pill.setText(`${type} ${count}`);
|
||||
@@ -317,8 +348,9 @@ export class WaypointView extends ItemView {
|
||||
navFileTitle.addEventListener('dragstart', (event: DragEvent) => {
|
||||
const tfile = this.app.metadataCache.getFirstLinkpathDest(file.path, '');
|
||||
if (tfile) {
|
||||
(this.app as any).dragManager.dragFile(event, tfile);
|
||||
(this.app as any).dragManager.onDragStart(event, (this.app as any).dragManager.dragFile(event, tfile));
|
||||
const dragManager = getDragManager(this.app);
|
||||
const draggable = dragManager.dragFile(event, tfile);
|
||||
dragManager.onDragStart(event, draggable);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -376,10 +408,10 @@ export class WaypointView extends ItemView {
|
||||
}
|
||||
}
|
||||
|
||||
private focusFile(file: { path: string; basename: string }, newLeaf: boolean | string | 'split'): void {
|
||||
private focusFile(file: { path: string; basename: string }, newLeaf: PaneType | boolean): void {
|
||||
const targetFile = this.app.vault.getFiles().find(f => f.path === file.path);
|
||||
if (targetFile) {
|
||||
const leaf = this.app.workspace.getLeaf(newLeaf as any);
|
||||
const leaf = this.app.workspace.getLeaf(newLeaf);
|
||||
leaf.openFile(targetFile);
|
||||
} else {
|
||||
new Notice('Cannot find file');
|
||||
@@ -477,45 +509,7 @@ export class WaypointView extends ItemView {
|
||||
rowEl.style.cursor = 'grab';
|
||||
|
||||
// Drag events
|
||||
rowEl.addEventListener('dragstart', (e) => {
|
||||
this.dragId = item.id;
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer!.setData('text/plain', item.id);
|
||||
rowEl.addClass('waypoint-bm-dragging');
|
||||
});
|
||||
rowEl.addEventListener('dragend', () => {
|
||||
this.dragId = null;
|
||||
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
|
||||
el.removeClass('waypoint-bm-dragging');
|
||||
el.removeClass('waypoint-bm-drop-line');
|
||||
el.removeClass('waypoint-bm-drop-below');
|
||||
});
|
||||
});
|
||||
rowEl.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.dragId || this.dragId === item.id) return;
|
||||
this.showDropIndicator(rowEl, e, false);
|
||||
});
|
||||
rowEl.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.dragId || this.dragId === item.id) return;
|
||||
this.showDropIndicator(rowEl, e, false);
|
||||
});
|
||||
rowEl.addEventListener('dragleave', () => {
|
||||
rowEl.removeClass('waypoint-bm-drop-line');
|
||||
rowEl.removeClass('waypoint-bm-drop-below');
|
||||
rowEl.removeClass('waypoint-bm-drop-into');
|
||||
});
|
||||
rowEl.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
this.dragId = null;
|
||||
rowEl.removeClass('waypoint-bm-drop-line');
|
||||
rowEl.removeClass('waypoint-bm-drop-below');
|
||||
const draggedId = e.dataTransfer?.getData('text/plain');
|
||||
if (!draggedId || draggedId === item.id) return;
|
||||
const dropAbove = (rowEl as any).__dropAbove;
|
||||
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
|
||||
});
|
||||
this.attachBookmarkDragHandlers(rowEl, container, item, false);
|
||||
|
||||
// Context menu
|
||||
rowEl.addEventListener('contextmenu', (event: MouseEvent) => {
|
||||
@@ -536,45 +530,7 @@ export class WaypointView extends ItemView {
|
||||
rowEl.style.cursor = 'grab';
|
||||
|
||||
// Drag events
|
||||
rowEl.addEventListener('dragstart', (e) => {
|
||||
this.dragId = item.id;
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer!.setData('text/plain', item.id);
|
||||
rowEl.addClass('waypoint-bm-dragging');
|
||||
});
|
||||
rowEl.addEventListener('dragend', () => {
|
||||
this.dragId = null;
|
||||
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
|
||||
el.removeClass('waypoint-bm-dragging');
|
||||
el.removeClass('waypoint-bm-drop-line');
|
||||
el.removeClass('waypoint-bm-drop-below');
|
||||
});
|
||||
});
|
||||
rowEl.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.dragId || this.dragId === item.id) return;
|
||||
this.showDropIndicator(rowEl, e, false);
|
||||
});
|
||||
rowEl.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.dragId || this.dragId === item.id) return;
|
||||
this.showDropIndicator(rowEl, e, false);
|
||||
});
|
||||
rowEl.addEventListener('dragleave', () => {
|
||||
rowEl.removeClass('waypoint-bm-drop-line');
|
||||
rowEl.removeClass('waypoint-bm-drop-below');
|
||||
rowEl.removeClass('waypoint-bm-drop-into');
|
||||
});
|
||||
rowEl.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
this.dragId = null;
|
||||
rowEl.removeClass('waypoint-bm-drop-line');
|
||||
rowEl.removeClass('waypoint-bm-drop-below');
|
||||
const draggedId = e.dataTransfer?.getData('text/plain');
|
||||
if (!draggedId || draggedId === item.id) return;
|
||||
const dropAbove = (rowEl as any).__dropAbove;
|
||||
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
|
||||
});
|
||||
this.attachBookmarkDragHandlers(rowEl, container, item, false);
|
||||
|
||||
// Context menu
|
||||
rowEl.addEventListener('contextmenu', (event: MouseEvent) => {
|
||||
@@ -597,65 +553,7 @@ export class WaypointView extends ItemView {
|
||||
}
|
||||
|
||||
// ── Drag events ──
|
||||
rowEl.addEventListener('dragstart', (e) => {
|
||||
this.dragId = item.id;
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer!.setData('text/plain', item.id);
|
||||
rowEl.addClass('waypoint-bm-dragging');
|
||||
});
|
||||
|
||||
const canAcceptChildren = true; // all file/group bookmarks can accept drops
|
||||
|
||||
rowEl.addEventListener('dragend', () => {
|
||||
this.dragId = null;
|
||||
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
|
||||
el.removeClass('waypoint-bm-dragging');
|
||||
el.removeClass('waypoint-bm-drop-line');
|
||||
el.removeClass('waypoint-bm-drop-below');
|
||||
el.removeClass('waypoint-bm-drop-into');
|
||||
});
|
||||
});
|
||||
|
||||
rowEl.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.dragId || this.dragId === item.id) return;
|
||||
this.showDropIndicator(rowEl, e, canAcceptChildren);
|
||||
});
|
||||
|
||||
rowEl.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.dragId || this.dragId === item.id) return;
|
||||
this.showDropIndicator(rowEl, e, canAcceptChildren);
|
||||
});
|
||||
|
||||
rowEl.addEventListener('dragleave', () => {
|
||||
rowEl.removeClass('waypoint-bm-drop-line');
|
||||
rowEl.removeClass('waypoint-bm-drop-below');
|
||||
rowEl.removeClass('waypoint-bm-drop-into');
|
||||
});
|
||||
|
||||
rowEl.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
this.dragId = null;
|
||||
rowEl.removeClass('waypoint-bm-drop-line');
|
||||
rowEl.removeClass('waypoint-bm-drop-below');
|
||||
rowEl.removeClass('waypoint-bm-drop-into');
|
||||
|
||||
const draggedId = e.dataTransfer?.getData('text/plain');
|
||||
if (!draggedId || draggedId === item.id) return;
|
||||
|
||||
const dropInto = (rowEl as any).__dropInto;
|
||||
if (dropInto && canAcceptChildren) {
|
||||
if (isGroup) {
|
||||
this.moveBookmarkToGroup(draggedId, item.id);
|
||||
} else {
|
||||
this.createParentNoteAndMove(draggedId, item.id);
|
||||
}
|
||||
} else {
|
||||
const dropAbove = (rowEl as any).__dropAbove;
|
||||
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
|
||||
}
|
||||
});
|
||||
this.attachBookmarkDragHandlers(rowEl, container, item, true);
|
||||
|
||||
// ── Group: chevron + icon + label ──
|
||||
if (isGroup) {
|
||||
@@ -677,7 +575,7 @@ export class WaypointView extends ItemView {
|
||||
const tfile = this.app.vault.getFileByPath(item.filePath);
|
||||
if (tfile) {
|
||||
const newLeaf = Keymap.isModEvent(event);
|
||||
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
|
||||
this.app.workspace.getLeaf(newLeaf).openFile(tfile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -729,7 +627,7 @@ export class WaypointView extends ItemView {
|
||||
const tfile = this.app.vault.getFileByPath(item.filePath);
|
||||
if (tfile) {
|
||||
const newLeaf = Keymap.isModEvent(event);
|
||||
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
|
||||
this.app.workspace.getLeaf(newLeaf).openFile(tfile);
|
||||
} else {
|
||||
new Notice('File not found');
|
||||
this.plugin.removeBookmark(item.id);
|
||||
@@ -913,6 +811,75 @@ export class WaypointView extends ItemView {
|
||||
menu.showAtPosition({ x: event.clientX, y: event.clientY });
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the shared bookmark drag-and-drop listeners to a row element.
|
||||
* `canAcceptChildren` selects 3-zone (above/into/below) drop targeting for
|
||||
* file and group rows; separators and spacers use 2-zone (above/below).
|
||||
*/
|
||||
private attachBookmarkDragHandlers(
|
||||
rowEl: HTMLElement,
|
||||
container: HTMLElement,
|
||||
item: BookmarkItem,
|
||||
canAcceptChildren: boolean,
|
||||
): void {
|
||||
const clearIndicators = (): void => {
|
||||
rowEl.removeClass('waypoint-bm-drop-line');
|
||||
rowEl.removeClass('waypoint-bm-drop-below');
|
||||
rowEl.removeClass('waypoint-bm-drop-into');
|
||||
};
|
||||
|
||||
rowEl.addEventListener('dragstart', (e) => {
|
||||
this.dragId = item.id;
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer!.setData('text/plain', item.id);
|
||||
rowEl.addClass('waypoint-bm-dragging');
|
||||
});
|
||||
|
||||
rowEl.addEventListener('dragend', () => {
|
||||
this.dragId = null;
|
||||
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
|
||||
el.removeClass('waypoint-bm-dragging');
|
||||
el.removeClass('waypoint-bm-drop-line');
|
||||
el.removeClass('waypoint-bm-drop-below');
|
||||
el.removeClass('waypoint-bm-drop-into');
|
||||
});
|
||||
});
|
||||
|
||||
rowEl.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.dragId || this.dragId === item.id) return;
|
||||
this.showDropIndicator(rowEl, e, canAcceptChildren);
|
||||
});
|
||||
|
||||
rowEl.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.dragId || this.dragId === item.id) return;
|
||||
this.showDropIndicator(rowEl, e, canAcceptChildren);
|
||||
});
|
||||
|
||||
rowEl.addEventListener('dragleave', clearIndicators);
|
||||
|
||||
rowEl.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
this.dragId = null;
|
||||
clearIndicators();
|
||||
|
||||
const draggedId = e.dataTransfer?.getData('text/plain');
|
||||
if (!draggedId || draggedId === item.id) return;
|
||||
|
||||
const zone = this.dropZones.get(rowEl);
|
||||
if (canAcceptChildren && zone?.into) {
|
||||
if (item.type === 'group') {
|
||||
this.moveBookmarkToGroup(draggedId, item.id);
|
||||
} else {
|
||||
this.createParentNoteAndMove(draggedId, item.id);
|
||||
}
|
||||
} else {
|
||||
this.moveBookmarkToPosition(draggedId, item.id, zone?.above ?? false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showDropIndicator(el: HTMLElement, e: MouseEvent, isGroupLike: boolean): void {
|
||||
// Clear all indicators
|
||||
const parent = el.parentElement;
|
||||
@@ -934,25 +901,21 @@ export class WaypointView extends ItemView {
|
||||
|
||||
if (y < topThreshold) {
|
||||
el.addClass('waypoint-bm-drop-line');
|
||||
(el as any).__dropAbove = true;
|
||||
(el as any).__dropInto = false;
|
||||
this.dropZones.set(el, { above: true, into: false });
|
||||
} else if (y > bottomThreshold) {
|
||||
el.addClass('waypoint-bm-drop-line');
|
||||
el.addClass('waypoint-bm-drop-below');
|
||||
(el as any).__dropAbove = false;
|
||||
(el as any).__dropInto = false;
|
||||
this.dropZones.set(el, { above: false, into: false });
|
||||
} else {
|
||||
el.addClass('waypoint-bm-drop-into');
|
||||
(el as any).__dropAbove = false;
|
||||
(el as any).__dropInto = true;
|
||||
this.dropZones.set(el, { above: false, into: true });
|
||||
}
|
||||
} else {
|
||||
// 2-zone: top half = above, bottom half = below
|
||||
const above = y < rect.top + rect.height / 2;
|
||||
el.addClass('waypoint-bm-drop-line');
|
||||
if (!above) el.addClass('waypoint-bm-drop-below');
|
||||
(el as any).__dropAbove = above;
|
||||
(el as any).__dropInto = false;
|
||||
this.dropZones.set(el, { above, into: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,9 +1071,8 @@ export class WaypointView extends ItemView {
|
||||
|
||||
private promptIcon(item: BookmarkItem): void {
|
||||
new IconSuggestModal(this.app, item.icon, (iconName) => {
|
||||
if (iconName) {
|
||||
// An empty string is a valid value meaning "no icon", so always apply.
|
||||
this.plugin.updateBookmark(item.id, { icon: iconName });
|
||||
}
|
||||
}).open();
|
||||
}
|
||||
}
|
||||
@@ -1166,6 +1128,31 @@ class RenameModal extends Modal {
|
||||
|
||||
// ── Icon picker modal (full Lucide icon set, grid layout) ──
|
||||
|
||||
// Lucide icon metadata, fetched at most once per session and shared by every
|
||||
// picker. Concurrent opens await the same in-flight promise; a failed fetch is
|
||||
// not cached, so a later open retries once the network is back.
|
||||
let iconCatalog: Promise<Record<string, string[]>> | null = null;
|
||||
|
||||
function loadIconCatalog(): Promise<Record<string, string[]>> {
|
||||
if (!iconCatalog) {
|
||||
iconCatalog = (async () => {
|
||||
try {
|
||||
const res = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
|
||||
return (await res.json()) as Record<string, string[]>;
|
||||
} catch {
|
||||
try {
|
||||
const res = await fetch('https://lucide.dev/api/tags');
|
||||
return (await res.json()) as Record<string, string[]>;
|
||||
} catch {
|
||||
iconCatalog = null;
|
||||
return FALLBACK_ICONS;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
return iconCatalog;
|
||||
}
|
||||
|
||||
class IconSuggestModal extends Modal {
|
||||
private onSubmit: (icon: string) => void;
|
||||
private selected: string;
|
||||
@@ -1241,15 +1228,6 @@ class IconSuggestModal extends Modal {
|
||||
const statusEl = statusRow.createSpan();
|
||||
statusEl.setText('Loading\u2026');
|
||||
|
||||
const clearEl = statusRow.createSpan();
|
||||
clearEl.style.cursor = 'var(--cursor)';
|
||||
clearEl.style.color = 'var(--text-accent)';
|
||||
clearEl.setText('No icon');
|
||||
clearEl.addEventListener('click', () => {
|
||||
this.onSubmit('');
|
||||
this.close();
|
||||
});
|
||||
|
||||
// ── Load icons ──
|
||||
this.loadIcons().then(() => {
|
||||
this.loaded = true;
|
||||
@@ -1258,7 +1236,7 @@ class IconSuggestModal extends Modal {
|
||||
});
|
||||
|
||||
// ── Render grid ──
|
||||
let debounce: any = null;
|
||||
let debounce: number | undefined;
|
||||
|
||||
const renderGrid = (query: string) => {
|
||||
grid.empty();
|
||||
@@ -1284,16 +1262,15 @@ class IconSuggestModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < matches.length; i++) {
|
||||
var name = matches[i];
|
||||
var tile = grid.createDiv();
|
||||
for (const name of matches) {
|
||||
const tile = grid.createDiv();
|
||||
tile.setAttr('data-icon', name);
|
||||
tile.style.display = 'flex';
|
||||
tile.style.alignItems = 'center';
|
||||
tile.style.justifyContent = 'center';
|
||||
tile.style.aspectRatio = '1';
|
||||
tile.style.borderRadius = '6px';
|
||||
tile.style.cursor = 'var(--cursor)';
|
||||
tile.style.cursor = 'var(--cursor-link, pointer)';
|
||||
tile.style.transition = 'background 80ms';
|
||||
tile.setAttr('title', name);
|
||||
|
||||
@@ -1304,27 +1281,26 @@ class IconSuggestModal extends Modal {
|
||||
tile.style.color = 'var(--text-muted)';
|
||||
}
|
||||
|
||||
var svg = tile.createSpan();
|
||||
const svg = tile.createSpan();
|
||||
svg.style.display = 'flex';
|
||||
setIcon(svg, name);
|
||||
|
||||
;(function(_self, _tile, _name, _query, _grid, _previewIcon, _previewLabel) {
|
||||
_tile.addEventListener('mouseenter', function() {
|
||||
if (_name !== _self.selected) _tile.style.background = 'var(--background-modifier-hover)';
|
||||
tile.addEventListener('mouseenter', () => {
|
||||
if (name !== this.selected) tile.style.background = 'var(--background-modifier-hover)';
|
||||
});
|
||||
_tile.addEventListener('mouseleave', function() {
|
||||
if (_name !== _self.selected) _tile.style.background = '';
|
||||
tile.addEventListener('mouseleave', () => {
|
||||
if (name !== this.selected) tile.style.background = '';
|
||||
});
|
||||
|
||||
_tile.addEventListener('click', function() {
|
||||
_self.selected = _name;
|
||||
renderGrid(_query);
|
||||
_previewIcon.empty();
|
||||
setIcon(_previewIcon, _name);
|
||||
_previewLabel.setText(_name);
|
||||
_grid.querySelectorAll('div[data-icon]').forEach(function(_el) {
|
||||
var el = _el as HTMLElement;
|
||||
if (el.getAttr('data-icon') === _name) {
|
||||
tile.addEventListener('click', () => {
|
||||
this.selected = name;
|
||||
renderGrid(query);
|
||||
previewIcon.empty();
|
||||
setIcon(previewIcon, name);
|
||||
previewLabel.setText(name);
|
||||
grid.querySelectorAll('div[data-icon]').forEach((node) => {
|
||||
const el = node as HTMLElement;
|
||||
if (el.getAttr('data-icon') === name) {
|
||||
el.style.background = 'var(--interactive-accent)';
|
||||
el.style.color = 'var(--text-on-accent)';
|
||||
} else {
|
||||
@@ -1333,31 +1309,39 @@ class IconSuggestModal extends Modal {
|
||||
}
|
||||
});
|
||||
});
|
||||
})(this, tile, name, query, grid, previewIcon, previewLabel);
|
||||
}
|
||||
|
||||
statusEl.setText(matches.length + ' of ' + this.allIcons.length + ' icons');
|
||||
};
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
if (debounce !== null) clearTimeout(debounce);
|
||||
debounce = setTimeout(function() { renderGrid(input.value); }, 60);
|
||||
input.addEventListener('input', () => {
|
||||
window.clearTimeout(debounce);
|
||||
debounce = window.setTimeout(() => renderGrid(input.value), 60);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function(e: KeyboardEvent) {
|
||||
input.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') this.close();
|
||||
}.bind(this));
|
||||
});
|
||||
|
||||
// ── Buttons ──
|
||||
var btns = modal.createDiv({ cls: 'modal-button-container' });
|
||||
var cancel = btns.createEl('button', { text: 'Cancel' });
|
||||
cancel.addEventListener('click', function() { this.close(); }.bind(this));
|
||||
var saveBtn = btns.createEl('button', { text: 'Save', cls: 'mod-cta' });
|
||||
const btns = modal.createDiv({ cls: 'modal-button-container' });
|
||||
|
||||
// Clearing the icon commits a value and closes, exactly like Save, so it
|
||||
// belongs with the buttons rather than as a bare span in the status row.
|
||||
const clearBtn = btns.createEl('button', { text: 'No icon', cls: 'waypoint-icon-clear' });
|
||||
clearBtn.addEventListener('click', () => {
|
||||
this.onSubmit('');
|
||||
this.close();
|
||||
});
|
||||
|
||||
const cancel = btns.createEl('button', { text: 'Cancel' });
|
||||
cancel.addEventListener('click', () => this.close());
|
||||
const saveBtn = btns.createEl('button', { text: 'Save', cls: 'mod-cta' });
|
||||
saveBtn.style.marginLeft = '8px';
|
||||
saveBtn.addEventListener('click', function() {
|
||||
saveBtn.addEventListener('click', () => {
|
||||
this.onSubmit(this.selected);
|
||||
this.close();
|
||||
}.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
@@ -1365,22 +1349,9 @@ class IconSuggestModal extends Modal {
|
||||
}
|
||||
|
||||
private async loadIcons(): Promise<void> {
|
||||
try {
|
||||
var r = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
|
||||
var d = await r.json();
|
||||
this.tagsMap = d as Record<string, string[]>;
|
||||
this.allIcons = Object.keys(d).sort();
|
||||
} catch (_e) {
|
||||
try {
|
||||
var r2 = await fetch('https://lucide.dev/api/tags');
|
||||
var d2 = await r2.json();
|
||||
this.tagsMap = d2 as Record<string, string[]>;
|
||||
this.allIcons = Object.keys(d2).sort();
|
||||
} catch (_e2) {
|
||||
this.tagsMap = {};
|
||||
this.allIcons = Object.keys(FALLBACK_ICONS).sort();
|
||||
}
|
||||
}
|
||||
const catalog = await loadIconCatalog();
|
||||
this.tagsMap = catalog;
|
||||
this.allIcons = Object.keys(catalog).sort();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-9
@@ -36,7 +36,7 @@
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
color: var(--text-faint);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
.waypoint-calendar-breadcrumb .waypoint-clickable {
|
||||
color: var(--text-muted);
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -102,7 +102,7 @@
|
||||
.waypoint-calendar-today-group button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
@@ -142,7 +142,7 @@
|
||||
font-size: calc(var(--font-ui-small) * 0.75);
|
||||
color: var(--text-faint);
|
||||
font-weight: var(--font-light);
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
padding: 2px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -153,7 +153,7 @@
|
||||
}
|
||||
|
||||
.waypoint-calendar .waypoint-day {
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
min-height: var(--wp-cal-cell-size, 32px);
|
||||
padding: 2px 0;
|
||||
border-radius: 6px;
|
||||
@@ -189,6 +189,7 @@
|
||||
.waypoint-calendar .waypoint-day.today.has-note::after {
|
||||
background-color: var(--text-accent);
|
||||
}
|
||||
|
||||
/* ── Recent Files ── */
|
||||
|
||||
.waypoint-recent-filter {
|
||||
@@ -204,7 +205,7 @@
|
||||
background: var(--background-modifier-hover);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
transition: color 80ms, background 80ms;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -235,7 +236,7 @@
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -262,7 +263,7 @@
|
||||
padding: 2px 8px 2px 4px;
|
||||
font-size: var(--wp-font-size, 13px);
|
||||
min-height: var(--wp-row-size, 26px);
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
@@ -309,6 +310,27 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-type="waypoint-view"] button.waypoint-header-more,
|
||||
button.waypoint-calendar-nav-btn,
|
||||
button.waypoint-calendar-today-btn {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.waypoint-calendar-breadcrumb {
|
||||
gap: 4px;
|
||||
|
||||
.waypoint-clickable {
|
||||
padding: 0px;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Drag-and-drop ── */
|
||||
|
||||
.waypoint-bm-dragging {
|
||||
@@ -370,7 +392,7 @@
|
||||
.waypoint-settings-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
@@ -390,3 +412,11 @@
|
||||
border-bottom: 2px solid var(--text-accent);
|
||||
margin-bottom: -9px;
|
||||
}
|
||||
|
||||
/* ── Icon picker ── */
|
||||
|
||||
/* Sits left of Cancel/Save so clearing reads as a separate, secondary action. */
|
||||
.waypoint-icon-clear {
|
||||
margin-right: auto;
|
||||
cursor: var(--cursor-link, pointer);
|
||||
}
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
"ES7",
|
||||
"ES2017"
|
||||
],
|
||||
"paths": {
|
||||
"src/*": ["./src/*"]
|
||||
|
||||
Reference in New Issue
Block a user