mirror of
https://github.com/philemonchiro/obsidian-notekeeper.git
synced 2026-07-22 06:51:40 +00:00
The bigger half is the new "wall" view: a sticky-note styled, vertically-scrolled, curated subset of your notes. - New density modes 'wall' and 'wall-compact' selectable from the toolbar. Wall is now the default density on fresh installs. - Per-card sticky-note look: deterministic 1-3 degree rotation seeded by file path, paper-style drop shadow, hover lift to 0deg. - Eight wall backgrounds (cork, whiteboard, slate, paper, graph, blueprint, linen, chalkboard) chosen from the settings tab. Default: slate. - Walls are curated, not auto-filled. The wall starts empty; users add notes via the floating + button (FuzzySuggestModal picker) or via the empty state's "Fill from current view" shortcut (only offered when the current filter has <= 200 notes). - Right-click any card on the wall: "Remove from wall". - Command "Clear wall" (palette) wipes the current wall. - The freeform-canvas prototype was scrapped in favor of the column layout after testing showed dragging cards onto a giant scattered canvas was unusable at vault scale. Bot fixes: - async onClose / async render / async make-trash arrow had no awaits. Made onClose and onOpen non-async (return Promise.resolve) to match Obsidian's signature; render is now sync; the trash arrow is sync too (its body's awaits are inside a ConfirmModal callback). - Switched view content access from this.containerEl.children[1] to this.contentEl. Fixes a tab-switching ghosting bug — newer Obsidian versions can wrap views in extra layers, shifting the children index, so renders were occasionally landing on the wrong element. Per the developer docs, contentEl is the stable getter. - The wall FAB moved from position: fixed to position: absolute inside the body container, so it disappears with its tab instead of bleeding across panes. - Added @typescript-eslint/require-await to the local eslint config so the next round of these doesn't slip past.
2558 lines
84 KiB
TypeScript
2558 lines
84 KiB
TypeScript
import * as obsidian from 'obsidian';
|
|
import type { TFile, WorkspaceLeaf } from 'obsidian';
|
|
|
|
// ============================ TYPES ============================
|
|
type Density = 'comfortable' | 'compact' | 'list' | 'wall' | 'wall-compact';
|
|
|
|
type WallBackground =
|
|
| '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: 'slate',
|
|
};
|
|
|
|
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();
|
|
|
|
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);
|
|
|
|
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) {
|
|
const wrap = header.createDiv({ cls: 'keep-cards-capture-wrap' });
|
|
const ta = wrap.createEl('textarea', {
|
|
cls: 'keep-cards-capture',
|
|
attr: { placeholder: 'Take a note…', rows: '1' },
|
|
});
|
|
|
|
const grow = () => {
|
|
ta.setCssStyles({ height: 'auto' });
|
|
ta.setCssStyles({ height: `${Math.min(ta.scrollHeight, 240)}px` });
|
|
};
|
|
ta.addEventListener('input', grow);
|
|
ta.addEventListener('focus', grow);
|
|
ta.addEventListener('blur', () => {
|
|
ta.setCssStyles({ height: '' });
|
|
});
|
|
ta.addEventListener('keydown', async (e) => {
|
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
if (ta.value.trim()) {
|
|
await this.captureNote(ta.value);
|
|
ta.value = '';
|
|
grow();
|
|
}
|
|
} else if (e.key === 'Enter' && !e.shiftKey && ta.value.indexOf('\n') === -1 && ta.value.trim()) {
|
|
e.preventDefault();
|
|
await this.captureNote(ta.value);
|
|
ta.value = '';
|
|
grow();
|
|
}
|
|
});
|
|
ta.addEventListener('paste', async (e) => {
|
|
const items = e.clipboardData && e.clipboardData.items;
|
|
if (!items) return;
|
|
for (const item of items) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
const file = item.getAsFile();
|
|
if (!file) continue;
|
|
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);
|
|
const ins = `\n![[${path}]]`;
|
|
ta.value = ta.value + ins;
|
|
grow();
|
|
} catch (err) {
|
|
new obsidian.Notice('Paste failed: ' + err.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);
|
|
this.renderWallFloatingActions(grid, wall);
|
|
return;
|
|
}
|
|
|
|
onWall.sort(this.getSortFn());
|
|
|
|
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);
|
|
|
|
this.renderWallFloatingActions(grid, wall);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
renderWallFloatingActions(grid: HTMLElement, wall: Wall): void {
|
|
const body = grid.parentElement;
|
|
if (!body) return;
|
|
const fab = body.createDiv({ cls: 'keep-wall-fab' });
|
|
fab.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
|
|
const addBtn = fab.createEl('button', {
|
|
cls: 'keep-wall-fab-btn keep-wall-fab-add',
|
|
attr: { type: 'button', 'aria-label': 'Add notes', title: 'Add notes to this wall' },
|
|
});
|
|
obsidian.setIcon(addBtn, 'plus');
|
|
addBtn.addEventListener('click', () => this.openAddToWallPicker(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) });
|
|
}
|
|
} 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 });
|
|
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: '' });
|
|
if (color) {
|
|
if (this.plugin.settings.colorAsBorder) {
|
|
card.setCssStyles({ borderLeft: `4px solid ${color}` });
|
|
} else {
|
|
card.setCssStyles({ background: 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) {
|
|
const folder = this.plugin.settings.notesFolder.trim();
|
|
const firstLine = content.trim().split('\n')[0].slice(0, 60);
|
|
const safe = firstLine.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 */
|
|
}
|
|
}
|
|
await this.app.vault.create(path, content);
|
|
} 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();
|
|
}
|
|
|
|
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);
|
|
|
|
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> = {
|
|
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;
|