commit 06b3bd4a350760489b5b038490c55492f71de1bd Author: Philemon Chiro <49866367+PhilemonChiro@users.noreply.github.com> Date: Wed Apr 29 15:28:42 2026 +0200 Initial release: Notekeeper 0.1.0 A Google Keep-style masonry card view for Obsidian. Quick capture, inline edit, pin, color, archive, multi-select, lazy infinite scroll, smart search, three densities, and a markdown-rendered preview modal. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb62478 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +*.log +data.json +.env +.env.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5052264 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Philemon Chiro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..765404f --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Notekeeper + +A Google Keep–style masonry of cards for your Obsidian vault. Browse, capture, pin, color, archive, and filter your notes from one screen — without leaving the grid. + + + +## Features + +- **Masonry card layout** — every note as a card, sized to its content, balanced across columns +- **Quick capture** — type into the centered input, hit Enter to create a note +- **Inline edit** — click the pencil to edit a card without leaving the view +- **Note preview modal** — click any card to open a full markdown-rendered preview with one-click pin / color / archive / open-in-tab actions +- **Pin, color, archive** — per-card hover actions; 12-color palette +- **Drag pinned cards** to reorder them +- **Multi-select** — shift- or cmd-click to select multiple cards; bulk pin / archive / color / delete from a floating action bar +- **Right-click context menu** — rename, copy link, copy embed, open in new pane, delete +- **Inline checkboxes** — `- [ ]` items render as live checkboxes that update the source file +- **Image thumbnails** — first attached image surfaces at the top of each card +- **Backlinks badge** — count of incoming links on each card +- **Smart search** — combine free text with `tag:`, `pinned:`, `archived:`, `color:`, `path:`, `is:` filters +- **Sort by** modified, created, or title — pinned cards always float to the top +- **Three densities** — comfortable, compact, list view +- **Lazy infinite scroll** — fast on vaults with thousands of notes; cards populate only as they scroll into view +- **Auto-collapse side panes** — Obsidian's left and right sidebars collapse while the card view is active and restore when you leave +- **Keyboard navigation** — arrow keys, `Enter`, `e` (edit), `p` (pin), `a` (archive), `x` (select), `/` (focus search) +- **Tag chips are clickable** — filter by any label with one click; clear with the active-filter chip's `×` +- **Manage labels** command — rename a tag across the whole vault and assign per-tag colors + +## Frontmatter conventions + +Cards read these fields from each note's YAML frontmatter: + +```yaml +--- +pinned: true +color: "#5c2b29" +archived: false +tags: [ideas, work] +pinOrder: 1 +--- +``` + +- `pinned: true` floats the card to the top +- `color` paints the card background (or a left border, depending on settings) +- `archived: true` hides the card from the default view; toggle the archive button in the toolbar to see it +- `tags` populate the searchable label list +- `pinOrder` (number) controls the order among pinned cards; updated automatically when you drag + +All of these mutations happen via Obsidian's safe `processFrontMatter` API, so they preserve your existing frontmatter. + +## Search syntax + +Type any of these in the search box: + +| Token | Meaning | +| --- | --- | +| `tag:foo` | Notes carrying the `foo` tag (frontmatter or inline) | +| `pinned:true` / `pinned:false` | Filter by pin state | +| `archived:true` | Show only archived notes | +| `color:#5c2b29` | Notes with this exact color | +| `path:Inbox` | Notes whose path includes "Inbox" | +| `is:archived`, `is:pinned` | Shortcuts for the above | +| Free text | Matches title and path | + +Combine freely: `tag:work pinned:true urgent` + +## Keyboard shortcuts + +When the card view has focus: + +| Key | Action | +| --- | --- | +| `/` | Focus the search box | +| `↑ ↓ ← →` | Move focus between cards | +| `Enter` | Open the focused card in the preview modal | +| `e` | Begin inline edit on the focused card | +| `p` | Toggle pin | +| `a` | Toggle archive | +| `x` | Toggle selection | +| `Esc` | Clear selection / exit edit mode | + +## Settings + +- **Notes folder** — limit the card view to a single folder (leave empty for the entire vault) +- **Preview lines** — body lines visible on each card +- **Color as left border** — show note color as a 4px left border instead of the full card background +- **Pinned section header** — group pinned cards under a "PINNED / OTHERS" divider +- **Show image thumbnails** — render the first attached image on each card +- **Auto-collapse sidebars in card view** — collapse Obsidian's side panes while the card view is active + +## Commands + +- **Open card view** — opens the masonry view in a new tab (also accessible via the ribbon icon) +- **Manage labels** — opens a modal listing every tag in your vault, with rename and color actions + +## Installation + +### From the community plugin store (after approval) + +Open Obsidian → Settings → Community plugins → Browse, search "Notekeeper", install, and enable. + +### Manual install + +1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/PhilemonChiro/obsidian-notekeeper/releases) +2. Copy them into `/.obsidian/plugins/notekeeper/` +3. Reload Obsidian and enable Notekeeper in Settings → Community plugins + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/main.js b/main.js new file mode 100644 index 0000000..6cb2d33 --- /dev/null +++ b/main.js @@ -0,0 +1,2059 @@ +'use strict'; + +const obsidian = require('obsidian'); + +// ============================ CONSTANTS ============================ +const VIEW_TYPE_CARDS = 'keep-cards-view'; + +const COLORS = [ + { 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 = { + notesFolder: '', + previewLines: 10, + density: 'comfortable', + colorAsBorder: false, + tagColors: {}, + pinnedHeader: true, + showImages: true, + autoCollapseSidebars: true, + sortMode: 'mtime-desc', +}; + +const SORT_LABELS = { + '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) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function parseQuery(raw) { + const parts = raw.match(/(?:[^\s"]+|"[^"]*")+/g) || []; + const out = { 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, content, el, sourcePath, component) { + if (obsidian.MarkdownRenderer.render) { + await obsidian.MarkdownRenderer.render(app, content, el, sourcePath, component); + } else if (obsidian.MarkdownRenderer.renderMarkdown) { + await obsidian.MarkdownRenderer.renderMarkdown(content, el, sourcePath, component); + } else { + el.setText(content); + } +} + +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 { + constructor(host) { + this.host = host; + this.columns = []; + this.heights = []; + } + + setColumnCount(n) { + 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, estHeight) { + 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 { + constructor() { + super(...arguments); + this.settings = DEFAULT_SETTINGS; + } + + async onload() { + 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: '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) => set.add(t.replace(/^#/, ''))); + } + new TagListModal(this.app, this, Array.from(set).sort()).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.activeLeaf); + }); + } + + onunload() { + this.restoreSidebars(); + } + + handleActiveLeafChange(leaf) { + 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) { + if (!leaf || !leaf.view) return false; + if (typeof leaf.view.getViewType !== 'function') return false; + try { + return leaf.view.getViewType() === VIEW_TYPE_CARDS; + } catch (e) { + return false; + } + } + + captureAndCollapse() { + const ws = this.app.workspace; + this.savedSidebarState = { + left: ws.leftSplit ? !!ws.leftSplit.collapsed : false, + right: ws.rightSplit ? !!ws.rightSplit.collapsed : false, + }; + this.collapseSidebars(); + } + + collapseSidebars() { + const apply = () => { + const ws = this.app.workspace; + try { if (ws.leftSplit && !ws.leftSplit.collapsed) ws.leftSplit.collapse(); } catch (e) {} + try { if (ws.rightSplit && !ws.rightSplit.collapsed) ws.rightSplit.collapse(); } catch (e) {} + }; + apply(); + window.setTimeout(apply, 60); + window.setTimeout(apply, 240); + } + + restoreSidebars() { + 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 (e) {} + try { if (!right && ws.rightSplit && ws.rightSplit.collapsed) ws.rightSplit.expand(); } catch (e) {} + }; + apply(); + window.setTimeout(apply, 60); + } + + async activateView() { + 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 }); + } + workspace.revealLeaf(leaf); + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(opts) { + 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(); + }); + } +} + +// ============================ VIEW ============================ +class CardsView extends obsidian.ItemView { + constructor(leaf, plugin) { + super(leaf); + this.plugin = plugin; + this.query = ''; + this.parsedQuery = parseQuery(''); + this.filter = { type: 'notes' }; + this.selection = new Set(); + this.editing = null; + this.focusedPath = 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; + } + + getViewType() { return VIEW_TYPE_CARDS; } + getDisplayText() { return 'Card view'; } + getIcon() { return 'layout-grid'; } + + async onOpen() { + await 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', refresh)); + this.registerEvent(this.app.vault.on('rename', 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); + } + } + + async onClose() { + if (this.observer) this.observer.disconnect(); + if (this.resizeObs) this.resizeObs.disconnect(); + for (const c of this.cardComponents.values()) c.unload(); + this.cardComponents.clear(); + if (this.keydownHandler) this.containerEl.removeEventListener('keydown', this.keydownHandler); + } + + scheduleRender() { + if (this.debounceTimer) window.clearTimeout(this.debounceTimer); + this.debounceTimer = window.setTimeout(() => this.render(), 200); + } + + async render() { + 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.containerEl.children[1]; + container.empty(); + container.addClass('keep-cards-root'); + container.toggleClass('density-compact', this.plugin.settings.density === 'compact'); + container.toggleClass('density-list', this.plugin.settings.density === 'list'); + + 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; + 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._batch; + if (batch) this.renderNextBatch(batch); + } + } + }, + { root: grid, rootMargin: '600px' } + ); + + 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.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 240) + 'px'; + }; + ta.addEventListener('input', grow); + ta.addEventListener('focus', grow); + ta.addEventListener('blur', () => { + ta.style.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 folder = (this.app.vault.getConfig && this.app.vault.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); + }); + } + + 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 k of Object.keys(SORT_LABELS)) { + menu.addItem((mi) => + mi + .setTitle(SORT_LABELS[k]) + .setChecked(this.plugin.settings.sortMode === k) + .onClick(async () => { + this.plugin.settings.sortMode = k; + await 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) { + const tb = header.createDiv({ cls: 'keep-cards-toolbar' }); + const modes = [ + { v: 'comfortable', icon: 'layout-grid', label: 'Comfortable' }, + { v: 'compact', icon: 'grid', label: 'Compact' }, + { v: 'list', icon: 'list', label: 'List' }, + ]; + 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', async () => { + this.plugin.settings.density = m.v; + await 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.style.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() { + const set = new Set(); + 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) => 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 = !!(fm && fm.archived); + + 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) { + if (!fm || (fm.color || '').toLowerCase() !== q.color.toLowerCase()) return false; + } + if (q.path !== null) { + if (!f.path.toLowerCase().includes(q.path)) return false; + } + if (q.pinned !== null) { + const isP = !!(fm && fm.pinned); + if (q.pinned !== isP) 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 fc = this.app.metadataCache.getFileCache(file); + return !!(fc && fc.frontmatter && fc.frontmatter.pinned); + } + + pinOrderOf(file) { + const fc = this.app.metadataCache.getFileCache(file); + const v = fc && fc.frontmatter && fc.frontmatter.pinOrder; + return typeof v === 'number' ? v : 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) { + const d = this.plugin.settings.density; + if (d === 'list') return 1; + if (d === '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'); + + if (fm && fm.color) { + if (this.plugin.settings.colorAsBorder) { + card.style.borderLeft = '4px solid ' + String(fm.color); + } else { + card.style.background = String(fm.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.style.borderLeft = '4px solid ' + c; + else card.style.background = c; + break; + } + } + } + } + + if (fm && fm.pinned) 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 (fm && fm.pinned) { + 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', async (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(); + await this.swapPinOrder(fromFile, file); + }); + } + + card.addEventListener('click', (e) => { + if (e.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); + }); + + layout.add(card, estimateHeight(file, fm, this.plugin.settings)); + this.observer.observe(card); + } + + async populateCard(card, file) { + const fc = this.app.metadataCache.getFileCache(file); + const fm = fc && fc.frontmatter; + 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 (e) { + 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('.keep-card-body'); + 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 (e) { + 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.createEl('span', { 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 actions = card.createDiv({ cls: 'keep-card-actions' }); + this.makeAction( + actions, + fm && fm.pinned ? 'pin-off' : 'pin', + fm && fm.pinned ? '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, + fm && fm.archived ? 'archive-restore' : 'archive', + fm && fm.archived ? 'Unarchive' : 'Archive', + async () => { + await this.toggleArchived(file); + } + ); + } + + clampBody(bodyEl, lines) { + bodyEl.style.maxHeight = (lines * 1.5) + 'em'; + bodyEl.style.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) { + new NotePreviewModal(this.app, this.plugin, this, file).open(); + } + + // -------------------- inline edit -------------------- + beginEdit(file) { + this.editing = file.path; + const cards = this.containerEl.querySelectorAll('.keep-card[data-path]'); + let card = 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('.keep-card-body'); + if (!body) return; + body.empty(); + this.app.vault.read(file).then((raw) => { + this.mountEditor(body, file, raw); + }); + } + + mountEditor(body, file, raw) { + const { front, body: stripped } = stripFrontmatter(raw); + const ta = body.createEl('textarea', { cls: 'keep-card-editor' }); + ta.value = stripped; + ta.style.height = 'auto'; + setTimeout(() => { + ta.focus(); + ta.style.height = ta.scrollHeight + 'px'; + }, 0); + ta.addEventListener('input', () => { + ta.style.height = 'auto'; + ta.style.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', async () => { + await save(); + 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.style.background = c.value; + else swatch.addClass('keep-color-swatch--default'); + swatch.addEventListener('click', async (e) => { + e.stopPropagation(); + await this.setColor(file, c.value); + }); + } + } + + // -------------------- frontmatter mutations -------------------- + async togglePinned(file) { + try { + let order = null; + await this.app.fileManager.processFrontMatter(file, (fm) => { + if (fm.pinned) { + delete fm.pinned; + delete fm.pinOrder; + } else { + fm.pinned = true; + if (typeof fm.pinOrder !== 'number') { + const max = this.maxPinOrder(); + fm.pinOrder = max + 1; + order = fm.pinOrder; + } + } + }); + } catch (err) { + new obsidian.Notice('Could not pin: ' + err.message); + } + } + + maxPinOrder() { + let m = 0; + for (const f of this.app.vault.getMarkdownFiles()) { + 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 { + const aOrder = this.pinOrderOf(a); + const bOrder = this.pinOrderOf(b); + await this.app.fileManager.processFrontMatter(a, (fm) => { fm.pinOrder = bOrder === Number.MAX_SAFE_INTEGER ? this.maxPinOrder() + 1 : bOrder; }); + await this.app.fileManager.processFrontMatter(b, (fm) => { fm.pinOrder = aOrder === Number.MAX_SAFE_INTEGER ? this.maxPinOrder() + 1 : aOrder; }); + } catch (err) { + new obsidian.Notice('Reorder failed: ' + err.message); + } + } + + async toggleArchived(file) { + try { + const wasArchived = !!(this.app.metadataCache.getFileCache(file) && this.app.metadataCache.getFileCache(file).frontmatter && this.app.metadataCache.getFileCache(file).frontmatter.archived); + await this.app.fileManager.processFrontMatter(file, (fm) => { + if (fm.archived) delete fm.archived; + else fm.archived = true; + }); + this.showUndoToast(wasArchived ? 'Unarchived' : 'Archived', async () => { + await this.app.fileManager.processFrontMatter(file, (fm) => { + if (wasArchived) fm.archived = true; + else delete fm.archived; + }); + }); + } catch (err) { + new obsidian.Notice('Could not archive: ' + err.message); + } + } + + async setColor(file, color) { + try { + await this.app.fileManager.processFrontMatter(file, (fm) => { + if (color === null) delete fm.color; + else fm.color = color; + }); + } catch (err) { + new obsidian.Notice('Could not set color: ' + err.message); + } + } + + 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 fm = (this.app.metadataCache.getFileCache(file) || {}).frontmatter || {}; + + menu.addItem((mi) => + mi.setTitle('Open in new tab').setIcon('external-link').onClick(() => { + this.app.workspace.getLeaf('tab').openFile(file); + }) + ); + menu.addItem((mi) => + mi.setTitle('Open in new pane').setIcon('separator-vertical').onClick(() => { + 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(fm.pinned ? 'Unpin' : 'Pin') + .setIcon(fm.pinned ? 'pin-off' : 'pin') + .onClick(() => this.togglePinned(file)) + ); + menu.addItem((mi) => + mi + .setTitle(fm.archived ? 'Unarchive' : 'Archive') + .setIcon(fm.archived ? 'archive-restore' : 'archive') + .onClick(() => 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]; + if (fe && fe.view && fe.view.revealInFolder) { + fe.view.revealInFolder(file); + } else { + this.app.workspace.revealLeaf && 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'); + }) + ); + menu.addSeparator(); + menu.addItem((mi) => + mi.setTitle('Delete').setIcon('trash').onClick(async () => { + try { + await this.app.vault.trash(file, true); + this.showUndoToast('Deleted', null); + } catch (err) { + new obsidian.Notice('Delete failed: ' + err.message); + } + }) + ); + + menu.showAtMouseEvent(evt); + } + + // -------------------- selection + bulk -------------------- + toggleSelection(path, range) { + if (range && this.lastClickedPath) { + const cards = Array.from(this.containerEl.querySelectorAll('.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('.keep-card[data-path]'); + cards.forEach((c) => { + const sel = this.selection.has(c.dataset.path); + c.toggleClass('is-selected', sel); + const cb = c.querySelector('.keep-card-checkbox'); + if (cb) cb.checked = sel; + }); + this.renderBulkActions(this.containerEl.children[1]); + } + + 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', async () => { + const paths = Array.from(this.selection); + const ok = confirm('Delete ' + paths.length + ' note' + (paths.length === 1 ? '' : 's') + ' to system trash?'); + if (!ok) return; + for (const path of paths) { + const f = this.app.vault.getAbstractFileByPath(path); + if (f instanceof obsidian.TFile) { + try { await this.app.vault.trash(f, true); } catch (e) { /* skip */ } + } + } + this.selection.clear(); + }); + + 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 (e) {} + } + await this.app.vault.create(path, content); + } catch (err) { + new obsidian.Notice('Could not create note: ' + err.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', async () => { + try { await undo(); } catch (e) { new obsidian.Notice('Undo failed: ' + e.message); } + toast.remove(); + }); + } + setTimeout(() => toast.remove(), 5000); + } + + // -------------------- keyboard -------------------- + handleKeydown(e) { + const tag = e.target.tagName; + const inField = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target instanceof HTMLElement && e.target.isContentEditable); + + if (e.key === '/' && !inField) { + e.preventDefault(); + const search = this.containerEl.querySelector('.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('.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) => { + 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) 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) 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 { + constructor(app, plugin, view, file) { + 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() { + this.titleEl.empty(); + this.contentEl.empty(); + this.renderHeader(); + this.renderBody(); + } + + renderHeader() { + const fc = this.app.metadataCache.getFileCache(this.file); + const fm = (fc && fc.frontmatter) || {}; + + 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, fm.pinned ? 'pin-off' : 'pin', fm.pinned ? '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, fm.archived ? 'archive-restore' : 'archive', fm.archived ? 'Unarchive' : 'Archive', async () => { + await this.view.toggleArchived(this.file); + }); + + this.makeAction(actions, 'external-link', 'Open in tab', () => { + if (this.editing) this.saveEdit(); + 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 (e) { + 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', async (e) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + await this.saveEdit(); + this.editing = false; + this.rerender(); + } + }); + 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.createEl('span', { 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 (e) { + 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') }); + } + const path = 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); + } + } + + async onClose() { + if (this.editing && this._dirty) { + await 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 RenameModal extends obsidian.Modal { + constructor(app, current, onSubmit) { + super(app); + this.current = current; + this.onSubmit = onSubmit; + this.value = current; + } + onOpen() { + this.titleEl.setText('Rename note'); + const input = this.contentEl.createEl('input', { attr: { type: 'text' } }); + input.value = this.current; + input.style.width = '100%'; + input.addEventListener('input', () => { this.value = input.value; }); + input.addEventListener('keydown', async (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + await this.onSubmit(this.value); + this.close(); + } + }); + setTimeout(() => input.focus(), 0); + + const row = this.contentEl.createDiv(); + row.style.marginTop = '12px'; + row.style.display = 'flex'; + row.style.justifyContent = 'flex-end'; + row.style.gap = '8px'; + 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', async () => { + await this.onSubmit(this.value); + this.close(); + }); + } +} + +class TagListModal extends obsidian.Modal { + constructor(app, plugin, tags) { + super(app); + this.plugin = plugin; + this.tags = tags; + } + onOpen() { + this.titleEl.setText('Edit labels'); + const list = this.contentEl.createDiv(); + list.style.display = 'flex'; + list.style.flexDirection = 'column'; + list.style.gap = '8px'; + for (const t of this.tags) { + const row = list.createDiv(); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '8px'; + row.style.padding = '4px 0'; + const dot = row.createEl('span'); + dot.style.width = '12px'; + dot.style.height = '12px'; + dot.style.borderRadius = '50%'; + dot.style.background = this.plugin.settings.tagColors[t] || 'transparent'; + dot.style.border = '1px solid var(--background-modifier-border)'; + row.createSpan({ text: '#' + t }); + const spacer = row.createDiv(); + spacer.style.flex = '1'; + 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 { + constructor(app, plugin, tag) { + super(app); + this.plugin = plugin; + this.tag = tag; + } + onOpen() { + this.titleEl.setText('Color for #' + this.tag); + const grid = this.contentEl.createDiv(); + grid.style.display = 'grid'; + grid.style.gridTemplateColumns = 'repeat(6, 1fr)'; + grid.style.gap = '8px'; + grid.style.marginTop = '8px'; + for (const c of COLORS) { + const sw = grid.createEl('div'); + sw.style.width = '32px'; + sw.style.height = '32px'; + sw.style.borderRadius = '50%'; + sw.style.cursor = 'pointer'; + sw.style.border = '1px solid var(--background-modifier-border)'; + if (c.value) sw.style.background = c.value; + sw.title = c.name; + sw.addEventListener('click', async () => { + if (c.value === null) delete this.plugin.settings.tagColors[this.tag]; + else this.plugin.settings.tagColors[this.tag] = c.value; + await this.plugin.saveSettings(); + this.close(); + }); + } + } +} + +class TagRenameModal extends obsidian.Modal { + constructor(app, plugin, oldTag) { + super(app); + this.plugin = plugin; + this.oldTag = oldTag; + this.newTag = oldTag; + } + onOpen() { + this.titleEl.setText('Rename label "' + this.oldTag + '"'); + const input = this.contentEl.createEl('input', { attr: { type: 'text' } }); + input.value = this.oldTag; + input.style.width = '100%'; + input.addEventListener('input', () => { this.newTag = input.value; }); + setTimeout(() => input.focus(), 0); + + const note = this.contentEl.createDiv(); + note.style.marginTop = '8px'; + note.style.fontSize = '12px'; + note.style.color = 'var(--text-muted)'; + note.setText('Renames in frontmatter (tags:) and inline #tags across the vault.'); + + const row = this.contentEl.createDiv(); + row.style.marginTop = '12px'; + row.style.display = 'flex'; + row.style.justifyContent = 'flex-end'; + row.style.gap = '8px'; + 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', async () => { + await this.doRename(); + 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 (e) { + // skip + } + } + 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 { + constructor(app, plugin) { + 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('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(async (v) => { + this.plugin.settings.autoCollapseSidebars = v; + await this.plugin.saveSettings(); + if (!v) this.plugin.restoreSidebars(); + else this.plugin.handleActiveLeafChange(this.app.workspace.activeLeaf); + }) + ); + } +} + +module.exports = KeepCardsPlugin; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..27ac2b9 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "notekeeper", + "name": "Notekeeper", + "version": "0.1.0", + "minAppVersion": "1.4.0", + "description": "Browse your vault as a Google Keep-style masonry of cards. Quick capture, pin, color, archive, and filter notes by labels — sticky-note style.", + "author": "Philemon Chiro", + "authorUrl": "https://github.com/PhilemonChiro", + "fundingUrl": "", + "isDesktopOnly": false +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..33efc86 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "obsidian-notekeeper", + "version": "0.1.0", + "description": "Browse your vault as a Google Keep-style masonry of cards. Quick capture, pin, color, archive, and filter notes by labels.", + "author": "Philemon Chiro", + "license": "MIT", + "private": true, + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "node esbuild.config.mjs production" + }, + "devDependencies": { + "@types/node": "^20", + "builtin-modules": "^3.3.0", + "esbuild": "^0.21.0", + "obsidian": "latest", + "tslib": "^2.6.0", + "typescript": "^5.4.0" + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..ae5c047 --- /dev/null +++ b/styles.css @@ -0,0 +1,1021 @@ +/* ========== root ========== */ +.keep-cards-root { + padding: 0 !important; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} + +/* ========== header ========== */ +.keep-cards-header { + display: flex; + flex-direction: column; + gap: 14px; + flex-shrink: 0; + background: var(--background-primary); + padding: 12px 16px 12px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.keep-cards-header-top { + display: flex; + gap: 12px; + align-items: center; +} + +.keep-cards-header-capture { + display: flex; + justify-content: center; +} + +/* search */ +.keep-cards-search-wrap { + flex: 1; + display: flex; + align-items: center; + border-radius: 8px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + overflow: hidden; + height: 36px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.keep-cards-search-wrap:focus-within { + border-color: var(--text-accent); +} + +.keep-cards-search-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px 0 12px; + color: var(--text-faint); +} + +.keep-cards-search-icon svg, +.keep-cards-search-icon .svg-icon { + width: 15px; + height: 15px; +} + +.keep-cards-search { + flex: 1; + padding: 0 12px 0 0; + border: none; + background: transparent; + color: var(--text-normal); + font-size: 13px; + height: 100%; + outline: none; +} + +/* sort button */ +.keep-cards-sort-btn { + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + color: var(--text-muted); + width: 36px; + height: 36px; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + transition: background 0.12s ease, color 0.12s ease; +} + +.keep-cards-sort-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.keep-cards-sort-btn.is-active { + background: var(--background-modifier-hover); + color: var(--text-accent); + border-color: var(--text-accent); +} + +/* active filter chip */ +.keep-cards-active-filter { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 0 4px 0 12px; + height: 28px; + background: var(--background-modifier-hover); + border: 1px solid var(--background-modifier-border); + border-radius: 14px; + color: var(--text-normal); + font-size: 12px; + flex-shrink: 0; +} + +.keep-cards-active-filter-label { + white-space: nowrap; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; +} + +.keep-cards-active-filter-x { + background: transparent; + border: none; + color: var(--text-muted); + width: 20px; + height: 20px; + border-radius: 50%; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + margin-left: 2px; +} + +.keep-cards-active-filter-x:hover { + background: var(--background-modifier-active-hover, var(--background-modifier-border)); + color: var(--text-normal); +} + +.keep-cards-active-filter-x svg, +.keep-cards-active-filter-x .svg-icon { + width: 12px; + height: 12px; +} + +.keep-cards-sort-btn svg, +.keep-cards-sort-btn .svg-icon { + width: 16px; + height: 16px; +} + +/* toolbar — segmented control */ +.keep-cards-toolbar { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + overflow: hidden; + background: var(--background-secondary); + height: 36px; + flex-shrink: 0; +} + +.keep-cards-tb-btn { + background: transparent; + border: none; + color: var(--text-muted); + width: 38px; + height: 100%; + border-radius: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border-right: 1px solid var(--background-modifier-border); + transition: background 0.12s ease, color 0.12s ease; +} + +.keep-cards-tb-btn:last-child { + border-right: none; +} + +.keep-cards-tb-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.keep-cards-tb-btn.is-active { + background: var(--background-modifier-hover); + color: var(--text-accent); +} + +.keep-cards-tb-btn svg, +.keep-cards-tb-btn .svg-icon { + width: 16px; + height: 16px; +} + +/* capture */ +.keep-cards-capture-wrap { + width: 100%; + max-width: 600px; + display: flex; + align-items: stretch; + border-radius: 10px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + overflow: hidden; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.keep-cards-capture-wrap:focus-within { + border-color: var(--background-modifier-border-hover, var(--background-modifier-border)); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.25); +} + +.keep-cards-capture { + flex: 1; + padding: 12px 16px; + border: none; + background: transparent; + color: var(--text-normal); + font-size: 14px; + resize: none; + font-family: inherit; + line-height: 1.5; + outline: none; +} + +.keep-cards-capture::placeholder { + color: var(--text-faint); +} + +/* shared icon sizing across buttons */ +.keep-card-action svg, +.keep-card-action .svg-icon, +.keep-cards-bulk-btn svg, +.keep-cards-bulk-btn .svg-icon, +.keep-modal-action-btn svg, +.keep-modal-action-btn .svg-icon, +.keep-sidebar-icon svg, +.keep-sidebar-icon .svg-icon, +.keep-sidebar-edit svg, +.keep-sidebar-edit .svg-icon, +.keep-sidebar-more svg, +.keep-sidebar-more .svg-icon, +.keep-card-backlinks-icon svg, +.keep-card-backlinks-icon .svg-icon, +.keep-empty-icon svg, +.keep-empty-icon .svg-icon { + width: 16px; + height: 16px; +} + +.keep-empty-icon svg, +.keep-empty-icon .svg-icon { + width: 32px; + height: 32px; +} + +/* ========== body row ========== */ +.keep-cards-body { + display: flex; + gap: 16px; + align-items: stretch; + flex: 1; + min-height: 0; + overflow: hidden; + padding: 12px 16px 16px; +} + +/* ========== sidebar ========== */ +.keep-cards-sidebar { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 4px; + padding-bottom: 12px; + overflow-y: auto; + overflow-x: hidden; + align-self: stretch; + min-height: 0; +} + +.keep-sidebar-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 0 24px 24px 0; + cursor: pointer; + color: var(--text-muted); + font-size: 13px; + user-select: none; + position: relative; +} + +.keep-sidebar-item:hover { + background: var(--background-modifier-hover); +} + +.keep-sidebar-item.is-active { + background: var(--background-modifier-active-hover, var(--background-modifier-hover)); + color: var(--text-normal); + font-weight: 600; +} + +.keep-sidebar-item.is-drop-target { + outline: 2px dashed var(--text-accent); + outline-offset: -2px; +} + +.keep-sidebar-icon { + display: inline-flex; + width: 16px; + height: 16px; +} + +.keep-sidebar-color-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.keep-sidebar-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.keep-sidebar-more { + background: transparent; + border: none; + color: var(--text-muted); + padding: 0; + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; +} + +.keep-sidebar-item:hover .keep-sidebar-more { + opacity: 1; +} + +.keep-sidebar-more:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.keep-sidebar-divider { + height: 1px; + background: var(--background-modifier-border); + margin: 8px 0; +} + +.keep-sidebar-heading { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-faint); + padding: 4px 12px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.keep-sidebar-edit { + background: transparent; + border: none; + color: var(--text-faint); + padding: 0; + width: 18px; + height: 18px; + border-radius: 4px; + cursor: pointer; +} + +.keep-sidebar-edit:hover { + color: var(--text-muted); + background: var(--background-modifier-hover); +} + +/* ========== grid + sections ========== */ +.keep-cards-grid { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 24px; + overflow-y: auto; + align-self: stretch; + min-height: 0; + padding-bottom: 12px; +} + +.keep-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.keep-section-header { + font-size: 11px; + letter-spacing: 0.08em; + color: var(--text-faint); + text-transform: uppercase; + padding-left: 4px; +} + +.keep-section-cols { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.keep-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +/* ========== card ========== */ +.keep-card { + position: relative; + border-radius: 10px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.keep-card:hover { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); +} + +.keep-card.is-focused { + outline: 2px solid var(--text-accent); + outline-offset: 2px; +} + +.keep-card.is-selected { + outline: 2px solid var(--text-accent); + outline-offset: 2px; +} + +.keep-card.is-dragging { + opacity: 0.4; +} + +.keep-card.is-drop-target { + border-color: var(--text-accent); + box-shadow: 0 0 0 2px var(--text-accent); +} + +.keep-card--pinned { + border-color: var(--text-accent); +} + +.keep-card-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 2; + opacity: 0; + margin: 0; + cursor: pointer; +} + +.keep-card:hover .keep-card-checkbox, +.keep-card.is-selected .keep-card-checkbox { + opacity: 1; +} + +.keep-card-image { + width: 100%; + max-height: 220px; + object-fit: cover; + display: block; +} + +.keep-card-title { + font-weight: 600; + font-size: 15px; + padding: 12px 14px 4px; + color: var(--text-normal); + word-wrap: break-word; +} + +.keep-card-body { + padding: 0 14px 8px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; + word-wrap: break-word; + overflow: hidden; +} + +.keep-card-body p { + margin: 0 0 8px; +} + +.keep-card-body p:last-child { margin-bottom: 0; } + +.keep-card-body ul, +.keep-card-body ol { + margin: 0 0 8px; + padding-left: 20px; +} + +.keep-card-body h1, +.keep-card-body h2, +.keep-card-body h3, +.keep-card-body h4 { + margin: 8px 0 4px; + font-size: 14px; + color: var(--text-normal); +} + +.keep-card-body code { + font-size: 12px; +} + +.keep-card-body pre { + font-size: 12px; + overflow-x: auto; + max-height: 120px; +} + +.keep-card-body img { + max-width: 100%; + height: auto; +} + +.keep-card-body a.tag { + cursor: pointer; +} + +.keep-card-body input[type="checkbox"] { + cursor: pointer; +} + +.keep-card-tags { + margin-top: 4px; + padding: 0 14px 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.keep-card-tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 12px; + background: var(--background-modifier-hover); + color: var(--text-muted); + cursor: pointer; +} + +.keep-card-tag:hover { + background: var(--background-modifier-active-hover, var(--background-modifier-hover)); + color: var(--text-normal); +} + +.keep-card-meta { + padding: 0 14px; + display: flex; + gap: 8px; + font-size: 11px; + color: var(--text-faint); + align-items: center; + min-height: 0; +} + +.keep-card-backlinks { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.keep-card-backlinks-icon { + display: inline-flex; + width: 12px; + height: 12px; +} + +.keep-card-actions { + display: flex; + gap: 2px; + padding: 4px 6px 6px; + opacity: 0; + transition: opacity 0.12s ease; + justify-content: flex-start; +} + +.keep-card:hover .keep-card-actions, +.keep-card.is-focused .keep-card-actions { + opacity: 1; +} + +.keep-card-action { + background: transparent; + border: none; + color: var(--text-muted); + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.keep-card-action:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* ========== inline editor ========== */ +.keep-card-editor { + width: 100%; + margin: 0; + padding: 0 14px 8px; + border: none; + background: transparent; + color: var(--text-normal); + font-family: inherit; + font-size: 13px; + line-height: 1.5; + resize: none; + outline: none; + min-height: 60px; + box-sizing: border-box; +} + +.keep-card.is-editing-card { + cursor: default; + outline: 2px solid var(--text-accent); + outline-offset: 2px; +} + +/* ========== color picker ========== */ +.keep-color-picker { + position: absolute; + bottom: 44px; + left: 6px; + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 6px; + padding: 8px; + border-radius: 10px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); + z-index: 5; +} + +.keep-color-swatch { + width: 22px; + height: 22px; + border-radius: 50%; + cursor: pointer; + border: 1px solid var(--background-modifier-border); +} + +.keep-color-swatch:hover { + outline: 2px solid var(--text-accent); + outline-offset: 1px; +} + +.keep-color-swatch--default { + background: + linear-gradient(45deg, transparent 45%, var(--text-muted) 45%, var(--text-muted) 55%, transparent 55%), + var(--background-primary); +} + +/* ========== skeleton shimmer ========== */ +.keep-card.is-skeleton .keep-card-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 6px 14px 14px; +} + +.keep-shimmer { + height: 10px; + border-radius: 4px; + background: linear-gradient( + 90deg, + var(--background-modifier-border) 0%, + var(--background-modifier-hover) 50%, + var(--background-modifier-border) 100% + ); + background-size: 200% 100%; + animation: keep-shimmer 1.6s infinite; +} + +.keep-shimmer:nth-child(2) { width: 80%; } +.keep-shimmer:nth-child(3) { width: 60%; } + +@keyframes keep-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ========== empty state ========== */ +.keep-cards-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 16px; + color: var(--text-muted); + text-align: center; + gap: 8px; +} + +.keep-empty-icon { + display: inline-flex; + width: 48px; + height: 48px; + color: var(--text-faint); + margin-bottom: 8px; +} + +.keep-empty-title { + font-size: 16px; + color: var(--text-normal); + font-weight: 600; +} + +.keep-empty-sub { + font-size: 13px; +} + +/* ========== bulk actions bar ========== */ +.keep-cards-bulk { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 32px; + padding: 6px 12px; + display: flex; + align-items: center; + gap: 6px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + z-index: 100; +} + +.keep-cards-bulk-count { + padding: 0 8px; + color: var(--text-normal); + font-size: 13px; + font-weight: 600; +} + +.keep-cards-bulk-btn { + background: transparent; + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.keep-cards-bulk-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* ========== undo toast ========== */ +.keep-undo-toast { + position: fixed; + bottom: 24px; + right: 24px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 10px 14px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + color: var(--text-normal); + z-index: 100; + font-size: 13px; +} + +.keep-undo-btn { + background: transparent; + border: none; + color: var(--text-accent); + cursor: pointer; + font-size: 13px; + font-weight: 600; + padding: 0; +} + +/* ========== density: list ========== */ +.density-list .keep-section-cols { + flex-direction: column; + gap: 8px; +} + +.density-list .keep-col { + flex-direction: column; + gap: 8px; +} + +.density-list .keep-card { + flex-direction: row; + align-items: stretch; + max-height: 120px; +} + +.density-list .keep-card-image { + width: 120px; + height: auto; + max-height: 120px; + flex-shrink: 0; +} + +.density-list .keep-card-body { + -webkit-line-clamp: 3; + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.density-list .keep-card-actions { + flex-direction: column; + padding: 4px; +} + +/* ========== density: compact ========== */ +.density-compact .keep-card-title { + font-size: 13px; + padding: 8px 10px 2px; +} + +.density-compact .keep-card-body { + font-size: 12px; + padding: 0 10px 6px; +} + +.density-compact .keep-card-tags { + padding: 0 10px 6px; +} + +.density-compact .keep-card-image { + max-height: 140px; +} + +/* ========== reduced motion ========== */ +@media (prefers-reduced-motion: reduce) { + .keep-card, + .keep-card-actions, + .keep-shimmer { + transition: none !important; + animation: none !important; + } +} + +/* ========== note preview modal ========== */ +.keep-note-modal { + width: min(900px, 92vw); + max-height: 88vh; +} + +.keep-note-modal .modal-title { + margin-bottom: 8px; +} + +.keep-modal-titlerow { + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +.keep-modal-title { + flex: 1; + font-size: 18px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.keep-modal-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.keep-modal-action-btn { + background: transparent; + border: none; + color: var(--text-muted); + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.keep-modal-action-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.keep-modal-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.keep-modal-body { + max-height: 65vh; + overflow-y: auto; + padding: 4px 4px 8px; +} + +.keep-modal-md { + font-size: 14px; + line-height: 1.6; + color: var(--text-normal); +} + +.keep-modal-md p { margin: 0 0 12px; } +.keep-modal-md p:last-child { margin-bottom: 0; } +.keep-modal-md h1, .keep-modal-md h2, .keep-modal-md h3 { margin-top: 16px; } +.keep-modal-md ul, .keep-modal-md ol { padding-left: 24px; margin: 0 0 12px; } +.keep-modal-md img { max-width: 100%; border-radius: 6px; } +.keep-modal-md pre { padding: 10px; border-radius: 6px; background: var(--background-secondary); overflow-x: auto; } +.keep-modal-md blockquote { border-left: 3px solid var(--background-modifier-border); padding-left: 12px; margin: 0 0 12px; color: var(--text-muted); } + +.keep-modal-meta { + display: flex; + gap: 16px; + align-items: center; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--background-modifier-border); + font-size: 11px; + color: var(--text-faint); +} + +.keep-modal-path { + font-family: var(--font-monospace); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.keep-modal-editor { + width: 100%; + min-height: 60vh; + padding: 14px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + color: var(--text-normal); + font-family: inherit; + font-size: 14px; + line-height: 1.6; + resize: vertical; + outline: none; + box-sizing: border-box; +} + +.keep-modal-editor:focus { + border-color: var(--text-accent); +} + +/* ========== batch sentinel ========== */ +.keep-batch-sentinel { + height: 1px; + width: 100%; + pointer-events: none; +} + +/* ========== narrow widths ========== */ +@media (max-width: 700px) { + .keep-cards-sidebar { display: none; } + .keep-cards-search { width: 160px; } +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..cdffaed --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.4.0" +}