Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abf4d6ccf7 | |||
| 141121878f | |||
| 6a89660cf4 | |||
| 44c2dd2abc | |||
| 49c8da9863 | |||
| 993017f669 | |||
| e0c3deee86 | |||
| 7331a455ad | |||
| 7a5be506e0 | |||
| 765c6f3b47 | |||
| 846726a5c7 | |||
| e92903a7aa | |||
| 7bf1223673 | |||
| a36c8ab6f4 | |||
| eeb6059fe5 | |||
| 2e8ac7047f | |||
| a692dc9a34 | |||
| 6978401613 | |||
| 3c4fc2d91c | |||
| 4278507ae2 | |||
| 99a3ea80ae | |||
| cb09ec0e83 | |||
| a4714f6fa8 |
@@ -0,0 +1,375 @@
|
|||||||
|
# Waypoint Sidebar — Technical Documentation
|
||||||
|
|
||||||
|
> Obsidian plugin (id: `waypoint-sidebar`) providing a unified sidebar with calendar, recent files, and custom bookmarks. Replaces separate Calendar and Bookmarks core plugins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
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)
|
||||||
|
├── models/
|
||||||
|
│ └── bookmark.ts BookmarkItem + WaypointData interfaces
|
||||||
|
├── utils/
|
||||||
|
│ └── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
|
||||||
|
└── views/
|
||||||
|
└── waypoint-view.ts ItemView subclass — full sidebar rendering + interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build:** esbuild bundles `src/main.ts` → `main.js`. TypeScript strict, ESNext target.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin Lifecycle
|
||||||
|
|
||||||
|
### `onload()`
|
||||||
|
|
||||||
|
1. **Load data** — `loadSettings()` then `loadWaypointData()`, both merge saved partials over defaults via `Object.assign({}, DEFAULT, partial)`.
|
||||||
|
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`.
|
||||||
|
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).
|
||||||
|
|
||||||
|
### `onunload()`
|
||||||
|
|
||||||
|
Detaches all leaves of `WAYPOINT_VIEW_TYPE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### WaypointSettings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface WaypointSettings {
|
||||||
|
calendar: CalendarSettings; // firstDayOfWeek (0=Sun,1=Mon), showNoteIndicators
|
||||||
|
daily: PeriodNoteSettings;
|
||||||
|
weekly: PeriodNoteSettings;
|
||||||
|
monthly: PeriodNoteSettings;
|
||||||
|
quarterly: PeriodNoteSettings;
|
||||||
|
yearly: PeriodNoteSettings;
|
||||||
|
recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeriodNoteSettings {
|
||||||
|
folder: string; // e.g. "periodic/daily"
|
||||||
|
templateFile: string; // path without .md, e.g. "Templates/Daily note"
|
||||||
|
nameFormat: string; // moment.js format string
|
||||||
|
typeProperty: string; // frontmatter type value, e.g. "daily-note"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarSettings {
|
||||||
|
firstDayOfWeek: number; // 0 = Sunday, 1 = Monday
|
||||||
|
showNoteIndicators: boolean; // dot on days with existing .md files
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### BookmarkItem (recursive tree)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BookmarkItem {
|
||||||
|
id: string; // "bm-{timestamp}-{random4}"
|
||||||
|
type: 'file' | 'group' | 'separator' | 'spacer';
|
||||||
|
label: string; // display text (empty for separator/spacer)
|
||||||
|
filePath: string; // vault-relative path (empty for non-file types)
|
||||||
|
icon: string; // Lucide icon name
|
||||||
|
children: BookmarkItem[]; // nested items (groups contain children)
|
||||||
|
collapsed: boolean; // group collapse state
|
||||||
|
indent: number; // depth level (0 = root)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaypointData {
|
||||||
|
bookmarks: BookmarkItem[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The bookmark tree is stored flat in `data.json` under `waypointData.bookmarks` but rendered recursively. Groups contain children; files/separators/spacers are leaf nodes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Period note navigation (with default hotkeys)
|
||||||
|
|
||||||
|
| ID | Name | Hotkey |
|
||||||
|
|---|---|---|
|
||||||
|
| `waypoint-go-to-daily` | Go to daily note | `Mod+Shift+Alt+D` |
|
||||||
|
| `waypoint-go-to-weekly` | Go to weekly note | `Mod+Shift+Alt+W` |
|
||||||
|
| `waypoint-go-to-monthly` | Go to monthly note | `Mod+Shift+Alt+M` |
|
||||||
|
| `waypoint-go-to-quarterly` | Go to quarterly note | `Mod+Shift+Alt+Q` |
|
||||||
|
| `waypoint-go-to-yearly` | Go to yearly note | `Mod+Shift+Alt+Y` |
|
||||||
|
|
||||||
|
All call `openPeriodNote(period, moment())` — opens or creates the current period's note.
|
||||||
|
|
||||||
|
### Next/Previous period (no default hotkeys)
|
||||||
|
|
||||||
|
10 commands generated in a loop: `waypoint-go-to-{next|prev}-{daily|weekly|monthly|quarterly|yearly}`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Other commands
|
||||||
|
|
||||||
|
| ID | Name |
|
||||||
|
|---|---|
|
||||||
|
| `waypoint-open-view` | Open Waypoint sidebar |
|
||||||
|
| `waypoint-add-bookmark` | Add current file as Waypoint bookmark |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Period Note Creation (`openPeriodNote`)
|
||||||
|
|
||||||
|
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.
|
||||||
|
3. **Check existence:** `vault.getFileByPath(fullPath)`.
|
||||||
|
4. **If not found, create:**
|
||||||
|
- 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)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Handling
|
||||||
|
|
||||||
|
### `file-open`
|
||||||
|
|
||||||
|
If `settings.recentFiles.updateOn === 'file-open'` → calls `addToRecentFiles(file)`.
|
||||||
|
|
||||||
|
If `updateOn === 'file-edit'` → currently skipped (placeholder for future edit-detection).
|
||||||
|
|
||||||
|
### `vault:create` / `vault:delete`
|
||||||
|
|
||||||
|
Both trigger `broadcastRedraw()` — tells all WaypointView instances to re-render (needed for calendar note indicators and 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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent Files (in-memory)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
recentFiles: { path: string; basename: string }[]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update flow:**
|
||||||
|
1. `addToRecentFiles(file)` — dedupes (removes existing entry), prepends to front, truncates to `maxItems`.
|
||||||
|
2. Calls `broadcastRedraw()`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**On file not found:** When clicking a recent file that no longer exists, `focusFile()` shows a Notice and removes the stale entry from `recentFiles`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sidebar View (`WaypointView`)
|
||||||
|
|
||||||
|
Extends `ItemView`. View type: `"waypoint-view"`, icon: `compass`.
|
||||||
|
|
||||||
|
### Rendering (`redraw`)
|
||||||
|
|
||||||
|
Called on every change. Full DOM rebuild — `contentEl.empty()` then three sections in order:
|
||||||
|
|
||||||
|
1. **Favorites** (`renderFavorites`) — bookmark tree
|
||||||
|
2. **Recent Files** (`renderRecentFiles`) — flat list
|
||||||
|
3. **Calendar** (`renderCalendar`) — month grid
|
||||||
|
|
||||||
|
The last section (`waypoint-section:last-child`) gets `margin-top: auto`, pushing it to the bottom of the sidebar.
|
||||||
|
|
||||||
|
### Calendar Panel
|
||||||
|
|
||||||
|
**State:** `currentDisplayMonth` (0-indexed), `currentDisplayYear` — allows navigating months independently of today.
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Q2 June 2026 ◀ Today ▶ │ ← breadcrumb (clickable) + nav
|
||||||
|
├──┬───┬───┬───┬───┬───┬───┬───┤
|
||||||
|
│24│sun│mon│tue│wed│thu│fri│sat│ ← header row + week# col
|
||||||
|
├──┼───┼───┼───┼───┼───┼───┼───┤
|
||||||
|
│25│ 1 │ 2 │ 3 │... ← day cells (clickable)
|
||||||
|
└──┴───┴───┴───┴───┴───┴───┴───┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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)`).
|
||||||
|
- **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
|
||||||
|
|
||||||
|
Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the file explorer's CSS classes (`tree-item`, `nav-file-title`, `nav-file-title-content`).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
- **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.
|
||||||
|
- **Click:** `Keymap.isModEvent(event)` determines if opening in new leaf; otherwise same leaf.
|
||||||
|
- **Middle click:** Opens in new tab.
|
||||||
|
|
||||||
|
### Bookmarks/Favorites Panel
|
||||||
|
|
||||||
|
**Empty state:** Right-click on "Waypoint Bookmarks" header shows add menu:
|
||||||
|
- Add current file → `addBookmark(path, basename, 'file')`
|
||||||
|
- New group → `addBookmark('', 'New Group', 'group', 'folder')`
|
||||||
|
- Add separator → `addBookmark('', '', 'separator')`
|
||||||
|
- Add spacer → `addBookmark('', '', 'spacer')`
|
||||||
|
|
||||||
|
**Rendering:** `renderBookmarkList(container, items, depth)` recursively renders items with `paddingLeft: 8 + depth * 16px` for indentation.
|
||||||
|
|
||||||
|
**Item types:**
|
||||||
|
| Type | Render | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `file` | icon + label | Click opens file. Tooltip = filePath. |
|
||||||
|
| `group` | chevron + icon + label | Click toggles `collapsed`. Children rendered in nested `waypoint-bookmark-children` div (hidden when collapsed). |
|
||||||
|
| `separator` | horizontal line (::after pseudo-element) | No click handler. Context menu for delete. |
|
||||||
|
| `spacer` | 14px empty div | No click handler. Context menu for delete. |
|
||||||
|
|
||||||
|
**Drag-and-drop:** All bookmark items are draggable. Drop indicators show a 3px accent border above/below the target. On drop:
|
||||||
|
1. Remove dragged item from its current position (recursive search).
|
||||||
|
2. Insert at the target position (before or after based on cursor Y position relative to target midY).
|
||||||
|
3. If target not found (edge case), push to root.
|
||||||
|
4. Save + redraw.
|
||||||
|
|
||||||
|
**Context menu (right-click):**
|
||||||
|
- File items: "Open in new tab"
|
||||||
|
- File/Group items: "Rename" (opens `RenameModal`), "Change icon" (opens `IconSuggestModal`)
|
||||||
|
- "Move to group" submenu: Lists all available groups (excluding self + descendants) + "(Root)" for ungrouping
|
||||||
|
- Group items: "Expand/Collapse", "Add bookmark here", "New sub-group"
|
||||||
|
- All items: "Insert separator above/below", "Insert spacer above/below", "Remove"
|
||||||
|
|
||||||
|
**Move to group (`moveBookmarkToGroup`):**
|
||||||
|
1. Remove item from current position.
|
||||||
|
2. If targetGroupId: find group, set `item.indent = group.indent + 1`, push to group's children.
|
||||||
|
3. If null (root): set `item.indent = 0`, push to `waypointData.bookmarks`.
|
||||||
|
|
||||||
|
**Rename modal (`RenameModal`):** Simple Modal with text input + Cancel/Save buttons. Enter key submits.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
- Click to select, "No icon" link to clear, Save/Cancel buttons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Architecture (`styles.css`)
|
||||||
|
|
||||||
|
All classes prefixed with `waypoint-`. Uses Obsidian CSS variables throughout:
|
||||||
|
|
||||||
|
- `--font-ui-small`, `--font-ui-medium`, `--font-semibold`, `--font-medium`, `--font-light`
|
||||||
|
- `--text-muted`, `--text-faint`, `--text-accent`, `--text-on-accent`
|
||||||
|
- `--background-modifier-border`, `--background-modifier-active-hover`, `--background-primary`, `--background-secondary`
|
||||||
|
- `--interactive-accent`
|
||||||
|
- `--cursor` (for custom cursor support)
|
||||||
|
|
||||||
|
Key layout:
|
||||||
|
- `.waypoint-view` — flex column, `overflow-y: auto`, 8px padding.
|
||||||
|
- `.waypoint-section` — `flex-shrink: 0`, 16px bottom margin. Last section gets `margin-top: auto` (pins calendar to bottom).
|
||||||
|
- `.waypoint-section-header` — uppercase, muted, with bottom border.
|
||||||
|
- Calendar table — `table-layout: fixed`, `border-collapse: collapse`.
|
||||||
|
- `.waypoint-day.today` — accent color text + 1px accent border.
|
||||||
|
- `.waypoint-day.has-note::after` — 4px dot indicator.
|
||||||
|
- `.waypoint-bm-chevron` — `rotate(-90deg)` on collapsed groups.
|
||||||
|
- `.waypoint-bm-drop-line` / `.waypoint-bm-drop-below` — 3px accent border for drag indicators.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Date Utilities (`date-utils.ts`)
|
||||||
|
|
||||||
|
All use Obsidian's bundled `moment` (not the npm package).
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `getPeriodInfo(date)` | `Moment → PeriodInfo` | `{day, week, month, quarter, year}` strings for the date |
|
||||||
|
| `getQuarterString(date)` | `Moment → string` | e.g. `"2026-Q3"` |
|
||||||
|
| `getTodayPeriod()` | `() → PeriodInfo` | Current date's period info |
|
||||||
|
| `getMonthGrid(year, month, firstDayOfWeek)` | `number, number, number → CalendarWeek[]` | Up to 6 weeks, each with 7 `CalendarDay` objects |
|
||||||
|
| `formatDateLabel(date)` | `Moment → string` | `"June 1st, 2026"` |
|
||||||
|
| `formatMonthLabel(date)` | `Moment → string` | `"June 2026"` |
|
||||||
|
| `formatQuarterLabel(date)` | `Moment → string` | `"Q3 2026"` |
|
||||||
|
| `navigateDate(date, period, delta)` | `Moment, period, number → Moment` | Clones + adds delta (quarters: delta×3 months) |
|
||||||
|
| `formatPeriodName(date, format)` | `Moment, string → string` | `date.format(format)` wrapper |
|
||||||
|
| `getISOWeek(date)` | `Moment → number` | `date.isoWeek()` |
|
||||||
|
|
||||||
|
**`getMonthGrid` algorithm:**
|
||||||
|
1. Find the first day to display by subtracting `(firstOfMonth.day() - firstDayOfWeek + 7) % 7` days from the 1st.
|
||||||
|
2. Iterate day-by-day, building 7-day weeks.
|
||||||
|
3. Each `CalendarDay` tracks: `date` (Moment), `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`.
|
||||||
|
4. Week's `weekNumber` = first day's ISO week number.
|
||||||
|
5. Safety break after 6 weeks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings Persistence
|
||||||
|
|
||||||
|
Settings and waypoint data share a single `data.json` via Obsidian's `Plugin.loadData()/saveData()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// loadData() returns:
|
||||||
|
{
|
||||||
|
settings: { calendar: {...}, daily: {...}, ... },
|
||||||
|
waypointData: { bookmarks: [...] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redraw Mechanism
|
||||||
|
|
||||||
|
`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
|
||||||
|
- Bookmark add/remove/update
|
||||||
|
- Period navigation
|
||||||
|
- Settings changes (via `onSettingsChange` callback)
|
||||||
|
- Midnight detection (10-min interval)
|
||||||
|
|
||||||
|
The sidebar is re-rendered from scratch on every change. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Waypoint Sidebar — QA Checklist
|
||||||
|
|
||||||
|
## Bookmarks: Middle Click
|
||||||
|
|
||||||
|
- [ ] Middle-click on a **file bookmark** opens the file in a new tab
|
||||||
|
- [ ] Middle-click on a **group bookmark with a linked file** opens that file in a new tab
|
||||||
|
- [ ] Middle-click on a **group bookmark with no file** does nothing (no error)
|
||||||
|
- [ ] Ctrl/Cmd+click on a **file bookmark** opens in a new leaf (same as Obsidian default)
|
||||||
|
- [ ] Ctrl/Cmd+click on a **group bookmark with a linked file** opens in a new leaf
|
||||||
|
- [ ] Ctrl/Cmd+click on a **group bookmark with no file** still toggles collapse
|
||||||
|
- [ ] Regular left-click on a **file bookmark** opens the file in the same leaf
|
||||||
|
- [ ] Regular left-click on a **group with a file** opens the file (does NOT toggle collapse)
|
||||||
|
- [ ] Regular left-click on a **group without a file** toggles collapse
|
||||||
|
|
||||||
|
## Bookmarks: Parent Notes (File as Group)
|
||||||
|
|
||||||
|
- [ ] Right-click header → "Add as parent note" creates a group with the current file linked
|
||||||
|
- [ ] Clicking the parent note row opens the linked file
|
||||||
|
- [ ] Clicking the chevron toggles collapse of children
|
||||||
|
- [ ] Children render correctly under the parent note (indented)
|
||||||
|
- [ ] Collapsing the parent hides its children
|
||||||
|
- [ ] A **file bookmark** that has children shows a chevron
|
||||||
|
- [ ] Clicking the chevron on a file bookmark toggles its children
|
||||||
|
- [ ] Clicking the file bookmark row itself still opens the file
|
||||||
|
- [ ] Drag-and-drop still works for reordering items inside/outside parent notes
|
||||||
|
|
||||||
|
## Bookmarks: No Default Icon
|
||||||
|
|
||||||
|
- [ ] New file bookmarks have **no icon** (empty space where icon was)
|
||||||
|
- [ ] New groups have **no icon** (empty space where folder icon was)
|
||||||
|
- [ ] "Add bookmark here" from group context menu creates child with no icon
|
||||||
|
- [ ] "Add child note" from group context menu creates child with no icon
|
||||||
|
- [ ] Existing bookmarks with icons still show their icons (no regression)
|
||||||
|
- [ ] Right-click → "Change icon" still works to add an icon later
|
||||||
|
|
||||||
|
## Bookmarks: Icon Search (Lucide Tags)
|
||||||
|
|
||||||
|
- [ ] Open the icon picker → type "meeting" → should find `calendar` (tag match)
|
||||||
|
- [ ] Type "heart" → should find `heart`, `red-heart`, etc.
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] 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
|
||||||
|
|
||||||
|
## Bookmarks: General (Regression)
|
||||||
|
|
||||||
|
- [ ] Drag-and-drop reordering still works between all item types
|
||||||
|
- [ ] Rename still works (right-click → Rename)
|
||||||
|
- [ ] Remove still works (right-click → Remove)
|
||||||
|
- [ ] Move to group still works (right-click → group submenu)
|
||||||
|
- [ ] Separators and spacers still render and can be dragged/deleted
|
||||||
|
- [ ] "Insert separator above/below" and "Insert spacer above/below" still work
|
||||||
|
- [ ] 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
|
||||||
|
|
||||||
|
## Calendar (Regression)
|
||||||
|
|
||||||
|
- [ ] Calendar renders with correct month grid
|
||||||
|
- [ ] Click on a day → opens/creates daily note
|
||||||
|
- [ ] Click on week number → opens/creates weekly note
|
||||||
|
- [ ] Click Q/M/Y breadcrumb → opens respective period note
|
||||||
|
- [ ] ◀/▶ navigation shifts month
|
||||||
|
- [ ] "Today" button returns to current month
|
||||||
|
- [ ] Note indicator dots show on days with existing .md files
|
||||||
|
- [ ] Today is highlighted with accent border
|
||||||
|
|
||||||
|
## Recent Files (Regression)
|
||||||
|
|
||||||
|
- [ ] Opening a file adds it to recent files
|
||||||
|
- [ ] Most recent file appears at the top
|
||||||
|
- [ ] Active file is highlighted
|
||||||
|
- [ ] Remove button (×) works on hover
|
||||||
|
- [ ] Left-click opens file, middle-click opens in new tab
|
||||||
|
- [ ] Right-click shows "Open in new tab" + native file menu
|
||||||
|
- [ ] Hover preview popup works
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Waypoint
|
||||||
|
|
||||||
|
Calendar, recent files, and custom bookmarks sidebar for Obsidian.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Calendar panel** — month grid with clickable days, week numbers, period indicators (day/week/month/quarter/year)
|
||||||
|
- **Recent files** — track recently opened/edited files
|
||||||
|
- **Favorites** — custom bookmarks with groups, icons, and rename
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Default Hotkey |
|
||||||
|
|---|---|
|
||||||
|
| Go to daily note | `Ctrl+Shift+Alt+D` |
|
||||||
|
| Go to weekly note | `Ctrl+Shift+Alt+W` |
|
||||||
|
| Go to monthly note | `Ctrl+Shift+Alt+M` |
|
||||||
|
| Go to quarterly note | `Ctrl+Shift+Alt+Q` |
|
||||||
|
| Go to yearly note | `Ctrl+Shift+Alt+Y` |
|
||||||
|
| Next/Previous daily/weekly/monthly/quarterly/yearly note | — |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Via BRAT
|
||||||
|
|
||||||
|
1. Install the [BRAT](https://obsidian.md/plugins?search=brat) plugin
|
||||||
|
2. Add `https://gitea.mithrilforge.dev/olivier/waypoint` as a Beta Plugin
|
||||||
|
3. Enable **Waypoint** in Community Plugins
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
1. Download the latest release from [Gitea](https://gitea.mithrilforge.dev/olivier/waypoint/releases)
|
||||||
|
2. Extract `main.js`, `manifest.json`, and `styles.css` to `{vault}/.obsidian/plugins/waypoint/`
|
||||||
|
3. Enable **Waypoint** in Community Plugins
|
||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "waypoint",
|
"id": "waypoint-sidebar",
|
||||||
"name": "Waypoint",
|
"name": "Waypoint Sidebar",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"minAppVersion": "0.16.3",
|
"minAppVersion": "0.16.3",
|
||||||
"description": "Calendar, recent files, and custom bookmarks sidebar.",
|
"description": "Calendar, recent files, and custom bookmarks sidebar.",
|
||||||
"author": "Olivier",
|
"author": "Olivier",
|
||||||
"isDesktopOnly": false
|
"isDesktopOnly": false
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waypoint",
|
"name": "waypoint",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"description": "Calendar, recent files, and custom bookmarks sidebar.",
|
"description": "Calendar, recent files, and custom bookmarks sidebar.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,4 +16,4 @@
|
|||||||
"obsidian": "latest",
|
"obsidian": "latest",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+213
-5
@@ -16,12 +16,14 @@ import { BookmarkItem, WaypointData } from 'src/models/bookmark';
|
|||||||
|
|
||||||
const DEFAULT_DATA: WaypointData = {
|
const DEFAULT_DATA: WaypointData = {
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
|
recentFiles: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class WaypointPlugin extends Plugin {
|
export default class WaypointPlugin extends Plugin {
|
||||||
public settings: WaypointSettings;
|
public settings: WaypointSettings;
|
||||||
public waypointData: WaypointData;
|
public waypointData: WaypointData;
|
||||||
public recentFiles: { path: string; basename: string }[] = [];
|
public recentFiles: { path: string; basename: string }[] = [];
|
||||||
|
private recentFilesSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
async onload(): Promise<void> {
|
async onload(): Promise<void> {
|
||||||
console.debug('Waypoint: loading plugin v' + this.manifest.version);
|
console.debug('Waypoint: loading plugin v' + this.manifest.version);
|
||||||
@@ -41,7 +43,10 @@ export default class WaypointPlugin extends Plugin {
|
|||||||
this.app,
|
this.app,
|
||||||
this,
|
this,
|
||||||
this.settings,
|
this.settings,
|
||||||
() => this.redrawAll(),
|
() => {
|
||||||
|
this.enforceRecentFilesLimit();
|
||||||
|
this.redrawAll();
|
||||||
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
// ── Commands ──
|
// ── Commands ──
|
||||||
@@ -78,13 +83,70 @@ export default class WaypointPlugin extends Plugin {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: 'waypoint-go-to-today',
|
id: 'waypoint-go-to-daily',
|
||||||
name: 'Go to today\'s daily note',
|
name: 'Go to daily note',
|
||||||
|
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'd' }],
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
await this.openPeriodNote('day', moment());
|
await this.openPeriodNote('day', moment());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'waypoint-go-to-weekly',
|
||||||
|
name: 'Go to weekly note',
|
||||||
|
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'w' }],
|
||||||
|
callback: async () => {
|
||||||
|
await this.openPeriodNote('week', moment());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'waypoint-go-to-monthly',
|
||||||
|
name: 'Go to monthly note',
|
||||||
|
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'm' }],
|
||||||
|
callback: async () => {
|
||||||
|
await this.openPeriodNote('month', moment());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'waypoint-go-to-quarterly',
|
||||||
|
name: 'Go to quarterly note',
|
||||||
|
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'q' }],
|
||||||
|
callback: async () => {
|
||||||
|
await this.openPeriodNote('quarter', moment());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'waypoint-go-to-yearly',
|
||||||
|
name: 'Go to yearly note',
|
||||||
|
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'y' }],
|
||||||
|
callback: async () => {
|
||||||
|
await this.openPeriodNote('year', moment());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Period navigation commands (no hotkeys) ──
|
||||||
|
|
||||||
|
const directions = ['next', 'prev'] as const;
|
||||||
|
const periodLabels = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'] as const;
|
||||||
|
const directionLabels: Record<string, string> = { next: 'Next', prev: 'Previous' };
|
||||||
|
|
||||||
|
for (const period of periodLabels) {
|
||||||
|
for (const dir of directions) {
|
||||||
|
const id = `waypoint-go-to-${dir}-${period}`;
|
||||||
|
const name = `${directionLabels[dir]} ${period} note`;
|
||||||
|
this.addCommand({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
callback: async () => {
|
||||||
|
await this.navigatePeriodNote(dir);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Events ──
|
// ── Events ──
|
||||||
|
|
||||||
this.registerEvent(
|
this.registerEvent(
|
||||||
@@ -149,14 +211,47 @@ export default class WaypointPlugin extends Plugin {
|
|||||||
const saved = await this.loadData() as Record<string, unknown> | null;
|
const saved = await this.loadData() as Record<string, unknown> | null;
|
||||||
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
|
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
|
||||||
this.waypointData = Object.assign({}, DEFAULT_DATA, d);
|
this.waypointData = Object.assign({}, DEFAULT_DATA, d);
|
||||||
|
|
||||||
|
// Load persisted recent files
|
||||||
|
this.recentFiles = this.waypointData.recentFiles || [];
|
||||||
|
|
||||||
|
// Apply current limit (in case maxItems was reduced since last save)
|
||||||
|
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
|
||||||
|
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
|
||||||
|
this.waypointData.recentFiles = this.recentFiles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim recent files to the current maxItems limit and persist.
|
||||||
|
* Called when the maxItems setting changes.
|
||||||
|
*/
|
||||||
|
enforceRecentFilesLimit(): void {
|
||||||
|
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
|
||||||
|
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
|
||||||
|
this.persistRecentFiles();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveWaypointData(): Promise<void> {
|
async saveWaypointData(): Promise<void> {
|
||||||
|
// Sync recentFiles into waypointData before saving
|
||||||
|
this.waypointData.recentFiles = this.recentFiles;
|
||||||
const all = (await this.loadData()) as Record<string, unknown> || {};
|
const all = (await this.loadData()) as Record<string, unknown> || {};
|
||||||
all.waypointData = this.waypointData;
|
all.waypointData = this.waypointData;
|
||||||
await this.saveData(all);
|
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(() => {
|
||||||
|
this.saveWaypointData();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Recent Files ──
|
// ── Recent Files ──
|
||||||
|
|
||||||
private onFileOpen(file: TFile): void {
|
private onFileOpen(file: TFile): void {
|
||||||
@@ -168,14 +263,26 @@ export default class WaypointPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addToRecentFiles(file: TFile): void {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.recentFiles = this.recentFiles.filter(f => f.path !== file.path);
|
this.recentFiles = this.recentFiles.filter(f => f.path !== file.path);
|
||||||
this.recentFiles.unshift({ path: file.path, basename: file.basename });
|
this.recentFiles.unshift({ path: file.path, basename: file.basename });
|
||||||
|
|
||||||
// Prune
|
// Apply max items limit
|
||||||
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
|
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
|
||||||
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
|
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.persistRecentFiles();
|
||||||
this.broadcastRedraw();
|
this.broadcastRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +291,7 @@ export default class WaypointPlugin extends Plugin {
|
|||||||
if (entry) {
|
if (entry) {
|
||||||
entry.path = file.path;
|
entry.path = file.path;
|
||||||
entry.basename = (file as TFile).basename || file.name.replace(/\.[^/.]+$/, '');
|
entry.basename = (file as TFile).basename || file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
this.persistRecentFiles();
|
||||||
this.broadcastRedraw();
|
this.broadcastRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +305,7 @@ export default class WaypointPlugin extends Plugin {
|
|||||||
|
|
||||||
// ── Bookmarks ──
|
// ── Bookmarks ──
|
||||||
|
|
||||||
addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = 'file'): BookmarkItem {
|
addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = ''): BookmarkItem {
|
||||||
const id = `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
const id = `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const item: BookmarkItem = {
|
const item: BookmarkItem = {
|
||||||
id,
|
id,
|
||||||
@@ -319,6 +427,106 @@ export default class WaypointPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (file) {
|
||||||
|
await leaf.openFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period note navigation (next/prev from current file) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the period type from a filename's 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') };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the next or previous period note based on the currently active file.
|
||||||
|
*/
|
||||||
|
async navigatePeriodNote(direction: 'next' | 'prev'): Promise<void> {
|
||||||
|
const file = this.app.workspace.getActiveFile();
|
||||||
|
if (!file) {
|
||||||
|
new Notice('No active file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detected = this.detectPeriodType(file.basename);
|
||||||
|
if (!detected) {
|
||||||
|
new Notice('Current file is not a periodic note (daily/weekly/monthly/quarterly/yearly)');
|
||||||
|
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
|
||||||
|
const newDate = period === 'quarter'
|
||||||
|
? date.clone().add(amount * 3, 'months')
|
||||||
|
: date.clone().add(amount, `${period}s` as moment.unitOfTime.DurationConstructor);
|
||||||
|
|
||||||
|
await this.openPeriodNote(period, newDate);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Redraw ──
|
// ── Redraw ──
|
||||||
|
|
||||||
private redrawAll(): void {
|
private redrawAll(): void {
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export interface BookmarkItem {
|
|||||||
|
|
||||||
export interface WaypointData {
|
export interface WaypointData {
|
||||||
bookmarks: BookmarkItem[];
|
bookmarks: BookmarkItem[];
|
||||||
|
recentFiles: { path: string; basename: string }[];
|
||||||
}
|
}
|
||||||
|
|||||||
+255
-66
@@ -1,10 +1,11 @@
|
|||||||
import { Setting, PluginSettingTab, App, Plugin } from 'obsidian';
|
import { Setting, PluginSettingTab, App, Plugin, setIcon } from 'obsidian';
|
||||||
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
|
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
|
||||||
|
|
||||||
export class WaypointSettingTab extends PluginSettingTab {
|
export class WaypointSettingTab extends PluginSettingTab {
|
||||||
private plugin: Plugin;
|
private plugin: Plugin;
|
||||||
private settings: WaypointSettings;
|
private settings: WaypointSettings;
|
||||||
private onSettingsChange: () => void;
|
private onSettingsChange: () => void;
|
||||||
|
private activeTab: 'calendar' | 'periodic' | 'recent' | 'display' | 'about' = 'calendar';
|
||||||
|
|
||||||
constructor(app: App, plugin: Plugin, settings: WaypointSettings, onSettingsChange: () => void) {
|
constructor(app: App, plugin: Plugin, settings: WaypointSettings, onSettingsChange: () => void) {
|
||||||
super(app, plugin);
|
super(app, plugin);
|
||||||
@@ -17,10 +18,54 @@ export class WaypointSettingTab extends PluginSettingTab {
|
|||||||
const { containerEl } = this;
|
const { containerEl } = this;
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
|
|
||||||
// ── Calendar Section ──
|
// ── Tab bar ──
|
||||||
new Setting(containerEl).setHeading().setName('Calendar');
|
const tabBar = containerEl.createDiv({ cls: 'waypoint-settings-tabs' });
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'calendar' as const, label: 'Calendar' },
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
new Setting(containerEl)
|
for (const tab of tabs) {
|
||||||
|
const btn = tabBar.createEl('button', {
|
||||||
|
cls: `waypoint-settings-tab${this.activeTab === tab.key ? ' is-active' : ''}`,
|
||||||
|
text: tab.label,
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.activeTab = tab.key;
|
||||||
|
this.display();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabContent = containerEl.createDiv({ cls: 'waypoint-settings-content' });
|
||||||
|
|
||||||
|
switch (this.activeTab) {
|
||||||
|
case 'calendar':
|
||||||
|
this.renderCalendarTab(tabContent);
|
||||||
|
break;
|
||||||
|
case 'periodic':
|
||||||
|
this.renderPeriodicTab(tabContent);
|
||||||
|
break;
|
||||||
|
case 'recent':
|
||||||
|
this.renderRecentTab(tabContent);
|
||||||
|
break;
|
||||||
|
case 'display':
|
||||||
|
this.renderDisplayTab(tabContent);
|
||||||
|
break;
|
||||||
|
case 'about':
|
||||||
|
this.renderAboutTab(tabContent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════
|
||||||
|
// Calendar tab
|
||||||
|
// ═══════════════════════════════
|
||||||
|
|
||||||
|
private renderCalendarTab(container: HTMLElement): void {
|
||||||
|
new Setting(container)
|
||||||
.setName('First day of week')
|
.setName('First day of week')
|
||||||
.setDesc('Which day the calendar week starts on.')
|
.setDesc('Which day the calendar week starts on.')
|
||||||
.addDropdown((dropdown) => {
|
.addDropdown((dropdown) => {
|
||||||
@@ -34,7 +79,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(container)
|
||||||
.setName('Show note indicators')
|
.setName('Show note indicators')
|
||||||
.setDesc('Show a dot on days that have existing notes.')
|
.setDesc('Show a dot on days that have existing notes.')
|
||||||
.addToggle((toggle) => {
|
.addToggle((toggle) => {
|
||||||
@@ -45,20 +90,78 @@ export class WaypointSettingTab extends PluginSettingTab {
|
|||||||
this.saveAndRefresh();
|
this.saveAndRefresh();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Periodic Note Settings ──
|
// ═══════════════════════════════
|
||||||
new Setting(containerEl).setHeading().setName('Periodic Note Paths');
|
// Periodic Notes tab
|
||||||
|
// ═══════════════════════════════
|
||||||
|
|
||||||
this.addPeriodNoteSettings('Daily', this.settings.daily);
|
private renderPeriodicTab(container: HTMLElement): void {
|
||||||
this.addPeriodNoteSettings('Weekly', this.settings.weekly);
|
this.addPeriodNoteSettings(container, 'Daily', this.settings.daily);
|
||||||
this.addPeriodNoteSettings('Monthly', this.settings.monthly);
|
this.addPeriodNoteSettings(container, 'Weekly', this.settings.weekly);
|
||||||
this.addPeriodNoteSettings('Quarterly', this.settings.quarterly);
|
this.addPeriodNoteSettings(container, 'Monthly', this.settings.monthly);
|
||||||
this.addPeriodNoteSettings('Yearly', this.settings.yearly);
|
this.addPeriodNoteSettings(container, 'Quarterly', this.settings.quarterly);
|
||||||
|
this.addPeriodNoteSettings(container, 'Yearly', this.settings.yearly);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Recent Files Section ──
|
private addPeriodNoteSettings(container: HTMLElement, label: string, period: PeriodNoteSettings): void {
|
||||||
new Setting(containerEl).setHeading().setName('Recent Files');
|
new Setting(container).setHeading().setName(label);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(container)
|
||||||
|
.setName('Folder')
|
||||||
|
.setDesc(`Folder path for ${label.toLowerCase()} notes.`)
|
||||||
|
.addText((text) => {
|
||||||
|
text.setPlaceholder('periodic/daily');
|
||||||
|
text.setValue(period.folder);
|
||||||
|
text.onChange((value) => {
|
||||||
|
period.folder = value;
|
||||||
|
this.saveAndRefresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(container)
|
||||||
|
.setName('Name format')
|
||||||
|
.setDesc(`Date format for ${label.toLowerCase()} note filenames (moment.js format).`)
|
||||||
|
.addText((text) => {
|
||||||
|
text.setPlaceholder('yyyy-MM-dd');
|
||||||
|
text.setValue(period.nameFormat);
|
||||||
|
text.onChange((value) => {
|
||||||
|
period.nameFormat = value;
|
||||||
|
this.saveAndRefresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(container)
|
||||||
|
.setName('Template file')
|
||||||
|
.setDesc(`Path to the template file (without .md extension).`)
|
||||||
|
.addText((text) => {
|
||||||
|
text.setPlaceholder('Templates/Daily note');
|
||||||
|
text.setValue(period.templateFile);
|
||||||
|
text.onChange((value) => {
|
||||||
|
period.templateFile = value;
|
||||||
|
this.saveAndRefresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(container)
|
||||||
|
.setName('Type property')
|
||||||
|
.setDesc(`Value for the 'type' frontmatter property.`)
|
||||||
|
.addText((text) => {
|
||||||
|
text.setPlaceholder('daily-note');
|
||||||
|
text.setValue(period.typeProperty);
|
||||||
|
text.onChange((value) => {
|
||||||
|
period.typeProperty = value;
|
||||||
|
this.saveAndRefresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════
|
||||||
|
// Recent Files tab
|
||||||
|
// ═══════════════════════════════
|
||||||
|
|
||||||
|
private renderRecentTab(container: HTMLElement): void {
|
||||||
|
new Setting(container)
|
||||||
.setName('Max items')
|
.setName('Max items')
|
||||||
.setDesc('Maximum number of recent files to track.')
|
.setDesc('Maximum number of recent files to track.')
|
||||||
.addText((text) => {
|
.addText((text) => {
|
||||||
@@ -74,7 +177,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(container)
|
||||||
.setName('Update on')
|
.setName('Update on')
|
||||||
.setDesc('When to add a file to the recent list.')
|
.setDesc('When to add a file to the recent list.')
|
||||||
.addDropdown((dropdown) => {
|
.addDropdown((dropdown) => {
|
||||||
@@ -90,7 +193,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
const pathDesc = new DocumentFragment();
|
const pathDesc = new DocumentFragment();
|
||||||
pathDesc.appendText('Regex patterns for paths to exclude. One per line.');
|
pathDesc.appendText('Regex patterns for paths to exclude. One per line.');
|
||||||
new Setting(containerEl)
|
new Setting(container)
|
||||||
.setName('Omitted paths')
|
.setName('Omitted paths')
|
||||||
.setDesc(pathDesc)
|
.setDesc(pathDesc)
|
||||||
.addTextArea((text) => {
|
.addTextArea((text) => {
|
||||||
@@ -105,7 +208,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
const tagDesc = new DocumentFragment();
|
const tagDesc = new DocumentFragment();
|
||||||
tagDesc.appendText('Regex patterns for frontmatter tags to exclude. One per line.');
|
tagDesc.appendText('Regex patterns for frontmatter tags to exclude. One per line.');
|
||||||
new Setting(containerEl)
|
new Setting(container)
|
||||||
.setName('Omitted tags')
|
.setName('Omitted tags')
|
||||||
.setDesc(tagDesc)
|
.setDesc(tagDesc)
|
||||||
.addTextArea((text) => {
|
.addTextArea((text) => {
|
||||||
@@ -117,62 +220,148 @@ export class WaypointSettingTab extends PluginSettingTab {
|
|||||||
this.saveAndRefresh();
|
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();
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private addPeriodNoteSettings(label: string, period: PeriodNoteSettings): void {
|
// ═══════════════════════════════
|
||||||
new Setting(this.containerEl).setHeading().setName(label);
|
// Display tab
|
||||||
|
// ═══════════════════════════════
|
||||||
|
|
||||||
new Setting(this.containerEl)
|
private renderDisplayTab(container: HTMLElement): void {
|
||||||
.setName('Folder')
|
new Setting(container).setHeading().setName('Bookmarks');
|
||||||
.setDesc(`Folder path for ${label.toLowerCase()} notes.`)
|
|
||||||
.addText((text) => {
|
this.addSliderSetting(container,
|
||||||
text.setPlaceholder('periodic/daily');
|
'Row size', 'Height of bookmark items.',
|
||||||
text.setValue(period.folder);
|
this.settings.display, 'rowSize', 18, 40, 1, 'px',
|
||||||
text.onChange((value) => {
|
);
|
||||||
period.folder = value;
|
this.addSliderSetting(container,
|
||||||
|
'Row spacing', 'Gap between bookmark items.',
|
||||||
|
this.settings.display, 'rowSpacing', 0, 12, 1, 'px',
|
||||||
|
);
|
||||||
|
this.addSliderSetting(container,
|
||||||
|
'Indent size', 'Indent per nesting depth.',
|
||||||
|
this.settings.display, 'indentSize', 8, 32, 2, 'px',
|
||||||
|
);
|
||||||
|
this.addSliderSetting(container,
|
||||||
|
'Font size', 'Label font size.',
|
||||||
|
this.settings.display, 'fontSize', 10, 18, 1, 'px',
|
||||||
|
);
|
||||||
|
this.addSliderSetting(container,
|
||||||
|
'Icon size', 'Bookmark icon size.',
|
||||||
|
this.settings.display, 'iconSize', 12, 24, 1, 'px',
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(container).setHeading().setName('Calendar');
|
||||||
|
|
||||||
|
this.addSliderSetting(container,
|
||||||
|
'Cell size', 'Height of calendar day cells.',
|
||||||
|
this.settings.display, 'calendarCellSize', 20, 48, 2, 'px',
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(container)
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText('Reset to defaults')
|
||||||
|
.onClick(() => {
|
||||||
|
this.settings.display = { ...DEFAULT_SETTINGS.display };
|
||||||
|
this.saveAndRefresh();
|
||||||
|
this.display();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addSliderSetting(
|
||||||
|
container: HTMLElement,
|
||||||
|
name: string,
|
||||||
|
desc: string,
|
||||||
|
obj: any,
|
||||||
|
key: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
step: number,
|
||||||
|
unit: string,
|
||||||
|
): void {
|
||||||
|
const setting = new Setting(container)
|
||||||
|
.setName(name)
|
||||||
|
.setDesc(`${desc} (${obj[key]}${unit})`);
|
||||||
|
|
||||||
|
setting.addSlider((slider) => {
|
||||||
|
slider
|
||||||
|
.setLimits(min, max, step)
|
||||||
|
.setValue(obj[key])
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange((value) => {
|
||||||
|
obj[key] = value;
|
||||||
|
setting.setDesc(`${desc} (${value}${unit})`);
|
||||||
this.saveAndRefresh();
|
this.saveAndRefresh();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
new Setting(this.containerEl)
|
|
||||||
.setName('Name format')
|
|
||||||
.setDesc(`Date format for ${label.toLowerCase()} note filenames (moment.js format).`)
|
|
||||||
.addText((text) => {
|
|
||||||
text.setPlaceholder('yyyy-MM-dd');
|
|
||||||
text.setValue(period.nameFormat);
|
|
||||||
text.onChange((value) => {
|
|
||||||
period.nameFormat = value;
|
|
||||||
this.saveAndRefresh();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(this.containerEl)
|
|
||||||
.setName('Template file')
|
|
||||||
.setDesc(`Path to the template file (without .md extension).`)
|
|
||||||
.addText((text) => {
|
|
||||||
text.setPlaceholder('Templates/Daily note');
|
|
||||||
text.setValue(period.templateFile);
|
|
||||||
text.onChange((value) => {
|
|
||||||
period.templateFile = value;
|
|
||||||
this.saveAndRefresh();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(this.containerEl)
|
|
||||||
.setName('Type property')
|
|
||||||
.setDesc(`Value for the 'type' frontmatter property.`)
|
|
||||||
.addText((text) => {
|
|
||||||
text.setPlaceholder('daily-note');
|
|
||||||
text.setValue(period.typeProperty);
|
|
||||||
text.onChange((value) => {
|
|
||||||
period.typeProperty = value;
|
|
||||||
this.saveAndRefresh();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveAndRefresh(): Promise<void> {
|
private async saveAndRefresh(): Promise<void> {
|
||||||
await this.plugin.saveData(this.settings);
|
await (this.plugin as any).saveSettings();
|
||||||
this.onSettingsChange();
|
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,16 @@ export interface RecentFilesSettings {
|
|||||||
updateOn: 'file-open' | 'file-edit';
|
updateOn: 'file-open' | 'file-edit';
|
||||||
omittedPaths: string[];
|
omittedPaths: string[];
|
||||||
omittedTags: string[];
|
omittedTags: string[];
|
||||||
|
filterTags: string[]; // tags to show as filter pills (empty = auto-detect from frontmatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplaySettings {
|
||||||
|
rowSize: number; // px, base height of bookmark items (20–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)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WaypointSettings {
|
export interface WaypointSettings {
|
||||||
@@ -27,6 +37,7 @@ export interface WaypointSettings {
|
|||||||
quarterly: PeriodNoteSettings;
|
quarterly: PeriodNoteSettings;
|
||||||
yearly: PeriodNoteSettings;
|
yearly: PeriodNoteSettings;
|
||||||
recentFiles: RecentFilesSettings;
|
recentFiles: RecentFilesSettings;
|
||||||
|
display: DisplaySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: WaypointSettings = {
|
export const DEFAULT_SETTINGS: WaypointSettings = {
|
||||||
@@ -69,5 +80,14 @@ export const DEFAULT_SETTINGS: WaypointSettings = {
|
|||||||
updateOn: 'file-open',
|
updateOn: 'file-open',
|
||||||
omittedPaths: [],
|
omittedPaths: [],
|
||||||
omittedTags: [],
|
omittedTags: [],
|
||||||
|
filterTags: [],
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
rowSize: 26,
|
||||||
|
rowSpacing: 2,
|
||||||
|
indentSize: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
iconSize: 16,
|
||||||
|
calendarCellSize: 32,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+407
-181
@@ -51,6 +51,15 @@ export class WaypointView extends ItemView {
|
|||||||
this.contentEl.empty();
|
this.contentEl.empty();
|
||||||
this.contentEl.addClass('waypoint-view');
|
this.contentEl.addClass('waypoint-view');
|
||||||
|
|
||||||
|
// Apply display settings as CSS variables
|
||||||
|
const d = this.plugin.settings.display;
|
||||||
|
this.contentEl.style.setProperty('--wp-row-size', d.rowSize + 'px');
|
||||||
|
this.contentEl.style.setProperty('--wp-row-spacing', d.rowSpacing + 'px');
|
||||||
|
this.contentEl.style.setProperty('--wp-indent-size', d.indentSize + 'px');
|
||||||
|
this.contentEl.style.setProperty('--wp-font-size', d.fontSize + 'px');
|
||||||
|
this.contentEl.style.setProperty('--wp-icon-size', d.iconSize + 'px');
|
||||||
|
this.contentEl.style.setProperty('--wp-cal-cell-size', d.calendarCellSize + 'px');
|
||||||
|
|
||||||
this.renderFavorites();
|
this.renderFavorites();
|
||||||
this.renderRecentFiles();
|
this.renderRecentFiles();
|
||||||
this.renderCalendar();
|
this.renderCalendar();
|
||||||
@@ -63,6 +72,7 @@ export class WaypointView extends ItemView {
|
|||||||
private currentDisplayMonth: number = moment().month(); // 0-indexed
|
private currentDisplayMonth: number = moment().month(); // 0-indexed
|
||||||
private currentDisplayYear: number = moment().year();
|
private currentDisplayYear: number = moment().year();
|
||||||
private dragId: string | null = null;
|
private dragId: string | null = null;
|
||||||
|
private recentFilesFilter: string | null = null; // null = show all, else filter by type
|
||||||
|
|
||||||
private renderCalendar(): void {
|
private renderCalendar(): void {
|
||||||
const section = this.contentEl.createDiv({ cls: 'waypoint-section' });
|
const section = this.contentEl.createDiv({ cls: 'waypoint-section' });
|
||||||
@@ -84,18 +94,36 @@ export class WaypointView extends ItemView {
|
|||||||
qEl.addEventListener('click', () => {
|
qEl.addEventListener('click', () => {
|
||||||
this.plugin.openPeriodNote('quarter', displayDate);
|
this.plugin.openPeriodNote('quarter', displayDate);
|
||||||
});
|
});
|
||||||
|
qEl.addEventListener('mousedown', (event: MouseEvent) => {
|
||||||
|
if (event.button === 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.plugin.openPeriodNoteInLeaf('quarter', displayDate, this.app.workspace.getLeaf('tab'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const mLabel = displayDate.format('MMMM');
|
const mLabel = displayDate.format('MMMM');
|
||||||
const mEl = breadcrumb.createSpan({ cls: 'waypoint-clickable', text: mLabel });
|
const mEl = breadcrumb.createSpan({ cls: 'waypoint-clickable', text: mLabel });
|
||||||
mEl.addEventListener('click', () => {
|
mEl.addEventListener('click', () => {
|
||||||
this.plugin.openPeriodNote('month', displayDate);
|
this.plugin.openPeriodNote('month', displayDate);
|
||||||
});
|
});
|
||||||
|
mEl.addEventListener('mousedown', (event: MouseEvent) => {
|
||||||
|
if (event.button === 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.plugin.openPeriodNoteInLeaf('month', displayDate, this.app.workspace.getLeaf('tab'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const yLabel = displayDate.format('YYYY');
|
const yLabel = displayDate.format('YYYY');
|
||||||
const yEl = breadcrumb.createSpan({ cls: 'waypoint-clickable', text: yLabel });
|
const yEl = breadcrumb.createSpan({ cls: 'waypoint-clickable', text: yLabel });
|
||||||
yEl.addEventListener('click', () => {
|
yEl.addEventListener('click', () => {
|
||||||
this.plugin.openPeriodNote('year', displayDate);
|
this.plugin.openPeriodNote('year', displayDate);
|
||||||
});
|
});
|
||||||
|
yEl.addEventListener('mousedown', (event: MouseEvent) => {
|
||||||
|
if (event.button === 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.plugin.openPeriodNoteInLeaf('year', displayDate, this.app.workspace.getLeaf('tab'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Right: ◀ Today ▶
|
// Right: ◀ Today ▶
|
||||||
const todayGroup = topRow.createDiv({ cls: 'waypoint-calendar-today-group' });
|
const todayGroup = topRow.createDiv({ cls: 'waypoint-calendar-today-group' });
|
||||||
@@ -148,6 +176,13 @@ export class WaypointView extends ItemView {
|
|||||||
const monday = week.days[0].date;
|
const monday = week.days[0].date;
|
||||||
this.plugin.openPeriodNote('week', monday);
|
this.plugin.openPeriodNote('week', monday);
|
||||||
});
|
});
|
||||||
|
wnCell.addEventListener('mousedown', (event: MouseEvent) => {
|
||||||
|
if (event.button === 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
const file = this.app.workspace.getLeaf('tab');
|
||||||
|
this.plugin.openPeriodNoteInLeaf('week', monday, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const day of week.days) {
|
for (const day of week.days) {
|
||||||
const cell = row.createEl('td', { cls: 'waypoint-day' });
|
const cell = row.createEl('td', { cls: 'waypoint-day' });
|
||||||
@@ -173,6 +208,13 @@ export class WaypointView extends ItemView {
|
|||||||
cell.addEventListener('click', () => {
|
cell.addEventListener('click', () => {
|
||||||
this.plugin.openPeriodNote('day', day.date);
|
this.plugin.openPeriodNote('day', day.date);
|
||||||
});
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,17 +240,84 @@ export class WaypointView extends ItemView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openFile = this.app.workspace.getActiveFile();
|
// ── Type filter bar ──
|
||||||
|
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) {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(tfile);
|
||||||
|
const type = cache?.frontmatter?.type;
|
||||||
|
if (type && typeof type === 'string') {
|
||||||
|
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(typeCounts).length > 0) {
|
||||||
|
const filterBar = section.createDiv({ cls: 'waypoint-recent-filter' });
|
||||||
|
// "All" pill
|
||||||
|
const allPill = filterBar.createSpan({ cls: `waypoint-recent-pill${!this.recentFilesFilter ? ' is-active' : ''}`, text: 'all' });
|
||||||
|
allPill.addEventListener('click', () => {
|
||||||
|
this.recentFilesFilter = null;
|
||||||
|
this.redraw();
|
||||||
|
});
|
||||||
|
// Type pills
|
||||||
|
const sortedTypes = configuredTags.length > 0
|
||||||
|
? Object.entries(typeCounts) // preserve configured order
|
||||||
|
: Object.entries(typeCounts).sort(([,a], [,b]) => b - a); // 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}`);
|
||||||
|
pill.addEventListener('click', () => {
|
||||||
|
this.recentFilesFilter = this.recentFilesFilter === type ? null : type;
|
||||||
|
this.redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter the file list ──
|
||||||
|
let filteredFiles = this.plugin.recentFiles;
|
||||||
|
if (this.recentFilesFilter) {
|
||||||
|
filteredFiles = this.plugin.recentFiles.filter(file => {
|
||||||
|
const tfile = this.app.vault.getAbstractFileByPath(file.path);
|
||||||
|
if (tfile instanceof TFile) {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(tfile);
|
||||||
|
const type = cache?.frontmatter?.type;
|
||||||
|
return type === this.recentFilesFilter;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFile = this.app.workspace.getActiveFile();
|
||||||
const rootEl = section.createDiv({ cls: 'nav-folder mod-root' });
|
const rootEl = section.createDiv({ cls: 'nav-folder mod-root' });
|
||||||
const childrenEl = rootEl.createDiv({ cls: 'nav-folder-children' });
|
const childrenEl = rootEl.createDiv({ cls: 'nav-folder-children' });
|
||||||
|
|
||||||
for (const file of this.plugin.recentFiles) {
|
for (const file of filteredFiles) {
|
||||||
const navFile = childrenEl.createDiv({ cls: 'tree-item nav-file' });
|
const navFile = childrenEl.createDiv({ cls: 'tree-item nav-file' });
|
||||||
const navFileTitle = navFile.createDiv({ cls: 'tree-item-self is-clickable nav-file-title' });
|
const navFileTitle = navFile.createDiv({ cls: 'tree-item-self is-clickable nav-file-title' });
|
||||||
const navFileTitleContent = navFileTitle.createDiv({ cls: 'tree-item-inner nav-file-title-content' });
|
const navFileTitleContent = navFileTitle.createDiv({ cls: 'tree-item-inner nav-file-title-content' });
|
||||||
navFileTitleContent.setText(file.basename);
|
navFileTitleContent.setText(file.basename);
|
||||||
|
|
||||||
const spacer = navFileTitle.createDiv({ cls: 'tree-item-spacer' });
|
const spacer = navFileTitle.createDiv({ cls: 'tree-item-spacer' });
|
||||||
|
|
||||||
// Remove button
|
// Remove button
|
||||||
@@ -217,12 +326,10 @@ export class WaypointView extends ItemView {
|
|||||||
removeBtn.addEventListener('click', (e) => {
|
removeBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.plugin.recentFiles = this.plugin.recentFiles.filter(f => f.path !== file.path);
|
this.plugin.recentFiles = this.plugin.recentFiles.filter(f => f.path !== file.path);
|
||||||
this.plugin.saveSettings();
|
this.plugin.persistRecentFiles();
|
||||||
this.redraw();
|
this.redraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
setTooltip(navFile, file.path);
|
setTooltip(navFile, file.path);
|
||||||
|
|
||||||
if (openFile && file.path === openFile.path) {
|
if (openFile && file.path === openFile.path) {
|
||||||
navFileTitle.addClass('is-active');
|
navFileTitle.addClass('is-active');
|
||||||
}
|
}
|
||||||
@@ -258,6 +365,16 @@ export class WaypointView extends ItemView {
|
|||||||
.setIcon('file-plus')
|
.setIcon('file-plus')
|
||||||
.onClick(() => this.focusFile(file, 'tab')),
|
.onClick(() => this.focusFile(file, 'tab')),
|
||||||
);
|
);
|
||||||
|
menu.addItem((item) =>
|
||||||
|
item
|
||||||
|
.setSection('action')
|
||||||
|
.setTitle('Add to bookmarks')
|
||||||
|
.setIcon('bookmark')
|
||||||
|
.onClick(() => {
|
||||||
|
this.plugin.addBookmark(file.path, file.basename, 'file');
|
||||||
|
new Notice(`Bookmarked: ${file.basename}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
const tfile = this.app.vault.getAbstractFileByPath(file.path);
|
const tfile = this.app.vault.getAbstractFileByPath(file.path);
|
||||||
if (tfile) {
|
if (tfile) {
|
||||||
this.app.workspace.trigger('file-menu', menu, tfile, 'link-context-menu');
|
this.app.workspace.trigger('file-menu', menu, tfile, 'link-context-menu');
|
||||||
@@ -281,7 +398,7 @@ 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: boolean | string | 'split'): void {
|
||||||
const targetFile = this.app.vault.getFiles().find(f => f.path === file.path);
|
const targetFile = this.app.vault.getFiles().find(f => f.path === file.path);
|
||||||
if (targetFile) {
|
if (targetFile) {
|
||||||
const leaf = this.app.workspace.getLeaf(newLeaf as any);
|
const leaf = this.app.workspace.getLeaf(newLeaf as any);
|
||||||
@@ -289,7 +406,7 @@ export class WaypointView extends ItemView {
|
|||||||
} else {
|
} else {
|
||||||
new Notice('Cannot find file');
|
new Notice('Cannot find file');
|
||||||
this.plugin.recentFiles = this.plugin.recentFiles.filter(f => f.path !== file.path);
|
this.plugin.recentFiles = this.plugin.recentFiles.filter(f => f.path !== file.path);
|
||||||
this.plugin.saveSettings();
|
this.plugin.persistRecentFiles();
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,8 +420,11 @@ export class WaypointView extends ItemView {
|
|||||||
const headerEl = section.createDiv({ cls: 'waypoint-section-header' });
|
const headerEl = section.createDiv({ cls: 'waypoint-section-header' });
|
||||||
headerEl.setText('Waypoint Bookmarks');
|
headerEl.setText('Waypoint Bookmarks');
|
||||||
|
|
||||||
// Right-click on header area → add bookmark / group
|
// ⋯ button — visible add menu
|
||||||
headerEl.addEventListener('contextmenu', (event: MouseEvent) => {
|
const moreBtn = headerEl.createEl('button', { cls: 'waypoint-header-more' });
|
||||||
|
setIcon(moreBtn, 'more-horizontal');
|
||||||
|
setTooltip(moreBtn, 'Add bookmark');
|
||||||
|
moreBtn.addEventListener('click', (event: MouseEvent) => {
|
||||||
const menu = new Menu();
|
const menu = new Menu();
|
||||||
menu.addItem((item) => {
|
menu.addItem((item) => {
|
||||||
item
|
item
|
||||||
@@ -316,14 +436,28 @@ export class WaypointView extends ItemView {
|
|||||||
this.plugin.addBookmark(file.path, file.basename, 'file');
|
this.plugin.addBookmark(file.path, file.basename, 'file');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
menu.addItem((item) => {
|
||||||
|
item
|
||||||
|
.setTitle('Add as parent note')
|
||||||
|
.setIcon('folder-plus')
|
||||||
|
.onClick(() => {
|
||||||
|
const file = this.app.workspace.getActiveFile();
|
||||||
|
if (!file) return;
|
||||||
|
const bm = this.plugin.addBookmark(file.path, file.basename, 'group', '');
|
||||||
|
bm.filePath = file.path;
|
||||||
|
this.plugin.saveWaypointData();
|
||||||
|
this.redraw();
|
||||||
|
});
|
||||||
|
});
|
||||||
menu.addItem((item) => {
|
menu.addItem((item) => {
|
||||||
item
|
item
|
||||||
.setTitle('New group')
|
.setTitle('New group')
|
||||||
.setIcon('folder-plus')
|
.setIcon('folder-plus')
|
||||||
.onClick(() => {
|
.onClick(() => {
|
||||||
this.plugin.addBookmark('', 'New Group', 'group', 'folder');
|
this.plugin.addBookmark('', 'New Group', 'group', '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
menu.addSeparator();
|
||||||
menu.addItem((item) => {
|
menu.addItem((item) => {
|
||||||
item
|
item
|
||||||
.setTitle('Add separator')
|
.setTitle('Add separator')
|
||||||
@@ -340,7 +474,6 @@ export class WaypointView extends ItemView {
|
|||||||
this.plugin.addBookmark('', '', 'spacer');
|
this.plugin.addBookmark('', '', 'spacer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
menu.showAtPosition({ x: event.clientX, y: event.clientY });
|
menu.showAtPosition({ x: event.clientX, y: event.clientY });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,7 +507,7 @@ export class WaypointView extends ItemView {
|
|||||||
});
|
});
|
||||||
rowEl.addEventListener('dragend', () => {
|
rowEl.addEventListener('dragend', () => {
|
||||||
this.dragId = null;
|
this.dragId = null;
|
||||||
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below').forEach(el => {
|
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-dragging');
|
||||||
el.removeClass('waypoint-bm-drop-line');
|
el.removeClass('waypoint-bm-drop-line');
|
||||||
el.removeClass('waypoint-bm-drop-below');
|
el.removeClass('waypoint-bm-drop-below');
|
||||||
@@ -383,16 +516,17 @@ export class WaypointView extends ItemView {
|
|||||||
rowEl.addEventListener('dragenter', (e) => {
|
rowEl.addEventListener('dragenter', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!this.dragId || this.dragId === item.id) return;
|
if (!this.dragId || this.dragId === item.id) return;
|
||||||
this.showDropIndicator(rowEl, e);
|
this.showDropIndicator(rowEl, e, false);
|
||||||
});
|
});
|
||||||
rowEl.addEventListener('dragover', (e) => {
|
rowEl.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!this.dragId || this.dragId === item.id) return;
|
if (!this.dragId || this.dragId === item.id) return;
|
||||||
this.showDropIndicator(rowEl, e);
|
this.showDropIndicator(rowEl, e, false);
|
||||||
});
|
});
|
||||||
rowEl.addEventListener('dragleave', () => {
|
rowEl.addEventListener('dragleave', () => {
|
||||||
rowEl.removeClass('waypoint-bm-drop-line');
|
rowEl.removeClass('waypoint-bm-drop-line');
|
||||||
rowEl.removeClass('waypoint-bm-drop-below');
|
rowEl.removeClass('waypoint-bm-drop-below');
|
||||||
|
rowEl.removeClass('waypoint-bm-drop-into');
|
||||||
});
|
});
|
||||||
rowEl.addEventListener('drop', (e) => {
|
rowEl.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -432,7 +566,7 @@ export class WaypointView extends ItemView {
|
|||||||
});
|
});
|
||||||
rowEl.addEventListener('dragend', () => {
|
rowEl.addEventListener('dragend', () => {
|
||||||
this.dragId = null;
|
this.dragId = null;
|
||||||
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below').forEach(el => {
|
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-dragging');
|
||||||
el.removeClass('waypoint-bm-drop-line');
|
el.removeClass('waypoint-bm-drop-line');
|
||||||
el.removeClass('waypoint-bm-drop-below');
|
el.removeClass('waypoint-bm-drop-below');
|
||||||
@@ -441,16 +575,17 @@ export class WaypointView extends ItemView {
|
|||||||
rowEl.addEventListener('dragenter', (e) => {
|
rowEl.addEventListener('dragenter', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!this.dragId || this.dragId === item.id) return;
|
if (!this.dragId || this.dragId === item.id) return;
|
||||||
this.showDropIndicator(rowEl, e);
|
this.showDropIndicator(rowEl, e, false);
|
||||||
});
|
});
|
||||||
rowEl.addEventListener('dragover', (e) => {
|
rowEl.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!this.dragId || this.dragId === item.id) return;
|
if (!this.dragId || this.dragId === item.id) return;
|
||||||
this.showDropIndicator(rowEl, e);
|
this.showDropIndicator(rowEl, e, false);
|
||||||
});
|
});
|
||||||
rowEl.addEventListener('dragleave', () => {
|
rowEl.addEventListener('dragleave', () => {
|
||||||
rowEl.removeClass('waypoint-bm-drop-line');
|
rowEl.removeClass('waypoint-bm-drop-line');
|
||||||
rowEl.removeClass('waypoint-bm-drop-below');
|
rowEl.removeClass('waypoint-bm-drop-below');
|
||||||
|
rowEl.removeClass('waypoint-bm-drop-into');
|
||||||
});
|
});
|
||||||
rowEl.addEventListener('drop', (e) => {
|
rowEl.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -491,30 +626,34 @@ export class WaypointView extends ItemView {
|
|||||||
rowEl.addClass('waypoint-bm-dragging');
|
rowEl.addClass('waypoint-bm-dragging');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canAcceptChildren = true; // all file/group bookmarks can accept drops
|
||||||
|
|
||||||
rowEl.addEventListener('dragend', () => {
|
rowEl.addEventListener('dragend', () => {
|
||||||
this.dragId = null;
|
this.dragId = null;
|
||||||
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below').forEach(el => {
|
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-dragging');
|
||||||
el.removeClass('waypoint-bm-drop-line');
|
el.removeClass('waypoint-bm-drop-line');
|
||||||
el.removeClass('waypoint-bm-drop-below');
|
el.removeClass('waypoint-bm-drop-below');
|
||||||
|
el.removeClass('waypoint-bm-drop-into');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
rowEl.addEventListener('dragenter', (e) => {
|
rowEl.addEventListener('dragenter', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!this.dragId || this.dragId === item.id) return;
|
if (!this.dragId || this.dragId === item.id) return;
|
||||||
this.showDropIndicator(rowEl, e);
|
this.showDropIndicator(rowEl, e, canAcceptChildren);
|
||||||
});
|
});
|
||||||
|
|
||||||
rowEl.addEventListener('dragover', (e) => {
|
rowEl.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!this.dragId || this.dragId === item.id) return;
|
if (!this.dragId || this.dragId === item.id) return;
|
||||||
this.showDropIndicator(rowEl, e);
|
this.showDropIndicator(rowEl, e, canAcceptChildren);
|
||||||
});
|
});
|
||||||
|
|
||||||
rowEl.addEventListener('dragleave', () => {
|
rowEl.addEventListener('dragleave', () => {
|
||||||
rowEl.removeClass('waypoint-bm-drop-line');
|
rowEl.removeClass('waypoint-bm-drop-line');
|
||||||
rowEl.removeClass('waypoint-bm-drop-below');
|
rowEl.removeClass('waypoint-bm-drop-below');
|
||||||
|
rowEl.removeClass('waypoint-bm-drop-into');
|
||||||
});
|
});
|
||||||
|
|
||||||
rowEl.addEventListener('drop', (e) => {
|
rowEl.addEventListener('drop', (e) => {
|
||||||
@@ -522,28 +661,62 @@ export class WaypointView extends ItemView {
|
|||||||
this.dragId = null;
|
this.dragId = null;
|
||||||
rowEl.removeClass('waypoint-bm-drop-line');
|
rowEl.removeClass('waypoint-bm-drop-line');
|
||||||
rowEl.removeClass('waypoint-bm-drop-below');
|
rowEl.removeClass('waypoint-bm-drop-below');
|
||||||
|
rowEl.removeClass('waypoint-bm-drop-into');
|
||||||
|
|
||||||
const draggedId = e.dataTransfer?.getData('text/plain');
|
const draggedId = e.dataTransfer?.getData('text/plain');
|
||||||
if (!draggedId || draggedId === item.id) return;
|
if (!draggedId || draggedId === item.id) return;
|
||||||
|
|
||||||
const dropAbove = (rowEl as any).__dropAbove;
|
const dropInto = (rowEl as any).__dropInto;
|
||||||
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Group: chevron + icon + label ──
|
// ── Group: chevron + icon + label ──
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
const chevron = rowEl.createDiv({ cls: 'waypoint-bm-chevron' });
|
|
||||||
setIcon(chevron, 'chevron-down');
|
|
||||||
|
|
||||||
const iconEl = rowEl.createDiv({ cls: 'waypoint-bm-icon' });
|
const iconEl = rowEl.createDiv({ cls: 'waypoint-bm-icon' });
|
||||||
setIcon(iconEl, item.icon || 'folder');
|
if (item.icon) setIcon(iconEl, item.icon);
|
||||||
|
|
||||||
const labelEl = rowEl.createDiv({ cls: 'waypoint-bm-label', text: item.label });
|
const labelEl = rowEl.createDiv({ cls: 'waypoint-bm-label', text: item.label });
|
||||||
|
|
||||||
rowEl.addEventListener('click', () => {
|
const chevron = rowEl.createDiv({ cls: 'waypoint-bm-chevron' });
|
||||||
|
setIcon(chevron, 'chevron-down');
|
||||||
|
chevron.addEventListener('click', (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
this.plugin.updateBookmark(item.id, { collapsed: !item.collapsed });
|
this.plugin.updateBookmark(item.id, { collapsed: !item.collapsed });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Click group row → open linked file (if any), otherwise toggle collapse
|
||||||
|
rowEl.addEventListener('click', (event: MouseEvent) => {
|
||||||
|
if (item.filePath) {
|
||||||
|
const tfile = this.app.vault.getFileByPath(item.filePath);
|
||||||
|
if (tfile) {
|
||||||
|
const newLeaf = Keymap.isModEvent(event);
|
||||||
|
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.plugin.updateBookmark(item.id, { collapsed: !item.collapsed });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middle click on group → open in new tab
|
||||||
|
rowEl.addEventListener('mousedown', (event: MouseEvent) => {
|
||||||
|
if (event.button === 1 && item.filePath) {
|
||||||
|
event.preventDefault();
|
||||||
|
const tfile = this.app.vault.getFileByPath(item.filePath);
|
||||||
|
if (tfile) {
|
||||||
|
this.app.workspace.getLeaf('tab').openFile(tfile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const childrenContainer = container.createDiv({
|
const childrenContainer = container.createDiv({
|
||||||
cls: `waypoint-bookmark-children${item.collapsed ? ' collapsed' : ''}`,
|
cls: `waypoint-bookmark-children${item.collapsed ? ' collapsed' : ''}`,
|
||||||
});
|
});
|
||||||
@@ -554,23 +727,56 @@ export class WaypointView extends ItemView {
|
|||||||
// ── File: icon + label ──
|
// ── File: icon + label ──
|
||||||
} else {
|
} else {
|
||||||
const iconEl = rowEl.createDiv({ cls: 'waypoint-bm-icon' });
|
const iconEl = rowEl.createDiv({ cls: 'waypoint-bm-icon' });
|
||||||
setIcon(iconEl, item.icon || 'file');
|
if (item.icon) setIcon(iconEl, item.icon);
|
||||||
|
|
||||||
const labelEl = rowEl.createDiv({ cls: 'waypoint-bm-label', text: item.label });
|
const labelEl = rowEl.createDiv({ cls: 'waypoint-bm-label', text: item.label });
|
||||||
|
|
||||||
|
// Chevron for file bookmarks that have children
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const chevron = rowEl.createDiv({ cls: 'waypoint-bm-chevron' });
|
||||||
|
setIcon(chevron, 'chevron-down');
|
||||||
|
chevron.addEventListener('click', (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.plugin.updateBookmark(item.id, { collapsed: !item.collapsed });
|
||||||
|
});
|
||||||
|
if (item.collapsed) {
|
||||||
|
rowEl.addClass('collapsed');
|
||||||
|
chevron.style.transform = 'rotate(-90deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
setTooltip(rowEl, item.filePath);
|
setTooltip(rowEl, item.filePath);
|
||||||
|
|
||||||
rowEl.addEventListener('click', () => {
|
rowEl.addEventListener('click', (event: MouseEvent) => {
|
||||||
if (item.filePath) {
|
if (item.filePath) {
|
||||||
const tfile = this.app.vault.getFileByPath(item.filePath);
|
const tfile = this.app.vault.getFileByPath(item.filePath);
|
||||||
if (tfile) {
|
if (tfile) {
|
||||||
const leaf = this.app.workspace.getLeaf(false);
|
const newLeaf = Keymap.isModEvent(event);
|
||||||
leaf.openFile(tfile);
|
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
|
||||||
} else {
|
} else {
|
||||||
new Notice('File not found');
|
new Notice('File not found');
|
||||||
this.plugin.removeBookmark(item.id);
|
this.plugin.removeBookmark(item.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Middle click → open in new tab
|
||||||
|
rowEl.addEventListener('mousedown', (event: MouseEvent) => {
|
||||||
|
if (event.button === 1 && item.filePath) {
|
||||||
|
event.preventDefault();
|
||||||
|
const tfile = this.app.vault.getFileByPath(item.filePath);
|
||||||
|
if (tfile) {
|
||||||
|
this.app.workspace.getLeaf('tab').openFile(tfile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Children container for file bookmarks with children
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const childrenContainer = container.createDiv({
|
||||||
|
cls: `waypoint-bookmark-children${item.collapsed ? ' collapsed' : ''}`,
|
||||||
|
});
|
||||||
|
this.renderBookmarkList(childrenContainer, item.children, depth + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Right-click context menu (both types) ──
|
// ── Right-click context menu (both types) ──
|
||||||
@@ -585,6 +791,17 @@ export class WaypointView extends ItemView {
|
|||||||
private showBookmarkContextMenu(event: MouseEvent, item: BookmarkItem): void {
|
private showBookmarkContextMenu(event: MouseEvent, item: BookmarkItem): void {
|
||||||
const menu = new Menu();
|
const menu = new Menu();
|
||||||
|
|
||||||
|
if (item.type === 'separator' || item.type === 'spacer') {
|
||||||
|
menu.addItem((i) =>
|
||||||
|
i
|
||||||
|
.setTitle('Remove')
|
||||||
|
.setIcon('trash')
|
||||||
|
.onClick(() => this.plugin.removeBookmark(item.id)),
|
||||||
|
);
|
||||||
|
menu.showAtPosition({ x: event.clientX, y: event.clientY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === 'file') {
|
if (item.type === 'file') {
|
||||||
menu.addItem((i) =>
|
menu.addItem((i) =>
|
||||||
i
|
i
|
||||||
@@ -592,86 +809,91 @@ export class WaypointView extends ItemView {
|
|||||||
.setIcon('file-plus')
|
.setIcon('file-plus')
|
||||||
.onClick(() => {
|
.onClick(() => {
|
||||||
const tfile = this.app.vault.getFileByPath(item.filePath);
|
const tfile = this.app.vault.getFileByPath(item.filePath);
|
||||||
if (tfile) {
|
if (tfile) this.app.workspace.getLeaf('tab').openFile(tfile);
|
||||||
this.app.workspace.getLeaf('tab').openFile(tfile);
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type !== 'separator' && item.type !== 'spacer') {
|
|
||||||
menu.addItem((i) =>
|
menu.addItem((i) =>
|
||||||
i
|
i
|
||||||
.setTitle('Rename')
|
.setTitle('Rename')
|
||||||
.setIcon('pencil')
|
.setIcon('pencil')
|
||||||
.onClick(() => this.promptRename(item)),
|
.onClick(() => this.promptRename(item)),
|
||||||
);
|
);
|
||||||
|
|
||||||
menu.addItem((i) =>
|
menu.addItem((i) =>
|
||||||
i
|
i
|
||||||
.setTitle('Change icon')
|
.setTitle('Change icon')
|
||||||
.setIcon('image')
|
.setIcon('image')
|
||||||
.onClick(() => this.promptIcon(item)),
|
.onClick(() => this.promptIcon(item)),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// ── Move to group options ──
|
|
||||||
const allGroups = this.collectGroups(this.plugin.waypointData.bookmarks);
|
|
||||||
const ownIds = this.collectDescendantIds(item);
|
|
||||||
const availableGroups = allGroups.filter(g => !ownIds.includes(g.id) && g.id !== item.id);
|
|
||||||
|
|
||||||
if ((availableGroups.length > 0 || item.type === 'file' || item.type === 'group') && item.type !== 'separator' && item.type !== 'spacer') {
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
if (availableGroups.length > 0) {
|
menu.addItem((i) =>
|
||||||
for (const group of availableGroups) {
|
i
|
||||||
menu.addItem((mi) =>
|
.setTitle('Remove')
|
||||||
mi
|
.setIcon('trash')
|
||||||
.setTitle(` ${group.label}`)
|
.onClick(() => this.plugin.removeBookmark(item.id)),
|
||||||
.setIcon(group.icon || 'folder')
|
);
|
||||||
.onClick(() => {
|
} else if (item.type === 'group') {
|
||||||
this.moveBookmarkToGroup(item.id, group.id);
|
if (item.filePath) {
|
||||||
}),
|
menu.addItem((i) =>
|
||||||
);
|
i
|
||||||
}
|
.setTitle('Open in new tab')
|
||||||
|
.setIcon('file-plus')
|
||||||
|
.onClick(() => {
|
||||||
|
const tfile = this.app.vault.getFileByPath(item.filePath);
|
||||||
|
if (tfile) this.app.workspace.getLeaf('tab').openFile(tfile);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
menu.addSeparator();
|
||||||
}
|
}
|
||||||
// Add "Root" option for ungrouping
|
|
||||||
menu.addItem((mi) =>
|
|
||||||
mi
|
|
||||||
.setTitle(' (Root)')
|
|
||||||
.setIcon('layout')
|
|
||||||
.onClick(() => {
|
|
||||||
this.moveBookmarkToGroup(item.id, null);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === 'group') {
|
|
||||||
menu.addItem((i) =>
|
menu.addItem((i) =>
|
||||||
i
|
i
|
||||||
.setTitle(item.collapsed ? 'Expand' : 'Collapse')
|
.setTitle('Rename')
|
||||||
.setIcon(item.collapsed ? 'chevron-down' : 'chevron-up')
|
.setIcon('pencil')
|
||||||
.onClick(() => {
|
.onClick(() => this.promptRename(item)),
|
||||||
this.plugin.updateBookmark(item.id, { collapsed: !item.collapsed });
|
);
|
||||||
}),
|
menu.addItem((i) =>
|
||||||
|
i
|
||||||
|
.setTitle('Change icon')
|
||||||
|
.setIcon('image')
|
||||||
|
.onClick(() => this.promptIcon(item)),
|
||||||
);
|
);
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
menu.addItem((i) =>
|
menu.addItem((i) =>
|
||||||
i
|
i
|
||||||
.setTitle('Add bookmark here')
|
.setTitle('Add child bookmark')
|
||||||
.setIcon('plus')
|
.setIcon('file-plus')
|
||||||
.onClick(() => {
|
.onClick(() => {
|
||||||
const file = this.app.workspace.getActiveFile();
|
const file = this.app.workspace.getActiveFile();
|
||||||
if (!file) {
|
if (!file) { new Notice('No active file'); return; }
|
||||||
new Notice('No active file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const child: BookmarkItem = {
|
const child: BookmarkItem = {
|
||||||
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
type: 'file',
|
type: 'file',
|
||||||
label: file.basename,
|
label: file.basename,
|
||||||
filePath: file.path,
|
filePath: file.path,
|
||||||
icon: 'file',
|
icon: '',
|
||||||
|
children: [],
|
||||||
|
collapsed: false,
|
||||||
|
indent: item.indent + 1,
|
||||||
|
};
|
||||||
|
item.children.push(child);
|
||||||
|
this.plugin.saveWaypointData();
|
||||||
|
this.redraw();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
menu.addItem((i) =>
|
||||||
|
i
|
||||||
|
.setTitle('Add child note')
|
||||||
|
.setIcon('folder-plus')
|
||||||
|
.onClick(() => {
|
||||||
|
const file = this.app.workspace.getActiveFile();
|
||||||
|
if (!file) { new Notice('No active file'); return; }
|
||||||
|
const child: BookmarkItem = {
|
||||||
|
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
type: 'group',
|
||||||
|
label: file.basename,
|
||||||
|
filePath: file.path,
|
||||||
|
icon: '',
|
||||||
children: [],
|
children: [],
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
indent: item.indent + 1,
|
indent: item.indent + 1,
|
||||||
@@ -691,7 +913,7 @@ export class WaypointView extends ItemView {
|
|||||||
type: 'group',
|
type: 'group',
|
||||||
label: 'New Group',
|
label: 'New Group',
|
||||||
filePath: '',
|
filePath: '',
|
||||||
icon: 'folder',
|
icon: '',
|
||||||
children: [],
|
children: [],
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
indent: item.indent + 1,
|
indent: item.indent + 1,
|
||||||
@@ -701,119 +923,59 @@ export class WaypointView extends ItemView {
|
|||||||
this.redraw();
|
this.redraw();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
menu.addSeparator();
|
||||||
|
menu.addItem((i) =>
|
||||||
|
i
|
||||||
|
.setTitle('Remove')
|
||||||
|
.setIcon('trash')
|
||||||
|
.onClick(() => this.plugin.removeBookmark(item.id)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.addSeparator();
|
|
||||||
|
|
||||||
// ── Insert separator / spacer ──
|
|
||||||
menu.addItem((i) =>
|
|
||||||
i
|
|
||||||
.setTitle('Insert separator above')
|
|
||||||
.setIcon('separator-vertical')
|
|
||||||
.onClick(() => this.insertAdjacent(item.id, 'separator', 'above')),
|
|
||||||
);
|
|
||||||
menu.addItem((i) =>
|
|
||||||
i
|
|
||||||
.setTitle('Insert separator below')
|
|
||||||
.setIcon('separator-vertical')
|
|
||||||
.onClick(() => this.insertAdjacent(item.id, 'separator', 'below')),
|
|
||||||
);
|
|
||||||
menu.addItem((i) =>
|
|
||||||
i
|
|
||||||
.setTitle('Insert spacer above')
|
|
||||||
.setIcon('space')
|
|
||||||
.onClick(() => this.insertAdjacent(item.id, 'spacer', 'above')),
|
|
||||||
);
|
|
||||||
menu.addItem((i) =>
|
|
||||||
i
|
|
||||||
.setTitle('Insert spacer below')
|
|
||||||
.setIcon('space')
|
|
||||||
.onClick(() => this.insertAdjacent(item.id, 'spacer', 'below')),
|
|
||||||
);
|
|
||||||
|
|
||||||
menu.addSeparator();
|
|
||||||
|
|
||||||
menu.addItem((i) =>
|
|
||||||
i
|
|
||||||
.setTitle('Remove')
|
|
||||||
.setIcon('trash')
|
|
||||||
.onClick(() => {
|
|
||||||
this.plugin.removeBookmark(item.id);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
menu.showAtPosition({ x: event.clientX, y: event.clientY });
|
menu.showAtPosition({ x: event.clientX, y: event.clientY });
|
||||||
}
|
}
|
||||||
|
|
||||||
private insertAdjacent(targetId: string, type: 'separator' | 'spacer', position: 'above' | 'below'): void {
|
private showDropIndicator(el: HTMLElement, e: MouseEvent, isGroupLike: boolean): void {
|
||||||
const newItem: BookmarkItem = {
|
// Clear all indicators
|
||||||
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
||||||
type,
|
|
||||||
label: '',
|
|
||||||
filePath: '',
|
|
||||||
icon: '',
|
|
||||||
children: [],
|
|
||||||
collapsed: false,
|
|
||||||
indent: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const insert = (items: BookmarkItem[]): boolean => {
|
|
||||||
const idx = items.findIndex(i => i.id === targetId);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const insertIdx = position === 'above' ? idx : idx + 1;
|
|
||||||
items.splice(insertIdx, 0, newItem);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.children && insert(item.children)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
insert(this.plugin.waypointData.bookmarks);
|
|
||||||
this.plugin.saveWaypointData();
|
|
||||||
this.redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private collectGroups(items: BookmarkItem[]): BookmarkItem[] {
|
|
||||||
const groups: BookmarkItem[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.type === 'group') {
|
|
||||||
groups.push(item);
|
|
||||||
groups.push(...this.collectGroups(item.children));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
private collectDescendantIds(item: BookmarkItem): string[] {
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (const child of item.children) {
|
|
||||||
ids.push(child.id);
|
|
||||||
ids.push(...this.collectDescendantIds(child));
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
private showDropIndicator(el: HTMLElement, e: MouseEvent): void {
|
|
||||||
// Clear indicator from all siblings first
|
|
||||||
const parent = el.parentElement;
|
const parent = el.parentElement;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
parent.querySelectorAll('.waypoint-bm-drop-line, .waypoint-bm-drop-below').forEach(el2 => {
|
parent.querySelectorAll('.waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el2 => {
|
||||||
el2.removeClass('waypoint-bm-drop-line');
|
el2.removeClass('waypoint-bm-drop-line');
|
||||||
el2.removeClass('waypoint-bm-drop-below');
|
el2.removeClass('waypoint-bm-drop-below');
|
||||||
|
el2.removeClass('waypoint-bm-drop-into');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const midY = rect.top + rect.height / 2;
|
const y = e.clientY;
|
||||||
const above = e.clientY < midY;
|
|
||||||
|
|
||||||
el.addClass('waypoint-bm-drop-line');
|
if (isGroupLike) {
|
||||||
if (!above) {
|
// 3-zone: top 25% = above, middle 50% = into, bottom 25% = below
|
||||||
el.addClass('waypoint-bm-drop-below');
|
const topThreshold = rect.top + rect.height * 0.25;
|
||||||
|
const bottomThreshold = rect.top + rect.height * 0.75;
|
||||||
|
|
||||||
|
if (y < topThreshold) {
|
||||||
|
el.addClass('waypoint-bm-drop-line');
|
||||||
|
(el as any).__dropAbove = true;
|
||||||
|
(el as any).__dropInto = 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;
|
||||||
|
} else {
|
||||||
|
el.addClass('waypoint-bm-drop-into');
|
||||||
|
(el as any).__dropAbove = false;
|
||||||
|
(el as any).__dropInto = 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;
|
||||||
}
|
}
|
||||||
(el as any).__dropAbove = above;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveBookmarkToGroup(itemId: string, targetGroupId: string | null): void {
|
private moveBookmarkToGroup(itemId: string, targetGroupId: string | null): void {
|
||||||
@@ -833,6 +995,15 @@ export class WaypointView extends ItemView {
|
|||||||
const item = removeRecursive(this.plugin.waypointData.bookmarks);
|
const item = removeRecursive(this.plugin.waypointData.bookmarks);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
|
// Prevent dropping into self or own descendant
|
||||||
|
if (targetGroupId) {
|
||||||
|
const isDescendant = (parent: BookmarkItem, targetId: string): boolean => {
|
||||||
|
if (parent.id === targetId) return true;
|
||||||
|
return parent.children.some(c => isDescendant(c, targetId));
|
||||||
|
};
|
||||||
|
if (item.id === targetGroupId || isDescendant(item, targetGroupId)) return;
|
||||||
|
}
|
||||||
|
|
||||||
if (targetGroupId) {
|
if (targetGroupId) {
|
||||||
const findGroup = (items: BookmarkItem[]): BookmarkItem | null => {
|
const findGroup = (items: BookmarkItem[]): BookmarkItem | null => {
|
||||||
for (const i of items) {
|
for (const i of items) {
|
||||||
@@ -856,6 +1027,52 @@ export class WaypointView extends ItemView {
|
|||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new parent note group at the target's position,
|
||||||
|
* containing both the target and the dragged item.
|
||||||
|
*/
|
||||||
|
private createParentNoteAndMove(draggedId: string, targetId: string): void {
|
||||||
|
const removeById = (items: BookmarkItem[]): BookmarkItem | null => {
|
||||||
|
const idx = items.findIndex(i => i.id === draggedId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const [removed] = items.splice(idx, 1);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
for (const child of items) {
|
||||||
|
if (child.children) {
|
||||||
|
const found = removeById(child.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragged = removeById(this.plugin.waypointData.bookmarks);
|
||||||
|
if (!dragged) return;
|
||||||
|
|
||||||
|
// Find the target
|
||||||
|
const findTarget = (items: BookmarkItem[]): BookmarkItem | null => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === targetId) return item;
|
||||||
|
if (item.children) {
|
||||||
|
const found = findTarget(item.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = findTarget(this.plugin.waypointData.bookmarks);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
// Make dragged item a child of the target
|
||||||
|
dragged.indent = target.indent + 1;
|
||||||
|
target.children.push(dragged);
|
||||||
|
|
||||||
|
this.plugin.saveWaypointData();
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
private moveBookmarkToPosition(draggedId: string, targetId: string, before: boolean): void {
|
private moveBookmarkToPosition(draggedId: string, targetId: string, before: boolean): void {
|
||||||
// Find and remove the dragged item from wherever it is
|
// Find and remove the dragged item from wherever it is
|
||||||
const removeById = (items: BookmarkItem[]): { item: BookmarkItem | null; parent: BookmarkItem[] } => {
|
const removeById = (items: BookmarkItem[]): { item: BookmarkItem | null; parent: BookmarkItem[] } => {
|
||||||
@@ -975,6 +1192,7 @@ class IconSuggestModal extends Modal {
|
|||||||
private onSubmit: (icon: string) => void;
|
private onSubmit: (icon: string) => void;
|
||||||
private selected: string;
|
private selected: string;
|
||||||
private allIcons: string[] = [];
|
private allIcons: string[] = [];
|
||||||
|
private tagsMap: Record<string, string[]> = {};
|
||||||
private loaded = false;
|
private loaded = false;
|
||||||
|
|
||||||
constructor(app: App, currentIcon: string, onSubmit: (icon: string) => void) {
|
constructor(app: App, currentIcon: string, onSubmit: (icon: string) => void) {
|
||||||
@@ -1070,7 +1288,12 @@ class IconSuggestModal extends Modal {
|
|||||||
|
|
||||||
const q = query.toLowerCase().trim();
|
const q = query.toLowerCase().trim();
|
||||||
const matches = q
|
const matches = q
|
||||||
? this.allIcons.filter(function(n) { return n.includes(q); }).slice(0, 80)
|
? this.allIcons.filter((n) => {
|
||||||
|
if (n.includes(q)) return true;
|
||||||
|
const tags = this.tagsMap[n];
|
||||||
|
if (tags) return tags.some(t => t.includes(q));
|
||||||
|
return false;
|
||||||
|
}).slice(0, 80)
|
||||||
: this.allIcons.slice(0, 80);
|
: this.allIcons.slice(0, 80);
|
||||||
|
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
@@ -1167,13 +1390,16 @@ class IconSuggestModal extends Modal {
|
|||||||
try {
|
try {
|
||||||
var r = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
|
var r = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
|
this.tagsMap = d as Record<string, string[]>;
|
||||||
this.allIcons = Object.keys(d).sort();
|
this.allIcons = Object.keys(d).sort();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
try {
|
try {
|
||||||
var r2 = await fetch('https://lucide.dev/api/tags');
|
var r2 = await fetch('https://lucide.dev/api/tags');
|
||||||
var d2 = await r2.json();
|
var d2 = await r2.json();
|
||||||
|
this.tagsMap = d2 as Record<string, string[]>;
|
||||||
this.allIcons = Object.keys(d2).sort();
|
this.allIcons = Object.keys(d2).sort();
|
||||||
} catch (_e2) {
|
} catch (_e2) {
|
||||||
|
this.tagsMap = {};
|
||||||
this.allIcons = Object.keys(FALLBACK_ICONS).sort();
|
this.allIcons = Object.keys(FALLBACK_ICONS).sort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-8
@@ -27,6 +27,31 @@
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
border-bottom: 1px solid var(--background-modifier-border);
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-header-more {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: var(--cursor);
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 80ms, background 80ms;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-header-more:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background: none;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Calendar ── */
|
/* ── Calendar ── */
|
||||||
@@ -39,7 +64,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-calendar-breadcrumb {
|
.waypoint-calendar-breadcrumb {
|
||||||
@@ -48,7 +73,7 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: var(--font-ui-medium);
|
font-size: var(--font-ui-medium);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
padding: 2px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-calendar-breadcrumb .waypoint-clickable {
|
.waypoint-calendar-breadcrumb .waypoint-clickable {
|
||||||
@@ -102,8 +127,9 @@
|
|||||||
.waypoint-calendar th,
|
.waypoint-calendar th,
|
||||||
.waypoint-calendar td {
|
.waypoint-calendar td {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2px 1px;
|
padding: 4px 2px;
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-calendar th {
|
.waypoint-calendar th {
|
||||||
@@ -128,10 +154,12 @@
|
|||||||
|
|
||||||
.waypoint-calendar .waypoint-day {
|
.waypoint-calendar .waypoint-day {
|
||||||
cursor: var(--cursor);
|
cursor: var(--cursor);
|
||||||
padding: 3px 0;
|
min-height: var(--wp-cal-cell-size, 32px);
|
||||||
|
padding: 2px 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-calendar .waypoint-day:hover {
|
.waypoint-calendar .waypoint-day:hover {
|
||||||
@@ -163,6 +191,34 @@
|
|||||||
}
|
}
|
||||||
/* ── Recent Files ── */
|
/* ── Recent Files ── */
|
||||||
|
|
||||||
|
.waypoint-recent-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-recent-pill {
|
||||||
|
font-size: var(--font-ui-smaller);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: var(--cursor);
|
||||||
|
transition: color 80ms, background 80ms;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-recent-pill:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background: var(--background-modifier-active-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-recent-pill.is-active {
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
background: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.waypoint-recent-files .nav-file {
|
.waypoint-recent-files .nav-file {
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
@@ -202,9 +258,10 @@
|
|||||||
.waypoint-favorites .waypoint-bookmark-item {
|
.waypoint-favorites .waypoint-bookmark-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--wp-row-spacing, 4px);
|
||||||
padding: 2px 8px 2px 4px;
|
padding: 2px 8px 2px 4px;
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--wp-font-size, 13px);
|
||||||
|
min-height: var(--wp-row-size, 26px);
|
||||||
cursor: var(--cursor);
|
cursor: var(--cursor);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -219,6 +276,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: var(--wp-icon-size, 16px);
|
||||||
|
height: var(--wp-icon-size, 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-favorites .waypoint-bookmark-item .waypoint-bm-label {
|
.waypoint-favorites .waypoint-bookmark-item .waypoint-bm-label {
|
||||||
@@ -243,7 +302,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-favorites .waypoint-bookmark-children {
|
.waypoint-favorites .waypoint-bookmark-children {
|
||||||
padding-left: 16px;
|
padding-left: var(--wp-indent-size, 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-favorites .waypoint-bookmark-children.collapsed {
|
.waypoint-favorites .waypoint-bookmark-children.collapsed {
|
||||||
@@ -265,6 +324,11 @@
|
|||||||
border-bottom: 3px solid var(--text-accent) !important;
|
border-bottom: 3px solid var(--text-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.waypoint-favorites .waypoint-bm-drop-into {
|
||||||
|
background-color: var(--background-modifier-active-hover) !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Separator & Spacer ── */
|
/* ── Separator & Spacer ── */
|
||||||
|
|
||||||
.waypoint-favorites .waypoint-bookmark-item.waypoint-bookmark-separator {
|
.waypoint-favorites .waypoint-bookmark-item.waypoint-bookmark-separator {
|
||||||
@@ -291,4 +355,38 @@
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Settings Tabs ── */
|
||||||
|
|
||||||
|
.waypoint-settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-settings-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: var(--cursor);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
transition: color 100ms, background 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-settings-tab:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waypoint-settings-tab.is-active {
|
||||||
|
color: var(--text-accent);
|
||||||
|
background: var(--background-modifier-active-hover);
|
||||||
|
border-bottom: 2px solid var(--text-accent);
|
||||||
|
margin-bottom: -9px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
- [ ] when user middle clicks on a bookmark, it should open in a new tab
|
- [x] bookmarks
|
||||||
- [ ] the sidebar should never scroll. the content of recent files should wrap after 10 files or so (no sidebar visible)
|
- [x] when user middle clicks on a bookmark, it should open in a new tab
|
||||||
- [ ] it should be possible to add a separator line and/or an empty space
|
- [x] in the bookmarks, i want to have notes that are parents to other notes
|
||||||
- [ ] by default, the bookmark should have no icon
|
- [x] by default, the bookmark should have no icon
|
||||||
- [ ]
|
- [x] the way to search for icons should be improved. on the lucide icon website, the search works by more than only the icon's title. i want to be able to load all the icon (not by default though)
|
||||||
|
- [x] add a right click menu on the recent files to make them bookmarks
|
||||||
|
- [x] improve the drag and drop to have identations and groups
|
||||||
|
- [x] calendar
|
||||||
|
- [x] middle click on a date/week/etc. should open it in a new tab
|
||||||
|
- [x] i want to improve the look of the calendar. more space, a bit more height
|
||||||
|
- [x] when i hover over something that's clickable, I should see the correct cursor
|
||||||
|
- [x] recent files
|
||||||
|
- [x] I want to be able to filter the recent files. i want to have a little tag, that when i selectt, wsill show only recents notes where type == meeting, type == person
|
||||||
|
|||||||
Reference in New Issue
Block a user