23 Commits

Author SHA1 Message Date
olivier 3c7b6741f5 fix: calendar disappeared for existing users
Shallow Object.assign on settings meant filterTags (new in 1.5.0) was
undefined for anyone with prior saved settings. Accessing .length on
undefined threw, killing the entire redraw before calendar could render.

- Defensive fallback: filterTags || []
- Deep merge on loadSettings for recentFiles, calendar, display
2026-06-22 10:18:37 -04:00
olivier abf4d6ccf7 feat: configurable filter tags for recent files
New 'Filter tags' setting in Recent Files tab. Enter tag names one per line
to pin specific tags as filter pills. Leave empty to auto-detect from
frontmatter 'type' property (existing behavior).
2026-06-22 10:15:54 -04:00
olivier 141121878f feat: add About tab to settings with version and plugin description 2026-06-22 10:07:22 -04:00
olivier 6a89660cf4 release v1.4.1 2026-06-22 10:01:47 -04:00
olivier 44c2dd2abc fix: enforce recent files limit when maxItems setting changes
The limit was only applied on file-open and plugin load, not when the user
changed the maxItems setting in the settings tab. Now enforceRecentFilesLimit()
is called on every settings change via the onSettingsChange callback.
2026-06-22 10:00:45 -04:00
olivier 49c8da9863 feat: persistent recent files history + fix limit enforcement + fix settings data loss
- Recent files are now persisted to disk and survive restarts
- Max items limit is applied on load (in case setting was reduced)
- Omitted paths regex filtering is now wired up in addToRecentFiles
- Fixed saveAndRefresh() overwriting entire plugin data on settings change
- Debounced disk writes (300ms) to avoid excessive saves on rapid opens
2026-06-21 22:36:03 -04:00
olivier 993017f669 release v1.3.0: chevron right, tabbed settings, drag-into, display customization 2026-06-12 14:54:28 -04:00
olivier e0c3deee86 fix: drag onto file makes dragged item a child of that file
Dragging B onto A:
  ▶ File A
      File B

A becomes the parent, B becomes its child. No new group created.
2026-06-12 14:52:04 -04:00
olivier 7331a455ad fix: drag-to-create parent group is now an empty container
When dragging B onto A, the result is now:
- New Group (empty, no file)
  - File A
  - File B

Previously the group inherited A's file, creating a self-referencing parent.
2026-06-12 14:50:48 -04:00
olivier 7a5be506e0 feat: drag onto file bookmark creates parent note + fix calendar layout
Drag-and-drop:
- Dropping a bookmark onto a file bookmark (middle zone) creates a new
  parent note group containing both items
- Dropping onto a group still moves into the group as before
- All file/group bookmarks now accept drops (3-zone behavior)

Calendar:
- Fixed broken table layout caused by display:flex on td elements
- Uses vertical-align:middle instead for cell content centering
- Calendar cells still have configurable height via CSS variable
2026-06-12 13:18:25 -04:00
olivier 765c6f3b47 feat: chevron on right + tabbed settings + display customization
View:
- Chevron moved from left side to right side of bookmark rows
- Display settings applied as CSS variables on the view element

Settings:
- Tabbed UI with 4 tabs: Calendar, Periodic Notes, Recent Files, Display
- Display tab: row size, row spacing, indent size, font size, icon size, calendar cell size
- All settings use sliders with live preview and dynamic tooltips
- Reset to defaults button

CSS:
- Bookmark items use CSS variables (--wp-row-size, --wp-row-spacing, etc.)
- Calendar cells use --wp-cal-cell-size for configurable height
- Settings tab styling with active indicator
2026-06-12 12:56:27 -04:00
olivier 846726a5c7 docs: mark all todo items as done 2026-06-11 11:18:33 -04:00
olivier e92903a7aa feat: recent files type filter + calendar middle-click + calendar spacing
Recent files:
- Type filter bar with pill tags above the list
- Scans frontmatter 'type' property of each recent file
- Click a pill to filter, click again or 'all' to clear
- Active pill shows accent color

Calendar:
- Middle-click on day/week/quarter/month/year opens in new tab
- openPeriodNoteInLeaf() method for leaf-targeted opening
- More spacing: taller day cells, more padding, wider margins

Bookmarks:
- Right-click on recent file → 'Add to bookmarks' option
2026-06-11 11:16:17 -04:00
olivier 7bf1223673 style: improve calendar spacing — more room and height
- Day cell padding: 3px → 6px vertical
- Header/cell padding: 2px → 4px vertical
- Breadcrumb padding: 2px → 4px vertical
- Top row margin-bottom: 6px → 10px
- Line-height: 1.6 on calendar cells
2026-06-11 10:49:55 -04:00
olivier a36c8ab6f4 feat: add bookmark from recent files + calendar middle-click
- Recent files right-click now has 'Add to bookmarks' option
- Calendar: middle-click on day/week/quarter/month/year opens in new tab
- Added openPeriodNoteInLeaf() method for opening period notes in a specific leaf
2026-06-11 10:46:57 -04:00
olivier eeb6059fe5 feat: drag-and-drop into groups/parent notes + fix ⋯ button styling
Drag-and-drop:
- 3-zone drop indicator for groups and parent notes (files with children):
  top 25% = drop above, middle 50% = drop into, bottom 25% = drop below
- 2-zone for regular files/separators/spacers (unchanged)
- 'Drop into' shows background highlight instead of border line
- Circular reference guard: can't drop a group into itself or descendants

⋯ button:
- No visible button chrome when not hovered (no border, no background)
- Subtle opacity (0.6) at rest, full opacity on hover
- Hover shows text color change only, no background highlight
2026-06-10 21:53:22 -04:00
olivier 2e8ac7047f refactor: simplify bookmark menus — visible ⋯ button, flat context menus
Header:
- Replace hidden right-click with visible ⋯ button (more-horizontal icon)
- Menu: Add current file / Add as parent note / New group / separator / Add spacer

File bookmark right-click (5 items, flat):
  Open in new tab / Rename / Change icon / Remove

Group/parent note right-click (7 items max, flat):
  Open in new tab (only if linked) / Rename / Change icon / Expand|Collapse /
  Add child bookmark / Add child note / New sub-group / Remove

Separator/Spacer right-click (1 item):
  Remove

Removed dead code: insertAdjacent, collectGroups, collectDescendantIds,
moveBookmarkToGroup — all moving is now drag-and-drop only
2026-06-10 21:45:16 -04:00
olivier a692dc9a34 docs: add QA checklist for bookmark features 2026-06-10 20:20:27 -04:00
olivier 6978401613 feat: improved icon search — matches Lucide tags/keywords
The icon picker now searches against the Lucide tags.json keyword list,
not just the icon name. Searching 'meeting' now finds 'calendar',
'red-heart' finds 'heart', etc. Tags are loaded alongside icon names
from the CDN with fallback.
2026-06-10 20:19:57 -04:00
olivier 3c4fc2d91c feat: parent notes (file bookmarks with children) + no default icon
- File bookmarks can now have children, rendered with a chevron
- Group bookmarks can have an associated filePath; clicking opens the file,
  clicking chevron toggles collapse
- 'Add as parent note' option in header context menu creates a group
  with the current file linked
- 'Add child note' option in group context menu creates a sub-group
  with the current file linked (parent note pattern)
- Default icon changed from 'file'/'folder' to '' (no icon)
- Chevron click always toggles collapse regardless of type
2026-06-10 20:19:01 -04:00
olivier 4278507ae2 feat: middle-click + mod-click on bookmarks opens in new tab
- File bookmarks: middle-click opens in new tab, mod+click opens in new leaf
- Group bookmarks: middle-click opens group's linked file in new tab,
  mod+click opens group's linked file (click still toggles collapse)
2026-06-10 20:16:17 -04:00
olivier 99a3ea80ae new 2026-06-10 20:14:30 -04:00
olivier cb09ec0e83 bump to v1.2.0, rename plugin to Waypoint Sidebar 2026-06-10 15:42:13 -04:00
13 changed files with 1411 additions and 279 deletions
+375
View File
@@ -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.
+79
View File
@@ -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
+34
View File
@@ -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
+20 -10
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,7 +1,7 @@
{
"id": "waypoint",
"name": "Waypoint",
"version": "1.1.0",
"id": "waypoint-sidebar",
"name": "Waypoint Sidebar",
"version": "1.5.1",
"minAppVersion": "0.16.3",
"description": "Calendar, recent files, and custom bookmarks sidebar.",
"author": "Olivier",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "waypoint",
"version": "1.1.0",
"version": "1.5.1",
"description": "Calendar, recent files, and custom bookmarks sidebar.",
"main": "main.js",
"scripts": {
+95 -3
View File
@@ -16,12 +16,14 @@ import { BookmarkItem, WaypointData } from 'src/models/bookmark';
const DEFAULT_DATA: WaypointData = {
bookmarks: [],
recentFiles: [],
};
export default class WaypointPlugin extends Plugin {
public settings: WaypointSettings;
public waypointData: WaypointData;
public recentFiles: { path: string; basename: string }[] = [];
private recentFilesSaveTimer: ReturnType<typeof setTimeout> | null = null;
async onload(): Promise<void> {
console.debug('Waypoint: loading plugin v' + this.manifest.version);
@@ -41,7 +43,10 @@ export default class WaypointPlugin extends Plugin {
this.app,
this,
this.settings,
() => this.redrawAll(),
() => {
this.enforceRecentFilesLimit();
this.redrawAll();
},
));
// ── Commands ──
@@ -194,6 +199,10 @@ export default class WaypointPlugin extends Plugin {
const saved = await this.loadData() as Record<string, unknown> | null;
const s = (saved?.settings || {}) as Partial<WaypointSettings>;
this.settings = Object.assign({}, DEFAULT_SETTINGS, s);
// Deep merge nested objects that might be missing new fields
this.settings.recentFiles = Object.assign({}, DEFAULT_SETTINGS.recentFiles, s.recentFiles || {});
this.settings.calendar = Object.assign({}, DEFAULT_SETTINGS.calendar, s.calendar || {});
this.settings.display = Object.assign({}, DEFAULT_SETTINGS.display, s.display || {});
}
async saveSettings(): Promise<void> {
@@ -206,14 +215,47 @@ export default class WaypointPlugin extends Plugin {
const saved = await this.loadData() as Record<string, unknown> | null;
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
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> {
// Sync recentFiles into waypointData before saving
this.waypointData.recentFiles = this.recentFiles;
const all = (await this.loadData()) as Record<string, unknown> || {};
all.waypointData = this.waypointData;
await this.saveData(all);
}
/**
* Persist recent files to disk (debounced to avoid excessive writes on rapid opens).
*/
persistRecentFiles(): void {
this.waypointData.recentFiles = this.recentFiles;
if (this.recentFilesSaveTimer) clearTimeout(this.recentFilesSaveTimer);
this.recentFilesSaveTimer = setTimeout(() => {
this.saveWaypointData();
}, 300);
}
// ── Recent Files ──
private onFileOpen(file: TFile): void {
@@ -225,14 +267,26 @@ export default class WaypointPlugin extends Plugin {
}
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.unshift({ path: file.path, basename: file.basename });
// Prune
// Apply max items limit
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
}
this.persistRecentFiles();
this.broadcastRedraw();
}
@@ -241,6 +295,7 @@ export default class WaypointPlugin extends Plugin {
if (entry) {
entry.path = file.path;
entry.basename = (file as TFile).basename || file.name.replace(/\.[^/.]+$/, '');
this.persistRecentFiles();
this.broadcastRedraw();
}
@@ -254,7 +309,7 @@ export default class WaypointPlugin extends Plugin {
// ── 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 item: BookmarkItem = {
id,
@@ -376,6 +431,43 @@ 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) ──
/**
+1
View File
@@ -13,4 +13,5 @@ export interface BookmarkItem {
export interface WaypointData {
bookmarks: BookmarkItem[];
recentFiles: { path: string; basename: string }[];
}
+255 -66
View File
@@ -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';
export class WaypointSettingTab extends PluginSettingTab {
private plugin: Plugin;
private settings: WaypointSettings;
private onSettingsChange: () => void;
private activeTab: 'calendar' | 'periodic' | 'recent' | 'display' | 'about' = 'calendar';
constructor(app: App, plugin: Plugin, settings: WaypointSettings, onSettingsChange: () => void) {
super(app, plugin);
@@ -17,10 +18,54 @@ export class WaypointSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
// ── Calendar Section ──
new Setting(containerEl).setHeading().setName('Calendar');
// ── Tab bar ──
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')
.setDesc('Which day the calendar week starts on.')
.addDropdown((dropdown) => {
@@ -34,7 +79,7 @@ export class WaypointSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
new Setting(container)
.setName('Show note indicators')
.setDesc('Show a dot on days that have existing notes.')
.addToggle((toggle) => {
@@ -45,20 +90,78 @@ export class WaypointSettingTab extends PluginSettingTab {
this.saveAndRefresh();
});
});
}
// ── Periodic Note Settings ──
new Setting(containerEl).setHeading().setName('Periodic Note Paths');
// ═══════════════════════════════
// Periodic Notes tab
// ═══════════════════════════════
this.addPeriodNoteSettings('Daily', this.settings.daily);
this.addPeriodNoteSettings('Weekly', this.settings.weekly);
this.addPeriodNoteSettings('Monthly', this.settings.monthly);
this.addPeriodNoteSettings('Quarterly', this.settings.quarterly);
this.addPeriodNoteSettings('Yearly', this.settings.yearly);
private renderPeriodicTab(container: HTMLElement): void {
this.addPeriodNoteSettings(container, 'Daily', this.settings.daily);
this.addPeriodNoteSettings(container, 'Weekly', this.settings.weekly);
this.addPeriodNoteSettings(container, 'Monthly', this.settings.monthly);
this.addPeriodNoteSettings(container, 'Quarterly', this.settings.quarterly);
this.addPeriodNoteSettings(container, 'Yearly', this.settings.yearly);
}
// ── Recent Files Section ──
new Setting(containerEl).setHeading().setName('Recent Files');
private addPeriodNoteSettings(container: HTMLElement, label: string, period: PeriodNoteSettings): void {
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')
.setDesc('Maximum number of recent files to track.')
.addText((text) => {
@@ -74,7 +177,7 @@ export class WaypointSettingTab extends PluginSettingTab {
};
});
new Setting(containerEl)
new Setting(container)
.setName('Update on')
.setDesc('When to add a file to the recent list.')
.addDropdown((dropdown) => {
@@ -90,7 +193,7 @@ export class WaypointSettingTab extends PluginSettingTab {
const pathDesc = new DocumentFragment();
pathDesc.appendText('Regex patterns for paths to exclude. One per line.');
new Setting(containerEl)
new Setting(container)
.setName('Omitted paths')
.setDesc(pathDesc)
.addTextArea((text) => {
@@ -105,7 +208,7 @@ export class WaypointSettingTab extends PluginSettingTab {
const tagDesc = new DocumentFragment();
tagDesc.appendText('Regex patterns for frontmatter tags to exclude. One per line.');
new Setting(containerEl)
new Setting(container)
.setName('Omitted tags')
.setDesc(tagDesc)
.addTextArea((text) => {
@@ -117,62 +220,148 @@ export class WaypointSettingTab extends PluginSettingTab {
this.saveAndRefresh();
};
});
const filterDesc = new DocumentFragment();
filterDesc.appendText('Tags to show as filter pills above the file list. One per line. Leave empty to auto-detect from frontmatter `type` property.');
new Setting(container)
.setName('Filter tags')
.setDesc(filterDesc)
.addTextArea((text) => {
text.inputEl.setAttr('rows', 4);
text.setPlaceholder('meeting\nperson\nproject');
text.setValue(this.settings.recentFiles.filterTags.join('\n'));
text.inputEl.onblur = () => {
this.settings.recentFiles.filterTags = text.getValue().split('\n').filter(t => t.trim());
this.saveAndRefresh();
};
});
}
private addPeriodNoteSettings(label: string, period: PeriodNoteSettings): void {
new Setting(this.containerEl).setHeading().setName(label);
// ═══════════════════════════════
// Display tab
// ═══════════════════════════════
new Setting(this.containerEl)
.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;
private renderDisplayTab(container: HTMLElement): void {
new Setting(container).setHeading().setName('Bookmarks');
this.addSliderSetting(container,
'Row size', 'Height of bookmark items.',
this.settings.display, 'rowSize', 18, 40, 1, 'px',
);
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();
});
});
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> {
await this.plugin.saveData(this.settings);
await (this.plugin as any).saveSettings();
this.onSettingsChange();
}
// ═══════════════════════════════
// About tab
// ═══════════════════════════════
private renderAboutTab(container: HTMLElement): void {
const version = this.plugin.manifest.version;
const header = container.createDiv();
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.gap = '12px';
header.style.marginBottom = '16px';
const icon = header.createDiv();
icon.style.display = 'flex';
icon.style.alignItems = 'center';
icon.style.justifyContent = 'center';
icon.style.width = '48px';
icon.style.height = '48px';
icon.style.borderRadius = '12px';
icon.style.background = 'var(--interactive-accent)';
icon.style.color = 'var(--text-on-accent)';
icon.style.fontSize = '24px';
setIcon(icon, 'compass');
const titleGroup = header.createDiv();
const title = titleGroup.createEl('h2', { text: 'Waypoint Sidebar' });
title.style.margin = '0';
title.style.lineHeight = '1.2';
const subtitle = titleGroup.createDiv({ text: `v${version}` });
subtitle.style.color = 'var(--text-muted)';
subtitle.style.fontSize = 'var(--font-ui-small)';
const desc = container.createDiv();
desc.style.marginBottom = '20px';
desc.style.lineHeight = '1.6';
desc.style.color = 'var(--text-normal)';
desc.innerHTML = [
`<p><strong>Waypoint</strong> is a sidebar plugin that brings three essential panels into one view:</p>`,
`<ul style="padding-left: 20px; margin: 8px 0;">`,
`<li><strong>Calendar</strong> — a monthly grid for your periodic notes (daily, weekly, monthly, quarterly, yearly). Click any day, week, or month to open or create the corresponding note.</li>`,
`<li><strong>Recent Files</strong> — a list of recently opened files with type filtering, drag-and-drop, and right-click actions.</li>`,
`<li><strong>Bookmarks</strong> — custom bookmarks with icons, groups, nesting, and drag-and-drop reordering. Separate from Obsidian's native bookmarks.</li>`,
`</ul>`,
`<p style="color: var(--text-muted); font-size: var(--font-ui-small);">Made by Olivier. Licensed under MIT.</p>`,
].join('\n');
}
}
+20
View File
@@ -17,6 +17,16 @@ export interface RecentFilesSettings {
updateOn: 'file-open' | 'file-edit';
omittedPaths: string[];
omittedTags: string[];
filterTags: string[]; // tags to show as filter pills (empty = auto-detect from frontmatter)
}
export interface DisplaySettings {
rowSize: number; // px, base height of bookmark items (2040)
rowSpacing: number; // px, gap between items (012)
indentSize: number; // px, indent per depth level (832)
fontSize: number; // px, font size for bookmark labels (1018)
iconSize: number; // px, icon size (1224)
calendarCellSize: number; // px, calendar day cell height (2048)
}
export interface WaypointSettings {
@@ -27,6 +37,7 @@ export interface WaypointSettings {
quarterly: PeriodNoteSettings;
yearly: PeriodNoteSettings;
recentFiles: RecentFilesSettings;
display: DisplaySettings;
}
export const DEFAULT_SETTINGS: WaypointSettings = {
@@ -69,5 +80,14 @@ export const DEFAULT_SETTINGS: WaypointSettings = {
updateOn: 'file-open',
omittedPaths: [],
omittedTags: [],
filterTags: [],
},
display: {
rowSize: 26,
rowSpacing: 2,
indentSize: 16,
fontSize: 13,
iconSize: 16,
calendarCellSize: 32,
},
};
+407 -181
View File
@@ -51,6 +51,15 @@ export class WaypointView extends ItemView {
this.contentEl.empty();
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.renderRecentFiles();
this.renderCalendar();
@@ -63,6 +72,7 @@ export class WaypointView extends ItemView {
private currentDisplayMonth: number = moment().month(); // 0-indexed
private currentDisplayYear: number = moment().year();
private dragId: string | null = null;
private recentFilesFilter: string | null = null; // null = show all, else filter by type
private renderCalendar(): void {
const section = this.contentEl.createDiv({ cls: 'waypoint-section' });
@@ -84,18 +94,36 @@ export class WaypointView extends ItemView {
qEl.addEventListener('click', () => {
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 mEl = breadcrumb.createSpan({ cls: 'waypoint-clickable', text: mLabel });
mEl.addEventListener('click', () => {
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 yEl = breadcrumb.createSpan({ cls: 'waypoint-clickable', text: yLabel });
yEl.addEventListener('click', () => {
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 ▶
const todayGroup = topRow.createDiv({ cls: 'waypoint-calendar-today-group' });
@@ -148,6 +176,13 @@ export class WaypointView extends ItemView {
const monday = week.days[0].date;
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) {
const cell = row.createEl('td', { cls: 'waypoint-day' });
@@ -173,6 +208,13 @@ export class WaypointView extends ItemView {
cell.addEventListener('click', () => {
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;
}
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 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 navFileTitle = navFile.createDiv({ cls: 'tree-item-self is-clickable nav-file-title' });
const navFileTitleContent = navFileTitle.createDiv({ cls: 'tree-item-inner nav-file-title-content' });
navFileTitleContent.setText(file.basename);
const spacer = navFileTitle.createDiv({ cls: 'tree-item-spacer' });
// Remove button
@@ -217,12 +326,10 @@ export class WaypointView extends ItemView {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.plugin.recentFiles = this.plugin.recentFiles.filter(f => f.path !== file.path);
this.plugin.saveSettings();
this.plugin.persistRecentFiles();
this.redraw();
});
setTooltip(navFile, file.path);
if (openFile && file.path === openFile.path) {
navFileTitle.addClass('is-active');
}
@@ -258,6 +365,16 @@ export class WaypointView extends ItemView {
.setIcon('file-plus')
.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);
if (tfile) {
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);
if (targetFile) {
const leaf = this.app.workspace.getLeaf(newLeaf as any);
@@ -289,7 +406,7 @@ export class WaypointView extends ItemView {
} else {
new Notice('Cannot find file');
this.plugin.recentFiles = this.plugin.recentFiles.filter(f => f.path !== file.path);
this.plugin.saveSettings();
this.plugin.persistRecentFiles();
this.redraw();
}
}
@@ -303,8 +420,11 @@ export class WaypointView extends ItemView {
const headerEl = section.createDiv({ cls: 'waypoint-section-header' });
headerEl.setText('Waypoint Bookmarks');
// Right-click on header area → add bookmark / group
headerEl.addEventListener('contextmenu', (event: MouseEvent) => {
// ⋯ button — visible add menu
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();
menu.addItem((item) => {
item
@@ -316,14 +436,28 @@ export class WaypointView extends ItemView {
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) => {
item
.setTitle('New group')
.setIcon('folder-plus')
.onClick(() => {
this.plugin.addBookmark('', 'New Group', 'group', 'folder');
this.plugin.addBookmark('', 'New Group', 'group', '');
});
});
menu.addSeparator();
menu.addItem((item) => {
item
.setTitle('Add separator')
@@ -340,7 +474,6 @@ export class WaypointView extends ItemView {
this.plugin.addBookmark('', '', 'spacer');
});
});
menu.showAtPosition({ x: event.clientX, y: event.clientY });
});
@@ -374,7 +507,7 @@ export class WaypointView extends ItemView {
});
rowEl.addEventListener('dragend', () => {
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-drop-line');
el.removeClass('waypoint-bm-drop-below');
@@ -383,16 +516,17 @@ export class WaypointView extends ItemView {
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e);
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e);
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
@@ -432,7 +566,7 @@ export class WaypointView extends ItemView {
});
rowEl.addEventListener('dragend', () => {
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-drop-line');
el.removeClass('waypoint-bm-drop-below');
@@ -441,16 +575,17 @@ export class WaypointView extends ItemView {
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e);
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e);
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
@@ -491,30 +626,34 @@ export class WaypointView extends ItemView {
rowEl.addClass('waypoint-bm-dragging');
});
const canAcceptChildren = true; // all file/group bookmarks can accept drops
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below').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-drop-line');
el.removeClass('waypoint-bm-drop-below');
el.removeClass('waypoint-bm-drop-into');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e);
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e);
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
@@ -522,28 +661,62 @@ export class WaypointView extends ItemView {
this.dragId = null;
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const dropAbove = (rowEl as any).__dropAbove;
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
const dropInto = (rowEl as any).__dropInto;
if (dropInto && canAcceptChildren) {
if (isGroup) {
this.moveBookmarkToGroup(draggedId, item.id);
} else {
this.createParentNoteAndMove(draggedId, item.id);
}
} else {
const dropAbove = (rowEl as any).__dropAbove;
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
}
});
// ── Group: chevron + icon + label ──
if (isGroup) {
const chevron = rowEl.createDiv({ cls: 'waypoint-bm-chevron' });
setIcon(chevron, 'chevron-down');
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 });
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 });
});
// 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({
cls: `waypoint-bookmark-children${item.collapsed ? ' collapsed' : ''}`,
});
@@ -554,23 +727,56 @@ export class WaypointView extends ItemView {
// ── File: icon + label ──
} else {
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 });
// 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);
rowEl.addEventListener('click', () => {
rowEl.addEventListener('click', (event: MouseEvent) => {
if (item.filePath) {
const tfile = this.app.vault.getFileByPath(item.filePath);
if (tfile) {
const leaf = this.app.workspace.getLeaf(false);
leaf.openFile(tfile);
const newLeaf = Keymap.isModEvent(event);
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
} else {
new Notice('File not found');
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) ──
@@ -585,6 +791,17 @@ export class WaypointView extends ItemView {
private showBookmarkContextMenu(event: MouseEvent, item: BookmarkItem): void {
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') {
menu.addItem((i) =>
i
@@ -592,86 +809,91 @@ export class WaypointView extends ItemView {
.setIcon('file-plus')
.onClick(() => {
const tfile = this.app.vault.getFileByPath(item.filePath);
if (tfile) {
this.app.workspace.getLeaf('tab').openFile(tfile);
}
if (tfile) this.app.workspace.getLeaf('tab').openFile(tfile);
}),
);
menu.addSeparator();
}
if (item.type !== 'separator' && item.type !== 'spacer') {
menu.addItem((i) =>
i
.setTitle('Rename')
.setIcon('pencil')
.onClick(() => this.promptRename(item)),
);
menu.addItem((i) =>
i
.setTitle('Change icon')
.setIcon('image')
.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();
if (availableGroups.length > 0) {
for (const group of availableGroups) {
menu.addItem((mi) =>
mi
.setTitle(` ${group.label}`)
.setIcon(group.icon || 'folder')
.onClick(() => {
this.moveBookmarkToGroup(item.id, group.id);
}),
);
}
menu.addItem((i) =>
i
.setTitle('Remove')
.setIcon('trash')
.onClick(() => this.plugin.removeBookmark(item.id)),
);
} else if (item.type === 'group') {
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) =>
i
.setTitle(item.collapsed ? 'Expand' : 'Collapse')
.setIcon(item.collapsed ? 'chevron-down' : 'chevron-up')
.onClick(() => {
this.plugin.updateBookmark(item.id, { collapsed: !item.collapsed });
}),
.setTitle('Rename')
.setIcon('pencil')
.onClick(() => this.promptRename(item)),
);
menu.addItem((i) =>
i
.setTitle('Change icon')
.setIcon('image')
.onClick(() => this.promptIcon(item)),
);
menu.addSeparator();
menu.addItem((i) =>
i
.setTitle('Add bookmark here')
.setIcon('plus')
.setTitle('Add child bookmark')
.setIcon('file-plus')
.onClick(() => {
const file = this.app.workspace.getActiveFile();
if (!file) {
new Notice('No active file');
return;
}
if (!file) { new Notice('No active file'); return; }
const child: BookmarkItem = {
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'file',
label: file.basename,
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: [],
collapsed: false,
indent: item.indent + 1,
@@ -691,7 +913,7 @@ export class WaypointView extends ItemView {
type: 'group',
label: 'New Group',
filePath: '',
icon: 'folder',
icon: '',
children: [],
collapsed: false,
indent: item.indent + 1,
@@ -701,119 +923,59 @@ export class WaypointView extends ItemView {
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 });
}
private insertAdjacent(targetId: string, type: 'separator' | 'spacer', position: 'above' | 'below'): void {
const newItem: BookmarkItem = {
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
private showDropIndicator(el: HTMLElement, e: MouseEvent, isGroupLike: boolean): void {
// Clear all indicators
const parent = el.parentElement;
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-below');
el2.removeClass('waypoint-bm-drop-into');
});
}
const rect = el.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const above = e.clientY < midY;
const y = e.clientY;
el.addClass('waypoint-bm-drop-line');
if (!above) {
el.addClass('waypoint-bm-drop-below');
if (isGroupLike) {
// 3-zone: top 25% = above, middle 50% = into, bottom 25% = 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 {
@@ -833,6 +995,15 @@ export class WaypointView extends ItemView {
const item = removeRecursive(this.plugin.waypointData.bookmarks);
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) {
const findGroup = (items: BookmarkItem[]): BookmarkItem | null => {
for (const i of items) {
@@ -856,6 +1027,52 @@ export class WaypointView extends ItemView {
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 {
// Find and remove the dragged item from wherever it is
const removeById = (items: BookmarkItem[]): { item: BookmarkItem | null; parent: BookmarkItem[] } => {
@@ -975,6 +1192,7 @@ class IconSuggestModal extends Modal {
private onSubmit: (icon: string) => void;
private selected: string;
private allIcons: string[] = [];
private tagsMap: Record<string, string[]> = {};
private loaded = false;
constructor(app: App, currentIcon: string, onSubmit: (icon: string) => void) {
@@ -1070,7 +1288,12 @@ class IconSuggestModal extends Modal {
const q = query.toLowerCase().trim();
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);
if (matches.length === 0) {
@@ -1167,13 +1390,16 @@ class IconSuggestModal extends Modal {
try {
var r = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
var d = await r.json();
this.tagsMap = d as Record<string, string[]>;
this.allIcons = Object.keys(d).sort();
} catch (_e) {
try {
var r2 = await fetch('https://lucide.dev/api/tags');
var d2 = await r2.json();
this.tagsMap = d2 as Record<string, string[]>;
this.allIcons = Object.keys(d2).sort();
} catch (_e2) {
this.tagsMap = {};
this.allIcons = Object.keys(FALLBACK_ICONS).sort();
}
}
+105 -7
View File
@@ -27,6 +27,31 @@
margin-bottom: 4px;
padding-bottom: 4px;
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 ── */
@@ -39,7 +64,7 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
margin-bottom: 10px;
}
.waypoint-calendar-breadcrumb {
@@ -48,7 +73,7 @@
gap: 6px;
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
padding: 2px 0;
padding: 4px 0;
}
.waypoint-calendar-breadcrumb .waypoint-clickable {
@@ -102,8 +127,9 @@
.waypoint-calendar th,
.waypoint-calendar td {
text-align: center;
padding: 2px 1px;
padding: 4px 2px;
font-size: var(--font-ui-small);
line-height: 1.6;
}
.waypoint-calendar th {
@@ -128,10 +154,12 @@
.waypoint-calendar .waypoint-day {
cursor: var(--cursor);
padding: 3px 0;
min-height: var(--wp-cal-cell-size, 32px);
padding: 2px 0;
border-radius: 6px;
position: relative;
width: 100%;
vertical-align: middle;
}
.waypoint-calendar .waypoint-day:hover {
@@ -163,6 +191,34 @@
}
/* ── 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 {
padding: 2px 0;
}
@@ -202,9 +258,10 @@
.waypoint-favorites .waypoint-bookmark-item {
display: flex;
align-items: center;
gap: 4px;
gap: var(--wp-row-spacing, 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);
border-radius: 4px;
user-select: none;
@@ -219,6 +276,8 @@
display: flex;
align-items: center;
flex-shrink: 0;
width: var(--wp-icon-size, 16px);
height: var(--wp-icon-size, 16px);
}
.waypoint-favorites .waypoint-bookmark-item .waypoint-bm-label {
@@ -243,7 +302,7 @@
}
.waypoint-favorites .waypoint-bookmark-children {
padding-left: 16px;
padding-left: var(--wp-indent-size, 16px);
}
.waypoint-favorites .waypoint-bookmark-children.collapsed {
@@ -265,6 +324,11 @@
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 ── */
.waypoint-favorites .waypoint-bookmark-item.waypoint-bookmark-separator {
@@ -292,3 +356,37 @@
cursor: grab;
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;
}
+13 -5
View File
@@ -1,5 +1,13 @@
- [ ] when user middle clicks on a bookmark, it should open in a new tab
- [ ] the sidebar should never scroll. the content of recent files should wrap after 10 files or so (no sidebar visible)
- [ ] it should be possible to add a separator line and/or an empty space
- [ ] by default, the bookmark should have no icon
- [ ]
- [x] bookmarks
- [x] when user middle clicks on a bookmark, it should open in a new tab
- [x] in the bookmarks, i want to have notes that are parents to other notes
- [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