New Homepage

This commit is contained in:
Kazi Aidah Haque 2026-03-17 14:42:53 +06:00
parent e6b59afe1d
commit 6e37a33808
18 changed files with 4383 additions and 5729 deletions

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
.adstar
data.json
compare/
compare/
node_modules/

6578
main.js

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
.card-sidebar-container {
padding: 0;
background: var(--background-secondary);
@ -561,7 +563,6 @@
color: var(--text-muted) !important;
margin-right: 0 !important;
}
/*
.card-sidebar-cards-container {
gap: 8px;
}
@ -588,7 +589,7 @@
.vertical-card-mode .card-sidebar-card.has-pills, .card-sidebar-card:has(.card-tags) {
padding-bottom: 12px !important;
padding-top: 12px !important;
} */
}
.card-content p {
margin: 0;
}
@ -733,13 +734,14 @@ padding-top: 12px !important;
}
.sidecards-home-main {
margin: 90px 40px;
margin: 40px 40px;
}
.sidecards-home-color-dot {
border-radius: 50% !important;
border-radius: 10px !important;
}
.sidecards-home-toolbar {
border-bottom: 1px solid var(--background-modifier-border);
}
}

File diff suppressed because it is too large Load diff

View file

@ -45,8 +45,7 @@ export interface SideCardsSettings {
filterColors?: Record<string, { bgColor?: string; textColor?: string }>;
saveKey?: string;
nextLineKey?: string;
hideClearButton?: boolean;
sortMode?: 'manual' | 'created' | 'alpha';
sortMode?: 'manual' | 'created' | 'modified' | 'alpha' | 'status';
sortAscending?: boolean;
showPinnedOnly?: boolean;
enableCardStatus?: boolean;
@ -67,11 +66,8 @@ export interface SideCardsSettings {
inheritStatusColor?: boolean;
statusPillOpacity?: number;
replaceHomepageWithSidecards?: boolean;
// From act.js or similar
enableRegexSupport: boolean;
caseSensitive: boolean;
partialMatch: boolean;
searchBarVisible?: boolean;
pinnedNotes?: string[];
}
export const DEFAULT_SETTINGS: SideCardsSettings = {
@ -110,7 +106,6 @@ export const DEFAULT_SETTINGS: SideCardsSettings = {
filterColors: {},
saveKey: 'enter',
nextLineKey: 'shift-enter',
hideClearButton: false,
sortMode: 'manual',
sortAscending: true,
showPinnedOnly: false,
@ -132,7 +127,6 @@ export const DEFAULT_SETTINGS: SideCardsSettings = {
inheritStatusColor: false,
statusPillOpacity: 1,
replaceHomepageWithSidecards: false,
enableRegexSupport: false,
caseSensitive: false,
partialMatch: true
searchBarVisible: false,
pinnedNotes: [],
};

View file

@ -56,6 +56,7 @@ export class Card {
tags: this.tags,
category: this.category,
created: this.created,
modified: this.modified,
archived: this.archived,
pinned: this.pinned,
notePath: this.notePath,

View file

@ -34,6 +34,42 @@ export class CardStore {
});
}
private parseColorIndex(value: string | number | null | undefined): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 10) {
return value;
}
const raw = String(value ?? '').trim();
if (!raw) return null;
if (/^\d+$/.test(raw)) {
const parsed = Number(raw);
if (parsed >= 1 && parsed <= 10) return parsed;
}
const varMatch = raw.match(/var\(\s*--card-color-?(\d+)\s*\)/i);
if (varMatch) {
const parsed = Number(varMatch[1]);
if (parsed >= 1 && parsed <= 10) return parsed;
}
return null;
}
private normalizeCardColorValue(value: string | number | null | undefined, fallback: string): { color: string; frontmatterColor: number | null } {
const idx = this.parseColorIndex(value);
if (idx !== null) {
return { color: `var(--card-color-${idx})`, frontmatterColor: idx };
}
const raw = String(value ?? '').trim();
if (raw) {
return { color: raw, frontmatterColor: null };
}
return { color: fallback, frontmatterColor: this.parseColorIndex(fallback) };
}
private getFrontmatterColorValueForCard(color: string): string | number {
const idx = this.parseColorIndex(color);
if (idx !== null) return idx;
return color;
}
getAll(): Card[] {
return Array.from(this.cards.values());
}
@ -106,7 +142,7 @@ export class CardStore {
const frontmatter = [
'---',
tagsYaml,
`card-color: ${card.color}`,
`card-color: ${this.getFrontmatterColorValueForCard(card.color)}`,
`Created-Date: ${new Date(card.created).toISOString()}`,
card.archived ? 'Archived: true' : '',
card.pinned ? 'Pinned: true' : '',
@ -217,6 +253,8 @@ export class CardStore {
const expiresMatch = fm.match(/^\s*expires-at\s*:\s*(.+)$/im);
const statusMatch = fm.match(/^\s*status\s*:\s*(.+)$/im);
const colorMatch = fm.match(/^\s*card-color\s*:\s*(.+)$/im);
const colorRaw = colorMatch ? colorMatch[1].trim() : null;
const normalizedColor = this.normalizeCardColorValue(colorRaw, card.color);
await this.update(card.id, {
tags,
@ -225,9 +263,15 @@ export class CardStore {
category: categoryMatch ? categoryMatch[1].trim() : null,
expiresAt: expiresMatch ? new Date(expiresMatch[1].trim()).getTime() : null,
status: statusMatch ? { name: statusMatch[1].trim(), color: card.status?.color || '', textColor: card.status?.textColor || '#000' } : null,
color: colorMatch ? colorMatch[1].trim() : card.color,
color: normalizedColor.color,
content: content.replace(fmMatch[0], '').trim()
});
if (colorRaw && normalizedColor.frontmatterColor !== null && colorRaw !== String(normalizedColor.frontmatterColor)) {
const normalizedText = updateFrontmatter(content, 'card-color', normalizedColor.frontmatterColor);
if (normalizedText !== content) {
await this.app.vault.modify(file, normalizedText);
}
}
} else {
await this.update(card.id, { content: content.trim() });
}
@ -337,11 +381,40 @@ export class CardStore {
text = updateFrontmatter(text, 'Expires-At', updates.expiresAt ? new Date(updates.expiresAt).toISOString() : null);
}
if (typeof updates.color !== 'undefined') {
text = updateFrontmatter(text, 'card-color', updates.color || null);
const normalizedColor = this.normalizeCardColorValue(updates.color as any, card.color);
text = updateFrontmatter(text, 'card-color', normalizedColor.frontmatterColor ?? normalizedColor.color);
}
if (typeof updates.status !== 'undefined') {
text = updateFrontmatter(text, 'Status', updates.status?.name || null);
}
await this.app.vault.modify(file, text);
}
async migrateCardColorFrontmatterFormat(): Promise<void> {
const seen = new Set<string>();
for (const card of this.getAll()) {
if (!card.notePath || seen.has(card.notePath)) continue;
seen.add(card.notePath);
const file = this.app.vault.getAbstractFileByPath(card.notePath);
if (!(file instanceof TFile)) continue;
let text = '';
try {
text = await this.app.vault.read(file);
} catch (e) {
continue;
}
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
if (!fmMatch) continue;
const colorMatch = fmMatch[1].match(/^\s*card-color\s*:\s*(.+)$/im);
if (!colorMatch) continue;
const raw = colorMatch[1].trim();
const idx = this.parseColorIndex(raw);
if (idx === null) continue;
if (raw === String(idx)) continue;
const updated = updateFrontmatter(text, 'card-color', idx);
if (updated !== text) {
await this.app.vault.modify(file, updated);
}
}
}
}

View file

@ -14,17 +14,25 @@ export class SortService {
case 'manual':
return this.sortManual(sorted, ascending);
case 'created':
return sorted.sort((a, b) =>
ascending
? a.created - b.created
: b.created - a.created
);
return sorted.sort((a, b) => {
const primary = ascending ? a.created - b.created : b.created - a.created;
if (primary !== 0) return primary;
const am = typeof a.modified === 'number' ? a.modified : a.created;
const bm = typeof b.modified === 'number' ? b.modified : b.created;
const secondary = ascending ? am - bm : bm - am;
if (secondary !== 0) return secondary;
return ascending ? a.content.localeCompare(b.content) : b.content.localeCompare(a.content);
});
case 'modified':
return sorted.sort((a, b) =>
ascending
? (a as any).modified - (b as any).modified
: (b as any).modified - (a as any).modified
);
return sorted.sort((a, b) => {
const am = typeof a.modified === 'number' ? a.modified : a.created;
const bm = typeof b.modified === 'number' ? b.modified : b.created;
const primary = ascending ? am - bm : bm - am;
if (primary !== 0) return primary;
const secondary = ascending ? a.created - b.created : b.created - a.created;
if (secondary !== 0) return secondary;
return ascending ? a.content.localeCompare(b.content) : b.content.localeCompare(a.content);
});
case 'alpha':
return sorted.sort((a, b) =>
ascending

View file

@ -26,7 +26,7 @@ export async function flipAnimateAsync(
const stagger = opts.stagger ?? 20;
const entranceOffset = opts.offset ?? 28;
const oldEls = Array.from(container.querySelectorAll('.card-sidebar-card')) as HTMLElement[];
const oldEls = Array.from(container.querySelectorAll('.sc-card')) as HTMLElement[];
const oldMap = new Map<string, DOMRect>();
oldEls.forEach(el => {
const id = el.dataset.id;
@ -35,7 +35,7 @@ export async function flipAnimateAsync(
await asyncDomChange();
const newEls = Array.from(container.querySelectorAll('.card-sidebar-card')) as HTMLElement[];
const newEls = Array.from(container.querySelectorAll('.sc-card')) as HTMLElement[];
const newMap = new Map<string, DOMRect>();
const elById = new Map<string, HTMLElement>();
newEls.forEach(el => {
@ -63,7 +63,9 @@ export async function flipAnimateAsync(
el.style.transition = 'none';
el.style.transform = `translateY(${entranceOffset}px)`;
el.style.willChange = 'transform';
el.style.opacity = '0';
if (!settings.disableCardFadeIn) {
el.style.opacity = '0';
}
}
});
@ -77,7 +79,9 @@ export async function flipAnimateAsync(
setTimeout(() => {
el.style.transition = `transform ${duration}ms ease-out, opacity ${duration}ms ease-out`;
el.style.transform = '';
el.style.opacity = '1';
if (!settings.disableCardFadeIn) {
el.style.opacity = '1';
}
}, delay);
});
@ -86,8 +90,6 @@ export async function flipAnimateAsync(
const el = elById.get(id);
if (el) {
el.style.transition = '';
el.style.willChange = '';
el.style.transform = '';
}
});
}, duration + (ids.length * stagger) + 50);
@ -101,7 +103,7 @@ export function animateCardsEntrance(
if (!settings.animatedCards) return;
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const els = Array.from(container.querySelectorAll('.card-sidebar-card'))
const els = Array.from(container.querySelectorAll('.sc-card'))
.filter(el => (el as HTMLElement).style.display !== 'none') as HTMLElement[];
if (els.length === 0) return;

View file

@ -42,26 +42,35 @@ export function resolveColorVarToHex(colorVar: string, settings: any): string |
}
export function applyCardColorToElement(cardEl: HTMLElement, colorVar: string, settings: any): void {
const style = settings.cardStyle ?? 2;
const opacity = settings.cardBgOpacity ?? 0.08;
const style = Number(settings.cardStyle ?? 2);
const opacity = Number(settings.cardBgOpacity ?? 0.08);
const borderThickness = Number(settings.borderThickness ?? 2);
cardEl.style.borderLeft = '';
cardEl.style.border = '';
cardEl.style.backgroundColor = '';
cardEl.style.boxShadow = '';
// Reset styles using setProperty to ensure consistency
cardEl.style.removeProperty('border-left');
cardEl.style.removeProperty('border');
cardEl.style.removeProperty('background-color');
cardEl.style.removeProperty('box-shadow');
const hex = resolveColorVarToHex(colorVar, settings) || colorVar;
const rgba = hexToRgba(hex, opacity);
const borderRadius = settings.borderRadius ?? 6;
if (style === 1) {
cardEl.style.border = `${borderThickness}px solid ${colorVar}`;
cardEl.style.backgroundColor = hexToRgba(hex, opacity);
cardEl.style.setProperty('border', `${borderThickness}px solid ${colorVar}`, 'important');
cardEl.style.setProperty('background-color', rgba, 'important');
} else if (style === 3) {
cardEl.style.borderLeft = `4px solid ${colorVar}`;
cardEl.style.backgroundColor = hexToRgba(hex, opacity);
cardEl.style.setProperty('border-left', `${borderThickness}px solid ${colorVar}`, 'important');
cardEl.style.setProperty('background-color', rgba, 'important');
cardEl.style.setProperty('border-top', `1px solid var(--background-modifier-border)`, 'important');
cardEl.style.setProperty('border-right', `1px solid var(--background-modifier-border)`, 'important');
cardEl.style.setProperty('border-bottom', `1px solid var(--background-modifier-border)`, 'important');
} else {
cardEl.style.border = `${borderThickness}px solid ${colorVar}`;
cardEl.style.backgroundColor = hexToRgba(hex, opacity);
cardEl.style.boxShadow = `2px 2px 0 0 ${colorVar}`;
// Style 2 (Default)
cardEl.style.setProperty('border', `${borderThickness}px solid ${colorVar}`, 'important');
cardEl.style.setProperty('background-color', rgba, 'important');
cardEl.style.setProperty('box-shadow', `2px 2px 0 0 ${colorVar}`, 'important');
}
cardEl.style.setProperty('border-radius', `${borderRadius}px`, 'important');
}

View file

@ -42,15 +42,38 @@ export function updateFrontmatter(content: string, key: string, value: any): str
}
export function parseTagsFromFrontmatter(fm: string): string[] {
const match = fm.match(/tags:\s*\[(.*?)\]/i) || fm.match(/tags:\s*(.*)/i);
if (!match) return [];
const raw = match[1];
if (raw.startsWith('[')) {
const lines = fm.split(/\r?\n/);
const tagsLine = lines.find(l => /^\s*tags\s*:/i.test(l));
if (!tagsLine) return [];
const content = tagsLine.replace(/^\s*tags\s*:/i, '').trim();
const sanitize = (t: string) => t.trim().replace(/^[-#\s]+/, '').replace(/^- /g, '').trim();
if (!content) {
// Check if it's a list format below
const tagsIdx = lines.findIndex(l => /^\s*tags\s*:/i.test(l));
const listTags: string[] = [];
for (let i = tagsIdx + 1; i < lines.length; i++) {
const line = lines[i];
const match = line.match(/^\s*-\s+(.*)$/);
if (match) {
listTags.push(sanitize(match[1]));
} else if (line.trim() && !line.startsWith(' ')) {
break;
}
}
return listTags;
}
if (content.startsWith('[')) {
try {
return JSON.parse(raw.replace(/'/g, '"'));
const parsed = JSON.parse(content.replace(/'/g, '"'));
return Array.isArray(parsed) ? parsed.map(sanitize) : [sanitize(String(parsed))];
} catch (e) {
return raw.replace(/[\[\]"']/g, '').split(',').map(t => t.trim()).filter(Boolean);
return content.replace(/[\[\]"']/g, '').split(',').map(sanitize).filter(Boolean);
}
}
return raw.split(',').map(t => t.trim()).filter(Boolean);
// Single tag or comma-separated
return content.split(',').map(sanitize).filter(Boolean);
}

View file

@ -1,5 +1,5 @@
import { ItemView, WorkspaceLeaf, App, Notice, Menu, Modal, MarkdownView, TFile, setIcon } from "obsidian";
import { ItemView, WorkspaceLeaf, App, Notice, Menu, Modal, MarkdownView, TFile, setIcon, Scope, Editor } from "obsidian";
import { CardStore } from "../services/CardStore";
import { FilterService, FilterOptions } from "../services/FilterService";
import { SortService, SortMode } from "../services/SortService";
@ -19,6 +19,9 @@ export class CardSidebarView extends ItemView {
private activeFilters: FilterOptions = { query: '', tags: [] };
private currentSortMode: SortMode = 'manual';
private sortAscending: boolean = true;
private editorScope: Scope;
private editor!: Editor;
private owner!: any;
constructor(
leaf: WorkspaceLeaf,
@ -29,6 +32,66 @@ export class CardSidebarView extends ItemView {
private sortService: SortService
) {
super(leaf);
this.editorScope = new Scope(this.app.scope);
// Block native formatting keys so Obsidian's global commands take over
this.editorScope.register(["Mod"], "b", () => true);
this.editorScope.register(["Mod"], "i", () => true);
this.editorScope.register(["Mod"], "u", () => true);
this.setupMockEditor();
}
private setupMockEditor() {
this.editor = {
getSelection: () => {
const sel = window.getSelection();
return sel ? sel.toString() : "";
},
replaceSelection: (text: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
},
toggleBold: () => this.toggleMarkdownWrapper("**"),
toggleItalic: () => this.toggleMarkdownWrapper("*"),
} as any;
this.owner = {
editor: this.editor,
editMode: true,
};
}
private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==") {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const selectedText = sel.toString();
if (selectedText.length === 0) {
// Empty selection: insert **** and place cursor in middle
const text = wrapper + wrapper;
const node = document.createTextNode(text);
range.insertNode(node);
range.setStart(node, wrapper.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(wrapper);
const newText = alreadyWrapped
? selectedText.slice(wrapper.length, -wrapper.length)
: wrapper + selectedText + wrapper;
this.editor.replaceSelection(newText);
}
}
getViewType(): string {
@ -53,9 +116,13 @@ export class CardSidebarView extends ItemView {
async onOpen(): Promise<void> {
const container = this.containerEl;
container.empty();
container.addClass('card-sidebar-container');
container.addClass('sc-sidebar-container');
this.currentSortMode = ((this.plugin as any).settings.sortMode || 'manual') as SortMode;
this.sortAscending = typeof (this.plugin as any).settings.sortAscending === 'boolean'
? !!(this.plugin as any).settings.sortAscending
: true;
const mainContainer = container.createDiv('card-sidebar-main');
const mainContainer = container.createDiv('sc-sidebar-main');
mainContainer.style.display = 'flex';
mainContainer.style.flexDirection = 'column';
mainContainer.style.height = '100%';
@ -63,7 +130,7 @@ export class CardSidebarView extends ItemView {
this.createHeader(mainContainer);
this.createSearchBar(mainContainer);
this.cardsContainer = mainContainer.createDiv('card-sidebar-cards-container');
this.cardsContainer = mainContainer.createDiv('sc-sidebar-cards-container');
this.cardsContainer.style.flex = '1';
this.cardsContainer.style.overflow = 'auto';
this.cardsContainer.style.position = 'relative';
@ -79,21 +146,24 @@ export class CardSidebarView extends ItemView {
this.registerVaultEvents();
this.showLoadingOverlay();
await this.renderCards();
this.hideLoadingOverlay(300);
try {
await this.renderCards();
} finally {
this.hideLoadingOverlay(300);
}
}
private showLoadingOverlay(maxMs = 2000) {
const parent = this.cardsContainer || this.containerEl;
if (!parent || this._loadingEl) return;
this._loadingEl = parent.createDiv('card-sidebar-loading');
this._loadingEl = parent.createDiv('sc-sidebar-loading');
this._loadingEl.style.position = 'absolute';
this._loadingEl.style.inset = '0';
this._loadingEl.style.display = 'flex';
this._loadingEl.style.alignItems = 'center';
this._loadingEl.style.justifyContent = 'center';
this._loadingEl.style.background = 'var(--background-modifier-card, rgba(0,0,0,0.02))';
this._loadingEl.style.background = 'var(--background-primary)';
this._loadingEl.style.zIndex = '9999';
const box = this._loadingEl.createDiv();
@ -102,7 +172,7 @@ export class CardSidebarView extends ItemView {
box.style.alignItems = 'center';
box.style.gap = '8px';
const spinner = box.createDiv('card-sidebar-spinner');
const spinner = box.createDiv('sc-sidebar-spinner');
spinner.style.width = '36px';
spinner.style.height = '36px';
spinner.style.border = '4px solid var(--background-modifier-border)';
@ -111,7 +181,7 @@ export class CardSidebarView extends ItemView {
// Inline animation if not in CSS
spinner.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity });
box.createDiv({ text: 'Loading cards...', cls: 'card-sidebar-loading-text' });
box.createDiv({ text: 'Loading cards...', cls: 'sc-sidebar-loading-text' });
this._loadingTimeout = setTimeout(() => this.hideLoadingOverlay(), maxMs);
}
@ -178,42 +248,88 @@ export class CardSidebarView extends ItemView {
}
private createHeader(container: HTMLElement): void {
const header = container.createDiv('card-sidebar-header');
const filterGroup = header.createDiv('category-group');
filterGroup.addClass('card-sidebar-category-group');
if (this.plugin.settings.disableFilterButtons) return;
const header = container.createDiv('sc-sidebar-header');
const filterGroup = header.createDiv('sc-category-group');
filterGroup.style.display = 'flex';
filterGroup.style.gap = '8px';
filterGroup.style.overflowX = 'auto';
filterGroup.style.flexWrap = 'nowrap';
filterGroup.style.whiteSpace = 'nowrap';
const showTimeBasedChips = !this.plugin.settings.disableTimeBasedFiltering;
const settings = this.plugin.settings;
const cats: Array<{ id: string; label: string; showInMenu?: boolean }> =
Array.isArray(this.plugin.settings?.customCategories) ? this.plugin.settings.customCategories : [];
Array.isArray(settings.customCategories) ? settings.customCategories : [];
const chips: Array<{ type: string, label: string, value: string }> = [];
chips.push({ type: 'all', label: 'All', value: 'all' });
if (showTimeBasedChips) {
chips.push({ type: 'category', label: 'Today', value: 'today' });
chips.push({ type: 'category', label: 'Tomorrow', value: 'tomorrow' });
}
if (this.plugin.settings?.enableCustomCategories) {
cats.forEach((c) => {
if (!c || c.showInMenu === false) return;
chips.push({ type: 'category', label: c.label || '', value: c.id || c.label || '' });
});
}
if (!(this.plugin as any).settings?.hideArchivedFilterButton) {
chips.push({ type: 'archived', label: 'Archived', value: 'archived' });
// Build ordered chip list using allItemsOrder if available
const defaultOrder = ['filter-all']
.concat(!settings.disableTimeBasedFiltering ? ['filter-today', 'filter-tomorrow'] : [])
.concat(!settings.hideArchivedFilterButton ? ['filter-archived'] : [])
.concat(cats.map(c => String(c.id || '')));
const combinedOrder = Array.isArray(settings.allItemsOrder) && settings.allItemsOrder.length > 0
? settings.allItemsOrder
: defaultOrder;
const chips: Array<{ type: string; label: string; value: string }> = [];
combinedOrder.forEach(itemId => {
if (!itemId) return;
if (itemId === 'filter-all') {
chips.push({ type: 'all', label: 'All', value: 'all' });
return;
}
if (itemId === 'filter-today') {
if (!settings.disableTimeBasedFiltering) chips.push({ type: 'category', label: 'Today', value: 'today' });
return;
}
if (itemId === 'filter-tomorrow') {
if (!settings.disableTimeBasedFiltering) chips.push({ type: 'category', label: 'Tomorrow', value: 'tomorrow' });
return;
}
if (itemId === 'filter-archived') {
if (!settings.hideArchivedFilterButton) chips.push({ type: 'archived', label: 'Archived', value: 'archived' });
return;
}
const cat = cats.find(c => String(c.id) === String(itemId));
if (cat && cat.showInMenu !== false) {
chips.push({ type: 'category', label: cat.label || '', value: cat.id || cat.label || '' });
}
});
// Ensure 'All' is always present
if (!chips.find(c => c.value === 'all')) {
chips.unshift({ type: 'all', label: 'All', value: 'all' });
}
chips.forEach(chip => {
const btn = filterGroup.createEl('button', { text: chip.label });
btn.addClass('card-category-btn');
btn.addClass('sc-category-btn');
btn.dataset.filterType = chip.type;
btn.dataset.filterValue = chip.value;
// Apply custom colors from filterColors settings
const colorKey = chip.value === 'all' ? 'all'
: chip.value === 'today' ? 'today'
: chip.value === 'tomorrow' ? 'tomorrow'
: chip.value === 'archived' ? 'archived'
: chip.value;
const customColors = settings.filterColors?.[colorKey];
if (customColors?.bgColor) btn.style.setProperty('background-color', customColors.bgColor, 'important');
if (customColors?.textColor) btn.style.setProperty('color', customColors.textColor, 'important');
btn.addEventListener('click', async () => {
filterGroup.querySelectorAll('.card-category-btn').forEach(b => b.removeClass('active'));
filterGroup.querySelectorAll('.sc-category-btn').forEach(b => {
(b as HTMLElement).removeClass('active');
const bVal = (b as HTMLElement).dataset.filterValue || '';
const bColors = settings.filterColors?.[bVal];
if (bColors?.bgColor) (b as HTMLElement).style.setProperty('background-color', bColors.bgColor, 'important');
else (b as HTMLElement).style.removeProperty('background-color');
if (bColors?.textColor) (b as HTMLElement).style.setProperty('color', bColors.textColor, 'important');
else (b as HTMLElement).style.removeProperty('color');
});
btn.addClass('active');
if (chip.type === 'archived') {
this.activeFilters.archivedOnly = true;
@ -230,15 +346,32 @@ export class CardSidebarView extends ItemView {
});
}
private createSearchBar(container: HTMLElement): void {
const wrapper = container.createDiv('card-search-wrap');
const row = wrapper.createDiv('card-search-row');
private applyScrollbarSetting(): void {
if (!this.cardsContainer) return;
if (this.plugin.settings.hideScrollbar) {
this.cardsContainer.addClass('hide-scrollbar');
// Also apply to parent if needed
this.containerEl.querySelector('.view-content')?.addClass('hide-scrollbar');
} else {
this.cardsContainer.removeClass('hide-scrollbar');
this.containerEl.querySelector('.view-content')?.removeClass('hide-scrollbar');
}
}
const iconSpan = row.createSpan({ cls: 'card-search-input-icon' });
private createSearchBar(container: HTMLElement): void {
const wrapper = container.createDiv('sc-search-wrap');
if (this.plugin.settings.searchBarVisible) {
wrapper.style.display = '';
} else {
wrapper.style.display = 'none';
}
const row = wrapper.createDiv('sc-search-row');
const iconSpan = row.createSpan({ cls: 'sc-search-input-icon' });
try { setIcon(iconSpan as any, 'search'); } catch {}
const input = row.createEl('input', { type: 'search', placeholder: 'Search cards…', cls: 'card-search-input' });
const clearBtn = row.createEl('button', { text: '✕', cls: 'card-search-clear-btn' });
const input = row.createEl('input', { type: 'search', placeholder: 'Search cards…', cls: 'sc-search-input' });
const clearBtn = row.createEl('button', { text: '✕', cls: 'sc-search-clear-btn' });
clearBtn.style.display = 'none';
clearBtn.title = 'Clear search';
clearBtn.addEventListener('click', () => {
@ -246,6 +379,7 @@ export class CardSidebarView extends ItemView {
this.activeFilters.query = '';
this.renderCardsDebounced();
clearBtn.style.display = 'none';
input.focus();
});
input.addEventListener('input', () => {
@ -256,58 +390,95 @@ export class CardSidebarView extends ItemView {
}
private createInputBox(container: HTMLElement): void {
const inputContainer = container.createDiv('card-sidebar-input-container');
const inputContainer = container.createDiv('sc-sidebar-input-container');
inputContainer.style.padding = '8px';
inputContainer.style.borderTop = '1px solid var(--background-modifier-border)';
inputContainer.style.background = 'var(--background-primary)';
inputContainer.style.position = 'sticky';
inputContainer.style.bottom = '0';
const textarea = inputContainer.createEl('textarea');
textarea.addClass('card-sidebar-input');
textarea.placeholder = 'Type your idea here... (Use @category and #tag)';
textarea.rows = 1;
textarea.style.width = '100%';
textarea.style.minHeight = '36px';
textarea.style.maxHeight = '200px';
textarea.style.padding = '8px';
textarea.style.border = '1px solid var(--background-modifier-border)';
textarea.style.borderRadius = '4px';
textarea.style.resize = 'vertical';
textarea.style.overflowY = 'hidden';
const editorEl = inputContainer.createDiv({
cls: 'sc-sidebar-input',
});
editorEl.setAttribute('contenteditable', 'true');
editorEl.dataset.placeholder = 'Type here... (@category, #tag)';
editorEl.style.width = '100%';
editorEl.style.minHeight = '36px';
editorEl.style.maxHeight = '200px';
editorEl.style.padding = '8px';
editorEl.style.border = '1px solid var(--background-modifier-border)';
editorEl.style.overflowY = 'auto';
editorEl.style.whiteSpace = 'pre-wrap';
editorEl.style.position = 'relative';
// Simple placeholder logic for contenteditable
const updatePlaceholder = () => {
if (!editorEl.textContent?.trim()) {
editorEl.addClass('is-empty');
} else {
editorEl.removeClass('is-empty');
}
};
updatePlaceholder();
editorEl.addEventListener('input', updatePlaceholder);
const autoResize = () => {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
// For contenteditable, it auto-resizes by default, but we might want to enforce max-height
};
textarea.addEventListener('input', autoResize);
setTimeout(autoResize, 0);
const tagAutocomplete = new TagAutocomplete(textarea, this.store);
tagAutocomplete.attach();
editorEl.addEventListener('focusin', () => {
// @ts-ignore
this.app.keymap.pushScope(this.editorScope);
// @ts-ignore
this.app.workspace.activeEditor = this.owner;
});
const buttonContainer = inputContainer.createDiv('card-sidebar-button-container');
editorEl.addEventListener('blur', () => {
// @ts-ignore
this.app.keymap.popScope(this.editorScope);
// @ts-ignore
if (this.app.workspace.activeEditor === this.owner) {
// @ts-ignore
this.app.workspace.activeEditor = null;
}
});
// Tag autocomplete logic for contenteditable (simplified version for now)
editorEl.addEventListener('input', () => {
// Simplified autocomplete trigger or use the TagAutocomplete class if compatible
});
const buttonContainer = inputContainer.createDiv('sc-sidebar-button-container');
buttonContainer.style.display = 'flex';
buttonContainer.style.gap = '8px';
buttonContainer.style.justifyContent = 'flex-end';
buttonContainer.style.marginTop = '8px';
const searchBtn = buttonContainer.createEl('button');
searchBtn.addClass('sc-icon-btn');
try { setIcon(searchBtn, 'search'); } catch { searchBtn.textContent = 'Search'; }
searchBtn.title = 'Toggle search';
searchBtn.addEventListener('click', () => {
const wrap = this.containerEl.querySelector('.card-search-wrap') as HTMLElement;
if (wrap) wrap.style.display = wrap.style.display === 'none' ? '' : 'none';
const wrap = this.containerEl.querySelector('.sc-search-wrap') as HTMLElement;
if (wrap) {
const isVisible = wrap.style.display !== 'none';
wrap.style.display = isVisible ? 'none' : '';
this.plugin.settings.searchBarVisible = !isVisible;
this.plugin.saveSettings();
}
});
const reloadBtn = buttonContainer.createEl('button');
reloadBtn.addClass('sc-icon-btn');
reloadBtn.title = 'Reload cards';
try { setIcon(reloadBtn, 'refresh-cw'); } catch { reloadBtn.textContent = 'Reload'; }
reloadBtn.addEventListener('click', async () => {
await this.renderCards();
await this.renderCards(true);
new Notice('Cards reloaded');
});
const sortBtn = buttonContainer.createEl('button');
sortBtn.addClass('sc-icon-btn');
sortBtn.title = 'Sort';
try { setIcon(sortBtn, 'sort-desc'); } catch { sortBtn.textContent = 'Sort'; }
sortBtn.addEventListener('click', async (e) => {
@ -345,6 +516,7 @@ export class CardSidebarView extends ItemView {
});
const pinToggleBtn = buttonContainer.createEl('button');
pinToggleBtn.addClass('sc-icon-btn');
try { setIcon(pinToggleBtn, 'pin'); } catch { pinToggleBtn.textContent = 'Pin'; }
pinToggleBtn.title = 'Show pinned only';
pinToggleBtn.addEventListener('click', async () => {
@ -353,6 +525,7 @@ export class CardSidebarView extends ItemView {
});
const gridToggleBtn = buttonContainer.createEl('button');
gridToggleBtn.addClass('sc-icon-btn');
try { setIcon(gridToggleBtn, 'layout-grid'); } catch { gridToggleBtn.textContent = 'Grid'; }
gridToggleBtn.title = 'Toggle grid layout';
gridToggleBtn.addEventListener('click', async () => {
@ -365,25 +538,36 @@ export class CardSidebarView extends ItemView {
});
const addButton = buttonContainer.createEl('button');
addButton.addClass('card-add-btn');
addButton.addClass('sc-add-btn');
addButton.textContent = 'Add Card';
addButton.addEventListener('click', async () => {
const content = textarea.value.trim();
const content = editorEl.textContent?.trim();
if (!content) return;
const card = new Card({ content, category: this.activeFilters.category || undefined });
await this.store.add(card);
textarea.value = '';
await this.renderCards();
});
const clearButton = buttonContainer.createEl('button');
clearButton.addClass('card-clear-btn');
clearButton.textContent = 'Clear';
clearButton.style.display = ((this.plugin as any).settings?.hideClearButton ? 'none' : '');
clearButton.addEventListener('click', () => {
textarea.value = '';
textarea.focus();
autoResize();
// Extract tags (#tag) and categories (@category)
const tags: string[] = [];
const tagRegex = /#([^\s#@,.]+)/g;
let match;
while ((match = tagRegex.exec(content)) !== null) {
tags.push(match[1]);
}
const catRegex = /@([^\s#@,.]+)/g;
const catMatch = catRegex.exec(content);
const category = catMatch ? catMatch[1] : (this.activeFilters.category || undefined);
// Clean content (optional: user might want to keep them)
// For now, keep them in content but also add to metadata
const card = new Card({
content,
tags,
category: category === 'all' ? undefined : category
});
await this.store.add(card);
editorEl.textContent = '';
updatePlaceholder();
await this.renderCards();
});
}
@ -422,7 +606,18 @@ export class CardSidebarView extends ItemView {
this.renderTimeout = setTimeout(() => this.renderCards(), 300);
}
private async renderCards(): Promise<void> {
private async renderCards(isManualReload = false): Promise<void> {
const settings = { ...this.store.settings };
if (isManualReload) {
// If manually reloading, we might want to skip animation or use a faster one
// But user said "reload cards makes the cards come in with the fade in" as if it's an issue
// Let's make reload NOT fade in if they prefer instant
settings.animatedCards = false;
settings.disableCardFadeIn = true;
}
const scrollTop = this.cardsContainer?.scrollTop || 0;
await flipAnimateAsync(this.cardsContainer, async () => {
// Clear existing components
this.cardComponents.forEach(comp => comp.destroy());
@ -439,9 +634,15 @@ export class CardSidebarView extends ItemView {
const comp = new CardComponent(this.cardsContainer, card, this.store, this.app, this.plugin);
this.cardComponents.set(card.id, comp);
}
}, {}, this.store.settings);
this.applyLayoutMode();
// Apply layout mode (Masonry) BEFORE measurements are taken by flipAnimateAsync
this.applyLayoutMode();
// Restore scroll position BEFORE measurements are taken
if (this.cardsContainer) {
this.cardsContainer.scrollTop = scrollTop;
}
}, {}, settings);
}
async onClose(): Promise<void> {
@ -462,7 +663,8 @@ export class CardSidebarView extends ItemView {
}
private applyLayoutMode(): void {
const grid = !!(this.plugin as any).settings?.verticalCardMode;
if (!this.cardsContainer) return;
const grid = !!this.plugin.settings.verticalCardMode;
if (grid) {
this.cardsContainer.addClass('grid-mode');
this.cardsContainer.addClass('vertical-card-mode');
@ -479,7 +681,7 @@ export class CardSidebarView extends ItemView {
this._masonryMutationObserver.disconnect();
this._masonryMutationObserver = null;
}
this.cardsContainer.querySelectorAll('.card-sidebar-card').forEach((el) => {
this.cardsContainer.querySelectorAll('.sc-card').forEach((el) => {
(el as HTMLElement).style.gridRowEnd = '';
});
}
@ -497,15 +699,15 @@ export class CardSidebarView extends ItemView {
this._masonryTimeout = setTimeout(() => this.refreshMasonrySpans(), 50);
});
this._masonryObserver.observe(this.cardsContainer);
this.cardsContainer.querySelectorAll('.card-sidebar-card').forEach(el => {
this.cardsContainer.querySelectorAll('.sc-card').forEach(el => {
this._masonryObserver?.observe(el);
});
this.setupMasonryMutationObserver();
}
private refreshMasonrySpans(): void {
if (!(this.plugin as any).settings?.verticalCardMode || !this.cardsContainer) return;
const cards = this.cardsContainer.querySelectorAll('.card-sidebar-card:not(.drag-spacer)');
if (!this.plugin.settings.verticalCardMode || !this.cardsContainer) return;
const cards = this.cardsContainer.querySelectorAll('.sc-card:not(.drag-spacer)');
cards.forEach((el) => {
const card = el as HTMLElement;
card.style.gridRowEnd = 'auto';
@ -541,4 +743,8 @@ export class CardSidebarView extends ItemView {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['class']
});
}
}

View file

@ -1,20 +1,94 @@
import { ItemView, WorkspaceLeaf, Menu, Notice, setIcon } from "obsidian";
import { ItemView, WorkspaceLeaf, Menu, Notice, setIcon, Scope, Editor, TFile } from "obsidian";
import type SideCardsPlugin from "../core/Plugin";
import { CardStore } from "../services/CardStore";
import { Card } from "../models/Card";
import { CardComponent } from "./components/Card";
import { SortService, SortMode } from "../services/SortService";
export class SideCardsHomeView extends ItemView {
private selectedColor = 'var(--card-color-1)';
private selectedTags: string[] = [];
private filterType = '';
private filterValue = '';
private editorScope: Scope;
private editor!: Editor;
private owner!: any;
private recentFiles: TFile[] = [];
private pinnedFiles: TFile[] = [];
private cardComponents: Map<string, CardComponent> = new Map();
private currentSortMode: SortMode = 'created';
private sortAscending: boolean = false;
private pinnedOnly: boolean = false;
private currentSearchQuery: string = '';
private iconicFileIconsCache: Record<string, any> | null = null;
constructor(
leaf: WorkspaceLeaf,
private plugin: SideCardsPlugin,
private store: CardStore
private store: CardStore,
private sortService: SortService
) {
super(leaf);
this.editorScope = new Scope(this.app.scope);
// Block native formatting keys so Obsidian's global commands take over
this.editorScope.register(["Mod"], "b", () => true);
this.editorScope.register(["Mod"], "i", () => true);
this.editorScope.register(["Mod"], "u", () => true);
this.setupMockEditor();
}
private setupMockEditor() {
this.editor = {
getSelection: () => {
const sel = window.getSelection();
return sel ? sel.toString() : "";
},
replaceSelection: (text: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
},
toggleBold: () => this.toggleMarkdownWrapper("**"),
toggleItalic: () => this.toggleMarkdownWrapper("*"),
} as any;
this.owner = {
editor: this.editor,
editMode: true,
};
}
private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==") {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const selectedText = sel.toString();
if (selectedText.length === 0) {
// Empty selection: insert **** and place cursor in middle
const text = wrapper + wrapper;
const node = document.createTextNode(text);
range.insertNode(node);
range.setStart(node, wrapper.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(wrapper);
const newText = alreadyWrapped
? selectedText.slice(wrapper.length, -wrapper.length)
: wrapper + selectedText + wrapper;
this.editor.replaceSelection(newText);
}
}
getViewType(): string {
@ -32,37 +106,19 @@ export class SideCardsHomeView extends ItemView {
async onOpen(): Promise<void> {
const container = this.containerEl;
container.empty();
container.addClass('sidecards-home-container');
container.addClass('sc-home-container');
this.currentSortMode = (this.plugin.settings.sortMode as SortMode) || 'created';
this.sortAscending = typeof this.plugin.settings.sortAscending === 'boolean' ? this.plugin.settings.sortAscending : false;
const main = container.createDiv({ cls: 'sidecards-home-main' });
main.style.padding = '32px';
const title = main.createEl('h2', { text: 'SideCards' });
title.style.margin = '0 0 12px 0';
const main = container.createDiv({ cls: 'sc-home-main' });
// Top Section
const topSection = main.createDiv({ cls: 'sc-home-top' });
topSection.createEl('h2', { text: 'SideCards', cls: 'sc-home-title' });
const inputBox = main.createDiv({ cls: 'sidecards-home-input' });
inputBox.style.margin = '12px 0';
const input = inputBox.createEl('textarea');
input.placeholder = 'Type card content…';
input.rows = 4;
input.style.width = '100%';
input.style.minHeight = '100px';
input.style.padding = '12px';
input.style.border = '1px solid var(--background-modifier-border)';
input.style.borderRadius = '6px';
input.style.resize = 'vertical';
const paletteRow = main.createDiv({ cls: 'sidecards-home-palette-row' });
paletteRow.style.display = 'flex';
paletteRow.style.gap = '6px';
paletteRow.style.alignItems = 'center';
paletteRow.style.marginTop = '8px';
paletteRow.style.marginBottom = '20px';
const categoryBtn = paletteRow.createEl('button', { text: 'category', cls: 'sidecards-home-category-btn' });
categoryBtn.style.padding = '6px 10px';
categoryBtn.style.border = '1px solid var(--background-modifier-border)';
categoryBtn.style.borderRadius = '6px';
categoryBtn.addEventListener('click', () => {
const paletteRow = topSection.createDiv({ cls: 'sc-home-palette-row' });
const categoryBtn = paletteRow.createEl('button', { text: 'category', cls: 'sc-home-category-btn' });
categoryBtn.addEventListener('click', (e) => {
const menu = new Menu();
this.getAvailableFilters().forEach((f) => {
menu.addItem(item => item.setTitle(f.label).onClick(() => {
@ -71,16 +127,11 @@ export class SideCardsHomeView extends ItemView {
categoryBtn.textContent = f.label;
}));
});
const r = categoryBtn.getBoundingClientRect();
menu.showAtPosition({ x: r.left, y: r.bottom });
menu.showAtMouseEvent(e);
});
const separator = paletteRow.createDiv({ cls: 'sidecards-home-separator' });
const separator = paletteRow.createDiv({ cls: 'sc-home-separator' });
separator.textContent = '|';
separator.style.color = 'var(--background-modifier-border)';
separator.style.margin = '0 8px';
separator.style.fontSize = '18px';
separator.style.opacity = '0.6';
const colors = [
{ name: 'gray', var: 'var(--card-color-1)' },
@ -96,177 +147,74 @@ export class SideCardsHomeView extends ItemView {
];
const swatches: HTMLElement[] = [];
colors.forEach((color) => {
const swatch = paletteRow.createDiv({ cls: 'sidecards-home-color-dot' });
swatch.style.width = '28px';
swatch.style.height = '28px';
swatch.style.borderRadius = '4px';
swatch.style.border = this.selectedColor === color.var ? '2px solid var(--text-accent)' : '2px solid var(--background-modifier-border)';
const swatch = paletteRow.createDiv({ cls: 'sc-home-color-dot' });
const root = document.documentElement;
const computedColor = getComputedStyle(root).getPropertyValue(color.var.replace('var(', '').replace(')', ''));
swatch.style.backgroundColor = computedColor.trim() || color.var;
swatch.style.cursor = 'pointer';
swatch.style.transition = 'transform 0.15s ease';
swatch.addEventListener('mouseenter', () => { swatch.style.transform = 'scale(1.1)'; });
swatch.addEventListener('mouseleave', () => { swatch.style.transform = 'scale(1)'; });
if (this.selectedColor === color.var) swatch.addClass('is-selected');
swatch.addEventListener('click', () => {
swatches.forEach((s) => s.style.border = '2px solid var(--background-modifier-border)');
swatch.style.border = '2px solid var(--text-accent)';
swatches.forEach((s) => s.removeClass('is-selected'));
swatch.addClass('is-selected');
this.selectedColor = color.var;
});
swatches.push(swatch);
});
const autocompleteWrap = main.createDiv({ cls: 'sidecards-home-autocomplete-wrap' });
autocompleteWrap.style.position = 'relative';
autocompleteWrap.appendChild(inputBox);
const editorEl = topSection.createDiv({ cls: 'sc-home-editor' });
editorEl.setAttribute('contenteditable', 'true');
editorEl.dataset.placeholder = 'Type here... (@category, #tag)';
const homeTagAutocompleteContainer = autocompleteWrap.createDiv({ cls: 'card-tag-autocomplete' });
homeTagAutocompleteContainer.style.display = 'none';
homeTagAutocompleteContainer.style.position = 'absolute';
homeTagAutocompleteContainer.style.bottom = 'calc(100% + 4px)';
homeTagAutocompleteContainer.style.left = '0';
homeTagAutocompleteContainer.style.right = '0';
const homeGroupAutocompleteContainer = autocompleteWrap.createDiv({ cls: 'card-group-autocomplete' });
homeGroupAutocompleteContainer.style.display = 'none';
homeGroupAutocompleteContainer.style.position = 'absolute';
homeGroupAutocompleteContainer.style.bottom = 'calc(100% + 4px)';
homeGroupAutocompleteContainer.style.left = '0';
homeGroupAutocompleteContainer.style.right = '0';
let homeTagSelectedIndex = -1;
let homeGroupSelectedIndex = -1;
const updateHomeTagAutocomplete = () => {
try {
const cursorPos = input.selectionStart || 0;
const textBeforeCursor = input.value.substring(0, cursorPos);
const lastHashIdx = textBeforeCursor.lastIndexOf('#');
if (lastHashIdx === -1 || lastHashIdx < textBeforeCursor.length - 1) {
homeTagAutocompleteContainer.style.display = 'none';
return;
}
const currentWord = textBeforeCursor.substring(lastHashIdx + 1).toLowerCase();
const allTags = this.getAllUsedTags();
const suggestions = allTags.filter(t => t.startsWith(currentWord)).slice(0, 8);
if (suggestions.length === 0 && currentWord.length > 0) {
homeTagAutocompleteContainer.style.display = 'none';
return;
}
homeTagAutocompleteContainer.empty();
homeTagSelectedIndex = -1;
const displayTags = currentWord.length === 0 ? allTags.slice(0, 8) : suggestions;
if (displayTags.length === 0) {
homeTagAutocompleteContainer.style.display = 'none';
return;
}
displayTags.forEach((tag, idx) => {
const item = homeTagAutocompleteContainer.createDiv();
item.style.padding = '4px 8px';
item.style.cursor = 'pointer';
item.style.borderBottom = '1px solid var(--background-modifier-border)';
item.textContent = '#' + tag;
item.addEventListener('mouseenter', () => { item.style.background = 'var(--background-modifier-hover)'; homeTagSelectedIndex = idx; });
item.addEventListener('mouseleave', () => { item.style.background = ''; });
item.addEventListener('click', () => {
const before = input.value.substring(0, lastHashIdx);
const after = input.value.substring(cursorPos);
input.value = before + '#' + tag + ' ' + after;
input.selectionStart = input.selectionEnd = before.length + tag.length + 2;
input.focus();
updateHomeTagAutocomplete();
});
});
homeTagAutocompleteContainer.style.display = '';
} catch {}
const updatePlaceholder = () => {
if (!editorEl.textContent?.trim()) editorEl.addClass('is-empty');
else editorEl.removeClass('is-empty');
};
const updateHomeGroupAutocomplete = () => {
try {
const cursorPos = input.selectionStart || 0;
const textBeforeCursor = input.value.substring(0, cursorPos);
const lines = textBeforeCursor.split('\n');
const currentLine = lines[lines.length - 1];
const atIdx = currentLine.lastIndexOf('@');
if (atIdx === -1) {
homeGroupAutocompleteContainer.style.display = 'none';
return;
}
const currentWord = currentLine.substring(atIdx + 1).toLowerCase();
const groups = ['all', 'today', 'tomorrow'];
const customCats = Array.isArray(this.plugin.settings.customCategories) ? this.plugin.settings.customCategories : [];
const allSuggestions = [
...groups.map(g => ({ text: '@' + g, label: g })),
...customCats.map(c => ({ text: '@' + (c.id || c.label), label: c.label || c.id }))
];
const suggestions = currentWord.length === 0 ? allSuggestions : allSuggestions.filter(s => s.text.substring(1).startsWith(currentWord)).slice(0, 8);
if (suggestions.length === 0) {
homeGroupAutocompleteContainer.style.display = 'none';
return;
}
homeGroupAutocompleteContainer.empty();
homeGroupSelectedIndex = -1;
suggestions.forEach(({ text, label }, idx) => {
const item = homeGroupAutocompleteContainer.createDiv();
item.style.padding = '4px 8px';
item.style.cursor = 'pointer';
item.style.borderBottom = '1px solid var(--background-modifier-border)';
item.style.fontSize = '12px';
item.textContent = String(label || '');
item.addEventListener('mouseenter', () => { item.style.background = 'var(--background-modifier-hover)'; homeGroupSelectedIndex = idx; });
item.addEventListener('mouseleave', () => { item.style.background = ''; });
item.addEventListener('click', () => {
const lineStart = textBeforeCursor.lastIndexOf('\n') + 1;
const atAbs = lineStart + atIdx;
const before = input.value.substring(0, atAbs);
const after = input.value.substring(cursorPos);
input.value = before + text + ' ' + after;
input.selectionStart = input.selectionEnd = before.length + text.length + 1;
input.focus();
updateHomeGroupAutocomplete();
});
});
homeGroupAutocompleteContainer.style.display = '';
} catch {}
};
input.addEventListener('input', () => {
updateHomeTagAutocomplete();
updateHomeGroupAutocomplete();
updatePlaceholder();
editorEl.addEventListener('input', updatePlaceholder);
editorEl.addEventListener('focusin', () => {
// @ts-ignore
this.app.keymap.pushScope(this.editorScope);
// @ts-ignore
this.app.workspace.activeEditor = this.owner;
});
editorEl.addEventListener('blur', () => {
// @ts-ignore
this.app.keymap.popScope(this.editorScope);
// @ts-ignore
if (this.app.workspace.activeEditor === this.owner) this.app.workspace.activeEditor = null;
});
input.addEventListener('keydown', async (e) => {
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && homeGroupAutocompleteContainer.style.display !== 'none') {
e.preventDefault();
const items = homeGroupAutocompleteContainer.querySelectorAll('div');
if (items.length === 0) return;
if (e.key === 'ArrowDown') homeGroupSelectedIndex = (homeGroupSelectedIndex + 1) % items.length;
else homeGroupSelectedIndex = (homeGroupSelectedIndex - 1 + items.length) % items.length;
items.forEach((item, idx) => (item as HTMLElement).style.background = idx === homeGroupSelectedIndex ? 'var(--background-modifier-hover)' : '');
return;
}
if (e.key === 'Enter' && homeGroupAutocompleteContainer.style.display !== 'none' && homeGroupSelectedIndex >= 0) {
e.preventDefault();
const items = homeGroupAutocompleteContainer.querySelectorAll('div');
(items[homeGroupSelectedIndex] as HTMLElement)?.click();
return;
}
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && homeTagAutocompleteContainer.style.display !== 'none') {
e.preventDefault();
const items = homeTagAutocompleteContainer.querySelectorAll('div');
if (items.length === 0) return;
if (e.key === 'ArrowDown') homeTagSelectedIndex = (homeTagSelectedIndex + 1) % items.length;
else homeTagSelectedIndex = (homeTagSelectedIndex - 1 + items.length) % items.length;
items.forEach((item, idx) => (item as HTMLElement).style.background = idx === homeTagSelectedIndex ? 'var(--background-modifier-hover)' : '');
return;
}
if (e.key === 'Enter' && homeTagAutocompleteContainer.style.display !== 'none' && homeTagSelectedIndex >= 0) {
e.preventDefault();
const items = homeTagAutocompleteContainer.querySelectorAll('div');
(items[homeTagSelectedIndex] as HTMLElement)?.click();
return;
}
// Content Section (Two Columns)
const contentGrid = main.createDiv({ cls: 'sc-home-grid' });
// Left Column: Notes
const leftCol = contentGrid.createDiv({ cls: 'sc-home-left' });
leftCol.createEl('h3', { text: 'Pinned Notes', cls: 'sc-home-section-title' });
const pinnedList = leftCol.createDiv({ cls: 'sc-home-file-list pinned' });
await this.renderFileList(pinnedList, 'pinned');
leftCol.createEl('h3', { text: 'Recent Notes', cls: 'sc-home-section-title' });
const recentList = leftCol.createDiv({ cls: 'sc-home-file-list' });
await this.renderFileList(recentList, 'recent');
// Right Column: Cards
const rightCol = contentGrid.createDiv({ cls: 'sc-home-right' });
const toolbar = rightCol.createDiv({ cls: 'sc-home-toolbar' });
const cardList = rightCol.createDiv({ cls: 'sc-home-card-list' });
// Category Buttons Bar (Create before card list)
const categoryBar = rightCol.createDiv({ cls: 'sc-home-category-bar' });
rightCol.insertBefore(categoryBar, cardList);
this.renderToolbar(toolbar, editorEl, cardList, pinnedList, recentList);
this.renderCategoryBar(categoryBar, cardList);
await this.renderCards(cardList);
// Keydown for input
editorEl.addEventListener('keydown', async (e) => {
let pressed = '';
if (e.ctrlKey) pressed += 'ctrl-';
if (e.shiftKey) pressed += 'shift-';
@ -276,35 +224,376 @@ export class SideCardsHomeView extends ItemView {
const saveKey = normalizeKey(this.plugin.settings.saveKey || 'enter');
if (pressed === saveKey || (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey)) {
e.preventDefault();
await this.createCardFromHomeInput(input);
await this.createCardFromHomeInput(editorEl, cardList);
}
});
}
const toolbarRow = main.createDiv({ cls: 'sidecards-home-toolbar' });
toolbarRow.style.display = 'flex';
toolbarRow.style.gap = '6px';
toolbarRow.style.alignItems = 'center';
private async renderFileList(container: HTMLElement, type: 'recent' | 'pinned') {
container.empty();
const files = type === 'recent' ? await this.getRecentFiles() : await this.getPinnedFiles();
if (files.length === 0) {
container.createDiv({ text: `No ${type} notes found`, cls: 'sc-home-empty-msg' });
return;
}
const openSidebarBtn = toolbarRow.createEl('button', { cls: 'sidecards-home-reload-btn' });
openSidebarBtn.title = 'Open sidebar';
setIcon(openSidebarBtn, 'panel-right');
openSidebarBtn.addEventListener('click', async () => this.plugin.activateView());
for (const file of files) {
const item = container.createDiv({ cls: 'sc-home-file-item' });
const iconSpan = item.createSpan({ cls: 'sc-home-file-icon' });
await this.renderCustomFileIcon(iconSpan, file);
item.createSpan({ text: file.basename, cls: 'sc-home-file-name' });
item.addEventListener('click', () => {
this.app.workspace.getLeaf(false).openFile(file);
});
}
}
const searchBtn = toolbarRow.createEl('button', { cls: 'sidecards-home-sort-btn' });
searchBtn.title = 'Search cards';
setIcon(searchBtn, 'search');
searchBtn.addEventListener('click', async () => {
await this.plugin.activateView();
const view: any = this.app.workspace.getLeavesOfType('card-sidebar')?.[0]?.view;
if (view?._searchWrap) view._searchWrap.style.display = '';
if (view?._searchInput) view._searchInput.focus();
private async renderCustomFileIcon(iconEl: HTMLElement, file: TFile): Promise<void> {
const iconInfo = await this.getIconicFileIcon(file);
if (!iconInfo) {
setIcon(iconEl, 'file-text');
return;
}
const iconValue = String(iconInfo.icon || '').trim();
if (!iconValue) {
setIcon(iconEl, 'file-text');
return;
}
let rendered = false;
const normalizedLucide = this.normalizeLucideIconName(iconValue);
if (normalizedLucide) {
try {
setIcon(iconEl, normalizedLucide);
rendered = true;
} catch (e) {}
}
if (!rendered && iconValue.includes('<svg')) {
iconEl.innerHTML = iconValue;
rendered = true;
}
if (!rendered && /^:.*:$/.test(iconValue)) {
iconEl.setText(iconValue);
rendered = true;
}
if (!rendered) {
iconEl.setText(iconValue);
rendered = true;
}
if (iconInfo.color) {
iconEl.style.color = iconInfo.color;
}
}
private normalizeLucideIconName(rawIcon: string): string | null {
const cleaned = rawIcon.trim();
if (!cleaned) return null;
if (cleaned.includes('<svg')) return null;
if (/^:.*:$/.test(cleaned)) return null;
if (/[\u{1F300}-\u{1FAFF}]/u.test(cleaned)) return null;
let iconName = cleaned;
const prefixes = ['lucide:', 'lucide/', 'lucide-'];
for (const prefix of prefixes) {
if (iconName.startsWith(prefix)) {
iconName = iconName.slice(prefix.length);
}
}
if (iconName.includes(':')) {
const parts = iconName.split(':');
iconName = parts[parts.length - 1];
}
if (iconName.includes('/')) {
const parts = iconName.split('/');
iconName = parts[parts.length - 1];
}
iconName = iconName.trim();
if (!/^[a-z0-9\-]+$/i.test(iconName)) return null;
return iconName.toLowerCase();
}
private resolveIconicIconEntry(entry: any): { icon: string; color?: string } | null {
if (!entry) return null;
if (typeof entry === 'string') {
return { icon: entry };
}
if (typeof entry === 'object') {
const iconValue = entry.icon ?? entry.name ?? entry.value;
if (!iconValue) return null;
return {
icon: String(iconValue),
color: typeof entry.color === 'string' ? entry.color : undefined,
};
}
return null;
}
private async getIconicFileIcon(file: TFile): Promise<{ icon: string; color?: string } | null> {
const iconicPlugin = (this.app as any).plugins?.getPlugin?.('iconic');
if (!iconicPlugin) return null;
const path = file.path;
const immediateEntry = this.resolveIconicIconEntry(
iconicPlugin.settings?.fileIcons?.[path]
?? iconicPlugin.data?.fileIcons?.[path]
?? iconicPlugin.fileIcons?.[path]
);
if (immediateEntry) return immediateEntry;
if (this.iconicFileIconsCache === null) {
try {
const loaded = await iconicPlugin.loadData?.();
this.iconicFileIconsCache = loaded?.fileIcons && typeof loaded.fileIcons === 'object'
? loaded.fileIcons
: {};
} catch (e) {
this.iconicFileIconsCache = {};
}
}
const cache = this.iconicFileIconsCache || {};
return this.resolveIconicIconEntry(cache[path]);
}
private async getRecentFiles(): Promise<TFile[]> {
return this.app.vault.getMarkdownFiles()
.sort((a, b) => b.stat.mtime - a.stat.mtime)
.slice(0, 5);
}
private async getPinnedFiles(): Promise<TFile[]> {
const pinned: TFile[] = [];
// 1. Get from settings
if (this.plugin.settings.pinnedNotes) {
this.plugin.settings.pinnedNotes.forEach(path => {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) pinned.push(file);
});
}
// 2. Get from bookmarks plugin if available
try {
const bookmarks = (this.app as any).internalPlugins?.getPluginById("bookmarks")?.instance;
if (bookmarks && bookmarks.items) {
bookmarks.items.forEach((item: any) => {
if (item.type === 'file') {
const file = this.app.vault.getAbstractFileByPath(item.path);
if (file instanceof TFile && !pinned.includes(file)) pinned.push(file);
}
});
}
} catch (e) {}
return pinned.slice(0, 10); // Limit to 10 pinned notes
}
public async refreshPinnedNotes() {
const pinnedList = this.containerEl.querySelector('.sc-home-file-list.pinned');
if (pinnedList) {
await this.renderFileList(pinnedList as HTMLElement, 'pinned');
}
}
private async refreshHomeContent(cardList: HTMLElement, pinnedList: HTMLElement, recentList: HTMLElement): Promise<void> {
this.iconicFileIconsCache = null;
await this.renderFileList(pinnedList, 'pinned');
await this.renderFileList(recentList, 'recent');
await this.renderCards(cardList);
}
private renderToolbar(container: HTMLElement, editorEl: HTMLElement, cardList: HTMLElement, pinnedList: HTMLElement, recentList: HTMLElement) {
container.empty();
const leftActions = container.createDiv({ cls: 'sc-home-toolbar-left' });
const searchWrap = container.createDiv({ cls: 'sc-home-search-wrap' });
const rightActions = container.createDiv({ cls: 'sc-home-toolbar-right' });
// Left Actions
const sortBtn = leftActions.createEl('button', { cls: 'sc-icon-btn' });
setIcon(sortBtn, 'sort-desc');
sortBtn.title = 'Sort';
sortBtn.addEventListener('click', (e) => {
const menu = new Menu();
const modes: { label: string, mode: SortMode }[] = [
{ label: 'Manual', mode: 'manual' },
{ label: 'Created Time', mode: 'created' },
{ label: 'Modified Time', mode: 'modified' },
{ label: 'A → Z', mode: 'alpha' },
{ label: 'Status', mode: 'status' }
];
modes.forEach(({ label, mode }) => {
menu.addItem(item => {
item.setTitle(label)
.setChecked(this.currentSortMode === mode)
.onClick(async () => {
this.currentSortMode = mode;
this.plugin.settings.sortMode = mode;
await this.plugin.saveSettings();
await this.renderCards(cardList);
});
});
});
menu.addSeparator();
menu.addItem(item => {
item.setTitle('Direction: ' + (this.sortAscending ? 'Ascending' : 'Descending'))
.onClick(async () => {
this.sortAscending = !this.sortAscending;
this.plugin.settings.sortAscending = this.sortAscending;
await this.plugin.saveSettings();
await this.renderCards(cardList);
});
});
menu.showAtMouseEvent(e);
});
const addBtn = toolbarRow.createEl('button', { cls: 'sidecards-home-pinned-btn' });
addBtn.title = 'Add card';
setIcon(addBtn, 'plus');
addBtn.addEventListener('click', async () => {
await this.createCardFromHomeInput(input);
const refreshBtn = leftActions.createEl('button', { cls: 'sc-icon-btn' });
setIcon(refreshBtn, 'refresh-cw');
refreshBtn.title = 'Refresh';
refreshBtn.addEventListener('click', async () => {
await this.refreshHomeContent(cardList, pinnedList, recentList);
});
// Search Bar
const searchIcon = searchWrap.createSpan({ cls: 'sc-home-search-icon' });
setIcon(searchIcon, 'search');
const searchInput = searchWrap.createEl('input', { cls: 'sc-home-search-input', placeholder: 'Search card...' });
searchInput.addEventListener('input', () => {
this.currentSearchQuery = searchInput.value;
this.renderCards(cardList);
});
// Right Actions
const pinBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' });
setIcon(pinBtn, 'pin');
pinBtn.title = 'Pinned';
pinBtn.addEventListener('click', () => {
pinBtn.toggleClass('active', !pinBtn.hasClass('active'));
this.pinnedOnly = pinBtn.hasClass('active');
this.renderCards(cardList);
});
const moreBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' });
setIcon(moreBtn, 'more-vertical');
moreBtn.title = 'More';
moreBtn.addEventListener('click', (e) => {
const menu = new Menu();
menu.addItem(item => {
item.setTitle('Show tags')
.setChecked(!this.plugin.settings.groupTags)
.onClick(async () => {
this.plugin.settings.groupTags = !this.plugin.settings.groupTags;
await this.plugin.saveSettings();
this.renderCards(cardList);
});
});
menu.addItem(item => {
item.setTitle('Show timestamps')
.setChecked(this.plugin.settings.showTimestamps)
.onClick(async () => {
this.plugin.settings.showTimestamps = !this.plugin.settings.showTimestamps;
await this.plugin.saveSettings();
this.renderCards(cardList);
});
});
menu.showAtMouseEvent(e);
});
const sidebarBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' });
setIcon(sidebarBtn, 'panel-right');
sidebarBtn.title = 'Open Sidebar';
sidebarBtn.addEventListener('click', async () => {
this.plugin.activateView();
});
}
private renderCategoryBar(container: HTMLElement, cardList: HTMLElement) {
container.empty();
const filters = this.getAvailableFilters();
filters.forEach(f => {
const btn = container.createEl('button', { text: f.label, cls: 'sc-category-btn' });
if (this.filterValue === f.value) btn.addClass('active');
const colorKey = f.value === 'all' ? 'all'
: f.value === 'today' ? 'today'
: f.value === 'tomorrow' ? 'tomorrow'
: f.value === 'archived' ? 'archived'
: f.value;
btn.dataset.filterValue = f.value;
btn.dataset.filterColorKey = colorKey;
const customColors = this.plugin.settings.filterColors?.[colorKey];
if (customColors?.bgColor) btn.style.setProperty('background-color', customColors.bgColor, 'important');
if (customColors?.textColor) btn.style.setProperty('color', customColors.textColor, 'important');
btn.addEventListener('click', () => {
container.querySelectorAll('.sc-category-btn').forEach(b => {
(b as HTMLElement).removeClass('active');
const bColorKey = (b as HTMLElement).dataset.filterColorKey || '';
const bColors = this.plugin.settings.filterColors?.[bColorKey];
if (bColors?.bgColor) (b as HTMLElement).style.setProperty('background-color', bColors.bgColor, 'important');
else (b as HTMLElement).style.removeProperty('background-color');
if (bColors?.textColor) (b as HTMLElement).style.setProperty('color', bColors.textColor, 'important');
else (b as HTMLElement).style.removeProperty('color');
});
btn.addClass('active');
this.filterType = f.type;
this.filterValue = f.value;
this.renderCards(cardList);
});
});
}
private async renderCards(container: HTMLElement) {
container.empty();
this.cardComponents.forEach(c => c.destroy());
this.cardComponents.clear();
let cards = this.store.getAll();
if (this.pinnedOnly) {
cards = cards.filter(c => c.pinned);
}
if (this.currentSearchQuery) {
cards = cards.filter(c => c.content.toLowerCase().includes(this.currentSearchQuery.toLowerCase()));
}
const category = this.filterValue;
if (category && category !== 'all') {
if (category === 'today') {
const today = new Date().toDateString();
cards = cards.filter(c => new Date(c.created).toDateString() === today);
} else if (category === 'tomorrow') {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toDateString();
cards = cards.filter(c => new Date(c.created).toDateString() === tomorrowStr);
} else {
cards = cards.filter(c => c.category === category);
}
}
cards = this.sortService.sort(cards, this.currentSortMode, this.sortAscending);
cards.slice(0, 10).forEach(card => {
const comp = new CardComponent(container, card, this.store, this.app, this.plugin);
this.cardComponents.set(card.id, comp);
});
}
@ -338,23 +627,37 @@ export class SideCardsHomeView extends ItemView {
}
}
private async createCardFromHomeInput(input: HTMLTextAreaElement): Promise<void> {
let content = input.value.trim();
private async createCardFromHomeInput(editorEl: HTMLElement, cardList: HTMLElement) {
const content = editorEl.textContent?.trim();
if (!content) return;
const tagMatches = content.match(/#[a-zA-Z0-9_-]+/g) || [];
const tags = Array.from(new Set([...this.selectedTags, ...tagMatches.map(t => t.substring(1))]));
content = content.replace(/#[a-zA-Z0-9_-]+/g, '').trim();
const card = new Card({
content,
color: this.selectedColor,
tags,
category: this.filterType === 'category' ? this.filterValue : null,
archived: this.filterType === 'archived'
// Extract tags (#tag) and categories (@category)
const tags: string[] = [];
const tagRegex = /#([^\s#@,.]+)/g;
let match;
while ((match = tagRegex.exec(content)) !== null) {
tags.push(match[1]);
}
const catRegex = /@([^\s#@,.]+)/g;
const catMatch = catRegex.exec(content);
const category = catMatch ? catMatch[1] : (this.filterValue === 'all' ? undefined : this.filterValue);
const card = new Card({
content,
color: this.selectedColor,
tags: [...new Set([...tags, ...this.selectedTags])],
category
});
await this.store.add(card);
input.value = '';
editorEl.textContent = '';
// @ts-ignore
editorEl.dispatchEvent(new Event('input')); // trigger placeholder
// Clear selection state
this.selectedTags = [];
new Notice('Card added');
new Notice('Card created');
this.renderCards(cardList);
}
}

View file

@ -2,13 +2,28 @@
import { Card } from "../../models/Card";
import { CardStore } from "../../services/CardStore";
import { applyCardColorToElement } from "../../utils/dom";
import { App, MarkdownRenderer, Plugin, Menu, Notice, TFile, setIcon } from "obsidian";
import { App, MarkdownRenderer, Plugin, Menu, Notice, TFile, setIcon, Scope, Editor } from "obsidian";
import { DateTimeModal } from "../modals/DateTimeModal";
export class CardComponent {
public el: HTMLElement;
static activeEditor: CardComponent | null = null;
private static instanceCount = 0;
private static globalMouseDownBound = false;
private static readonly handleGlobalMouseDown = (event: MouseEvent) => {
const active = CardComponent.activeEditor;
if (!active || !active.isEditing) return;
const target = event.target as Node | null;
if (target && active.el.contains(target)) return;
active.blurAndSave();
};
private card: Card;
private unsubscribe: (() => void)[] = [];
private isEditing: boolean = false;
private renderCount: number = 0;
private scope: Scope;
private editor!: Editor;
private owner!: any;
constructor(
private container: HTMLElement,
@ -17,15 +32,85 @@ export class CardComponent {
private app: App,
private plugin: Plugin
) {
CardComponent.instanceCount += 1;
this.card = card;
this.el = container.createDiv('card-sidebar-card');
this.el = container.createDiv('sc-card');
this.scope = new Scope(this.app.scope);
// Block native formatting keys so Obsidian's global commands take over
this.scope.register(["Mod"], "b", () => true);
this.scope.register(["Mod"], "i", () => true);
this.scope.register(["Mod"], "u", () => true);
this.setupMockEditor();
this.ensureGlobalMouseDownHandler();
this.render();
this.setupListeners();
}
private ensureGlobalMouseDownHandler() {
if (CardComponent.globalMouseDownBound) return;
document.addEventListener('mousedown', CardComponent.handleGlobalMouseDown, true);
CardComponent.globalMouseDownBound = true;
}
private setupMockEditor() {
this.editor = {
getSelection: () => {
const sel = window.getSelection();
return sel ? sel.toString() : "";
},
replaceSelection: (text: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
},
toggleBold: () => this.toggleMarkdownWrapper("**"),
toggleItalic: () => this.toggleMarkdownWrapper("*"),
} as any;
this.owner = {
editor: this.editor,
editMode: true,
};
}
private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==") {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const selectedText = sel.toString();
if (selectedText.length === 0) {
// Empty selection: insert **** and place cursor in middle
const text = wrapper + wrapper;
const node = document.createTextNode(text);
range.insertNode(node);
range.setStart(node, wrapper.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(wrapper);
const newText = alreadyWrapped
? selectedText.slice(wrapper.length, -wrapper.length)
: wrapper + selectedText + wrapper;
this.editor.replaceSelection(newText);
}
}
private async render(): Promise<void> {
const currentRender = ++this.renderCount;
this.el.empty();
this.el.dataset.id = this.card.id;
this.el.draggable = !this.isEditing; // Disable dragging while editing
// Apply styling
applyCardColorToElement(this.el, this.card.color, this.store.settings);
@ -33,18 +118,23 @@ export class CardComponent {
// Apply max card height
const maxH = this.store.settings.maxCardHeight;
if (maxH && maxH > 0) {
this.el.style.maxHeight = `${maxH}px`;
this.el.style.overflow = 'hidden';
this.el.style.setProperty('max-height', `${maxH}px`, 'important');
this.el.style.setProperty('overflow', 'hidden', 'important');
} else {
this.el.style.maxHeight = '';
this.el.style.overflow = '';
this.el.style.removeProperty('max-height');
this.el.style.removeProperty('overflow');
}
// detect layout mode and apply masonry if needed
if (this.store.settings.cardStyle === 2) {
this.el.style.breakInside = 'avoid';
}
const fragment = document.createDocumentFragment();
// Copy button (if enabled)
if (this.store.settings.enableCopyCardContent) {
const copyBtn = fragment.createDiv('card-copy-btn');
const copyBtn = fragment.createDiv('sc-copy-btn');
try {
setIcon(copyBtn, 'copy');
} catch {
@ -58,38 +148,36 @@ export class CardComponent {
}
// Pill bar
const pillBar = fragment.createDiv('card-pill-bar');
const pillBar = fragment.createDiv('sc-pill-bar');
this.renderPills(pillBar);
// Content
const content = fragment.createDiv('card-content');
const content = fragment.createDiv('sc-content');
await this.renderContent(content);
// If a new render has started, discard this one
if (currentRender !== this.renderCount) return;
// Tags inline (when groupTags is off, show tags right after content)
if (!this.store.settings.groupTags && this.card.tags && this.card.tags.length > 0) {
const tagsEl = fragment.createDiv('card-tags');
const tagsEl = fragment.createDiv('sc-tags');
this.card.tags.forEach(tag => {
const tagEl = tagsEl.createSpan('card-tag');
tagEl.textContent = this.store.settings.omitTagHash ? tag : `#${tag}`;
const tagEl = tagsEl.createSpan('sc-tag');
const cleanTag = tag.trim().replace(/^[-#\s]+/, '').trim();
tagEl.textContent = this.store.settings.omitTagHash ? cleanTag : `#${cleanTag}`;
tagEl.addEventListener('click', (e) => {
e.stopPropagation();
// @ts-ignore
this.store.eventBus.emit('filter:tag', tag);
this.store.eventBus.emit('filter:tag', cleanTag);
});
});
}
// Footer (grouped tags + timestamp)
const footer = fragment.createDiv('card-footer');
const footer = fragment.createDiv('sc-footer');
this.renderFooter(footer);
this.el.appendChild(fragment);
// detect layout mode and apply masonry if needed
if (this.store.settings.cardStyle === 2) {
this.el.style.breakInside = 'avoid';
this.el.style.marginBottom = '12px';
}
}
private copyCardContent(): void {
@ -97,7 +185,7 @@ export class CardComponent {
let contentToCopy = this.card.content;
// Try to get text from card content element
const contentEl = this.el.querySelector('.card-content');
const contentEl = this.el.querySelector('.sc-content');
if (contentEl && !this.store.settings.disableCardRendering) {
contentToCopy = contentEl.textContent || this.card.content;
}
@ -112,11 +200,11 @@ export class CardComponent {
private renderPills(container: HTMLElement): void {
if (this.card.expiresAt) {
const pill = container.createDiv('card-expiry-pill');
const pill = container.createDiv('sc-expiry-pill');
pill.textContent = this.formatExpiry(this.card.expiresAt);
}
if (this.card.status) {
const pill = container.createDiv('card-status-pill');
const pill = container.createDiv('sc-status-pill');
pill.textContent = this.card.status.name;
pill.style.backgroundColor = this.card.status.color;
pill.style.color = this.card.status.textColor || '#000';
@ -124,16 +212,75 @@ export class CardComponent {
}
private async renderContent(container: HTMLElement): Promise<void> {
if (this.store.settings.disableCardRendering) {
if (this.isEditing || this.store.settings.disableCardRendering) {
container.setAttribute('contenteditable', 'true');
container.textContent = this.card.content;
container.addClass('is-editing');
// Auto-focus and place cursor at the end
setTimeout(() => {
container.focus();
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(container);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}, 0);
container.addEventListener('focusin', () => {
// @ts-ignore
this.app.keymap.pushScope(this.scope);
// @ts-ignore
this.app.workspace.activeEditor = this.owner;
});
container.addEventListener('blur', async () => {
if (container.textContent !== this.card.content) {
await this.store.update(this.card.id, { content: container.textContent || '' });
// @ts-ignore
this.app.keymap.popScope(this.scope);
// @ts-ignore
if (this.app.workspace.activeEditor === this.owner) {
// @ts-ignore
this.app.workspace.activeEditor = null;
}
if (this.isEditing) {
const newContent = container.textContent || '';
if (newContent !== this.card.content) {
await this.store.update(this.card.id, { content: newContent });
}
this.isEditing = false;
if (CardComponent.activeEditor === this) {
CardComponent.activeEditor = null;
}
// Removed redundant this.render() - CardSidebarView will re-render everything on card:updated
}
});
// Handle Enter and Shift+Enter according to settings
container.addEventListener('keydown', async (e) => {
const settings = this.store.settings;
const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s\+_]+/g, '-').replace(/[^a-z0-9\-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
const saveKey = normalizeKey(settings.saveKey || 'enter');
const nextLineKey = normalizeKey(settings.nextLineKey || 'shift-enter');
let pressed = '';
if (e.ctrlKey) pressed += 'ctrl-';
if (e.shiftKey) pressed += 'shift-';
if (e.altKey) pressed += 'alt-';
if (e.key && e.key.toLowerCase() === 'enter') pressed += 'enter';
if (pressed === saveKey) {
e.preventDefault();
container.blur();
} else if (pressed === nextLineKey) {
// Default behavior for contenteditable usually handles Enter/Shift+Enter
// but if we need manual control, we can insert a newline here.
}
});
} else {
container.setAttribute('contenteditable', 'false');
container.removeClass('is-editing');
const temp = document.createElement('div');
await MarkdownRenderer.render(
this.app,
@ -150,49 +297,68 @@ export class CardComponent {
const settings = this.store.settings;
const hasTags = this.card.tags && this.card.tags.length > 0;
if (settings.groupTags && !settings.timestampBelowTags) {
// Tags then timestamp
if (settings.groupTags) {
if (hasTags) {
const tagsEl = container.createDiv('card-tags');
const tagsEl = container.createDiv('sc-tags');
this.card.tags.forEach(tag => {
const tagEl = tagsEl.createSpan('card-tag');
tagEl.textContent = settings.omitTagHash ? tag : `#${tag}`;
const tagEl = tagsEl.createSpan('sc-tag');
const cleanTag = tag.trim().replace(/^[-#\s]+/, '').trim();
tagEl.textContent = settings.omitTagHash ? cleanTag : `#${cleanTag}`;
tagEl.addEventListener('click', (e) => {
e.stopPropagation();
// @ts-ignore
this.store.eventBus.emit('filter:tag', tag);
this.store.eventBus.emit('filter:tag', cleanTag);
});
});
}
if (settings.showTimestamps) {
container.createDiv('card-timestamp').textContent = this.formatTimestamp(this.card.created);
}
} else if (settings.groupTags && settings.timestampBelowTags) {
// Tags then timestamp below
if (hasTags) {
const tagsEl = container.createDiv('card-tags');
this.card.tags.forEach(tag => {
const tagEl = tagsEl.createSpan('card-tag');
tagEl.textContent = settings.omitTagHash ? tag : `#${tag}`;
tagEl.addEventListener('click', (e) => {
e.stopPropagation();
// @ts-ignore
this.store.eventBus.emit('filter:tag', tag);
});
});
}
if (settings.showTimestamps) {
container.createDiv('card-timestamp').textContent = this.formatTimestamp(this.card.created);
const ts = container.createDiv('sc-timestamp');
if (settings.timestampBelowTags) {
ts.style.display = 'block';
ts.style.marginTop = '4px';
} else {
ts.style.display = 'inline-block';
ts.style.marginLeft = hasTags ? '8px' : '0';
}
ts.textContent = this.formatTimestamp(this.card.created);
}
} else {
// Inline tags (rendered above footer in render()), just show timestamp here
// Tags already rendered above footer in render()
if (settings.showTimestamps) {
container.createDiv('card-timestamp').textContent = this.formatTimestamp(this.card.created);
container.createDiv('sc-timestamp').textContent = this.formatTimestamp(this.card.created);
}
}
}
private setupListeners(): void {
// Enter edit mode on click
this.el.addEventListener('click', (e) => {
// If another card is already being edited, blur it first.
if (CardComponent.activeEditor && CardComponent.activeEditor !== this) {
CardComponent.activeEditor.blurAndSave();
}
// Don't trigger if already editing or clicking a button/pill/tag
if (this.isEditing) return;
const target = e.target as HTMLElement;
if (
target.closest('.sc-copy-btn') ||
target.closest('.sc-expiry-pill') ||
target.closest('.sc-status-pill') ||
target.closest('.sc-tag') ||
target.tagName === 'A' || // Don't trigger if clicking a link
target.closest('button')
) {
return;
}
this.isEditing = true;
CardComponent.activeEditor = this;
this.render();
});
// Context menu
this.el.addEventListener('contextmenu', (e) => {
e.preventDefault();
@ -206,16 +372,25 @@ export class CardComponent {
menu.addItem(item => {
item.setTitle('Colors');
const container = document.createElement('div');
container.style.display = 'flex';
container.style.gap = '6px';
container.style.flexWrap = this.store.settings.twoRowSwatches ? 'wrap' : 'nowrap';
if (this.store.settings.twoRowSwatches) container.style.maxWidth = '180px';
container.className = 'sc-color-dots';
if (this.store.settings.twoRowSwatches) {
container.classList.add('two-row');
container.style.display = 'grid';
container.style.gridTemplateColumns = 'repeat(5, 18px)';
container.style.gridAutoRows = '18px';
container.style.columnGap = '8px';
container.style.rowGap = '8px';
container.style.width = 'fit-content';
}
colors.forEach((color, idx) => {
const swatch = document.createElement('div');
swatch.className = 'sc-color-dot';
swatch.style.width = '18px';
swatch.style.height = '18px';
swatch.style.borderRadius = '4px';
swatch.style.cursor = 'pointer';
swatch.style.flexShrink = '0';
swatch.style.border = this.card.color === color ? '2px solid var(--text-accent)' : '1px solid var(--background-modifier-border)';
swatch.title = this.store.settings.colorNames[idx] || `Color ${idx + 1}`;
const root = document.documentElement;
@ -316,4 +491,87 @@ export class CardComponent {
if (!path) return;
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await this
await this.app.workspace.getLeaf(true).openFile(file);
}
}
});
});
menu.addItem(item => {
item.setTitle(this.card.archived ? 'Unarchive' : 'Archive')
.setIcon('archive')
.onClick(async () => {
await this.store.toggleArchive(this.card.id, !this.card.archived);
});
});
menu.addItem(item => {
item.setTitle('Delete')
.setIcon('trash')
.onClick(async () => {
await this.store.delete(this.card.id);
});
});
menu.showAtMouseEvent(e);
});
// Drag start
this.el.addEventListener('dragstart', (e) => {
if (this.isEditing) {
e.preventDefault();
return;
}
// @ts-ignore
this.store.eventBus.emit('card:dragstart', { card: this.card, event: e });
if (e.dataTransfer) {
e.dataTransfer.setData('text/plain', this.card.content);
e.dataTransfer.setData('application/json', JSON.stringify(this.card));
}
});
// Store updates
// @ts-ignore
const unbind = this.store.eventBus.on('card:updated', (updated: Card) => {
if (updated.id === this.card.id) {
this.card = updated;
this.render();
}
});
this.unsubscribe.push(unbind);
}
blurAndSave() {
const contentEl = this.el.querySelector('.sc-content') as HTMLElement;
if (contentEl) {
contentEl.blur();
}
}
private formatTimestamp(ts: number): string {
const fmt = this.store.settings.datetimeFormat;
if (fmt && (window as any).moment) {
return (window as any).moment(ts).format(fmt);
}
return new Date(ts).toLocaleDateString() + ' ' + new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
private formatExpiry(ts: number): string {
const diff = ts - Date.now();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return 'Expired';
if (days === 0) return 'Expires today';
if (days === 1) return 'Expires tomorrow';
return `Expires in ${days} days`;
}
destroy(): void {
if (CardComponent.activeEditor === this) {
CardComponent.activeEditor = null;
}
CardComponent.instanceCount = Math.max(0, CardComponent.instanceCount - 1);
if (CardComponent.instanceCount === 0 && CardComponent.globalMouseDownBound) {
document.removeEventListener('mousedown', CardComponent.handleGlobalMouseDown, true);
CardComponent.globalMouseDownBound = false;
}
this.unsubscribe.forEach(fn => fn());
this.el.remove();
}
}

View file

@ -10,7 +10,7 @@ export class TagAutocomplete {
private store: CardStore
) {
const parent = this.input.parentElement || document.body;
this.container = parent.createDiv('card-tag-autocomplete');
this.container = parent.createDiv('sc-tag-autocomplete');
this.container.style.display = 'none';
this.container.style.position = 'absolute';
this.container.style.bottom = 'calc(100% + 4px)';
@ -52,7 +52,7 @@ export class TagAutocomplete {
this.container.empty();
this.selectedIndex = -1;
this.suggestions.forEach((tag, idx) => {
const item = this.container.createDiv('card-autocomplete-item');
const item = this.container.createDiv('sc-autocomplete-item');
item.textContent = '#' + tag;
item.addEventListener('mouseenter', () => {
item.style.background = 'var(--background-modifier-hover)';
@ -73,7 +73,7 @@ export class TagAutocomplete {
private onKeyDown(e: KeyboardEvent): void {
if (this.container.style.display === 'none') return;
const items = this.container.querySelectorAll('.card-autocomplete-item');
const items = this.container.querySelectorAll('.sc-autocomplete-item');
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && items.length > 0) {
e.preventDefault();
if (e.key === 'ArrowDown') this.selectedIndex = (this.selectedIndex + 1) % items.length;

View file

@ -1,75 +1,321 @@
import { Modal, App, Plugin, Notice, Setting } from "obsidian";
import { Modal, App, Plugin, Notice, setIcon, Menu, Scope, Editor } from "obsidian";
import { CardStore } from "../../services/CardStore";
import { Card } from "../../models/Card";
export class QuickCardWithFilterModal extends Modal {
private editorScope: Scope;
private editor!: Editor;
private owner!: any;
constructor(
app: App,
private plugin: Plugin,
private store: CardStore
) {
super(app);
this.editorScope = new Scope(this.app.scope);
// Block native formatting keys so Obsidian's global commands take over
this.editorScope.register(["Mod"], "b", () => true);
this.editorScope.register(["Mod"], "i", () => true);
this.editorScope.register(["Mod"], "u", () => true);
this.setupMockEditor();
}
private setupMockEditor() {
this.editor = {
getSelection: () => {
const sel = window.getSelection();
return sel ? sel.toString() : "";
},
replaceSelection: (text: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
},
toggleBold: () => this.toggleMarkdownWrapper("**"),
toggleItalic: () => this.toggleMarkdownWrapper("*"),
} as any;
this.owner = {
editor: this.editor,
editMode: true,
};
}
private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==") {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const selectedText = sel.toString();
if (selectedText.length === 0) {
// Empty selection: insert **** and place cursor in middle
const text = wrapper + wrapper;
const node = document.createTextNode(text);
range.insertNode(node);
range.setStart(node, wrapper.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(wrapper);
const newText = alreadyWrapped
? selectedText.slice(wrapper.length, -wrapper.length)
: wrapper + selectedText + wrapper;
this.editor.replaceSelection(newText);
}
}
getAvailableFilters() {
const filters = [
{ type: 'all', label: 'All', value: 'all' }
];
const showTimeBasedChips = !this.store.settings.disableTimeBasedFiltering;
if (showTimeBasedChips) {
filters.push(
{ type: 'category', label: 'Today', value: 'today' },
{ type: 'category', label: 'Tomorrow', value: 'tomorrow' }
);
}
if (this.store.settings.enableCustomCategories) {
const cats = this.store.settings.customCategories || [];
cats.forEach(cat => {
filters.push({
type: 'category',
label: cat.label,
value: cat.id || cat.label
});
});
}
if (!this.store.settings.hideArchivedFilterButton) {
filters.push({ type: 'archived', label: 'Archived', value: 'archived' });
}
return filters;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: 'Quick Card Add' });
contentEl.addClass('sc-quick-card-modal');
const textarea = contentEl.createEl('textarea', {
placeholder: 'Card content...',
cls: 'quick-card-textarea'
contentEl.createEl('h2', { text: 'Quick Card Add', cls: 'sc-modal-title' });
// Content Section
contentEl.createEl('h3', { text: 'Card Content', cls: 'sc-modal-section-title' });
const editorEl = contentEl.createDiv({
cls: 'sc-modal-textarea',
});
editorEl.setAttribute('contenteditable', 'true');
editorEl.dataset.placeholder = 'Type here... (@category, #tag)';
// Simple placeholder logic for contenteditable
if (!editorEl.textContent) {
editorEl.addClass('is-empty');
}
editorEl.addEventListener('input', () => {
editorEl.toggleClass('is-empty', !editorEl.textContent);
});
textarea.focus();
// Color swatches
const colorContainer = contentEl.createDiv('color-container');
editorEl.focus();
editorEl.addEventListener('focusin', () => {
// @ts-ignore
this.app.keymap.pushScope(this.editorScope);
// @ts-ignore
this.app.workspace.activeEditor = this.owner;
});
editorEl.addEventListener('blur', () => {
// @ts-ignore
this.app.keymap.popScope(this.editorScope);
// @ts-ignore
if (this.app.workspace.activeEditor === this.owner) {
// @ts-ignore
this.app.workspace.activeEditor = null;
}
});
// Color Section
contentEl.createEl('h3', { text: 'Color', cls: 'sc-modal-section-title' });
const colorContainer = contentEl.createDiv('sc-modal-color-container');
let selectedColor = 'var(--card-color-1)';
const colors = [
'var(--card-color-1)', 'var(--card-color-2)', 'var(--card-color-3)',
'var(--card-color-4)', 'var(--card-color-5)', 'var(--card-color-6)',
'var(--card-color-7)', 'var(--card-color-8)', 'var(--card-color-9)',
'var(--card-color-10)'
{ name: 'Gray', var: 'var(--card-color-1)' },
{ name: 'Red', var: 'var(--card-color-2)' },
{ name: 'Orange', var: 'var(--card-color-3)' },
{ name: 'Yellow', var: 'var(--card-color-4)' },
{ name: 'Green', var: 'var(--card-color-5)' },
{ name: 'Blue', var: 'var(--card-color-6)' },
{ name: 'Purple', var: 'var(--card-color-7)' },
{ name: 'Magenta', var: 'var(--card-color-8)' },
{ name: 'Pink', var: 'var(--card-color-9)' },
{ name: 'Brown', var: 'var(--card-color-10)' }
];
colors.forEach(color => {
const swatch = colorContainer.createDiv('color-swatch');
swatch.style.backgroundColor = color;
colors.forEach((color, idx) => {
const swatch = colorContainer.createDiv('sc-modal-color-swatch');
const hex = this.resolveColor(color.var);
swatch.style.backgroundColor = hex;
swatch.title = this.store.settings.colorNames[idx] || color.name;
if (selectedColor === color.var) swatch.addClass('is-selected');
swatch.addEventListener('click', () => {
colorContainer.querySelectorAll('.color-swatch').forEach(s => s.removeClass('selected'));
swatch.addClass('selected');
selectedColor = color;
colorContainer.querySelectorAll('.sc-modal-color-swatch').forEach(s => s.removeClass('is-selected'));
swatch.addClass('is-selected');
selectedColor = color.var;
});
});
// Category select
const select = contentEl.createEl('select', { cls: 'filter-select' });
const categories = this.store.settings.customCategories || [];
[ { id: 'all', label: 'All' }, ...categories ].forEach(cat => {
const opt = select.createEl('option', { value: cat.id, text: cat.label });
// Tags Section
contentEl.createEl('h3', { text: 'Tags', cls: 'sc-modal-section-title' });
const tagsWrapper = contentEl.createDiv('sc-modal-tags-wrapper');
const tagsInput = tagsWrapper.createEl('input', {
placeholder: 'Tags (comma separated)...',
cls: 'sc-modal-tags-input'
});
const tagsAutocomplete = tagsWrapper.createDiv('sc-modal-tags-autocomplete');
tagsAutocomplete.style.display = 'none';
// Create button
const btnContainer = contentEl.createDiv('modal-button-container');
const createBtn = btnContainer.createEl('button', { text: 'Create Card', cls: 'mod-cta' });
createBtn.addEventListener('click', async () => {
const content = textarea.value.trim();
if (content) {
const category = select.value === 'all' ? null : select.value;
const card = new Card({ content, color: selectedColor, category });
await this.store.add(card);
this.close();
} else {
new Notice('Content cannot be empty');
// Tag Autocomplete Logic
let selectedTagIdx = -1;
const updateAutocomplete = () => {
const val = tagsInput.value;
const lastComma = val.lastIndexOf(',');
const currentTag = val.substring(lastComma + 1).trim().toLowerCase();
if (!currentTag) {
tagsAutocomplete.style.display = 'none';
return;
}
const allTags = this.getAllTags();
const suggestions = allTags.filter(t => t.startsWith(currentTag)).slice(0, 8);
if (suggestions.length === 0) {
tagsAutocomplete.style.display = 'none';
return;
}
tagsAutocomplete.empty();
selectedTagIdx = -1;
suggestions.forEach((tag, idx) => {
const item = tagsAutocomplete.createDiv('sc-modal-autocomplete-item');
item.textContent = tag;
item.addEventListener('click', () => {
const before = val.substring(0, lastComma + 1);
tagsInput.value = (before ? before + ' ' : '') + tag + ', ';
tagsAutocomplete.style.display = 'none';
tagsInput.focus();
});
});
tagsAutocomplete.style.display = 'block';
};
tagsInput.addEventListener('input', updateAutocomplete);
tagsInput.addEventListener('keydown', (e) => {
if (tagsAutocomplete.style.display === 'none') return;
const items = tagsAutocomplete.querySelectorAll('.sc-modal-autocomplete-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedTagIdx = (selectedTagIdx + 1) % items.length;
items.forEach((it, i) => it.toggleClass('is-selected', i === selectedTagIdx));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedTagIdx = (selectedTagIdx - 1 + items.length) % items.length;
items.forEach((it, i) => it.toggleClass('is-selected', i === selectedTagIdx));
} else if (e.key === 'Enter' && selectedTagIdx >= 0) {
e.preventDefault();
(items[selectedTagIdx] as HTMLElement).click();
}
});
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
// Category Section
contentEl.createEl('h3', { text: 'Apply Category', cls: 'sc-modal-section-title' });
const select = contentEl.createEl('select', { cls: 'sc-modal-select' });
this.getAvailableFilters().forEach(f => {
const opt = select.createEl('option', { value: f.value, text: f.label });
// @ts-ignore
opt.dataset.type = f.type;
});
// Action Buttons
const btnContainer = contentEl.createDiv('sc-modal-buttons');
const cancelBtn = btnContainer.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
const createBtn = btnContainer.createEl('button', { text: 'Create Card', cls: 'mod-cta' });
const handleCreate = async () => {
const content = editorEl.textContent?.trim();
if (!content) {
new Notice('Content cannot be empty');
return;
}
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(t => !!t);
const category = select.value === 'all' ? null : select.value;
const card = new Card({ content, color: selectedColor, tags, category });
await this.store.add(card);
// If we chose a specific category, try to filter the sidebar to it
if (category) {
const view = this.app.workspace.getLeavesOfType('card-sidebar')[0]?.view as any;
if (view) {
view.activeFilters.category = category;
await view.renderCards();
}
}
this.close();
};
createBtn.addEventListener('click', handleCreate);
// Keyboard Shortcuts
editorEl.addEventListener('keydown', (e) => {
const settings = this.store.settings;
const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s\+_]+/g, '-').replace(/[^a-z0-9\-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
const saveKey = normalizeKey(settings.saveKey || 'enter');
let pressed = '';
if (e.ctrlKey) pressed += 'ctrl-';
if (e.shiftKey) pressed += 'shift-';
if (e.altKey) pressed += 'alt-';
if (e.key && e.key.toLowerCase() === 'enter') pressed += 'enter';
if (pressed === saveKey) {
e.preventDefault();
createBtn.click();
handleCreate();
}
});
}
private resolveColor(colorVar: string): string {
const root = document.documentElement;
const clean = colorVar.replace('var(', '').replace(')', '');
return getComputedStyle(root).getPropertyValue(clean).trim() || colorVar;
}
private getAllTags(): string[] {
const tags = new Set<string>();
this.store.getAll().forEach(c => {
if (c.tags) c.tags.forEach(t => tags.add(t.toLowerCase()));
});
return Array.from(tags).sort();
}
}

View file

@ -22,14 +22,14 @@ export class SearchModal extends Modal {
contentEl.empty();
this.titleEl.setText('Search Cards');
const searchWrapper = contentEl.createDiv('card-search-wrapper');
const searchWrapper = contentEl.createDiv('sc-search-wrapper');
this.searchInput = searchWrapper.createEl('input', {
type: 'text',
placeholder: 'Search cards...',
cls: 'card-search-input'
cls: 'sc-search-input'
});
this.resultsContainer = contentEl.createDiv('card-search-results');
this.resultsContainer = contentEl.createDiv('sc-search-results');
this.resultsContainer.style.maxHeight = '400px';
this.resultsContainer.style.overflow = 'auto';
@ -47,7 +47,7 @@ export class SearchModal extends Modal {
this.selectedIndex = -1;
this.searchResults.forEach((card, idx) => {
const result = this.resultsContainer.createDiv('card-search-result');
const result = this.resultsContainer.createDiv('sc-search-result');
result.textContent = card.content.substring(0, 100) + (card.content.length > 100 ? '...' : '');
result.style.padding = '8px';
result.style.cursor = 'pointer';
@ -63,7 +63,7 @@ export class SearchModal extends Modal {
}
private updateSelection() {
const els = Array.from(this.resultsContainer.querySelectorAll('.card-search-result')) as HTMLElement[];
const els = Array.from(this.resultsContainer.querySelectorAll('.sc-search-result')) as HTMLElement[];
els.forEach((el, idx) => {
if (idx === this.selectedIndex) {
el.addClass('selected');

View file

@ -1,93 +1,418 @@
/* --- == SC == --- */
.sc-home-category-bar {
margin: 0 !important;
gap: 10px !important;
}
.card-sidebar-header .category-group {
.sc-color-dots.two-row {
gap: 8px !important;
margin-bottom: 6px !important;
}
.sidecards-home, .sc-home-main {
margin-top: 70px !important;
transition: all 0.2s ease;
}
.sc-home-right,
.sc-home-toolbar,
.sc-home-category-bar,
.sc-home-card-list {
width: 100% !important;
min-width: 0;
}
.sc-content p {
margin: 0;
}
.sc-sidebar-header .sc-category-group {
background: var(--background-primary);
display: flex;
gap: 4px;
flex-wrap: nowrap;
overflow-x: auto;
padding: 6px 8px;
padding: 10px;
scrollbar-width: none;
scroll-behavior: smooth;
}
.card-category-btn {
padding: 4px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 3px;
.sc-sidebar-header .sc-category-group::-webkit-scrollbar {
display: none;
}
.sc-category-btn {
/* border: 2px solid var(--background-modifier-border); */
border-radius: var(--button-radius);
background: var(--background-primary);
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
transition: border-width 0s ease;
}
.card-category-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
.sc-category-btn:hover {
filter: brightness(1.2);
}
.card-search-wrap {
.sc-category-btn.active {
border: 2px solid var(--interactive-accent);
transition: border-width 0s ease;
}
.sc-search-wrap {
width: 100%;
padding: 8px;
padding: 10px;
background: var(--background-primary);
border-bottom: 1px solid var(--background-modifier-border);
padding-top: 0px;
}
.card-search-row {
.sc-search-row {
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.card-search-input-icon {
.sc-search-input-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
width: 16px;
height: 16px;
}
.card-search-input {
.sc-search-input {
flex: 1;
width: 100%;
padding: 8px 10px 8px 36px;
padding: 8px 36px 8px 34px !important;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
border-radius: var(--input-radius);
background: var(--background-primary-alt);
color: var(--text-normal);
font-size: 14px;
transition: all 0.2s ease;
line-height: 1.4;
}
.card-search-input:focus {
.sc-search-input::placeholder {
color: var(--text-muted) !important;
}
.sc-search-input:focus {
outline: none;
border-color: var(--interactive-accent);
background: var(--background-primary);
box-shadow: 0 0 0 2px rgba(var(--interactive-accent), 0.1);
}
.card-search-clear-btn {
position: absolute;
right: 10px;
padding: 4px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: transparent;
.sc-search-input::placeholder {
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
opacity: 0.5;
}
.card-search-clear-btn:hover {
background: var(--background-modifier-hover);
.sc-sidebar-input {
width: 100%;
min-height: 36px;
max-height: 200px;
padding: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--input-radius);
background-color: var(--background-modifier-form-field);
overflow-y: auto;
white-space: pre-wrap;
position: relative;
}
.sc-sidebar-input.is-empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
position: absolute;
pointer-events: none;
opacity: 0.6;
}
/* Home View Layout */
.sc-home-container {
height: 100%;
overflow-y: auto;
background-color: var(--background-primary);
}
.sc-home-main {
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
}
.sc-home-top {
margin-bottom: 40px;
}
.sc-home-title {
font-size: 28px;
font-weight: 700;
margin: 0 0 20px 0;
color: var(--text-normal);
}
.card-sidebar-cards-container {
.sc-home-palette-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.sc-home-category-btn {
background-color: var(--background-modifier-form-field);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 4px 12px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
}
.sc-home-separator {
color: var(--background-modifier-border);
margin: 0 4px;
font-size: 18px;
opacity: 0.5;
}
.sc-home-color-dot {
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: transform 0.1s ease, border-color 0.1s ease;
}
.sc-home-color-dot:hover {
transform: scale(1.15);
}
.sc-home-color-dot.is-selected {
border-color: var(--text-accent);
}
.sc-home-editor {
width: 100%;
min-height: 120px;
padding: 16px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--input-radius);
background-color: var(--background-modifier-form-field);
font-size: 16px;
line-height: 1.5;
white-space: pre-wrap;
position: relative;
}
.sc-home-editor.is-empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
position: absolute;
pointer-events: none;
opacity: 0.5;
}
.sc-home-grid {
display: grid;
grid-template-columns: 280px 1fr;
gap: 40px;
}
.sc-home-section-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
color: var(--text-normal);
}
.sc-home-file-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 32px;
}
.sc-home-file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background-color: var(--background-modifier-form-field);
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
}
.sc-home-file-item:hover {
background-color: var(--background-modifier-hover);
}
.sc-home-file-icon {
color: var(--text-muted);
display: flex;
align-items: center;
}
.sc-home-file-name {
font-size: 14px;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sc-home-empty-msg {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
padding: 4px 12px;
}
/* Right Column Toolbar */
.sc-home-toolbar {
display: flex;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--background-modifier-border);
gap: 8px;
}
.sc-home-toolbar-left,
.sc-home-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.sc-home-search-wrap {
flex: 1;
position: relative;
display: flex;
align-items: center;
background-color: var(--background-modifier-form-field);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 0 8px;
}
.sc-home-search-icon {
color: var(--text-muted);
display: flex;
align-items: center;
}
.sc-home-search-input {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 6px 8px !important;
font-size: 13px;
width: 100%;
}
.sc-home-category-bar {
display: flex;
gap: 4px;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 16px;
margin-bottom: 16px;
scrollbar-width: none;
}
.sc-home-category-bar::-webkit-scrollbar {
display: none;
}
.sc-home-card-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.sc-home-card-list .sc-card {
margin: 0;
width: 100%;
}
.workspace-leaf-content .sc-home-container {
container-type: inline-size;
}
@container (max-width: 768px) {
.sc-home-left {
display: none;
}
.sc-home-grid {
grid-template-columns: 1fr;
}
.sc-home-main {
padding: 20px 12px;
}
.sidecards-home, .sc-home-main {
margin: 50px 10px !important;
transition: all 0.2s ease;
}
.sc-home-right,
.sc-home-toolbar,
.sc-home-category-bar,
.sc-home-card-list {
width: 100% !important;
min-width: 0;
}
}
.sc-search-clear-btn {
font-size: 20px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
padding: 4px 10px;
border-radius: 100%;
border: none !important;
box-shadow: none !important;
color: var(--text-error) !important;
background: transparent !important;
/* background-color: transparent !important; */
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
opacity: 0.6;
}
.sc-search-clear-btn:hover {
color: var(--text-normal);
opacity: 1;
}
.sc-sidebar-cards-container {
flex: 1;
overflow-y: auto;
display: flex;
@ -98,7 +423,18 @@
-ms-overflow-style: auto;
}
.card-sidebar-cards-container.grid-mode {
.sc-sidebar-cards-container.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.sc-sidebar-cards-container.hide-scrollbar::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.sc-sidebar-cards-container.grid-mode {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-auto-rows: 1px;
@ -106,17 +442,16 @@
align-items: start;
}
.card-sidebar-cards-container.grid-mode .card-sidebar-card {
.sc-sidebar-cards-container.grid-mode .sc-card {
margin: 0 !important;
align-self: start;
height: auto;
padding-bottom: 14px;
}
.card-sidebar-card {
.sc-card {
background: var(--background-primary);
padding: 12px;
padding-top: 6px;
position: relative;
/* font-size: 14px; */
line-height: 1.4;
@ -124,7 +459,7 @@
border-radius: var(--card-border-radius, 6px);
}
.card-sidebar-card .card-copy-btn {
.sc-card .sc-copy-btn {
color: var(--text-normal);
position: absolute;
top: 6px;
@ -141,19 +476,264 @@
transition: all 0.2s ease;
}
.card-sidebar-card:hover .card-copy-btn {
.sc-card:hover .sc-copy-btn {
display: flex;
opacity: 1;
}
.card-sidebar-card .card-copy-btn:hover {
.sc-card .sc-copy-btn:hover {
color: var(--color-accent);
}
.card-sidebar-card .card-copy-btn:active {
.sc-card .sc-copy-btn:active {
transform: scale(0.8);
}
.category-row {
.sc-category-row {
border-radius: var(--setting-items-radius) !important;
}
.sc-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.sc-tag {
font-size: 11px;
color: var(--text-accent);
background-color: hsla(var(--interactive-accent-hsl), 0.2);
border-radius: var(--tag-radius);
padding: 1px 5px;
cursor: pointer;
transition: color 0.15s ease;
}
/* .sc-tag:hover {
color: var(--text-accent);
} */
.sc-card .sc-content.is-editing {
outline: none;
background: var(--background-primary-alt);
padding: 4px;
border-radius: 4px;
min-height: 1.4em;
white-space: pre-wrap;
}
.sc-card .sc-content {
word-break: break-word;
overflow-wrap: break-word;
}
.sc-timestamp {
font-size: 11px;
color: var(--text-normal) !important;
opacity: 0.7 !important;
margin-top: 4px;
}
.sc-footer {
margin-top: 4px;
}
.sc-color-dots {
display: flex;
gap: 4px;
flex-wrap: nowrap;
padding: 2px 0;
}
/* .sc-color-dots.two-row {
flex-wrap: wrap;
width: 114px;
} */
.sc-color-dot {
width: 22px !important;
height: 22px !important;
border-radius: 10px !important;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.1s ease;
}
de
.sc-home-color-dot {
border-radius: 10px !important;
border: 1px solid var(--background-primary);
}
.sc-icon-btn {
padding: 6px;
background-color: var(--background-modifier-form-field);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s ease;
}
.sc-icon-btn:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.sc-icon-btn svg {
width: 16px;
height: 16px;
}
.sc-color-dot:hover {
transform: scale(1.15);
}
@media (max-width: 768px) {
.sidecards-home-palette-row {
gap: 4px !important;
}
.sidecards-home-category-btn {
font-size: 10px;
}
.sidecards-home-separator {
margin: 0px 2px !important;
}
}
.sc-status-pill {
display: inline;
background-color: var(--background-modifier-hover);
color: var(--text-normal);
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.sc-pill-bar {
margin-bottom: 6px !important;
}
/* Modal Styles */
.sc-quick-card-modal .sc-modal-title {
margin-top: 0;
margin-bottom: 20px;
}
.sc-quick-card-modal .sc-modal-section-title {
font-size: 13px;
font-weight: 600;
margin: 16px 0 8px 0;
color: var(--text-muted);
}
.sc-quick-card-modal .sc-modal-textarea {
width: 100%;
min-height: 120px;
padding: 10px;
border-radius: var(--input-radius);
border: 1px solid var(--background-modifier-border);
background-color: var(--background-modifier-form-field);
font-family: var(--font-text);
resize: vertical;
white-space: pre-wrap;
position: relative;
}
.sc-quick-card-modal .sc-modal-textarea.is-empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
position: absolute;
pointer-events: none;
opacity: 0.6;
}
.sc-quick-card-modal .sc-modal-color-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sc-quick-card-modal .sc-modal-color-swatch {
width: 24px;
height: 24px;
border-radius: 10px;
cursor: pointer;
border: 2px solid transparent;
transition: transform 0.1s ease;
}
.sc-quick-card-modal .sc-modal-color-swatch:hover {
transform: scale(1.2);
}
.sc-quick-card-modal .sc-modal-color-swatch.is-selected {
border-color: var(--text-accent);
}
.sc-quick-card-modal .sc-modal-tags-wrapper {
position: relative;
}
.sc-quick-card-modal .sc-modal-tags-input {
width: 100%;
padding: 8px;
border-radius: var(--input-radius);
border: 1px solid var(--background-modifier-border);
}
.sc-quick-card-modal .sc-modal-tags-autocomplete {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--input-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
}
.sc-quick-card-modal .sc-modal-autocomplete-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--background-modifier-border);
}
.sc-quick-card-modal .sc-modal-autocomplete-item:last-child {
border-bottom: none;
}
.sc-quick-card-modal .sc-modal-autocomplete-item:hover,
.sc-quick-card-modal .sc-modal-autocomplete-item.is-selected {
background: var(--background-modifier-hover);
}
.sc-quick-card-modal .sc-modal-select {
width: 100%;
padding: 8px;
border-radius: var(--input-radius);
border: 1px solid var(--background-modifier-border);
}
.sc-quick-card-modal .sc-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.sidecards-home-separator {
height: 24px;
width: 1px;
background-color: var(--background-modifier-border);
}