Files
waypoint/src/views/waypoint-view.ts
T
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

1449 lines
48 KiB
TypeScript

// ── 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<void> {
this.redraw();
}
async onClose(): Promise<void> {
// 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<string, number> = {};
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<string, string[]> = {};
private loaded = false;
constructor(app: App, currentIcon: string, onSubmit: (icon: string) => void) {
super(app);
this.selected = currentIcon;
this.onSubmit = onSubmit;
}
async onOpen(): Promise<void> {
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<void> {
try {
var r = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
var d = await r.json();
this.tagsMap = d as Record<string, string[]>;
this.allIcons = Object.keys(d).sort();
} catch (_e) {
try {
var r2 = await fetch('https://lucide.dev/api/tags');
var d2 = await r2.json();
this.tagsMap = d2 as Record<string, string[]>;
this.allIcons = Object.keys(d2).sort();
} catch (_e2) {
this.tagsMap = {};
this.allIcons = Object.keys(FALLBACK_ICONS).sort();
}
}
}
}
const FALLBACK_ICONS: Record<string, string[]> = {
'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':[],
};