philemonchiro_obsidian-note.../main.ts
Philemon Chiro b3f1cd7ea6 Preserve search focus across renders; remove all !important declarations
Fixes #2: the debounced render emptied the view container on every
keystroke and re-created the search input, so focus dropped each time
filtering finished. Capture the active-element + selection range before
emptying, then restore focus and cursor position after the new input is
mounted.

Also rewrites every !important in styles.css (36 occurrences) by
bumping selector specificity instead — chaining 'body' / 'input.' /
'textarea.' / '.modal.' to win against Obsidian's defaults cleanly.
This clears the directory scanner's '!important' findings.
2026-05-16 04:55:28 +02:00

2732 lines
90 KiB
TypeScript

import * as obsidian from 'obsidian';
import type { TFile, WorkspaceLeaf } from 'obsidian';
// ============================ TYPES ============================
type Density = 'comfortable' | 'compact' | 'list' | 'wall' | 'wall-compact';
type WallBackground =
| 'theme'
| 'cork'
| 'whiteboard'
| 'slate'
| 'paper'
| 'graph'
| 'blueprint'
| 'linen'
| 'chalkboard';
interface Wall {
id: string;
name: string;
/** Maps a file path → presence on this wall. Position values are vestigial from the freeform prototype. */
positions: Record<string, { x: number; y: number; rotation: number; z: number }>;
}
type SortMode =
| 'mtime-desc'
| 'mtime-asc'
| 'ctime-desc'
| 'ctime-asc'
| 'name-asc'
| 'name-desc';
type Filter = { type: 'notes' } | { type: 'archive' } | { type: 'tag'; tag: string };
interface KeepCardsSettings {
notesFolder: string;
previewLines: number;
density: Density;
colorAsBorder: boolean;
tagColors: Record<string, string>;
cardColors: Record<string, string | null>;
pinnedFiles: Record<string, number | false>;
archivedFiles: Record<string, boolean>;
pinnedHeader: boolean;
showImages: boolean;
autoCollapseSidebars: boolean;
sortMode: SortMode;
walls: Record<string, Wall>;
currentWallId: string | null;
wallBackground: WallBackground;
}
interface ParsedQuery {
text: string[];
tag: string[];
color: string | null;
path: string | null;
pinned: boolean | null;
archived: boolean | null;
}
interface ColorDef { name: string; value: string | null }
interface BatchState {
host: HTMLElement;
layout: ColumnLayout;
files: TFile[];
renderedIndex: number;
sentinel: (HTMLElement & { _batch?: BatchState }) | null;
}
// ============================ CONSTANTS ============================
const VIEW_TYPE_CARDS = 'keep-cards-view';
const COLORS: ColorDef[] = [
{ name: 'Default', value: null },
{ name: 'Coral', value: '#5c2b29' },
{ name: 'Peach', value: '#614a19' },
{ name: 'Sand', value: '#635d19' },
{ name: 'Mint', value: '#345920' },
{ name: 'Sage', value: '#16504b' },
{ name: 'Fog', value: '#2d555e' },
{ name: 'Storm', value: '#1e3a5f' },
{ name: 'Dusk', value: '#42275e' },
{ name: 'Blossom', value: '#5b2245' },
{ name: 'Clay', value: '#442f19' },
{ name: 'Chalk', value: '#3c3f43' },
];
const DEFAULT_SETTINGS: KeepCardsSettings = {
notesFolder: '',
previewLines: 10,
density: 'wall',
colorAsBorder: false,
tagColors: {},
cardColors: {},
pinnedFiles: {},
archivedFiles: {},
pinnedHeader: true,
showImages: true,
autoCollapseSidebars: true,
sortMode: 'mtime-desc',
walls: {},
currentWallId: null,
wallBackground: 'theme',
};
const SORT_LABELS: Record<SortMode, string> = {
'mtime-desc': 'Modified · newest',
'mtime-asc': 'Modified · oldest',
'ctime-desc': 'Created · newest',
'ctime-asc': 'Created · oldest',
'name-asc': 'Title · A → Z',
'name-desc': 'Title · Z → A',
};
// ============================ HELPERS ============================
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function hashString(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
function makeWallId(): string {
return 'w' + Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
}
function isWallDensity(d: Density): boolean {
return d === 'wall' || d === 'wall-compact';
}
function parseQuery(raw: string): ParsedQuery {
const parts = raw.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
const out: ParsedQuery = { text: [], tag: [], color: null, path: null, pinned: null, archived: null };
for (const p of parts) {
const m = /^(\w+):(.+)$/.exec(p);
if (!m) {
out.text.push(p.replace(/^"|"$/g, '').toLowerCase());
continue;
}
const k = m[1].toLowerCase();
const v = m[2].replace(/^"|"$/g, '');
if (k === 'tag') out.tag.push(v.replace(/^#/, ''));
else if (k === 'color') out.color = v;
else if (k === 'path') out.path = v.toLowerCase();
else if (k === 'pinned') out.pinned = v === 'true' || v === 'yes';
else if (k === 'archived') out.archived = v === 'true' || v === 'yes';
else if (k === 'is') {
if (v === 'archived') out.archived = true;
if (v === 'pinned') out.pinned = true;
} else out.text.push(p.toLowerCase());
}
return out;
}
function backlinksCount(app, file) {
const links = app.metadataCache.resolvedLinks || {};
let count = 0;
for (const from in links) {
if (links[from][file.path]) count++;
}
return count;
}
function findFirstImage(app, file, content) {
let m = content.match(/!\[\[([^\]|]+?)(?:\|[^\]]*)?\]\]/);
if (m) {
const dest = app.metadataCache.getFirstLinkpathDest(m[1].trim(), file.path);
if (dest && /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(dest.path)) return dest;
}
m = content.match(/!\[[^\]]*\]\(([^)]+)\)/);
if (m) {
const p = m[1].split(/\s+/)[0].replace(/^<|>$/g, '');
let dest = app.metadataCache.getFirstLinkpathDest(p, file.path);
if (!dest) dest = app.vault.getAbstractFileByPath(p);
if (dest && /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(dest.path)) return dest;
}
return null;
}
function estimateHeight(file, fm, settings) {
let h = 60;
const sizeChars = file.stat.size;
const lines = Math.min(settings.previewLines, Math.max(1, Math.ceil(sizeChars / 80)));
h += lines * 19;
if (fm && (fm.tags || fm.color)) h += 28;
if (settings.showImages && /!\[/.test('') /* placeholder */) h += 120;
if (settings.density === 'compact') h *= 0.75;
return h;
}
async function renderMarkdownInto(
app: obsidian.App,
content: string,
el: HTMLElement,
sourcePath: string,
component: obsidian.Component,
): Promise<void> {
await obsidian.MarkdownRenderer.render(app, content, el, sourcePath, component);
}
function stripFrontmatter(raw) {
const m = raw.match(/^---\n[\s\S]*?\n---\n?/);
if (!m) return { front: '', body: raw };
return { front: m[0], body: raw.slice(m[0].length) };
}
function tagsMatchTarget(tags, target) {
if (!tags) return false;
for (const t of tags) {
const clean = t.replace(/^#/, '');
if (clean === target) return true;
if (clean.startsWith(target + '/')) return true;
}
return false;
}
// ============================ COLUMN LAYOUT ============================
class ColumnLayout {
host: HTMLElement;
columns: HTMLElement[];
heights: number[];
constructor(host: HTMLElement) {
this.host = host;
this.columns = [];
this.heights = [];
}
setColumnCount(n: number): void {
this.host.empty();
this.columns = [];
this.heights = new Array(n).fill(0);
for (let i = 0; i < n; i++) {
this.columns.push(this.host.createDiv({ cls: 'keep-col' }));
}
}
add(card: HTMLElement, estHeight: number): void {
let min = 0;
for (let i = 1; i < this.heights.length; i++) {
if (this.heights[i] < this.heights[min]) min = i;
}
this.columns[min].appendChild(card);
this.heights[min] += (estHeight || 200) + 16;
}
}
// ============================ PLUGIN ============================
class KeepCardsPlugin extends obsidian.Plugin {
settings: KeepCardsSettings = DEFAULT_SETTINGS;
savedSidebarState: { left: boolean; right: boolean } | null = null;
async onload(): Promise<void> {
await this.loadSettings();
this.savedSidebarState = null;
this.registerView(VIEW_TYPE_CARDS, (leaf) => new CardsView(leaf, this));
this.addRibbonIcon('layout-grid', 'Open card view', () => this.activateView());
this.addCommand({
id: 'open-cards-view',
name: 'Open card view',
callback: () => this.activateView(),
});
this.addCommand({
id: 'clear-wall',
name: 'Clear wall',
callback: () => {
const view = this.app.workspace.getLeavesOfType(VIEW_TYPE_CARDS)[0]?.view;
if (view instanceof CardsView) view.clearCurrentWall();
else new obsidian.Notice('Open the card view first.');
},
});
this.addCommand({
id: 'manage-labels',
name: 'Manage labels',
callback: () => {
const set = new Set();
const folder = (this.settings.notesFolder || '').trim();
for (const f of this.app.vault.getMarkdownFiles()) {
if (folder && !f.path.startsWith(folder + '/') && f.path !== folder) continue;
const cache = this.app.metadataCache.getFileCache(f);
if (!cache) continue;
const tags = obsidian.getAllTags(cache);
if (tags) tags.forEach((t: string) => set.add(t.replace(/^#/, '')));
}
new TagListModal(this.app, this, Array.from(set).sort() as string[]).open();
},
});
this.addSettingTab(new KeepCardsSettingTab(this.app, this));
this.registerEvent(
this.app.workspace.on('active-leaf-change', (leaf) => this.handleActiveLeafChange(leaf))
);
this.app.workspace.onLayoutReady(() => {
this.handleActiveLeafChange(this.app.workspace.getActiveViewOfType(CardsView)?.leaf ?? null);
});
}
onunload(): void {
this.restoreSidebars();
}
handleActiveLeafChange(leaf: WorkspaceLeaf | null): void {
if (!this.settings.autoCollapseSidebars) return;
const isCards = this.leafIsCardsView(leaf);
if (isCards && !this.savedSidebarState) {
this.captureAndCollapse();
} else if (!isCards && this.savedSidebarState) {
this.restoreSidebars();
}
}
leafIsCardsView(leaf: WorkspaceLeaf | null): boolean {
if (!leaf || !leaf.view) return false;
const view = leaf.view as { getViewType?: () => string };
if (typeof view.getViewType !== 'function') return false;
try {
return view.getViewType() === VIEW_TYPE_CARDS;
} catch {
return false;
}
}
captureAndCollapse(): void {
const ws = this.app.workspace;
this.savedSidebarState = {
left: ws.leftSplit ? !!ws.leftSplit.collapsed : false,
right: ws.rightSplit ? !!ws.rightSplit.collapsed : false,
};
this.collapseSidebars();
}
collapseSidebars(): void {
const apply = () => {
const ws = this.app.workspace;
try {
if (ws.leftSplit && !ws.leftSplit.collapsed) ws.leftSplit.collapse();
} catch { /* swallow transition races */ }
try {
if (ws.rightSplit && !ws.rightSplit.collapsed) ws.rightSplit.collapse();
} catch { /* swallow transition races */ }
};
apply();
window.setTimeout(apply, 60);
window.setTimeout(apply, 240);
}
restoreSidebars(): void {
if (!this.savedSidebarState) return;
const { left, right } = this.savedSidebarState;
this.savedSidebarState = null;
const apply = () => {
const ws = this.app.workspace;
try {
if (!left && ws.leftSplit && ws.leftSplit.collapsed) ws.leftSplit.expand();
} catch { /* swallow transition races */ }
try {
if (!right && ws.rightSplit && ws.rightSplit.collapsed) ws.rightSplit.expand();
} catch { /* swallow transition races */ }
};
apply();
window.setTimeout(apply, 60);
}
async activateView(): Promise<void> {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_CARDS)[0];
if (!leaf) {
leaf = workspace.getLeaf('tab');
await leaf.setViewState({ type: VIEW_TYPE_CARDS, active: true });
}
await workspace.revealLeaf(leaf);
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(opts?: { refresh?: boolean }): Promise<void> {
await this.saveData(this.settings);
if (!opts || opts.refresh !== false) this.refreshViews();
}
refreshViews() {
this.app.workspace.getLeavesOfType(VIEW_TYPE_CARDS).forEach((leaf) => {
const view = leaf.view;
if (view instanceof CardsView) view.scheduleRender();
});
}
// -------------------- walls --------------------
createWall(name: string): string {
if (!this.settings.walls) this.settings.walls = {};
const id = makeWallId();
this.settings.walls[id] = { id, name, positions: {} };
void this.saveData(this.settings);
return id;
}
}
// ============================ VIEW ============================
class CardsView extends obsidian.ItemView {
plugin: KeepCardsPlugin;
query: string;
parsedQuery: ParsedQuery;
filter: Filter;
selection: Set<string>;
editing: string | null;
focusedPath: string | null;
lastClickedPath: string | null;
debounceTimer: number | null;
searchDebounce: number | null;
layouts: Map<string, ColumnLayout>;
observer: IntersectionObserver | null;
resizeObs: ResizeObserver | null;
cardComponents: Map<string, obsidian.Component>;
suppressEvents: number;
batches: BatchState[];
batchObserver: IntersectionObserver | null;
batchSize: number;
activeModal: NotePreviewModal | null;
keydownHandler: ((e: KeyboardEvent) => void) | null;
constructor(leaf: WorkspaceLeaf, plugin: KeepCardsPlugin) {
super(leaf);
this.plugin = plugin;
this.query = '';
this.parsedQuery = parseQuery('');
this.filter = { type: 'notes' };
this.selection = new Set<string>();
this.editing = null;
this.focusedPath = null;
this.lastClickedPath = null;
this.debounceTimer = null;
this.searchDebounce = null;
this.layouts = new Map();
this.observer = null;
this.resizeObs = null;
this.cardComponents = new Map();
this.suppressEvents = 0;
this.batches = [];
this.batchObserver = null;
this.batchSize = 60;
this.activeModal = null;
this.keydownHandler = null;
}
getViewType(): string { return VIEW_TYPE_CARDS; }
getDisplayText(): string { return 'Card view'; }
getIcon(): string { return 'layout-grid'; }
onOpen(): Promise<void> {
this.render();
const refresh = () => {
if (this.suppressEvents > 0) {
this.suppressEvents--;
return;
}
this.scheduleRender();
};
this.registerEvent(this.app.vault.on('modify', refresh));
this.registerEvent(this.app.vault.on('create', refresh));
this.registerEvent(this.app.vault.on('delete', (file) => {
let dirty = false;
for (const key of ['cardColors', 'pinnedFiles', 'archivedFiles']) {
const data = this.plugin.settings[key];
if (data && data[file.path] !== undefined) {
delete data[file.path];
dirty = true;
}
}
if (dirty) void this.plugin.saveData(this.plugin.settings);
refresh();
}));
this.registerEvent(this.app.vault.on('rename', (file, oldPath) => {
let dirty = false;
for (const key of ['cardColors', 'pinnedFiles', 'archivedFiles']) {
const data = this.plugin.settings[key];
if (data && data[oldPath] !== undefined) {
data[file.path] = data[oldPath];
delete data[oldPath];
dirty = true;
}
}
if (dirty) void this.plugin.saveData(this.plugin.settings);
refresh();
}));
this.registerEvent(this.app.metadataCache.on('changed', refresh));
this.containerEl.tabIndex = -1;
this.keydownHandler = (e) => this.handleKeydown(e);
this.containerEl.addEventListener('keydown', this.keydownHandler);
if (this.plugin && this.plugin.handleActiveLeafChange) {
this.plugin.handleActiveLeafChange(this.leaf);
}
return Promise.resolve();
}
onClose(): Promise<void> {
if (this.observer) this.observer.disconnect();
if (this.resizeObs) this.resizeObs.disconnect();
if (this.batchObserver) this.batchObserver.disconnect();
for (const c of this.cardComponents.values()) c.unload();
this.cardComponents.clear();
if (this.keydownHandler) this.containerEl.removeEventListener('keydown', this.keydownHandler);
this.contentEl.empty();
return Promise.resolve();
}
scheduleRender(): void {
if (this.debounceTimer) window.clearTimeout(this.debounceTimer);
this.debounceTimer = window.setTimeout(() => { void this.render(); }, 200);
}
render(): void {
if (this.observer) this.observer.disconnect();
if (this.resizeObs) this.resizeObs.disconnect();
if (this.batchObserver) this.batchObserver.disconnect();
this.batches = [];
for (const c of this.cardComponents.values()) c.unload();
this.cardComponents.clear();
this.layouts.clear();
// Preserve search-box focus across the rebuild — without this, the user
// loses focus mid-typing whenever the debounced render fires.
const active = activeDocument.activeElement;
const searchHadFocus = active instanceof HTMLInputElement && active.classList.contains('keep-cards-search');
const searchSelStart = searchHadFocus ? active.selectionStart : null;
const searchSelEnd = searchHadFocus ? active.selectionEnd : null;
const container = this.contentEl;
container.empty();
container.addClass('keep-cards-root');
const density = this.plugin.settings.density;
container.toggleClass('density-compact', density === 'compact' || density === 'wall-compact');
container.toggleClass('density-list', density === 'list');
container.toggleClass('density-wall', isWallDensity(density));
const header = container.createDiv({ cls: 'keep-cards-header' });
const topRow = header.createDiv({ cls: 'keep-cards-header-top' });
this.renderSearch(topRow);
this.renderActiveFilter(topRow);
this.renderSortButton(topRow);
this.renderArchiveToggle(topRow);
this.renderToolbar(topRow);
const captureRow = header.createDiv({ cls: 'keep-cards-header-capture' });
this.renderCapture(captureRow);
if (searchHadFocus) {
const newSearch = container.querySelector<HTMLInputElement>('.keep-cards-search');
if (newSearch) {
newSearch.focus();
if (searchSelStart !== null && searchSelEnd !== null) {
newSearch.setSelectionRange(searchSelStart, searchSelEnd);
}
}
}
const body = container.createDiv({ cls: 'keep-cards-body' });
const grid = body.createDiv({ cls: 'keep-cards-grid' });
const files = this.collectFiles();
if (files.length === 0) {
this.renderEmpty(grid);
return;
}
this.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const card = entry.target as HTMLElement;
this.observer.unobserve(card);
const path = card.dataset.path;
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof obsidian.TFile) {
this.populateCard(card, file).catch((e) => console.error('populate failed', e));
}
}
}
},
{ root: grid, rootMargin: '300px' }
);
this.batchObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const batch = (entry.target as HTMLElement & { _batch?: BatchState })._batch;
if (batch) this.renderNextBatch(batch);
}
}
},
{ root: grid, rootMargin: '600px' }
);
if (isWallDensity(density)) {
this.renderWallView(grid, files);
this.attachResizeObserver(grid);
this.renderBulkActions(container);
return;
}
const colCount = this.computeColumnCount(grid.clientWidth || container.clientWidth || 1200);
const pinned = files.filter((f) => this.isPinned(f));
const others = files.filter((f) => !this.isPinned(f));
const showSections =
pinned.length > 0 &&
this.plugin.settings.pinnedHeader &&
this.plugin.settings.density !== 'list' &&
this.filter.type !== 'archive';
if (showSections) {
const pinSec = this.makeSection(grid, 'PINNED');
const pinLayout = new ColumnLayout(pinSec.cols);
pinLayout.setColumnCount(colCount);
this.layouts.set('pinned', pinLayout);
pinned.sort((a, b) => this.pinOrderOf(a) - this.pinOrderOf(b));
this.addBatch(pinSec.sec, pinLayout, pinned);
if (others.length > 0) {
const otherSec = this.makeSection(grid, 'OTHERS');
const otherLayout = new ColumnLayout(otherSec.cols);
otherLayout.setColumnCount(colCount);
this.layouts.set('others', otherLayout);
this.addBatch(otherSec.sec, otherLayout, others);
}
} else {
const sec = grid.createDiv({ cls: 'keep-section' });
const cols = sec.createDiv({ cls: 'keep-section-cols' });
const layout = new ColumnLayout(cols);
layout.setColumnCount(colCount);
this.layouts.set('all', layout);
const ordered = files.slice();
if (this.filter.type !== 'archive') {
const sortFn = this.getSortFn();
ordered.sort((a, b) => {
const pa = this.isPinned(a) ? 0 : 1;
const pb = this.isPinned(b) ? 0 : 1;
if (pa !== pb) return pa - pb;
if (pa === 0) return this.pinOrderOf(a) - this.pinOrderOf(b);
return sortFn(a, b);
});
}
this.addBatch(sec, layout, ordered);
}
this.attachResizeObserver(grid);
this.renderBulkActions(container);
}
renderCapture(header: HTMLElement): void {
const wrap = header.createDiv({ cls: 'keep-cards-capture-wrap' });
const titleInput = wrap.createEl('input', {
cls: 'keep-cards-capture-title',
attr: { type: 'text', placeholder: 'Title' },
});
const ta = wrap.createEl('textarea', {
cls: 'keep-cards-capture',
attr: { placeholder: 'Take a note…', rows: '1' },
});
const actions = wrap.createDiv({ cls: 'keep-cards-capture-actions' });
const state = { willPin: false, color: null as string | null };
const pinBtn = actions.createEl('button', {
cls: 'keep-cards-capture-action',
attr: { type: 'button', 'aria-label': 'Pin', title: 'Pin' },
});
obsidian.setIcon(pinBtn, 'pin');
pinBtn.addEventListener('mousedown', (e) => e.preventDefault());
pinBtn.addEventListener('click', () => {
state.willPin = !state.willPin;
pinBtn.toggleClass('is-active', state.willPin);
});
const colorBtn = actions.createEl('button', {
cls: 'keep-cards-capture-action',
attr: { type: 'button', 'aria-label': 'Color', title: 'Color' },
});
obsidian.setIcon(colorBtn, 'palette');
colorBtn.addEventListener('mousedown', (e) => e.preventDefault());
colorBtn.addEventListener('click', (e) => {
const menu = new obsidian.Menu();
for (const c of COLORS) {
menu.addItem((mi) =>
mi.setTitle(c.name).onClick(() => {
state.color = c.value;
if (c.value) {
colorBtn.setCssStyles({ background: c.value, color: 'var(--text-on-accent)' });
wrap.setCssStyles({ background: c.value });
wrap.addClass('has-pick-color');
} else {
colorBtn.setCssStyles({ background: '', color: '' });
wrap.setCssStyles({ background: '' });
wrap.removeClass('has-pick-color');
}
}),
);
}
menu.showAtMouseEvent(e);
});
actions.createDiv({ cls: 'keep-cards-capture-actions-spacer' });
const discardBtn = actions.createEl('button', {
cls: 'keep-cards-capture-discard',
text: 'Discard',
attr: { type: 'button', title: 'Discard (esc)' },
});
discardBtn.addEventListener('mousedown', (e) => e.preventDefault());
discardBtn.addEventListener('click', () => {
reset();
ta.blur();
});
const closeBtn = actions.createEl('button', {
cls: 'keep-cards-capture-close mod-cta',
text: 'Save',
attr: { type: 'button', title: 'Save note (⌘+Enter)' },
});
closeBtn.addEventListener('mousedown', (e) => e.preventDefault());
const grow = () => {
ta.setCssStyles({ height: 'auto' });
ta.setCssStyles({ height: `${Math.min(ta.scrollHeight, 240)}px` });
};
const reset = () => {
titleInput.value = '';
ta.value = '';
state.willPin = false;
state.color = null;
pinBtn.removeClass('is-active');
colorBtn.setCssStyles({ background: '', color: '' });
wrap.setCssStyles({ background: '' });
wrap.removeClass('has-pick-color');
grow();
};
const submit = (): void => {
const title = titleInput.value.trim();
const body = ta.value;
if (!body.trim() && !title) {
ta.blur();
return;
}
const opts = { title, pin: state.willPin, color: state.color };
void this.captureNote(body, opts).then(() => {
reset();
ta.blur();
});
};
closeBtn.addEventListener('click', () => submit());
// Auto-save on focus loss (Keep behavior). 200ms grace period so popups
// (color menu, etc.) and quick focus jumps don't trigger a premature save.
let blurTimer: number | null = null;
wrap.addEventListener('focusout', () => {
if (blurTimer) window.clearTimeout(blurTimer);
blurTimer = window.setTimeout(() => {
blurTimer = null;
const doc = activeDocument;
if (doc.activeElement && wrap.contains(doc.activeElement)) return;
submit();
}, 200);
});
wrap.addEventListener('focusin', () => {
if (blurTimer) {
window.clearTimeout(blurTimer);
blurTimer = null;
}
});
ta.addEventListener('input', grow);
ta.addEventListener('focus', grow);
ta.addEventListener('blur', () => {
ta.setCssStyles({ height: '' });
});
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
reset();
ta.blur();
}
});
titleInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
ta.focus();
} else if (e.key === 'Escape') {
reset();
titleInput.blur();
}
});
ta.addEventListener('paste', (e) => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
void (async () => {
try {
const buf = await file.arrayBuffer();
const ext = (item.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
const vaultAny = this.app.vault as unknown as {
getConfig?: (key: string) => string | undefined;
};
const folder = (vaultAny.getConfig && vaultAny.getConfig('attachmentFolderPath')) || '';
const safe = (folder || '').replace(/^\/+|\/+$/g, '');
const name = `Pasted ${Date.now()}.${ext}`;
const path = (safe ? safe + '/' : '') + name;
await this.app.vault.createBinary(path, buf);
ta.value = ta.value + `\n![[${path}]]`;
grow();
} catch (err) {
new obsidian.Notice('Paste failed: ' + (err as Error).message);
}
})();
}
}
});
}
renderSearch(header) {
const wrap = header.createDiv({ cls: 'keep-cards-search-wrap' });
const icon = wrap.createSpan({ cls: 'keep-cards-search-icon' });
obsidian.setIcon(icon, 'search');
const search = wrap.createEl('input', {
attr: { type: 'text', placeholder: 'Search notes', title: 'Try: tag:foo pinned:true color:#5c2b29 path:inbox is:archived' },
cls: 'keep-cards-search',
});
search.value = this.query;
search.addEventListener('input', () => {
this.query = search.value;
this.parsedQuery = parseQuery(this.query);
if (this.searchDebounce) window.clearTimeout(this.searchDebounce);
this.searchDebounce = window.setTimeout(() => this.scheduleRender(), 150);
});
}
// -------------------- wall switcher / view --------------------
getOrCreateCurrentWall(): Wall {
const settings = this.plugin.settings;
if (!settings.walls) settings.walls = {};
let id = settings.currentWallId;
if (!id || !settings.walls[id]) {
id = this.firstWallId() ?? this.plugin.createWall('Wall');
settings.currentWallId = id;
}
return settings.walls[id];
}
firstWallId(): string | null {
const keys = Object.keys(this.plugin.settings.walls || {});
return keys.length ? keys[0] : null;
}
clearCurrentWall(): void {
const wall = this.getOrCreateCurrentWall();
new ConfirmModal(
this.app,
'Clear wall?',
'Removes every note from the wall. The notes themselves are not deleted.',
'Clear',
() => {
wall.positions = {};
void this.plugin.saveData(this.plugin.settings);
this.scheduleRender();
},
).open();
}
renderWallView(grid: HTMLElement, files: TFile[]): void {
const wall = this.getOrCreateCurrentWall();
grid.addClass('is-wall');
grid.addClass(`wall-${this.plugin.settings.wallBackground}`);
const onWall = files.filter((f) => wall.positions[f.path]);
if (onWall.length === 0) {
this.renderWallEmptyState(grid, wall);
return;
}
const sortFn = this.getSortFn();
onWall.sort((a, b) => {
const pa = this.isPinned(a) ? 0 : 1;
const pb = this.isPinned(b) ? 0 : 1;
if (pa !== pb) return pa - pb;
if (pa === 0) return this.pinOrderOf(a) - this.pinOrderOf(b);
return sortFn(a, b);
});
const colCount = this.computeColumnCount(grid.clientWidth || 1200);
const sec = grid.createDiv({ cls: 'keep-section' });
const cols = sec.createDiv({ cls: 'keep-section-cols' });
const layout = new ColumnLayout(cols);
layout.setColumnCount(colCount);
this.layouts.set('wall', layout);
this.addBatch(sec, layout, onWall);
}
renderWallEmptyState(grid: HTMLElement, wall: Wall): void {
const empty = grid.createDiv({ cls: 'keep-wall-empty' });
empty.createDiv({ cls: 'keep-wall-empty-title', text: `"${wall.name}" is empty` });
empty.createDiv({
cls: 'keep-wall-empty-sub',
text: 'Walls are curated. Add the notes you want pinned to this wall.',
});
const row = empty.createDiv({ cls: 'keep-wall-empty-actions' });
const addBtn = row.createEl('button', {
cls: 'mod-cta',
text: 'Add notes…',
attr: { type: 'button' },
});
addBtn.addEventListener('click', () => this.openAddToWallPicker(wall));
const filterCount = this.collectFiles().length;
if (filterCount > 0 && filterCount <= 200) {
const fillBtn = row.createEl('button', {
text: `Fill from current view (${filterCount})`,
attr: { type: 'button' },
});
fillBtn.addEventListener('click', () => this.fillWallFromCurrentFilter(wall));
}
}
openAddToWallPicker(wall: Wall): void {
new WallAddNotesModal(this.app, this.plugin, wall, (file: TFile) => {
wall.positions[file.path] = { x: 0, y: 0, rotation: 0, z: 0 };
void this.plugin.saveData(this.plugin.settings);
this.scheduleRender();
}).open();
}
fillWallFromCurrentFilter(wall: Wall): void {
const files = this.collectFiles();
for (const f of files) {
if (!wall.positions[f.path]) {
wall.positions[f.path] = { x: 0, y: 0, rotation: 0, z: 0 };
}
}
void this.plugin.saveData(this.plugin.settings);
this.scheduleRender();
}
removeFromCurrentWall(file: TFile): void {
const wall = this.getOrCreateCurrentWall();
if (!wall.positions[file.path]) return;
delete wall.positions[file.path];
void this.plugin.saveData(this.plugin.settings);
this.scheduleRender();
}
renderArchiveToggle(parent) {
const isArchive = this.filter.type === 'archive';
const btn = parent.createEl('button', {
cls: 'keep-cards-sort-btn' + (isArchive ? ' is-active' : ''),
attr: {
type: 'button',
'aria-label': isArchive ? 'Showing archived notes' : 'Show archived notes',
title: isArchive ? 'Showing archived notes — click to return' : 'Show archived notes',
},
});
obsidian.setIcon(btn, isArchive ? 'archive-restore' : 'archive');
btn.addEventListener('click', () => {
this.filter = isArchive ? { type: 'notes' } : { type: 'archive' };
this.selection.clear();
this.scheduleRender();
});
}
renderActiveFilter(parent) {
if (this.filter.type !== 'tag') return;
const chip = parent.createDiv({ cls: 'keep-cards-active-filter' });
chip.createSpan({ cls: 'keep-cards-active-filter-label', text: '#' + this.filter.tag });
const x = chip.createEl('button', {
cls: 'keep-cards-active-filter-x',
attr: { type: 'button', 'aria-label': 'Clear filter' },
});
obsidian.setIcon(x, 'x');
x.addEventListener('click', () => {
this.filter = { type: 'notes' };
this.scheduleRender();
});
}
renderSortButton(parent) {
const current = this.plugin.settings.sortMode;
const btn = parent.createEl('button', {
cls: 'keep-cards-sort-btn',
attr: { type: 'button', 'aria-label': 'Sort', title: 'Sort: ' + (SORT_LABELS[current] || 'Modified · newest') },
});
obsidian.setIcon(btn, 'arrow-up-down');
btn.addEventListener('click', (e) => {
const menu = new obsidian.Menu();
for (const key of Object.keys(SORT_LABELS) as SortMode[]) {
menu.addItem((mi) =>
mi
.setTitle(SORT_LABELS[key])
.setChecked(this.plugin.settings.sortMode === key)
.onClick(() => {
this.plugin.settings.sortMode = key;
void this.plugin.saveSettings();
})
);
}
menu.showAtMouseEvent(e);
});
}
getSortFn() {
switch (this.plugin.settings.sortMode) {
case 'mtime-asc': return (a, b) => a.stat.mtime - b.stat.mtime;
case 'ctime-desc': return (a, b) => b.stat.ctime - a.stat.ctime;
case 'ctime-asc': return (a, b) => a.stat.ctime - b.stat.ctime;
case 'name-asc': return (a, b) => a.basename.localeCompare(b.basename);
case 'name-desc': return (a, b) => b.basename.localeCompare(a.basename);
case 'mtime-desc':
default: return (a, b) => b.stat.mtime - a.stat.mtime;
}
}
renderToolbar(header: HTMLElement): void {
const tb = header.createDiv({ cls: 'keep-cards-toolbar' });
const modes: { v: Density; icon: string; label: string }[] = [
{ v: 'comfortable', icon: 'layout-grid', label: 'Comfortable' },
{ v: 'compact', icon: 'grid', label: 'Compact' },
{ v: 'list', icon: 'list', label: 'List' },
{ v: 'wall', icon: 'sticky-note', label: 'Wall' },
{ v: 'wall-compact', icon: 'columns-3', label: 'Wall (compact)' },
];
for (const m of modes) {
const btn = tb.createEl('button', {
cls: 'keep-cards-tb-btn',
attr: { type: 'button', 'aria-label': m.label, title: m.label },
});
obsidian.setIcon(btn, m.icon);
if (this.plugin.settings.density === m.v) btn.addClass('is-active');
btn.addEventListener('click', () => {
this.plugin.settings.density = m.v;
void this.plugin.saveSettings();
});
}
}
renderSidebar(sidebar) {
const items = [
{ label: 'Notes', icon: 'lightbulb', filter: { type: 'notes' } },
{ label: 'Archive', icon: 'archive', filter: { type: 'archive' } },
];
for (const it of items) this.makeSidebarItem(sidebar, it.icon, it.label, it.filter);
const tags = this.collectTags();
if (tags.length > 0) {
sidebar.createDiv({ cls: 'keep-sidebar-divider' });
const heading = sidebar.createDiv({ cls: 'keep-sidebar-heading' });
heading.createSpan({ text: 'LABELS' });
const editBtn = heading.createEl('button', {
cls: 'keep-sidebar-edit',
attr: { type: 'button', 'aria-label': 'Edit labels', title: 'Edit labels' },
});
obsidian.setIcon(editBtn, 'pencil');
editBtn.addEventListener('click', () => {
new TagListModal(this.app, this.plugin, tags).open();
});
for (const tag of tags) this.makeSidebarItem(sidebar, 'tag', tag, { type: 'tag', tag });
}
}
makeSidebarItem(parent, icon, label, filter) {
const row = parent.createDiv({ cls: 'keep-sidebar-item' });
if (this.isActive(filter)) row.addClass('is-active');
const iconEl = row.createSpan({ cls: 'keep-sidebar-icon' });
obsidian.setIcon(iconEl, icon);
if (filter.type === 'tag') {
const color = this.plugin.settings.tagColors[filter.tag];
if (color) {
const dot = row.createSpan({ cls: 'keep-sidebar-color-dot' });
dot.setCssStyles({ background: color });
}
}
row.createSpan({ cls: 'keep-sidebar-label', text: label });
if (filter.type === 'tag') {
const more = row.createEl('button', {
cls: 'keep-sidebar-more',
attr: { type: 'button', 'aria-label': 'More' },
});
obsidian.setIcon(more, 'more-horizontal');
more.addEventListener('click', (e) => {
e.stopPropagation();
const menu = new obsidian.Menu();
menu.addItem((mi) =>
mi.setTitle('Rename label').setIcon('pencil').onClick(() => {
new TagRenameModal(this.app, this.plugin, filter.tag).open();
})
);
menu.addItem((mi) =>
mi.setTitle('Set label color').setIcon('palette').onClick(() => {
new TagColorModal(this.app, this.plugin, filter.tag).open();
})
);
menu.showAtMouseEvent(e);
});
}
row.addEventListener('click', () => {
this.filter = filter;
this.selection.clear();
this.scheduleRender();
});
row.addEventListener('dragover', (e) => {
if (filter.type === 'tag') {
e.preventDefault();
row.addClass('is-drop-target');
}
});
row.addEventListener('dragleave', () => row.removeClass('is-drop-target'));
row.addEventListener('drop', async (e) => {
row.removeClass('is-drop-target');
if (filter.type !== 'tag') return;
e.preventDefault();
const path = e.dataTransfer.getData('application/x-obsidian-path');
if (!path) return;
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof obsidian.TFile)) return;
await this.addTagToFile(file, filter.tag);
});
}
isActive(f) {
if (f.type !== this.filter.type) return false;
if (f.type === 'tag' && this.filter.type === 'tag') return f.tag === this.filter.tag;
return true;
}
collectTags(): string[] {
const set = new Set<string>();
const folder = this.plugin.settings.notesFolder.trim();
for (const f of this.app.vault.getMarkdownFiles()) {
if (folder && !f.path.startsWith(folder + '/') && f.path !== folder) continue;
const cache = this.app.metadataCache.getFileCache(f);
if (!cache) continue;
const tags = obsidian.getAllTags(cache);
if (tags) tags.forEach((t: string) => set.add(t.replace(/^#/, '')));
}
return Array.from(set).sort();
}
collectFiles() {
const folder = this.plugin.settings.notesFolder.trim();
const cache = this.app.metadataCache;
const q = this.parsedQuery;
const files = this.app.vault.getMarkdownFiles().filter((f) => {
if (folder && !f.path.startsWith(folder + '/') && f.path !== folder) return false;
const fc = cache.getFileCache(f);
const fm = fc && fc.frontmatter;
const archived = this.isArchived(f);
if (this.filter.type === 'archive') {
if (!archived) return false;
} else {
if (archived) return false;
}
if (this.filter.type === 'tag') {
const tags = fc ? obsidian.getAllTags(fc) : null;
if (!tagsMatchTarget(tags, this.filter.tag)) return false;
}
if (q.tag.length > 0) {
const tags = fc ? obsidian.getAllTags(fc) : null;
for (const t of q.tag) {
if (!tagsMatchTarget(tags, t)) return false;
}
}
if (q.color !== null) {
const dataColor = (this.plugin.settings.cardColors || {})[f.path];
const effective = (dataColor !== undefined ? dataColor : (fm && fm.color)) || '';
if (String(effective).toLowerCase() !== q.color.toLowerCase()) return false;
}
if (q.path !== null) {
if (!f.path.toLowerCase().includes(q.path)) return false;
}
if (q.pinned !== null) {
if (q.pinned !== this.isPinned(f)) return false;
}
if (q.archived !== null) {
if (q.archived !== archived) return false;
}
if (q.text.length > 0) {
const hay = (f.basename + ' ' + f.path).toLowerCase();
for (const t of q.text) {
if (!hay.includes(t)) return false;
}
}
return true;
});
files.sort(this.getSortFn());
return files;
}
isPinned(file) {
const data = this.plugin.settings.pinnedFiles || {};
if (data[file.path] !== undefined) return data[file.path] !== false;
const fc = this.app.metadataCache.getFileCache(file);
return !!(fc && fc.frontmatter && fc.frontmatter.pinned);
}
isArchived(file) {
const data = this.plugin.settings.archivedFiles || {};
if (data[file.path] !== undefined) return data[file.path] !== false;
const fc = this.app.metadataCache.getFileCache(file);
return !!(fc && fc.frontmatter && fc.frontmatter.archived);
}
pinOrderOf(file) {
const data = this.plugin.settings.pinnedFiles || {};
const v = data[file.path];
if (typeof v === 'number') return v;
const fc = this.app.metadataCache.getFileCache(file);
const fmv = fc && fc.frontmatter && fc.frontmatter.pinOrder;
if (typeof fmv === 'number') return fmv;
return Number.MAX_SAFE_INTEGER;
}
makeSection(grid, label) {
const sec = grid.createDiv({ cls: 'keep-section' });
sec.createDiv({ cls: 'keep-section-header', text: label });
const cols = sec.createDiv({ cls: 'keep-section-cols' });
return { sec, cols };
}
addBatch(host, layout, files) {
if (files.length === 0) return;
const batch = { host, layout, files, renderedIndex: 0, sentinel: null };
this.batches.push(batch);
this.renderNextBatch(batch);
}
renderNextBatch(batch) {
const { host, layout, files } = batch;
const start = batch.renderedIndex;
const end = Math.min(start + this.batchSize, files.length);
for (let i = start; i < end; i++) {
this.addSkeleton(layout, files[i]);
}
batch.renderedIndex = end;
if (batch.sentinel) {
this.batchObserver.unobserve(batch.sentinel);
batch.sentinel.remove();
batch.sentinel = null;
}
if (end < files.length) {
const sentinel = host.createDiv({ cls: 'keep-batch-sentinel' });
sentinel._batch = batch;
this.batchObserver.observe(sentinel);
batch.sentinel = sentinel;
}
}
computeColumnCount(width: number): number {
const d = this.plugin.settings.density;
if (d === 'list') return 1;
if (d === 'compact' || d === 'wall-compact') {
if (width < 600) return 1;
if (width < 900) return 3;
if (width < 1200) return 4;
if (width < 1600) return 5;
return 6;
}
if (width < 700) return 1;
if (width < 1000) return 2;
if (width < 1400) return 3;
return 4;
}
attachResizeObserver(grid) {
let lastCols = this.computeColumnCount(grid.clientWidth);
this.resizeObs = new ResizeObserver(() => {
const c = this.computeColumnCount(grid.clientWidth);
if (c !== lastCols) {
lastCols = c;
this.scheduleRender();
}
});
this.resizeObs.observe(grid);
}
renderEmpty(grid) {
const empty = grid.createDiv({ cls: 'keep-cards-empty' });
const ic = empty.createDiv({ cls: 'keep-empty-icon' });
obsidian.setIcon(ic, 'sticky-note');
empty.createDiv({ cls: 'keep-empty-title', text: 'No notes here' });
empty.createDiv({
cls: 'keep-empty-sub',
text: this.query
? 'Try a different search.'
: this.filter.type === 'archive'
? 'Archived notes will appear here.'
: this.filter.type === 'tag'
? 'No notes with this label yet.'
: 'Type a note above to get started.',
});
}
// -------------------- skeleton + populate --------------------
addSkeleton(layout, file) {
const fc = this.app.metadataCache.getFileCache(file);
const fm = fc && fc.frontmatter;
const card = createDiv({ cls: 'keep-card is-skeleton' });
card.dataset.path = file.path;
if (this.selection.has(file.path)) card.addClass('is-selected');
if (this.editing === file.path) card.addClass('is-editing-card');
const dataColor = (this.plugin.settings.cardColors || {})[file.path];
const fmColor = fm && fm.color;
const cardColor = dataColor !== undefined ? dataColor : fmColor;
if (cardColor) {
if (this.plugin.settings.colorAsBorder) {
card.setCssStyles({ borderLeft: `4px solid ${String(cardColor)}` });
} else {
card.setCssStyles({ background: String(cardColor) });
card.addClass('has-card-color');
}
} else {
const tags = fc ? obsidian.getAllTags(fc) : null;
if (tags) {
for (const t of tags) {
const c = this.plugin.settings.tagColors[t.replace(/^#/, '')];
if (c) {
if (this.plugin.settings.colorAsBorder) card.setCssStyles({ borderLeft: `4px solid ${c}` });
else {
card.setCssStyles({ background: c });
card.addClass('has-card-color');
}
break;
}
}
}
}
if (this.isPinned(file)) card.addClass('keep-card--pinned');
const cb = card.createEl('input', {
cls: 'keep-card-checkbox',
attr: { type: 'checkbox', 'aria-label': 'Select note' },
});
if (this.selection.has(file.path)) cb.checked = true;
cb.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleSelection(file.path, e.shiftKey);
});
card.createDiv({ cls: 'keep-card-title', text: file.basename });
const body = card.createDiv({ cls: 'keep-card-body' });
for (let i = 0; i < 3; i++) body.createDiv({ cls: 'keep-shimmer' });
card.draggable = true;
card.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-obsidian-path', file.path);
e.dataTransfer.setData('text/plain', file.basename);
card.addClass('is-dragging');
});
card.addEventListener('dragend', () => card.removeClass('is-dragging'));
if (this.isPinned(file)) {
card.addEventListener('dragover', (e) => {
const path = e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('application/x-obsidian-path');
if (!path) return;
e.preventDefault();
card.addClass('is-drop-target');
});
card.addEventListener('dragleave', () => card.removeClass('is-drop-target'));
card.addEventListener('drop', (e) => {
card.removeClass('is-drop-target');
const fromPath = e.dataTransfer?.getData('application/x-obsidian-path');
if (!fromPath || fromPath === file.path) return;
const fromFile = this.app.vault.getAbstractFileByPath(fromPath);
if (!(fromFile instanceof obsidian.TFile)) return;
if (!this.isPinned(fromFile)) return;
e.preventDefault();
void this.swapPinOrder(fromFile, file);
});
}
card.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.closest('input, button, a, .keep-card-editor, .keep-color-picker')) return;
if (e.shiftKey || e.metaKey || e.ctrlKey) {
this.toggleSelection(file.path, e.shiftKey);
return;
}
this.openNotePreview(file);
});
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.openContextMenu(e, file);
});
if (isWallDensity(this.plugin.settings.density)) {
const seed = hashString(file.path);
const rot = (seed % 7) - 3;
card.setCssStyles({ transform: `rotate(${rot}deg)` });
card.addClass('is-wall-card');
}
layout.add(card, estimateHeight(file, fm, this.plugin.settings));
this.observer.observe(card);
}
async populateCard(card: HTMLElement, file: TFile): Promise<void> {
const fc = this.app.metadataCache.getFileCache(file);
const settings = this.plugin.settings;
card.removeClass('is-skeleton');
const oldBody = card.querySelector('.keep-card-body');
if (oldBody) oldBody.empty();
let raw = '';
try {
raw = await this.app.vault.cachedRead(file);
} catch {
raw = '';
}
const { body: stripped } = stripFrontmatter(raw);
if (settings.showImages) {
const img = findFirstImage(this.app, file, stripped);
if (img) {
const url = this.app.vault.getResourcePath(img);
const imgEl = createEl('img', { cls: 'keep-card-image' });
imgEl.src = url;
imgEl.loading = 'lazy';
const titleEl = card.querySelector('.keep-card-title');
card.insertBefore(imgEl, titleEl);
}
}
const bodyEl = card.querySelector<HTMLElement>('.keep-card-body');
if (!bodyEl) return;
if (this.editing === file.path) {
this.mountEditor(bodyEl, file, raw);
} else {
const previewLines = settings.previewLines;
const lines = stripped.split('\n');
const cap = lines.slice(0, previewLines * 4).join('\n');
const comp = new obsidian.Component();
comp.load();
this.cardComponents.set(file.path, comp);
try {
await renderMarkdownInto(this.app, cap, bodyEl, file.path, comp);
} catch {
bodyEl.setText(stripped.split('\n').slice(0, previewLines).join('\n'));
}
this.wireCheckboxes(bodyEl, file);
this.clampBody(bodyEl, previewLines);
}
const tags = fc ? obsidian.getAllTags(fc) : null;
if (tags && tags.length > 0) {
const tagsEl = card.createDiv({ cls: 'keep-card-tags' });
for (const t of tags) {
const chip = tagsEl.createSpan({ cls: 'keep-card-tag', text: t });
chip.addEventListener('click', (e) => {
e.stopPropagation();
this.filter = { type: 'tag', tag: t.replace(/^#/, '') };
this.scheduleRender();
});
}
}
const meta = card.createDiv({ cls: 'keep-card-meta' });
const bl = backlinksCount(this.app, file);
if (bl > 0) {
const badge = meta.createSpan({ cls: 'keep-card-backlinks', attr: { title: bl + ' backlink' + (bl === 1 ? '' : 's') } });
const bicon = badge.createSpan({ cls: 'keep-card-backlinks-icon' });
obsidian.setIcon(bicon, 'links-coming-in');
badge.createSpan({ text: String(bl) });
}
const isPinnedNow = this.isPinned(file);
const isArchivedNow = this.isArchived(file);
const actions = card.createDiv({ cls: 'keep-card-actions' });
this.makeAction(
actions,
isPinnedNow ? 'pin-off' : 'pin',
isPinnedNow ? 'Unpin' : 'Pin',
async () => {
await this.togglePinned(file);
}
);
this.makeAction(actions, 'pencil', 'Edit', () => {
this.beginEdit(file);
});
this.makeAction(actions, 'palette', 'Color', () => {
this.toggleColorPicker(card, file);
});
this.makeAction(
actions,
isArchivedNow ? 'archive-restore' : 'archive',
isArchivedNow ? 'Unarchive' : 'Archive',
async () => {
await this.toggleArchived(file);
}
);
}
clampBody(bodyEl: HTMLElement, lines: number): void {
bodyEl.setCssStyles({ maxHeight: `${lines * 1.5}em`, overflow: 'hidden' });
}
wireCheckboxes(bodyEl, file) {
const boxes = bodyEl.querySelectorAll('input[type=checkbox]');
boxes.forEach((cb, i) => {
cb.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const raw = await this.app.vault.read(file);
let n = 0;
const updated = raw.replace(/^(\s*[-*+] \[)( |x|X)(\])/gm, (m, open, mark, close) => {
if (n++ === i) return open + (mark === ' ' ? 'x' : ' ') + close;
return m;
});
this.suppressEvents++;
await this.app.vault.modify(file, updated);
} catch (err) {
new obsidian.Notice('Could not toggle checkbox: ' + err.message);
}
});
});
}
makeAction(parent, icon, label, handler) {
const btn = parent.createEl('button', {
cls: 'keep-card-action',
attr: { 'aria-label': label, type: 'button', title: label },
});
obsidian.setIcon(btn, icon);
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await handler();
});
}
// -------------------- note preview --------------------
openNotePreview(file: TFile): void {
const modal = new NotePreviewModal(this.app, this.plugin, this, file);
this.activeModal = modal;
const origOnClose = modal.onClose.bind(modal);
modal.onClose = () => {
if (this.activeModal === modal) this.activeModal = null;
origOnClose();
};
modal.open();
}
refreshActiveModal(file: TFile): void {
if (this.activeModal && this.activeModal.file && this.activeModal.file.path === file.path) {
this.activeModal.rerender();
}
}
// -------------------- inline edit --------------------
beginEdit(file) {
this.editing = file.path;
const cards = Array.from(this.containerEl.querySelectorAll<HTMLElement>('.keep-card[data-path]'));
let card: HTMLElement | null = null;
for (const c of cards) {
if (c.dataset.path === file.path) { card = c; break; }
}
if (!card) return;
card.addClass('is-editing-card');
const body = card.querySelector<HTMLElement>('.keep-card-body');
if (!body) return;
body.empty();
this.app.vault
.read(file)
.then((raw) => this.mountEditor(body, file, raw))
.catch((err: Error) => new obsidian.Notice('Read failed: ' + err.message));
}
mountEditor(body: HTMLElement, file: TFile, raw: string): void {
const { front, body: stripped } = stripFrontmatter(raw);
const ta = body.createEl('textarea', { cls: 'keep-card-editor' });
ta.value = stripped;
ta.setCssStyles({ height: 'auto' });
activeWindow.setTimeout(() => {
ta.focus();
ta.setCssStyles({ height: `${ta.scrollHeight}px` });
}, 0);
ta.addEventListener('input', () => {
ta.setCssStyles({ height: 'auto' });
ta.setCssStyles({ height: `${ta.scrollHeight}px` });
});
ta.addEventListener('click', (e) => e.stopPropagation());
const save = async () => {
try {
this.suppressEvents++;
await this.app.vault.modify(file, front + ta.value);
} catch (err) {
new obsidian.Notice('Save failed: ' + err.message);
}
};
ta.addEventListener('blur', () => {
void save().then(() => {
this.editing = null;
this.scheduleRender();
});
});
ta.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
ta.blur();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
ta.blur();
}
});
}
// -------------------- color picker --------------------
toggleColorPicker(card, file) {
const existing = card.querySelector('.keep-color-picker');
if (existing) {
existing.remove();
return;
}
const picker = card.createDiv({ cls: 'keep-color-picker' });
picker.addEventListener('click', (e) => e.stopPropagation());
for (const c of COLORS) {
const swatch = picker.createDiv({
cls: 'keep-color-swatch',
attr: { 'aria-label': c.name, title: c.name },
});
if (c.value) swatch.setCssStyles({ background: c.value });
else swatch.addClass('keep-color-swatch--default');
swatch.addEventListener('click', async (e) => {
e.stopPropagation();
await this.setColor(file, c.value);
picker.remove();
});
}
}
// -------------------- frontmatter mutations --------------------
async togglePinned(file) {
try {
if (!this.plugin.settings.pinnedFiles) this.plugin.settings.pinnedFiles = {};
const data = this.plugin.settings.pinnedFiles;
if (this.isPinned(file)) {
data[file.path] = false;
} else {
data[file.path] = this.maxPinOrder() + 1;
}
await this.plugin.saveData(this.plugin.settings);
this.scheduleRender();
this.refreshActiveModal(file);
} catch (err) {
new obsidian.Notice('Could not pin: ' + err.message);
}
}
maxPinOrder() {
const data = this.plugin.settings.pinnedFiles || {};
let m = 0;
for (const k in data) {
const v = data[k];
if (typeof v === 'number' && v > m) m = v;
}
for (const f of this.app.vault.getMarkdownFiles()) {
if (data[f.path] !== undefined) continue;
const fc = this.app.metadataCache.getFileCache(f);
const v = fc && fc.frontmatter && fc.frontmatter.pinOrder;
if (typeof v === 'number' && v > m) m = v;
}
return m;
}
async swapPinOrder(a, b) {
try {
if (!this.plugin.settings.pinnedFiles) this.plugin.settings.pinnedFiles = {};
const data = this.plugin.settings.pinnedFiles;
let aOrder = this.pinOrderOf(a);
let bOrder = this.pinOrderOf(b);
const max = this.maxPinOrder();
if (aOrder === Number.MAX_SAFE_INTEGER) aOrder = max + 1;
if (bOrder === Number.MAX_SAFE_INTEGER) bOrder = max + 2;
data[a.path] = bOrder;
data[b.path] = aOrder;
await this.plugin.saveData(this.plugin.settings);
this.scheduleRender();
} catch (err) {
new obsidian.Notice('Reorder failed: ' + err.message);
}
}
async toggleArchived(file) {
try {
if (!this.plugin.settings.archivedFiles) this.plugin.settings.archivedFiles = {};
const data = this.plugin.settings.archivedFiles;
const wasArchived = this.isArchived(file);
data[file.path] = !wasArchived;
await this.plugin.saveData(this.plugin.settings);
this.scheduleRender();
this.refreshActiveModal(file);
this.showUndoToast(wasArchived ? 'Unarchived' : 'Archived', async () => {
const d = this.plugin.settings.archivedFiles;
d[file.path] = wasArchived;
await this.plugin.saveData(this.plugin.settings);
this.scheduleRender();
this.refreshActiveModal(file);
});
} catch (err) {
new obsidian.Notice('Could not archive: ' + err.message);
}
}
async setColor(file, color) {
try {
if (!this.plugin.settings.cardColors) this.plugin.settings.cardColors = {};
this.plugin.settings.cardColors[file.path] = color;
await this.plugin.saveData(this.plugin.settings);
this.applyCardColor(file.path, color);
this.refreshActiveModal(file);
} catch (err) {
new obsidian.Notice('Could not set color: ' + err.message);
}
}
applyCardColor(path: string, color: string | null) {
const cards = Array.from(this.containerEl.querySelectorAll<HTMLElement>('.keep-card[data-path]'));
for (const card of cards) {
if (card.dataset.path !== path) continue;
card.setCssStyles({ background: '', borderLeft: '' });
card.removeClass('has-card-color');
if (color) {
if (this.plugin.settings.colorAsBorder) {
card.setCssStyles({ borderLeft: `4px solid ${color}` });
} else {
card.setCssStyles({ background: color });
card.addClass('has-card-color');
}
}
break;
}
}
async addTagToFile(file, tag) {
try {
await this.app.fileManager.processFrontMatter(file, (fm) => {
const cur = fm.tags;
const target = tag.replace(/^#/, '');
if (Array.isArray(cur)) {
if (!cur.includes(target)) cur.push(target);
} else if (typeof cur === 'string') {
if (cur !== target) fm.tags = [cur, target];
} else {
fm.tags = [target];
}
});
new obsidian.Notice('Added #' + tag);
} catch (err) {
new obsidian.Notice('Could not add tag: ' + err.message);
}
}
// -------------------- context menu --------------------
openContextMenu(evt, file) {
const menu = new obsidian.Menu();
const isPinnedNow = this.isPinned(file);
const isArchivedNow = this.isArchived(file);
menu.addItem((mi) =>
mi.setTitle('Open in new tab').setIcon('external-link').onClick(() => {
void this.app.workspace.getLeaf('tab').openFile(file);
})
);
menu.addItem((mi) =>
mi.setTitle('Open in new pane').setIcon('separator-vertical').onClick(() => {
void this.app.workspace.getLeaf('split').openFile(file);
})
);
menu.addSeparator();
menu.addItem((mi) =>
mi.setTitle('Edit in card').setIcon('pencil').onClick(() => this.beginEdit(file))
);
menu.addItem((mi) =>
mi
.setTitle(isPinnedNow ? 'Unpin' : 'Pin')
.setIcon(isPinnedNow ? 'pin-off' : 'pin')
.onClick(() => void this.togglePinned(file))
);
menu.addItem((mi) =>
mi
.setTitle(isArchivedNow ? 'Unarchive' : 'Archive')
.setIcon(isArchivedNow ? 'archive-restore' : 'archive')
.onClick(() => void this.toggleArchived(file))
);
menu.addSeparator();
menu.addItem((mi) =>
mi.setTitle('Rename').setIcon('input').onClick(() => {
const old = file.basename;
const m = new RenameModal(this.app, old, async (next) => {
if (!next || next === old) return;
const newPath = (file.parent && file.parent.path && file.parent.path !== '/' ? file.parent.path + '/' : '') + next + '.md';
try {
await this.app.fileManager.renameFile(file, newPath);
} catch (err) {
new obsidian.Notice('Rename failed: ' + err.message);
}
});
m.open();
})
);
menu.addItem((mi) =>
mi.setTitle('Reveal in file explorer').setIcon('folder').onClick(() => {
const fe = this.app.workspace.getLeavesOfType('file-explorer')[0];
const feView = fe && (fe.view as { revealInFolder?: (f: TFile) => void });
if (feView && feView.revealInFolder) {
feView.revealInFolder(file);
} else {
void this.app.workspace.revealLeaf(this.app.workspace.getLeaf());
}
})
);
menu.addItem((mi) =>
mi.setTitle('Copy Obsidian link').setIcon('link').onClick(async () => {
await navigator.clipboard.writeText('[[' + file.basename + ']]');
new obsidian.Notice('Link copied');
})
);
menu.addItem((mi) =>
mi.setTitle('Copy embed').setIcon('layers').onClick(async () => {
await navigator.clipboard.writeText('![[' + file.basename + ']]');
new obsidian.Notice('Embed copied');
})
);
if (isWallDensity(this.plugin.settings.density)) {
const wall = this.getOrCreateCurrentWall();
if (wall.positions[file.path]) {
menu.addSeparator();
menu.addItem((mi) =>
mi.setTitle('Remove from wall').setIcon('x').onClick(() => this.removeFromCurrentWall(file))
);
}
}
menu.addSeparator();
menu.addItem((mi) =>
mi.setTitle('Delete note').setIcon('trash').onClick(async () => {
try {
await this.app.fileManager.trashFile(file);
this.showUndoToast('Deleted', null);
} catch (err) {
new obsidian.Notice('Delete failed: ' + (err as Error).message);
}
})
);
menu.showAtMouseEvent(evt);
}
// -------------------- selection + bulk --------------------
toggleSelection(path, range) {
if (range && this.lastClickedPath) {
const cards = Array.from(this.containerEl.querySelectorAll<HTMLElement>('.keep-card[data-path]'));
const paths = cards.map((c) => c.dataset.path);
const i1 = paths.indexOf(this.lastClickedPath);
const i2 = paths.indexOf(path);
if (i1 !== -1 && i2 !== -1) {
const lo = Math.min(i1, i2);
const hi = Math.max(i1, i2);
for (let i = lo; i <= hi; i++) this.selection.add(paths[i]);
this.lastClickedPath = path;
this.refreshSelectionUI();
return;
}
}
if (this.selection.has(path)) this.selection.delete(path);
else this.selection.add(path);
this.lastClickedPath = path;
this.refreshSelectionUI();
}
refreshSelectionUI() {
const cards = this.containerEl.querySelectorAll<HTMLElement>('.keep-card[data-path]');
cards.forEach((c) => {
const sel = this.selection.has(c.dataset.path);
c.toggleClass('is-selected', sel);
const cb = c.querySelector<HTMLInputElement>('.keep-card-checkbox');
if (cb) cb.checked = sel;
});
this.renderBulkActions(this.contentEl);
}
renderBulkActions(parent) {
const existing = parent.querySelector('.keep-cards-bulk');
if (existing) existing.remove();
if (this.selection.size === 0) return;
const bar = parent.createDiv({ cls: 'keep-cards-bulk' });
bar.createSpan({ cls: 'keep-cards-bulk-count', text: this.selection.size + ' selected' });
const make = (icon, label, fn) => {
const b = bar.createEl('button', { cls: 'keep-cards-bulk-btn', attr: { type: 'button', title: label, 'aria-label': label } });
obsidian.setIcon(b, icon);
b.addEventListener('click', async () => { await fn(); });
};
make('pin', 'Pin selected', async () => {
for (const path of Array.from(this.selection)) {
const f = this.app.vault.getAbstractFileByPath(path);
if (f instanceof obsidian.TFile) await this.togglePinned(f);
}
});
make('archive', 'Archive selected', async () => {
for (const path of Array.from(this.selection)) {
const f = this.app.vault.getAbstractFileByPath(path);
if (f instanceof obsidian.TFile) await this.toggleArchived(f);
}
this.selection.clear();
});
const colorBtn = bar.createEl('button', { cls: 'keep-cards-bulk-btn', attr: { type: 'button', title: 'Color', 'aria-label': 'Color' } });
obsidian.setIcon(colorBtn, 'palette');
colorBtn.addEventListener('click', (e) => {
const menu = new obsidian.Menu();
for (const c of COLORS) {
menu.addItem((mi) =>
mi.setTitle(c.name).onClick(async () => {
for (const path of Array.from(this.selection)) {
const f = this.app.vault.getAbstractFileByPath(path);
if (f instanceof obsidian.TFile) await this.setColor(f, c.value);
}
})
);
}
menu.showAtMouseEvent(e);
});
make('trash', 'Delete selected', () => {
const paths = Array.from(this.selection);
const message = `Delete ${paths.length} note${paths.length === 1 ? '' : 's'}?`;
new ConfirmModal(this.app, 'Delete selected', message, 'Delete', async () => {
for (const path of paths) {
const f = this.app.vault.getAbstractFileByPath(path);
if (f instanceof obsidian.TFile) {
try {
await this.app.fileManager.trashFile(f);
} catch {
/* skip individual failures */
}
}
}
this.selection.clear();
this.refreshSelectionUI();
}).open();
});
make('x', 'Clear selection', () => {
this.selection.clear();
this.refreshSelectionUI();
});
}
// -------------------- capture --------------------
async captureNote(
content: string,
opts?: { title?: string; pin?: boolean; color?: string | null },
): Promise<void> {
const folder = this.plugin.settings.notesFolder.trim();
const explicitTitle = opts?.title?.trim();
const fallbackTitle = content.trim().split('\n')[0].slice(0, 60);
const safe = (explicitTitle || fallbackTitle || '')
.replace(/[\\/:*?"<>|#]/g, '')
.trim() || 'Untitled';
const base = (folder ? folder + '/' : '') + safe;
let path = base + '.md';
let i = 1;
while (this.app.vault.getAbstractFileByPath(path)) {
path = base + ' ' + i++ + '.md';
}
try {
if (folder) {
try {
await this.app.vault.createFolder(folder);
} catch {
/* folder may already exist */
}
}
const created = await this.app.vault.create(path, content);
let dirty = false;
if (opts?.pin) {
if (!this.plugin.settings.pinnedFiles) this.plugin.settings.pinnedFiles = {};
const max = this.maxPinOrder();
this.plugin.settings.pinnedFiles[created.path] = max + 1;
dirty = true;
}
if (opts && opts.color !== undefined && opts.color !== null) {
if (!this.plugin.settings.cardColors) this.plugin.settings.cardColors = {};
this.plugin.settings.cardColors[created.path] = opts.color;
dirty = true;
}
if (isWallDensity(this.plugin.settings.density)) {
const wall = this.getOrCreateCurrentWall();
wall.positions[created.path] = { x: 0, y: 0, rotation: 0, z: 0 };
dirty = true;
}
if (dirty) await this.plugin.saveData(this.plugin.settings);
} catch (err) {
new obsidian.Notice('Could not create note: ' + (err as Error).message);
}
}
// -------------------- undo toast --------------------
showUndoToast(label, undo) {
const root = this.containerEl;
const old = root.querySelector('.keep-undo-toast');
if (old) old.remove();
const toast = root.createDiv({ cls: 'keep-undo-toast' });
toast.createSpan({ text: label });
if (undo) {
const btn = toast.createEl('button', { text: 'Undo', cls: 'keep-undo-btn', attr: { type: 'button' } });
btn.addEventListener('click', () => {
void Promise.resolve(undo())
.catch((err: Error) => new obsidian.Notice('Undo failed: ' + err.message))
.finally(() => toast.remove());
});
}
activeWindow.setTimeout(() => toast.remove(), 5000);
}
// -------------------- keyboard --------------------
handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
const tag = target ? target.tagName : '';
const inField = tag === 'INPUT' || tag === 'TEXTAREA' || (target.instanceOf(HTMLElement) && target.isContentEditable);
if (e.key === '/' && !inField) {
e.preventDefault();
const search = this.containerEl.querySelector<HTMLInputElement>('.keep-cards-search');
if (search) search.focus();
return;
}
if (e.key === 'Escape') {
if (this.selection.size > 0) {
this.selection.clear();
this.refreshSelectionUI();
e.preventDefault();
return;
}
}
if (inField) return;
const cards = Array.from(this.containerEl.querySelectorAll<HTMLElement>('.keep-card[data-path]'));
if (cards.length === 0) return;
let idx = cards.findIndex((c) => c.dataset.path === this.focusedPath);
if (idx === -1) idx = 0;
const moveTo = (n: number) => {
n = Math.max(0, Math.min(cards.length - 1, n));
const c = cards[n];
this.focusedPath = c.dataset.path!;
cards.forEach((x) => x.removeClass('is-focused'));
c.addClass('is-focused');
c.scrollIntoView({ block: 'nearest' });
};
if (e.key === 'ArrowRight') { e.preventDefault(); moveTo(idx + 1); return; }
if (e.key === 'ArrowLeft') { e.preventDefault(); moveTo(idx - 1); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); moveTo(idx + 1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); moveTo(idx - 1); return; }
if (e.key === 'Enter') {
const c = cards[idx];
if (!c) return;
const f = this.app.vault.getAbstractFileByPath(c.dataset.path);
if (f instanceof obsidian.TFile) this.openNotePreview(f);
e.preventDefault();
return;
}
if (e.key === 'e') {
const c = cards[idx];
if (!c) return;
const f = this.app.vault.getAbstractFileByPath(c.dataset.path);
if (f instanceof obsidian.TFile) this.beginEdit(f);
e.preventDefault();
return;
}
if (e.key === 'p') {
const c = cards[idx];
if (!c) return;
const f = this.app.vault.getAbstractFileByPath(c.dataset.path);
if (f instanceof obsidian.TFile) void this.togglePinned(f);
e.preventDefault();
return;
}
if (e.key === 'a') {
const c = cards[idx];
if (!c) return;
const f = this.app.vault.getAbstractFileByPath(c.dataset.path);
if (f instanceof obsidian.TFile) void this.toggleArchived(f);
e.preventDefault();
return;
}
if (e.key === 'x') {
const c = cards[idx];
if (!c) return;
this.toggleSelection(c.dataset.path, false);
e.preventDefault();
return;
}
}
}
// ============================ MODALS ============================
class NotePreviewModal extends obsidian.Modal {
plugin: KeepCardsPlugin;
view: CardsView;
file: TFile;
editing: boolean;
component: obsidian.Component | null;
eventRef: obsidian.EventRef | null;
_currentFront: string;
_currentTextarea: HTMLTextAreaElement | null;
_dirty: boolean;
constructor(app: obsidian.App, plugin: KeepCardsPlugin, view: CardsView, file: TFile) {
super(app);
this.plugin = plugin;
this.view = view;
this.file = file;
this.editing = false;
this.component = null;
this.eventRef = null;
this._currentFront = '';
this._currentTextarea = null;
this._dirty = false;
}
onOpen() {
this.modalEl.addClass('keep-note-modal');
this.component = new obsidian.Component();
this.component.load();
this.eventRef = this.app.metadataCache.on('changed', (changedFile) => {
if (this.editing) return;
if (changedFile && changedFile.path === this.file.path) {
this.rerender();
}
});
this.rerender();
}
applyNoteColor(): void {
const dataColor = (this.plugin.settings.cardColors || {})[this.file.path];
const fc = this.app.metadataCache.getFileCache(this.file);
const fmColor = fc && fc.frontmatter && fc.frontmatter.color;
const noteColor = dataColor !== undefined ? dataColor : fmColor;
if (noteColor) {
this.modalEl.addClass('has-note-color');
this.modalEl.setCssStyles({ background: String(noteColor) });
} else {
this.modalEl.removeClass('has-note-color');
this.modalEl.setCssStyles({ background: '' });
}
}
rerender(): void {
this.titleEl.empty();
this.contentEl.empty();
this.renderHeader();
void this.renderBody();
}
renderHeader() {
const isPinnedNow = this.view.isPinned(this.file);
const isArchivedNow = this.view.isArchived(this.file);
this.applyNoteColor();
const row = this.titleEl.createDiv({ cls: 'keep-modal-titlerow' });
row.createDiv({ cls: 'keep-modal-title', text: this.file.basename });
const actions = row.createDiv({ cls: 'keep-modal-actions' });
this.makeAction(actions, isPinnedNow ? 'pin-off' : 'pin', isPinnedNow ? 'Unpin' : 'Pin', async () => {
await this.view.togglePinned(this.file);
});
this.makeAction(actions, this.editing ? 'eye' : 'pencil', this.editing ? 'View' : 'Edit', async () => {
if (this.editing) {
await this.saveEdit();
this.editing = false;
} else {
this.editing = true;
}
this.rerender();
});
this.makeAction(actions, 'palette', 'Color', (e) => {
const menu = new obsidian.Menu();
for (const c of COLORS) {
menu.addItem((mi) =>
mi.setTitle(c.name).onClick(async () => {
await this.view.setColor(this.file, c.value);
})
);
}
menu.showAtMouseEvent(e);
});
this.makeAction(actions, isArchivedNow ? 'archive-restore' : 'archive', isArchivedNow ? 'Unarchive' : 'Archive', async () => {
await this.view.toggleArchived(this.file);
});
this.makeAction(actions, 'external-link', 'Open in tab', () => {
if (this.editing) void this.saveEdit();
void this.app.workspace.getLeaf('tab').openFile(this.file);
this.close();
});
}
makeAction(parent, icon, label, handler) {
const btn = parent.createEl('button', {
cls: 'keep-modal-action-btn',
attr: { type: 'button', 'aria-label': label, title: label },
});
obsidian.setIcon(btn, icon);
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await handler(e);
});
}
async renderBody() {
let raw = '';
try {
raw = await this.app.vault.read(this.file);
} catch {
raw = '';
}
const { front, body: stripped } = stripFrontmatter(raw);
this._currentFront = front;
if (this.editing) {
const ta = this.contentEl.createEl('textarea', { cls: 'keep-modal-editor' });
ta.value = stripped;
this._currentTextarea = ta;
this._dirty = false;
ta.addEventListener('input', () => { this._dirty = true; });
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void this.saveEdit().then(() => {
this.editing = false;
this.rerender();
});
}
});
activeWindow.setTimeout(() => ta.focus(), 0);
return;
}
const fc = this.app.metadataCache.getFileCache(this.file);
const tags = fc ? obsidian.getAllTags(fc) : null;
if (tags && tags.length > 0) {
const tagsRow = this.contentEl.createDiv({ cls: 'keep-modal-tags' });
for (const t of tags) {
const chip = tagsRow.createSpan({ cls: 'keep-card-tag', text: t });
chip.addEventListener('click', (e) => {
e.stopPropagation();
this.view.filter = { type: 'tag', tag: t.replace(/^#/, '') };
this.view.scheduleRender();
this.close();
});
}
}
const body = this.contentEl.createDiv({ cls: 'keep-modal-body' });
const md = body.createDiv({ cls: 'keep-modal-md' });
try {
await renderMarkdownInto(this.app, stripped, md, this.file.path, this.component);
} catch {
md.setText(stripped);
}
this.wireModalCheckboxes(md);
const meta = body.createDiv({ cls: 'keep-modal-meta' });
const bl = backlinksCount(this.app, this.file);
if (bl > 0) {
const span = meta.createSpan({ cls: 'keep-card-backlinks' });
const ic = span.createSpan({ cls: 'keep-card-backlinks-icon' });
obsidian.setIcon(ic, 'links-coming-in');
span.createSpan({ text: bl + ' backlink' + (bl === 1 ? '' : 's') });
}
meta.createSpan({ cls: 'keep-modal-path', text: this.file.path });
}
wireModalCheckboxes(el) {
const boxes = el.querySelectorAll('input[type=checkbox]');
boxes.forEach((cb, i) => {
cb.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const raw = await this.app.vault.read(this.file);
let n = 0;
const updated = raw.replace(/^(\s*[-*+] \[)( |x|X)(\])/gm, (m, open, mark, close) => {
if (n++ === i) return open + (mark === ' ' ? 'x' : ' ') + close;
return m;
});
await this.app.vault.modify(this.file, updated);
} catch (err) {
new obsidian.Notice('Toggle failed: ' + err.message);
}
});
});
}
async saveEdit() {
if (!this._currentTextarea) return;
if (!this._dirty) return;
try {
const front = this._currentFront || '';
this.view.suppressEvents++;
await this.app.vault.modify(this.file, front + this._currentTextarea.value);
this._dirty = false;
} catch (err) {
new obsidian.Notice('Save failed: ' + err.message);
}
}
onClose(): void {
if (this.editing && this._dirty) {
void this.saveEdit();
}
if (this.eventRef) {
this.app.metadataCache.offref(this.eventRef);
this.eventRef = null;
}
if (this.component) {
this.component.unload();
this.component = null;
}
this.contentEl.empty();
}
}
class WallAddNotesModal extends obsidian.FuzzySuggestModal<TFile> {
plugin: KeepCardsPlugin;
wall: Wall;
onChoose: (file: TFile) => void;
constructor(
app: obsidian.App,
plugin: KeepCardsPlugin,
wall: Wall,
onChoose: (file: TFile) => void,
) {
super(app);
this.plugin = plugin;
this.wall = wall;
this.onChoose = onChoose;
this.setPlaceholder(`Add a note to "${wall.name}"…`);
}
getItems(): TFile[] {
const folder = (this.plugin.settings.notesFolder || '').trim();
return this.app.vault.getMarkdownFiles().filter((f) => {
if (folder && !f.path.startsWith(folder + '/') && f.path !== folder) return false;
return !this.wall.positions[f.path];
});
}
getItemText(item: TFile): string {
return item.basename + ' · ' + item.path;
}
onChooseItem(item: TFile): void {
this.onChoose(item);
}
}
class ConfirmModal extends obsidian.Modal {
title: string;
message: string;
confirmLabel: string;
onConfirm: () => Promise<void> | void;
constructor(
app: obsidian.App,
title: string,
message: string,
confirmLabel: string,
onConfirm: () => Promise<void> | void,
) {
super(app);
this.title = title;
this.message = message;
this.confirmLabel = confirmLabel;
this.onConfirm = onConfirm;
}
onOpen(): void {
this.titleEl.setText(this.title);
this.contentEl.createDiv({ cls: 'keep-confirm-message', text: this.message });
const row = this.contentEl.createDiv({ cls: 'keep-modal-buttons' });
const cancel = row.createEl('button', { text: 'Cancel', attr: { type: 'button' } });
cancel.addEventListener('click', () => this.close());
const ok = row.createEl('button', {
text: this.confirmLabel,
cls: 'mod-warning',
attr: { type: 'button' },
});
ok.addEventListener('click', () => {
void Promise.resolve(this.onConfirm()).then(() => this.close());
});
}
}
class RenameModal extends obsidian.Modal {
current: string;
onSubmit: (value: string) => Promise<void> | void;
value: string;
constructor(app: obsidian.App, current: string, onSubmit: (value: string) => Promise<void> | void) {
super(app);
this.current = current;
this.onSubmit = onSubmit;
this.value = current;
}
onOpen(): void {
this.titleEl.setText('Rename note');
const input = this.contentEl.createEl('input', {
attr: { type: 'text' },
cls: 'keep-modal-input',
});
input.value = this.current;
input.addEventListener('input', () => { this.value = input.value; });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
void Promise.resolve(this.onSubmit(this.value)).then(() => this.close());
}
});
activeWindow.setTimeout(() => input.focus(), 0);
const row = this.contentEl.createDiv({ cls: 'keep-modal-buttons' });
const cancel = row.createEl('button', { text: 'Cancel', attr: { type: 'button' } });
cancel.addEventListener('click', () => this.close());
const ok = row.createEl('button', { text: 'Rename', cls: 'mod-cta', attr: { type: 'button' } });
ok.addEventListener('click', () => {
void Promise.resolve(this.onSubmit(this.value)).then(() => this.close());
});
}
}
class TagListModal extends obsidian.Modal {
plugin: KeepCardsPlugin;
tags: string[];
constructor(app: obsidian.App, plugin: KeepCardsPlugin, tags: string[]) {
super(app);
this.plugin = plugin;
this.tags = tags;
}
onOpen(): void {
this.titleEl.setText('Edit labels');
const list = this.contentEl.createDiv({ cls: 'keep-modal-stack' });
for (const t of this.tags) {
const row = list.createDiv({ cls: 'keep-modal-row' });
const dot = row.createSpan({ cls: 'keep-tag-dot' });
const dotColor = this.plugin.settings.tagColors[t] || 'transparent';
dot.setCssStyles({ background: dotColor });
row.createSpan({ text: '#' + t });
row.createDiv({ cls: 'keep-modal-spacer' });
const colorBtn = row.createEl('button', { text: 'Color', attr: { type: 'button' } });
colorBtn.addEventListener('click', () => {
new TagColorModal(this.app, this.plugin, t).open();
});
const renameBtn = row.createEl('button', {
text: 'Rename',
cls: 'mod-cta',
attr: { type: 'button' },
});
renameBtn.addEventListener('click', () => {
new TagRenameModal(this.app, this.plugin, t).open();
this.close();
});
}
}
}
class TagColorModal extends obsidian.Modal {
plugin: KeepCardsPlugin;
tag: string;
constructor(app: obsidian.App, plugin: KeepCardsPlugin, tag: string) {
super(app);
this.plugin = plugin;
this.tag = tag;
}
onOpen(): void {
this.titleEl.setText('Color for #' + this.tag);
const grid = this.contentEl.createDiv({ cls: 'keep-modal-color-grid' });
for (const c of COLORS) {
const sw = grid.createDiv({ cls: 'keep-modal-color-swatch', attr: { title: c.name } });
if (c.value) sw.setCssStyles({ background: c.value });
sw.addEventListener('click', () => {
if (c.value === null) delete this.plugin.settings.tagColors[this.tag];
else this.plugin.settings.tagColors[this.tag] = c.value;
void this.plugin.saveSettings().then(() => this.close());
});
}
}
}
class TagRenameModal extends obsidian.Modal {
plugin: KeepCardsPlugin;
oldTag: string;
newTag: string;
constructor(app: obsidian.App, plugin: KeepCardsPlugin, oldTag: string) {
super(app);
this.plugin = plugin;
this.oldTag = oldTag;
this.newTag = oldTag;
}
onOpen(): void {
this.titleEl.setText(`Rename label "${this.oldTag}"`);
const input = this.contentEl.createEl('input', {
attr: { type: 'text' },
cls: 'keep-modal-input',
});
input.value = this.oldTag;
input.addEventListener('input', () => { this.newTag = input.value; });
activeWindow.setTimeout(() => input.focus(), 0);
this.contentEl.createDiv({
cls: 'keep-modal-hint',
text: 'Renames in frontmatter (tags:) and inline #tags across the vault.',
});
const row = this.contentEl.createDiv({ cls: 'keep-modal-buttons' });
const cancel = row.createEl('button', { text: 'Cancel', attr: { type: 'button' } });
cancel.addEventListener('click', () => this.close());
const ok = row.createEl('button', { text: 'Rename', cls: 'mod-cta', attr: { type: 'button' } });
ok.addEventListener('click', () => {
void this.doRename().then(() => this.close());
});
}
async doRename() {
const oldT = this.oldTag.replace(/^#/, '');
const newT = (this.newTag || '').replace(/^#/, '').trim();
if (!newT || newT === oldT) return;
const files = this.app.vault.getMarkdownFiles();
let count = 0;
for (const file of files) {
try {
await this.app.fileManager.processFrontMatter(file, (fm) => {
if (!fm.tags) return;
if (Array.isArray(fm.tags)) {
const idx = fm.tags.findIndex((x) => String(x).replace(/^#/, '') === oldT);
if (idx !== -1) fm.tags[idx] = newT;
} else if (typeof fm.tags === 'string') {
if (fm.tags.replace(/^#/, '') === oldT) fm.tags = newT;
}
});
const raw = await this.app.vault.read(file);
const re = new RegExp('(^|[^\\w/])#' + escapeRegex(oldT) + '(?![\\w/-])', 'g');
if (re.test(raw)) {
const updated = raw.replace(re, '$1#' + newT);
if (updated !== raw) {
await this.app.vault.modify(file, updated);
count++;
}
}
} catch {
// skip files that fail to update; rename is best-effort across the vault
}
}
if (this.plugin.settings.tagColors[oldT]) {
this.plugin.settings.tagColors[newT] = this.plugin.settings.tagColors[oldT];
delete this.plugin.settings.tagColors[oldT];
await this.plugin.saveSettings();
}
new obsidian.Notice('Renamed #' + oldT + ' → #' + newT + ' (' + count + ' file' + (count === 1 ? '' : 's') + ' changed)');
}
}
// ============================ SETTINGS ============================
class KeepCardsSettingTab extends obsidian.PluginSettingTab {
plugin: KeepCardsPlugin;
constructor(app: obsidian.App, plugin: KeepCardsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new obsidian.Setting(containerEl)
.setName('Notes folder')
.setDesc('Only show notes from this folder. Leave empty for the whole vault.')
.addText((t) =>
t.setValue(this.plugin.settings.notesFolder).onChange(async (v) => {
this.plugin.settings.notesFolder = v.trim();
await this.plugin.saveSettings();
})
);
new obsidian.Setting(containerEl)
.setName('Preview lines')
.setDesc('Approximate lines of body shown on each card.')
.addText((t) =>
t.setValue(String(this.plugin.settings.previewLines)).onChange(async (v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n > 0) {
this.plugin.settings.previewLines = n;
await this.plugin.saveSettings();
}
})
);
new obsidian.Setting(containerEl)
.setName('Color as left border')
.setDesc('Show note color as a 4px left border instead of the full card background.')
.addToggle((t) =>
t.setValue(this.plugin.settings.colorAsBorder).onChange(async (v) => {
this.plugin.settings.colorAsBorder = v;
await this.plugin.saveSettings();
})
);
new obsidian.Setting(containerEl)
.setName('Pinned section header')
.setDesc('Group pinned cards under a "pinned" / "others" divider.')
.addToggle((t) =>
t.setValue(this.plugin.settings.pinnedHeader).onChange(async (v) => {
this.plugin.settings.pinnedHeader = v;
await this.plugin.saveSettings();
})
);
new obsidian.Setting(containerEl)
.setName('Wall background')
.setDesc('Background texture used in wall mode.')
.addDropdown((dd) => {
const options: Record<WallBackground, string> = {
theme: 'Theme (auto)',
cork: 'Cork',
whiteboard: 'Whiteboard',
slate: 'Slate',
paper: 'Paper',
graph: 'Graph paper',
blueprint: 'Blueprint',
linen: 'Linen',
chalkboard: 'Chalkboard',
};
for (const k of Object.keys(options) as WallBackground[]) {
dd.addOption(k, options[k]);
}
dd.setValue(this.plugin.settings.wallBackground);
dd.onChange(async (v) => {
this.plugin.settings.wallBackground = v as WallBackground;
await this.plugin.saveSettings();
});
});
new obsidian.Setting(containerEl)
.setName('Show image thumbnails')
.setDesc('Render the first attached image at the top of each card.')
.addToggle((t) =>
t.setValue(this.plugin.settings.showImages).onChange(async (v) => {
this.plugin.settings.showImages = v;
await this.plugin.saveSettings();
})
);
new obsidian.Setting(containerEl)
.setName('Auto-collapse sidebars in card view')
.setDesc('Collapse the left and right sidebars while a cards view is active; restore them when you leave.')
.addToggle((t) =>
t.setValue(this.plugin.settings.autoCollapseSidebars).onChange((v) => {
this.plugin.settings.autoCollapseSidebars = v;
void this.plugin.saveSettings();
if (!v) this.plugin.restoreSidebars();
else this.plugin.handleActiveLeafChange(
this.app.workspace.getActiveViewOfType(CardsView)?.leaf ?? null
);
})
);
}
}
export default KeepCardsPlugin;