fix: week-number middle-click crash and 'No icon' never clearing

- Middle-clicking a calendar week number threw ReferenceError: monday is not
  defined (shipped in v1.5.1) — the variable was declared inside the click
  closure and read from the sibling mousedown handler. Hoist it to weekStart.
- promptIcon guarded on if (iconName), so the picker's 'No icon' link
  submitting '' was silently dropped and the old icon stayed. '' is a valid
  value; always apply it.
- renderCalendar scanned vault.getFiles() per day cell (up to 42 full vault
  scans per render); use the O(1) plugin.hasNoteForDate().
- Collapse ~180 lines of triplicated drag listeners into one
  attachBookmarkDragHandlers(); replace (el as any).__dropAbove/__dropInto
  stashing with a typed WeakMap; drop the getLeaf(x as any) casts.
- dragManager.dragFile was called twice per dragstart; call it once behind a
  single narrowly-typed accessor.
- Cache the Lucide catalog per session instead of refetching the CDN on every
  picker open; normalize IconSuggestModal to the file's const/arrow style.
This commit is contained in:
2026-09-07 20:03:45 -04:00
parent 6a1a044566
commit dc9e4c3200
+163 -214
View File
@@ -12,6 +12,7 @@ import {
Notice,
TFile,
moment,
type PaneType,
} from 'obsidian';
import type WaypointPlugin from 'src/main';
import { getMonthGrid } from 'src/utils/date-utils';
@@ -19,6 +20,18 @@ import { BookmarkItem } from 'src/models/bookmark';
export const WAYPOINT_VIEW_TYPE = 'waypoint-view';
/** Obsidian's internal drag manager — undocumented, so it has no public typings. */
interface DragManager {
dragFile(event: DragEvent, file: TFile): unknown;
onDragStart(event: DragEvent, draggable: unknown): void;
}
function getDragManager(app: App): DragManager {
// `dragManager` is an internal Obsidian API, absent from the public typings.
const internal = app as unknown as { dragManager: DragManager };
return internal.dragManager;
}
export class WaypointView extends ItemView {
private plugin: WaypointPlugin;
@@ -72,6 +85,7 @@ export class WaypointView extends ItemView {
private currentDisplayMonth: number = moment().month(); // 0-indexed
private currentDisplayYear: number = moment().year();
private dragId: string | null = null;
private dropZones = new WeakMap<HTMLElement, { above: boolean; into: boolean }>();
private recentFilesFilter: string | null = null; // null = show all, else filter by type
private renderCalendar(): void {
@@ -97,7 +111,7 @@ export class WaypointView extends ItemView {
qEl.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
this.plugin.openPeriodNoteInLeaf('quarter', displayDate, this.app.workspace.getLeaf('tab'));
this.plugin.openPeriodNote('quarter', displayDate, this.app.workspace.getLeaf('tab'));
}
});
@@ -109,7 +123,7 @@ export class WaypointView extends ItemView {
mEl.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
this.plugin.openPeriodNoteInLeaf('month', displayDate, this.app.workspace.getLeaf('tab'));
this.plugin.openPeriodNote('month', displayDate, this.app.workspace.getLeaf('tab'));
}
});
@@ -121,7 +135,7 @@ export class WaypointView extends ItemView {
yEl.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
this.plugin.openPeriodNoteInLeaf('year', displayDate, this.app.workspace.getLeaf('tab'));
this.plugin.openPeriodNote('year', displayDate, this.app.workspace.getLeaf('tab'));
}
});
@@ -172,15 +186,14 @@ export class WaypointView extends ItemView {
// Week number cell
const wnCell = row.createEl('td', { cls: 'waypoint-weeknum' });
wnCell.setText(String(week.weekNumber));
const weekStart = week.days[0].date;
wnCell.addEventListener('click', () => {
const monday = week.days[0].date;
this.plugin.openPeriodNote('week', monday);
this.plugin.openPeriodNote('week', weekStart);
});
wnCell.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
const file = this.app.workspace.getLeaf('tab');
this.plugin.openPeriodNoteInLeaf('week', monday, file);
this.plugin.openPeriodNote('week', weekStart, this.app.workspace.getLeaf('tab'));
}
});
@@ -197,10 +210,7 @@ export class WaypointView extends ItemView {
if (this.plugin.settings.calendar.showNoteIndicators) {
const dateStr = day.date.format('YYYY-MM-DD');
const hasNote = this.plugin.app.vault.getFiles().some(
f => f.extension === 'md' && f.basename === dateStr,
);
if (hasNote) {
if (this.plugin.hasNoteForDate(dateStr)) {
cell.addClass('has-note');
}
}
@@ -211,8 +221,7 @@ export class WaypointView extends ItemView {
cell.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
const file = this.app.workspace.getLeaf('tab');
this.plugin.openPeriodNoteInLeaf('day', day.date, file);
this.plugin.openPeriodNote('day', day.date, this.app.workspace.getLeaf('tab'));
}
});
}
@@ -284,7 +293,7 @@ export class WaypointView extends ItemView {
// Type pills
const sortedTypes = configuredTags.length > 0
? Object.entries(typeCounts) // preserve configured order
: Object.entries(typeCounts).sort(([,a], [,b]) => b - a); // sort by count
: Object.entries(typeCounts).sort((a, b) => b[1] - a[1]); // sort by count
for (const [type, count] of sortedTypes) {
const pill = filterBar.createSpan({ cls: `waypoint-recent-pill${this.recentFilesFilter === type ? ' is-active' : ''}` });
pill.setText(`${type} ${count}`);
@@ -339,8 +348,9 @@ export class WaypointView extends ItemView {
navFileTitle.addEventListener('dragstart', (event: DragEvent) => {
const tfile = this.app.metadataCache.getFirstLinkpathDest(file.path, '');
if (tfile) {
(this.app as any).dragManager.dragFile(event, tfile);
(this.app as any).dragManager.onDragStart(event, (this.app as any).dragManager.dragFile(event, tfile));
const dragManager = getDragManager(this.app);
const draggable = dragManager.dragFile(event, tfile);
dragManager.onDragStart(event, draggable);
}
});
@@ -398,10 +408,10 @@ export class WaypointView extends ItemView {
}
}
private focusFile(file: { path: string; basename: string }, newLeaf: boolean | string | 'split'): void {
private focusFile(file: { path: string; basename: string }, newLeaf: PaneType | boolean): void {
const targetFile = this.app.vault.getFiles().find(f => f.path === file.path);
if (targetFile) {
const leaf = this.app.workspace.getLeaf(newLeaf as any);
const leaf = this.app.workspace.getLeaf(newLeaf);
leaf.openFile(targetFile);
} else {
new Notice('Cannot find file');
@@ -499,45 +509,7 @@ export class WaypointView extends ItemView {
rowEl.style.cursor = 'grab';
// Drag events
rowEl.addEventListener('dragstart', (e) => {
this.dragId = item.id;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', item.id);
rowEl.addClass('waypoint-bm-dragging');
});
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
el.removeClass('waypoint-bm-dragging');
el.removeClass('waypoint-bm-drop-line');
el.removeClass('waypoint-bm-drop-below');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
this.dragId = null;
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const dropAbove = (rowEl as any).__dropAbove;
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
});
this.attachBookmarkDragHandlers(rowEl, container, item, false);
// Context menu
rowEl.addEventListener('contextmenu', (event: MouseEvent) => {
@@ -558,45 +530,7 @@ export class WaypointView extends ItemView {
rowEl.style.cursor = 'grab';
// Drag events
rowEl.addEventListener('dragstart', (e) => {
this.dragId = item.id;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', item.id);
rowEl.addClass('waypoint-bm-dragging');
});
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
el.removeClass('waypoint-bm-dragging');
el.removeClass('waypoint-bm-drop-line');
el.removeClass('waypoint-bm-drop-below');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
this.dragId = null;
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const dropAbove = (rowEl as any).__dropAbove;
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
});
this.attachBookmarkDragHandlers(rowEl, container, item, false);
// Context menu
rowEl.addEventListener('contextmenu', (event: MouseEvent) => {
@@ -619,65 +553,7 @@ export class WaypointView extends ItemView {
}
// ── Drag events ──
rowEl.addEventListener('dragstart', (e) => {
this.dragId = item.id;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', item.id);
rowEl.addClass('waypoint-bm-dragging');
});
const canAcceptChildren = true; // all file/group bookmarks can accept drops
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
el.removeClass('waypoint-bm-dragging');
el.removeClass('waypoint-bm-drop-line');
el.removeClass('waypoint-bm-drop-below');
el.removeClass('waypoint-bm-drop-into');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
this.dragId = null;
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const dropInto = (rowEl as any).__dropInto;
if (dropInto && canAcceptChildren) {
if (isGroup) {
this.moveBookmarkToGroup(draggedId, item.id);
} else {
this.createParentNoteAndMove(draggedId, item.id);
}
} else {
const dropAbove = (rowEl as any).__dropAbove;
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
}
});
this.attachBookmarkDragHandlers(rowEl, container, item, true);
// ── Group: chevron + icon + label ──
if (isGroup) {
@@ -699,7 +575,7 @@ export class WaypointView extends ItemView {
const tfile = this.app.vault.getFileByPath(item.filePath);
if (tfile) {
const newLeaf = Keymap.isModEvent(event);
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
this.app.workspace.getLeaf(newLeaf).openFile(tfile);
return;
}
}
@@ -751,7 +627,7 @@ export class WaypointView extends ItemView {
const tfile = this.app.vault.getFileByPath(item.filePath);
if (tfile) {
const newLeaf = Keymap.isModEvent(event);
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
this.app.workspace.getLeaf(newLeaf).openFile(tfile);
} else {
new Notice('File not found');
this.plugin.removeBookmark(item.id);
@@ -935,6 +811,75 @@ export class WaypointView extends ItemView {
menu.showAtPosition({ x: event.clientX, y: event.clientY });
}
/**
* Attach the shared bookmark drag-and-drop listeners to a row element.
* `canAcceptChildren` selects 3-zone (above/into/below) drop targeting for
* file and group rows; separators and spacers use 2-zone (above/below).
*/
private attachBookmarkDragHandlers(
rowEl: HTMLElement,
container: HTMLElement,
item: BookmarkItem,
canAcceptChildren: boolean,
): void {
const clearIndicators = (): void => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
};
rowEl.addEventListener('dragstart', (e) => {
this.dragId = item.id;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', item.id);
rowEl.addClass('waypoint-bm-dragging');
});
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
el.removeClass('waypoint-bm-dragging');
el.removeClass('waypoint-bm-drop-line');
el.removeClass('waypoint-bm-drop-below');
el.removeClass('waypoint-bm-drop-into');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragleave', clearIndicators);
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
this.dragId = null;
clearIndicators();
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const zone = this.dropZones.get(rowEl);
if (canAcceptChildren && zone?.into) {
if (item.type === 'group') {
this.moveBookmarkToGroup(draggedId, item.id);
} else {
this.createParentNoteAndMove(draggedId, item.id);
}
} else {
this.moveBookmarkToPosition(draggedId, item.id, zone?.above ?? false);
}
});
}
private showDropIndicator(el: HTMLElement, e: MouseEvent, isGroupLike: boolean): void {
// Clear all indicators
const parent = el.parentElement;
@@ -956,25 +901,21 @@ export class WaypointView extends ItemView {
if (y < topThreshold) {
el.addClass('waypoint-bm-drop-line');
(el as any).__dropAbove = true;
(el as any).__dropInto = false;
this.dropZones.set(el, { above: true, into: false });
} else if (y > bottomThreshold) {
el.addClass('waypoint-bm-drop-line');
el.addClass('waypoint-bm-drop-below');
(el as any).__dropAbove = false;
(el as any).__dropInto = false;
this.dropZones.set(el, { above: false, into: false });
} else {
el.addClass('waypoint-bm-drop-into');
(el as any).__dropAbove = false;
(el as any).__dropInto = true;
this.dropZones.set(el, { above: false, into: true });
}
} else {
// 2-zone: top half = above, bottom half = below
const above = y < rect.top + rect.height / 2;
el.addClass('waypoint-bm-drop-line');
if (!above) el.addClass('waypoint-bm-drop-below');
(el as any).__dropAbove = above;
(el as any).__dropInto = false;
this.dropZones.set(el, { above, into: false });
}
}
@@ -1130,9 +1071,8 @@ export class WaypointView extends ItemView {
private promptIcon(item: BookmarkItem): void {
new IconSuggestModal(this.app, item.icon, (iconName) => {
if (iconName) {
// An empty string is a valid value meaning "no icon", so always apply.
this.plugin.updateBookmark(item.id, { icon: iconName });
}
}).open();
}
}
@@ -1188,6 +1128,31 @@ class RenameModal extends Modal {
// ── Icon picker modal (full Lucide icon set, grid layout) ──
// Lucide icon metadata, fetched at most once per session and shared by every
// picker. Concurrent opens await the same in-flight promise; a failed fetch is
// not cached, so a later open retries once the network is back.
let iconCatalog: Promise<Record<string, string[]>> | null = null;
function loadIconCatalog(): Promise<Record<string, string[]>> {
if (!iconCatalog) {
iconCatalog = (async () => {
try {
const res = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
return (await res.json()) as Record<string, string[]>;
} catch {
try {
const res = await fetch('https://lucide.dev/api/tags');
return (await res.json()) as Record<string, string[]>;
} catch {
iconCatalog = null;
return FALLBACK_ICONS;
}
}
})();
}
return iconCatalog;
}
class IconSuggestModal extends Modal {
private onSubmit: (icon: string) => void;
private selected: string;
@@ -1280,7 +1245,7 @@ class IconSuggestModal extends Modal {
});
// ── Render grid ──
let debounce: any = null;
let debounce: number | undefined;
const renderGrid = (query: string) => {
grid.empty();
@@ -1306,9 +1271,8 @@ class IconSuggestModal extends Modal {
return;
}
for (var i = 0; i < matches.length; i++) {
var name = matches[i];
var tile = grid.createDiv();
for (const name of matches) {
const tile = grid.createDiv();
tile.setAttr('data-icon', name);
tile.style.display = 'flex';
tile.style.alignItems = 'center';
@@ -1326,27 +1290,26 @@ class IconSuggestModal extends Modal {
tile.style.color = 'var(--text-muted)';
}
var svg = tile.createSpan();
const svg = tile.createSpan();
svg.style.display = 'flex';
setIcon(svg, name);
;(function(_self, _tile, _name, _query, _grid, _previewIcon, _previewLabel) {
_tile.addEventListener('mouseenter', function() {
if (_name !== _self.selected) _tile.style.background = 'var(--background-modifier-hover)';
tile.addEventListener('mouseenter', () => {
if (name !== this.selected) tile.style.background = 'var(--background-modifier-hover)';
});
_tile.addEventListener('mouseleave', function() {
if (_name !== _self.selected) _tile.style.background = '';
tile.addEventListener('mouseleave', () => {
if (name !== this.selected) tile.style.background = '';
});
_tile.addEventListener('click', function() {
_self.selected = _name;
renderGrid(_query);
_previewIcon.empty();
setIcon(_previewIcon, _name);
_previewLabel.setText(_name);
_grid.querySelectorAll('div[data-icon]').forEach(function(_el) {
var el = _el as HTMLElement;
if (el.getAttr('data-icon') === _name) {
tile.addEventListener('click', () => {
this.selected = name;
renderGrid(query);
previewIcon.empty();
setIcon(previewIcon, name);
previewLabel.setText(name);
grid.querySelectorAll('div[data-icon]').forEach((node) => {
const el = node as HTMLElement;
if (el.getAttr('data-icon') === name) {
el.style.background = 'var(--interactive-accent)';
el.style.color = 'var(--text-on-accent)';
} else {
@@ -1355,31 +1318,30 @@ class IconSuggestModal extends Modal {
}
});
});
})(this, tile, name, query, grid, previewIcon, previewLabel);
}
statusEl.setText(matches.length + ' of ' + this.allIcons.length + ' icons');
};
input.addEventListener('input', function() {
if (debounce !== null) clearTimeout(debounce);
debounce = setTimeout(function() { renderGrid(input.value); }, 60);
input.addEventListener('input', () => {
window.clearTimeout(debounce);
debounce = window.setTimeout(() => renderGrid(input.value), 60);
});
input.addEventListener('keydown', function(e: KeyboardEvent) {
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') this.close();
}.bind(this));
});
// ── Buttons ──
var btns = modal.createDiv({ cls: 'modal-button-container' });
var cancel = btns.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', function() { this.close(); }.bind(this));
var saveBtn = btns.createEl('button', { text: 'Save', cls: 'mod-cta' });
const btns = modal.createDiv({ cls: 'modal-button-container' });
const cancel = btns.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', () => this.close());
const saveBtn = btns.createEl('button', { text: 'Save', cls: 'mod-cta' });
saveBtn.style.marginLeft = '8px';
saveBtn.addEventListener('click', function() {
saveBtn.addEventListener('click', () => {
this.onSubmit(this.selected);
this.close();
}.bind(this));
});
}
onClose(): void {
@@ -1387,22 +1349,9 @@ class IconSuggestModal extends Modal {
}
private async loadIcons(): Promise<void> {
try {
var r = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
var d = await r.json();
this.tagsMap = d as Record<string, string[]>;
this.allIcons = Object.keys(d).sort();
} catch (_e) {
try {
var r2 = await fetch('https://lucide.dev/api/tags');
var d2 = await r2.json();
this.tagsMap = d2 as Record<string, string[]>;
this.allIcons = Object.keys(d2).sort();
} catch (_e2) {
this.tagsMap = {};
this.allIcons = Object.keys(FALLBACK_ICONS).sort();
}
}
const catalog = await loadIconCatalog();
this.tagsMap = catalog;
this.allIcons = Object.keys(catalog).sort();
}
}