// ── Waypoint Sidebar View ── import { ItemView, WorkspaceLeaf, Menu, Keymap, App, Modal, setIcon, setTooltip, Notice, TFile, moment, } from 'obsidian'; import type WaypointPlugin from 'src/main'; import { getMonthGrid } from 'src/utils/date-utils'; import { BookmarkItem } from 'src/models/bookmark'; export const WAYPOINT_VIEW_TYPE = 'waypoint-view'; export class WaypointView extends ItemView { private plugin: WaypointPlugin; constructor(leaf: WorkspaceLeaf, plugin: WaypointPlugin) { super(leaf); this.plugin = plugin; } getViewType(): string { return WAYPOINT_VIEW_TYPE; } getDisplayText(): string { return 'Waypoint'; } getIcon(): string { return 'compass'; } async onOpen(): Promise { this.redraw(); } async onClose(): Promise { // Nothing to clean up } readonly redraw = (): void => { 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(); }; // ════════════════════════════════════════ // Calendar panel // ════════════════════════════════════════ 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' }); section.createDiv({ cls: 'waypoint-section-header', text: 'Calendar' }); const cal = section.createDiv({ cls: 'waypoint-calendar' }); const now = moment(); const displayDate = moment({ year: this.currentDisplayYear, month: this.currentDisplayMonth, day: 1 }); // ── Top row: breadcrumb + today nav ── const topRow = cal.createDiv({ cls: 'waypoint-calendar-top' }); // Left: Q2 · June · 2026 const breadcrumb = topRow.createDiv({ cls: 'waypoint-calendar-breadcrumb' }); const qLabel = displayDate.format('[Q]Q'); const qEl = breadcrumb.createSpan({ cls: 'waypoint-clickable', text: qLabel }); 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' }); const prevBtn = todayGroup.createEl('button', { cls: 'waypoint-calendar-nav-btn' }); setIcon(prevBtn, 'chevron-left'); prevBtn.addEventListener('click', () => this.navigateMonth(-1)); const todayBtn = todayGroup.createEl('button', { cls: 'waypoint-calendar-today-btn', text: 'Today' }); todayBtn.addEventListener('click', () => { this.currentDisplayMonth = moment().month(); this.currentDisplayYear = moment().year(); this.redraw(); }); const nextBtn = todayGroup.createEl('button', { cls: 'waypoint-calendar-nav-btn' }); setIcon(nextBtn, 'chevron-right'); nextBtn.addEventListener('click', () => this.navigateMonth(1)); // ── Calendar grid ── const table = cal.createEl('table'); const thead = table.createEl('thead'); const headerRow = thead.createEl('tr'); // Week number column header (blank) headerRow.createEl('th', { text: '' }); const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; const firstDay = this.plugin.settings.calendar.firstDayOfWeek; for (let i = 0; i < 7; i++) { const idx = (firstDay + i) % 7; headerRow.createEl('th', { text: dayNames[idx] }); } // ── Day rows ── const tbody = table.createEl('tbody'); const weeks = getMonthGrid( this.currentDisplayYear, this.currentDisplayMonth, this.plugin.settings.calendar.firstDayOfWeek, ); for (const week of weeks) { const row = tbody.createEl('tr'); // Week number cell const wnCell = row.createEl('td', { cls: 'waypoint-weeknum' }); wnCell.setText(String(week.weekNumber)); wnCell.addEventListener('click', () => { 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' }); cell.setText(String(day.dayOfMonth)); if (!day.isCurrentMonth) { cell.addClass('other-month'); } if (day.isToday) { cell.addClass('today'); } if (this.plugin.settings.calendar.showNoteIndicators) { const dateStr = day.date.format('YYYY-MM-DD'); const hasNote = this.plugin.app.vault.getFiles().some( f => f.extension === 'md' && f.basename === dateStr, ); if (hasNote) { cell.addClass('has-note'); } } 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); } }); } } } private navigateMonth(delta: number): void { const d = moment({ year: this.currentDisplayYear, month: this.currentDisplayMonth }).add(delta, 'month'); this.currentDisplayMonth = d.month(); this.currentDisplayYear = d.year(); this.redraw(); } // ════════════════════════════════════════ // Recent Files panel // ════════════════════════════════════════ private renderRecentFiles(): void { const section = this.contentEl.createDiv({ cls: 'waypoint-section waypoint-recent-files' }); section.createDiv({ cls: 'waypoint-section-header', text: 'Recent Files' }); if (this.plugin.recentFiles.length === 0) { section.createDiv({ cls: 'nav-file', text: 'No recent files' }); return; } // ── Type filter bar ── const typeCounts: Record = {}; 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 = Object.entries(typeCounts).sort(([,a], [,b]) => b - a); 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 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 const removeBtn = navFileTitle.createDiv({ cls: 'waypoint-recent-remove' }); setIcon(removeBtn, 'x'); removeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.plugin.recentFiles = this.plugin.recentFiles.filter(f => f.path !== file.path); this.plugin.persistRecentFiles(); this.redraw(); }); setTooltip(navFile, file.path); if (openFile && file.path === openFile.path) { navFileTitle.addClass('is-active'); } // Drag navFileTitle.setAttr('draggable', 'true'); navFileTitle.addEventListener('dragstart', (event: DragEvent) => { const tfile = this.app.metadataCache.getFirstLinkpathDest(file.path, ''); if (tfile) { (this.app as any).dragManager.dragFile(event, tfile); (this.app as any).dragManager.onDragStart(event, (this.app as any).dragManager.dragFile(event, tfile)); } }); // Hover preview navFileTitle.addEventListener('mouseover', (event: MouseEvent) => { this.app.workspace.trigger('hover-link', { event, source: WAYPOINT_VIEW_TYPE, hoverParent: rootEl, targetEl: navFile, linktext: file.path, }); }); // Right-click navFileTitle.addEventListener('contextmenu', (event: MouseEvent) => { const menu = new Menu(); menu.addItem((item) => item .setSection('action') .setTitle('Open in new tab') .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'); } menu.showAtPosition({ x: event.clientX, y: event.clientY }); }); // Left click navFileTitle.addEventListener('click', (event: MouseEvent) => { const newLeaf = Keymap.isModEvent(event); this.focusFile(file, newLeaf); }); // Middle click navFileTitle.addEventListener('mousedown', (event: MouseEvent) => { if (event.button === 1) { event.preventDefault(); this.focusFile(file, 'tab'); } }); } } 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); leaf.openFile(targetFile); } else { new Notice('Cannot find file'); this.plugin.recentFiles = this.plugin.recentFiles.filter(f => f.path !== file.path); this.plugin.persistRecentFiles(); this.redraw(); } } // ════════════════════════════════════════ // Favorites panel // ════════════════════════════════════════ private renderFavorites(): void { const section = this.contentEl.createDiv({ cls: 'waypoint-section waypoint-favorites' }); const headerEl = section.createDiv({ cls: 'waypoint-section-header' }); headerEl.setText('Waypoint Bookmarks'); // ⋯ 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 .setTitle('Add current file') .setIcon('file-plus') .onClick(() => { const file = this.app.workspace.getActiveFile(); if (!file) return; 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', ''); }); }); menu.addSeparator(); menu.addItem((item) => { item .setTitle('Add separator') .setIcon('minus') .onClick(() => { this.plugin.addBookmark('', '', 'separator'); }); }); menu.addItem((item) => { item .setTitle('Add spacer') .setIcon('space') .onClick(() => { this.plugin.addBookmark('', '', 'spacer'); }); }); menu.showAtPosition({ x: event.clientX, y: event.clientY }); }); if (this.plugin.waypointData.bookmarks.length === 0) { section.createDiv({ cls: 'waypoint-bookmark-item', text: 'No bookmarks' }); return; } this.renderBookmarkList(section, this.plugin.waypointData.bookmarks, 0); } private renderBookmarkList(container: HTMLElement, items: BookmarkItem[], depth: number): void { for (let idx = 0; idx < items.length; idx++) { const item = items[idx]; if (item.type === 'separator') { const rowEl = container.createDiv({ cls: 'waypoint-bookmark-item waypoint-bookmark-separator', }); rowEl.setAttr('draggable', 'true'); rowEl.setAttr('data-bm-id', item.id); rowEl.style.paddingLeft = `${8 + depth * 16}px`; rowEl.style.cursor = 'grab'; // Drag events rowEl.addEventListener('dragstart', (e) => { this.dragId = item.id; e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', item.id); rowEl.addClass('waypoint-bm-dragging'); }); rowEl.addEventListener('dragend', () => { this.dragId = null; container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => { el.removeClass('waypoint-bm-dragging'); el.removeClass('waypoint-bm-drop-line'); el.removeClass('waypoint-bm-drop-below'); }); }); rowEl.addEventListener('dragenter', (e) => { e.preventDefault(); if (!this.dragId || this.dragId === item.id) return; this.showDropIndicator(rowEl, e, false); }); rowEl.addEventListener('dragover', (e) => { e.preventDefault(); if (!this.dragId || this.dragId === item.id) return; this.showDropIndicator(rowEl, e, false); }); rowEl.addEventListener('dragleave', () => { rowEl.removeClass('waypoint-bm-drop-line'); rowEl.removeClass('waypoint-bm-drop-below'); rowEl.removeClass('waypoint-bm-drop-into'); }); rowEl.addEventListener('drop', (e) => { e.preventDefault(); this.dragId = null; rowEl.removeClass('waypoint-bm-drop-line'); rowEl.removeClass('waypoint-bm-drop-below'); const draggedId = e.dataTransfer?.getData('text/plain'); if (!draggedId || draggedId === item.id) return; const dropAbove = (rowEl as any).__dropAbove; this.moveBookmarkToPosition(draggedId, item.id, dropAbove); }); // Context menu rowEl.addEventListener('contextmenu', (event: MouseEvent) => { event.stopPropagation(); event.preventDefault(); this.showBookmarkContextMenu(event, item); }); continue; } if (item.type === 'spacer') { const rowEl = container.createDiv({ cls: 'waypoint-bookmark-item waypoint-bookmark-spacer', }); rowEl.setAttr('draggable', 'true'); rowEl.setAttr('data-bm-id', item.id); rowEl.style.paddingLeft = `${8 + depth * 16}px`; rowEl.style.cursor = 'grab'; // Drag events rowEl.addEventListener('dragstart', (e) => { this.dragId = item.id; e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', item.id); rowEl.addClass('waypoint-bm-dragging'); }); rowEl.addEventListener('dragend', () => { this.dragId = null; container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => { el.removeClass('waypoint-bm-dragging'); el.removeClass('waypoint-bm-drop-line'); el.removeClass('waypoint-bm-drop-below'); }); }); rowEl.addEventListener('dragenter', (e) => { e.preventDefault(); if (!this.dragId || this.dragId === item.id) return; this.showDropIndicator(rowEl, e, false); }); rowEl.addEventListener('dragover', (e) => { e.preventDefault(); if (!this.dragId || this.dragId === item.id) return; this.showDropIndicator(rowEl, e, false); }); rowEl.addEventListener('dragleave', () => { rowEl.removeClass('waypoint-bm-drop-line'); rowEl.removeClass('waypoint-bm-drop-below'); rowEl.removeClass('waypoint-bm-drop-into'); }); rowEl.addEventListener('drop', (e) => { e.preventDefault(); this.dragId = null; rowEl.removeClass('waypoint-bm-drop-line'); rowEl.removeClass('waypoint-bm-drop-below'); const draggedId = e.dataTransfer?.getData('text/plain'); if (!draggedId || draggedId === item.id) return; const dropAbove = (rowEl as any).__dropAbove; this.moveBookmarkToPosition(draggedId, item.id, dropAbove); }); // Context menu rowEl.addEventListener('contextmenu', (event: MouseEvent) => { event.stopPropagation(); event.preventDefault(); this.showBookmarkContextMenu(event, item); }); continue; } const isGroup = item.type === 'group'; const rowEl = container.createDiv({ cls: `waypoint-bookmark-item${isGroup ? ' waypoint-bookmark-group' : ''}${item.collapsed ? ' collapsed' : ''}`, }); rowEl.setAttr('draggable', 'true'); rowEl.setAttr('data-bm-id', item.id); if (!isGroup) { rowEl.style.paddingLeft = `${8 + depth * 16}px`; } // ── Drag events ── rowEl.addEventListener('dragstart', (e) => { this.dragId = item.id; e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', item.id); rowEl.addClass('waypoint-bm-dragging'); }); const canAcceptChildren = true; // all file/group bookmarks can accept drops rowEl.addEventListener('dragend', () => { this.dragId = null; container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => { el.removeClass('waypoint-bm-dragging'); el.removeClass('waypoint-bm-drop-line'); el.removeClass('waypoint-bm-drop-below'); el.removeClass('waypoint-bm-drop-into'); }); }); rowEl.addEventListener('dragenter', (e) => { e.preventDefault(); if (!this.dragId || this.dragId === item.id) return; this.showDropIndicator(rowEl, e, canAcceptChildren); }); rowEl.addEventListener('dragover', (e) => { e.preventDefault(); if (!this.dragId || this.dragId === item.id) return; this.showDropIndicator(rowEl, e, canAcceptChildren); }); rowEl.addEventListener('dragleave', () => { rowEl.removeClass('waypoint-bm-drop-line'); rowEl.removeClass('waypoint-bm-drop-below'); rowEl.removeClass('waypoint-bm-drop-into'); }); rowEl.addEventListener('drop', (e) => { e.preventDefault(); this.dragId = null; rowEl.removeClass('waypoint-bm-drop-line'); rowEl.removeClass('waypoint-bm-drop-below'); rowEl.removeClass('waypoint-bm-drop-into'); const draggedId = e.dataTransfer?.getData('text/plain'); if (!draggedId || draggedId === item.id) return; const dropInto = (rowEl as any).__dropInto; if (dropInto && canAcceptChildren) { if (isGroup) { this.moveBookmarkToGroup(draggedId, item.id); } else { this.createParentNoteAndMove(draggedId, item.id); } } else { const dropAbove = (rowEl as any).__dropAbove; this.moveBookmarkToPosition(draggedId, item.id, dropAbove); } }); // ── Group: chevron + icon + label ── if (isGroup) { const iconEl = rowEl.createDiv({ cls: 'waypoint-bm-icon' }); if (item.icon) setIcon(iconEl, item.icon); const labelEl = rowEl.createDiv({ cls: 'waypoint-bm-label', text: item.label }); 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' : ''}`, }); if (item.children && item.children.length > 0) { this.renderBookmarkList(childrenContainer, item.children, depth + 1); } // ── File: icon + label ── } else { const iconEl = rowEl.createDiv({ cls: 'waypoint-bm-icon' }); 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', (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); } 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) ── rowEl.addEventListener('contextmenu', (event: MouseEvent) => { event.stopPropagation(); event.preventDefault(); this.showBookmarkContextMenu(event, item); }); } } 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 .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(); 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)), ); menu.addSeparator(); 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(); } 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)), ); menu.addSeparator(); menu.addItem((i) => i .setTitle('Add child bookmark') .setIcon('file-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: 'file', label: file.basename, filePath: file.path, 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, }; item.children.push(child); this.plugin.saveWaypointData(); this.redraw(); }), ); menu.addItem((i) => i .setTitle('New sub-group') .setIcon('folder-plus') .onClick(() => { const child: BookmarkItem = { id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, type: 'group', label: 'New Group', filePath: '', icon: '', children: [], collapsed: false, indent: item.indent + 1, }; item.children.push(child); this.plugin.saveWaypointData(); this.redraw(); }), ); 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 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, .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 y = e.clientY; 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; } } private moveBookmarkToGroup(itemId: string, targetGroupId: string | null): void { const removeRecursive = (items: BookmarkItem[]): BookmarkItem | null => { const idx = items.findIndex(i => i.id === itemId); if (idx >= 0) { const [removed] = items.splice(idx, 1); return removed; } for (const item of items) { const found = removeRecursive(item.children); if (found) return found; } return null; }; 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) { if (i.id === targetGroupId) return i; const found = findGroup(i.children); if (found) return found; } return null; }; const group = findGroup(this.plugin.waypointData.bookmarks); if (group) { item.indent = group.indent + 1; group.children.push(item); } } else { item.indent = 0; this.plugin.waypointData.bookmarks.push(item); } this.plugin.saveWaypointData(); 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[] } => { const idx = items.findIndex(i => i.id === draggedId); if (idx >= 0) { const [removed] = items.splice(idx, 1); return { item: removed, parent: items }; } for (const child of items) { if (child.children) { const result = removeById(child.children); if (result.item) return result; } } return { item: null, parent: [] }; }; const { item: dragged } = removeById(this.plugin.waypointData.bookmarks); if (!dragged) return; // Find the target's parent array and index const findTargetParent = (items: BookmarkItem[]): { parent: BookmarkItem[]; idx: number } | null => { const idx = items.findIndex(i => i.id === targetId); if (idx >= 0) return { parent: items, idx }; for (const child of items) { if (child.children) { const result = findTargetParent(child.children); if (result) return result; } } return null; }; const targetInfo = findTargetParent(this.plugin.waypointData.bookmarks); if (!targetInfo) { // Fallback: push to root dragged.indent = 0; this.plugin.waypointData.bookmarks.push(dragged); } else { const insertIdx = before ? targetInfo.idx : targetInfo.idx + 1; targetInfo.parent.splice(insertIdx, 0, dragged); } this.plugin.saveWaypointData(); this.redraw(); } private promptRename(item: BookmarkItem): void { new RenameModal(this.app, item.label, (newLabel) => { if (newLabel && newLabel.trim()) { this.plugin.updateBookmark(item.id, { label: newLabel.trim() }); } }).open(); } private promptIcon(item: BookmarkItem): void { new IconSuggestModal(this.app, item.icon, (iconName) => { if (iconName) { this.plugin.updateBookmark(item.id, { icon: iconName }); } }).open(); } } // ── Rename modal ── class RenameModal extends Modal { private currentValue: string; private onSubmit: (value: string) => void; constructor(app: App, currentValue: string, onSubmit: (value: string) => void) { super(app); this.currentValue = currentValue; this.onSubmit = onSubmit; } onOpen(): void { this.titleEl.setText('Rename bookmark'); const input = this.contentEl.createEl('input', { type: 'text', value: this.currentValue, }); input.style.width = '100%'; input.style.marginBottom = '12px'; input.focus(); input.select(); const btnContainer = this.contentEl.createDiv({ cls: 'modal-button-container' }); const cancelBtn = btnContainer.createEl('button', { text: 'Cancel', cls: 'mod-cta' }); cancelBtn.style.marginRight = '8px'; cancelBtn.addEventListener('click', () => this.close()); const saveBtn = btnContainer.createEl('button', { text: 'Save', cls: 'mod-cta' }); saveBtn.addEventListener('click', () => { this.onSubmit(input.value); this.close(); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { this.onSubmit(input.value); this.close(); } }); } onClose(): void { this.contentEl.empty(); } } // ── Icon picker modal (full Lucide icon set, grid layout) ── class IconSuggestModal extends Modal { private onSubmit: (icon: string) => void; private selected: string; private allIcons: string[] = []; private tagsMap: Record = {}; private loaded = false; constructor(app: App, currentIcon: string, onSubmit: (icon: string) => void) { super(app); this.selected = currentIcon; this.onSubmit = onSubmit; } async onOpen(): Promise { const modal = this.contentEl; modal.style.display = 'flex'; modal.style.flexDirection = 'column'; modal.style.gap = '10px'; this.titleEl.setText('Change icon'); // ── Preview ── const preview = modal.createDiv({ cls: 'waypoint-icon-preview' }); preview.style.display = 'flex'; preview.style.alignItems = 'center'; preview.style.gap = '10px'; preview.style.padding = '12px 16px'; preview.style.borderRadius = '8px'; preview.style.background = 'var(--background-secondary)'; preview.style.minHeight = '48px'; const previewIcon = preview.createSpan(); previewIcon.style.display = 'flex'; if (this.selected) setIcon(previewIcon, this.selected); const previewLabel = preview.createSpan(); previewLabel.style.fontWeight = 'var(--font-medium)'; previewLabel.style.fontSize = 'var(--font-ui-medium)'; previewLabel.setText(this.selected || 'No icon'); // ── Search ── const input = modal.createEl('input', { type: 'text', placeholder: 'Type to search (e.g. arrow, chart, home)...', }); Object.assign(input.style, { width: '100%', boxSizing: 'border-box', padding: '8px 10px', borderRadius: '6px', border: '1px solid var(--background-modifier-border)', background: 'var(--background-primary)', color: 'var(--text-normal)', fontSize: 'var(--font-ui-medium)', }); input.focus(); // ── Icon grid ── const grid = modal.createDiv({ cls: 'waypoint-icon-grid' }); grid.style.display = 'grid'; grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(52px, 1fr))'; grid.style.gap = '4px'; grid.style.maxHeight = '320px'; grid.style.overflowY = 'auto'; grid.style.padding = '2px 0'; const statusRow = modal.createDiv(); statusRow.style.display = 'flex'; statusRow.style.justifyContent = 'space-between'; statusRow.style.alignItems = 'center'; statusRow.style.fontSize = 'var(--font-ui-smaller)'; statusRow.style.color = 'var(--text-muted)'; statusRow.style.padding = '0 4px'; const statusEl = statusRow.createSpan(); statusEl.setText('Loading\u2026'); const clearEl = statusRow.createSpan(); clearEl.style.cursor = 'var(--cursor)'; clearEl.style.color = 'var(--text-accent)'; clearEl.setText('No icon'); clearEl.addEventListener('click', () => { this.onSubmit(''); this.close(); }); // ── Load icons ── this.loadIcons().then(() => { this.loaded = true; statusEl.setText(this.allIcons.length + ' icons'); renderGrid(input.value); }); // ── Render grid ── let debounce: any = null; const renderGrid = (query: string) => { grid.empty(); if (!this.loaded) { grid.createDiv({ text: 'Loading\u2026' }); return; } const q = query.toLowerCase().trim(); const matches = q ? 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) { const empty = grid.createDiv(); empty.style.gridColumn = '1 / -1'; empty.style.textAlign = 'center'; empty.style.color = 'var(--text-muted)'; empty.style.padding = '20px'; empty.setText('No icons match "' + query + '"'); return; } for (var i = 0; i < matches.length; i++) { var name = matches[i]; var tile = grid.createDiv(); tile.setAttr('data-icon', name); tile.style.display = 'flex'; tile.style.alignItems = 'center'; tile.style.justifyContent = 'center'; tile.style.aspectRatio = '1'; tile.style.borderRadius = '6px'; tile.style.cursor = 'var(--cursor)'; tile.style.transition = 'background 80ms'; tile.setAttr('title', name); if (name === this.selected) { tile.style.background = 'var(--interactive-accent)'; tile.style.color = 'var(--text-on-accent)'; } else { tile.style.color = 'var(--text-muted)'; } var svg = tile.createSpan(); svg.style.display = 'flex'; setIcon(svg, name); ;(function(_self, _tile, _name, _query, _grid, _previewIcon, _previewLabel) { _tile.addEventListener('mouseenter', function() { if (_name !== _self.selected) _tile.style.background = 'var(--background-modifier-hover)'; }); _tile.addEventListener('mouseleave', function() { if (_name !== _self.selected) _tile.style.background = ''; }); _tile.addEventListener('click', function() { _self.selected = _name; renderGrid(_query); _previewIcon.empty(); setIcon(_previewIcon, _name); _previewLabel.setText(_name); _grid.querySelectorAll('div[data-icon]').forEach(function(_el) { var el = _el as HTMLElement; if (el.getAttr('data-icon') === _name) { el.style.background = 'var(--interactive-accent)'; el.style.color = 'var(--text-on-accent)'; } else { el.style.background = ''; el.style.color = 'var(--text-muted)'; } }); }); })(this, tile, name, query, grid, previewIcon, previewLabel); } statusEl.setText(matches.length + ' of ' + this.allIcons.length + ' icons'); }; input.addEventListener('input', function() { if (debounce !== null) clearTimeout(debounce); debounce = setTimeout(function() { renderGrid(input.value); }, 60); }); input.addEventListener('keydown', function(e: KeyboardEvent) { if (e.key === 'Escape') this.close(); }.bind(this)); // ── Buttons ── var btns = modal.createDiv({ cls: 'modal-button-container' }); var cancel = btns.createEl('button', { text: 'Cancel' }); cancel.addEventListener('click', function() { this.close(); }.bind(this)); var saveBtn = btns.createEl('button', { text: 'Save', cls: 'mod-cta' }); saveBtn.style.marginLeft = '8px'; saveBtn.addEventListener('click', function() { this.onSubmit(this.selected); this.close(); }.bind(this)); } onClose(): void { this.contentEl.empty(); } private async loadIcons(): Promise { 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; 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; this.allIcons = Object.keys(d2).sort(); } catch (_e2) { this.tagsMap = {}; this.allIcons = Object.keys(FALLBACK_ICONS).sort(); } } } } const FALLBACK_ICONS: Record = { 'file':[], 'folder':[], 'star':[], 'heart':[], 'bookmark':[], 'flag':[], 'pin':[], 'tag':[], 'book':[], 'book-open':[], 'library':[], 'calendar':[], 'calendar-days':[], 'clock':[], 'home':[], 'inbox':[], 'mail':[], 'search':[], 'settings':[], 'cog':[], 'user':[], 'users':[], 'zap':[], 'sparkles':[], 'target':[], 'link':[], 'globe':[], 'edit':[], 'pencil':[], 'anchor':[], 'award':[], 'bell':[], 'bell-ring':[], 'brain':[], 'briefcase':[], 'camera':[], 'chart-bar':[], 'chart-line':[], 'chart-pie':[], 'check':[], 'check-circle':[], 'chevron-down':[], 'chevron-left':[], 'chevron-right':[], 'chevron-up':[], 'circle':[], 'clipboard':[], 'code':[], 'command':[], 'compass':[], 'copy':[], 'credit-card':[], 'crown':[], 'database':[], 'download':[], 'external-link':[], 'eye':[], 'eye-off':[], 'file-text':[], 'filter':[], 'fingerprint':[], 'flashlight':[], 'folder-open':[], 'folder-plus':[], 'gift':[], 'git-branch':[], 'git-commit':[], 'git-merge':[], 'git-pull-request':[], 'github':[], 'grid':[], 'hash':[], 'headphones':[], 'image':[], 'info':[], 'key':[], 'layers':[], 'layout':[], 'life-buoy':[], 'link-2':[], 'list':[], 'loader':[], 'lock':[], 'log-in':[], 'log-out':[], 'map':[], 'map-pin':[], 'maximize':[], 'megaphone':[], 'menu':[], 'message-circle':[], 'message-square':[], 'mic':[], 'minimize':[], 'moon':[], 'more-horizontal':[], 'more-vertical':[], 'mouse-pointer':[], 'move':[], 'music':[], 'navigation':[], 'navigation-2':[], 'package':[], 'palette':[], 'paperclip':[], 'pause':[], 'phone':[], 'play':[], 'plus':[], 'plus-circle':[], 'power':[], 'printer':[], 'radio':[], 'refresh-cw':[], 'repeat':[], 'rotate-ccw':[], 'rotate-cw':[], 'rss':[], 'save':[], 'scissors':[], 'screen':[], 'send':[], 'server':[], 'share':[], 'share-2':[], 'shield':[], 'shield-off':[], 'shopping-bag':[], 'shopping-cart':[], 'shuffle':[], 'sidebar':[], 'slack':[], 'slash':[], 'sliders':[], 'smartphone':[], 'smile':[], 'speaker':[], 'square':[], 'stop-circle':[], 'sun':[], 'sunrise':[], 'sunset':[], 'swords':[], 'table':[], 'tablet':[], 'terminal':[], 'thermometer':[], 'thumbs-down':[], 'thumbs-up':[], 'toggle-left':[], 'toggle-right':[], 'tool':[], 'trash':[], 'trash-2':[], 'trello':[], 'trending-down':[], 'trending-up':[], 'triangle':[], 'truck':[], 'tv':[], 'twitter':[], 'type':[], 'umbrella':[], 'unlock':[], 'upload':[], 'user-check':[], 'user-minus':[], 'user-plus':[], 'user-x':[], 'video':[], 'video-off':[], 'voicemail':[], 'volume':[], 'volume-1':[], 'volume-2':[], 'volume-x':[], 'watch':[], 'wifi':[], 'wifi-off':[], 'wind':[], 'x':[], 'x-circle':[], 'x-square':[], 'youtube':[], 'zap-off':[], 'zoom-in':[], 'zoom-out':[], 'arrow-down':[], 'arrow-left':[], 'arrow-right':[], 'arrow-up':[], 'airplay':[], 'alarm-clock':[], 'archive':[], 'armchair':[], 'atom':[], 'baby':[], 'backpack':[], 'badge':[], 'badge-check':[], 'ban':[], 'banknote':[], 'barcode':[], 'bath':[], 'battery':[], 'battery-charging':[], 'beer':[], 'bike':[], 'bird':[], 'bluetooth':[], 'bolt':[], 'bone':[], 'bookmark-plus':[], 'bot':[], 'box':[], 'bug':[], 'building':[], 'bus':[], 'cake':[], 'calculator':[], 'car':[], 'clipboard-check':[], 'cloud':[], 'cloud-download':[], 'cloud-lightning':[], 'cloud-rain':[], 'cloud-sun':[], 'cloud-upload':[], 'clover':[], 'coffee':[], 'coins':[], 'contact':[], 'cookie':[], 'corner-down-left':[], 'corner-down-right':[], 'corner-up-left':[], 'corner-up-right':[], 'crosshair':[], 'dice-1':[], 'dice-6':[], 'dollar-sign':[], 'door-open':[], 'drama':[], 'droplet':[], 'drum':[], 'egg':[], 'equal':[], 'euro':[], 'factory':[], 'fan':[], 'feather':[], 'film':[], 'fish':[], 'flame':[], 'flask':[], 'flower':[], 'frown':[], 'fuel':[], 'gamepad':[], 'gauge':[], 'gem':[], 'ghost':[], 'glasses':[], 'graduation-cap':[], 'hammer':[], 'hard-drive':[], 'haze':[], 'help-circle':[], 'ice-cream':[], 'infinity':[], 'italic':[], 'japanese-yen':[], 'keyboard':[], 'knife':[], 'lamp':[], 'landmark':[], 'languages':[], 'laptop':[], 'laugh':[], 'leaf':[], 'lightbulb':[], 'list-plus':[], 'magnet':[], 'mail-plus':[], 'meh':[], 'microscope':[], 'milestone':[], 'minimize-2':[], 'monitor':[], 'mountain':[], 'mouse':[], 'network':[], 'newspaper':[], 'package-check':[], 'package-search':[], 'paint-bucket':[], 'parking':[], 'party-popper':[], 'pen-tool':[], 'percent':[], 'person-standing':[], 'picture-in-picture-2':[], 'plane':[], 'plug':[], 'podcast':[], 'pointer':[], 'pound-sterling':[], 'puzzle':[], 'qr-code':[], 'rabbit':[], 'radar':[], 'rainbow':[], 'rocket':[], 'roller-coaster':[], 'route':[], 'ruler':[], 'sailboat':[], 'scale':[], 'scan':[], 'school':[], 'ship':[], 'shirt':[], 'shopping-basket':[], 'shovel':[], 'sigma':[], 'siren':[], 'skull':[], 'snowflake':[], 'soup':[], 'space':[], 'sparkle':[], 'stamp':[], 'store':[], 'subscript':[], 'superscript':[], 'syringe':[], 'tent':[], 'tent-tree':[], 'test-tube':[], 'theater':[], 'timer':[], 'train':[], 'tree-deciduous':[], 'tree-pine':[], 'trophy':[], 'typing':[], 'utensils':[], 'vibrate':[], 'wallet':[], 'wand':[], 'warehouse':[], 'waves':[], 'webcam':[], 'wheat':[], 'wine':[], 'wrench':[], };