diff --git a/LICENSE b/LICENSE
index ed0220c..be35d8b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,21 @@
MIT License
Copyright (c) 2026 Kazi Aidah Haque
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index d603a5d..ffdbcc7 100644
--- a/README.md
+++ b/README.md
@@ -1,135 +1,135 @@
-   
-
-# SideCards
-Quickly create cards in your Obsidian sidebar; color, tag, and drag them straight into your notes.
-
-
-
----
-
-## Create & Manage Cards Instantly
-
-### Quick Card Creation
-Type directly into the sidebar input box to create cards instantly. Cards can also be created from the homepage or via the quick-add command.
-
-### Inline Editing
-Edit card content directly: no modals, no interruptions.
-
-
-
-### Drag to Editor
-Drag any card into your markdown editor to insert its content.
-
-
-
-### Essentials
-- Real-time Auto-save: Changes are saved automatically as you type.
-
-- Note Conversion: Convert any card to a full markdown note with one click.
-
-- Bidirectional Sync: Changes made in notes automatically update the cards and vice versa.
-
-- Folder Integration: Auto-import markdown files from a specified storage folder.
-
-
-## Style Your Cards
-
-### 10 Custom Colors
-Color-code your cards for visual organization. Enable two row color swatches in two rows for a more compact layout.
-
-
-
-
-### Auto-color Rules
-Automatically assign colors based on text content or tag matches.
-
-
-### Multiple Card Styles
-Choose from 3 visual styles and adjust border radius, opacity, and border thickness to customize it to your liking!
-
-
-
-## Categories
-
-By default, SideCards includes **Today**, **Tomorrow**, and **Archived** categories.
-
-- **Today** shows cards created or modified today.
-- **Tomorrow** is for cards you want to postpone as they’ll automatically move to Today when the day changes.
-- **Archived** stores all archived cards.
-
-
-### Custom Categories
-Build your own categories with custom labels, colors, and icons.
-
-
-
-Icons currently appear only in the context menu and category list, not on the button itself.
-
-## Card Status System
-Assign statuses with custom names, colors, and text colors.
-
-
-
-### Color Inheritance
-Optionally inherit the status color into the card background.
-
-## Expiry
-Set expiry times with optional auto-archive and a time-left pill display.
-
-
-
-## Homepage
-
-### Dedicated Homepage View
-A full-page view with a card editor, pinned notes, and recent notes.
-
-
-
-You can replace your default new tab with this homepage.
-
-
-
-Notes can be pinned by right-clicking a tab and selecting “Pin tab to SideCards Homepage,” or by using a command. Icons are supported via the [Iconic plugin](https://github.com/gfxholo/iconic/).
-
-
-## Other Features
-
-### Auto-pair Brackets
-Automatically pairs `(`, `[`, `{`, `` ` ``, `=`, `%`, and `"` while typing.
-
-### Formatting Shortcuts
-Bold, italic, highlight, and comment wrapping work inside card editors.
-
-
-## Installation
-
-*SideCards is not yet available in Obsidian Community Plugins or BRAT.*
-
-1. **Clone the repository** and place the plugin folder in your vault’s `.obsidian/plugins/` directory. The folder structure should look like this:
-
-```
-sidecards
-├─ main.js
-├─ styles.css
-└─ manifest.json
-```
-
-Inside your vault, it should end up like:
-
-```
-.your-vault
-└─ .obsidian
- └─ plugins
- └─ sidecards
- ├─ main.js
- ├─ styles.css
- └─ manifest.json
-```
-
-2. **Enable the plugin** in Obsidian.
-
-3. **Set a storage folder** on first launch. This is where your cards will be saved as notes.
-
-
-### Questions or Suggestions?
+   
+
+# SideCards
+Quickly create cards in your Obsidian sidebar; color, tag, and drag them straight into your notes.
+
+
+
+---
+
+## Create & Manage Cards Instantly
+
+### Quick Card Creation
+Type directly into the sidebar input box to create cards instantly. Cards can also be created from the homepage or via the quick-add command.
+
+### Inline Editing
+Edit card content directly: no modals, no interruptions.
+
+
+
+### Drag to Editor
+Drag any card into your markdown editor to insert its content.
+
+
+
+### Essentials
+- Real-time Auto-save: Changes are saved automatically as you type.
+
+- Note Conversion: Convert any card to a full markdown note with one click.
+
+- Bidirectional Sync: Changes made in notes automatically update the cards and vice versa.
+
+- Folder Integration: Auto-import markdown files from a specified storage folder.
+
+
+## Style Your Cards
+
+### 10 Custom Colors
+Color-code your cards for visual organization. Enable two row color swatches in two rows for a more compact layout.
+
+
+
+
+### Auto-color Rules
+Automatically assign colors based on text content or tag matches.
+
+
+### Multiple Card Styles
+Choose from 3 visual styles and adjust border radius, opacity, and border thickness to customize it to your liking!
+
+
+
+## Categories
+
+By default, SideCards includes **Today**, **Tomorrow**, and **Archived** categories.
+
+- **Today** shows cards created or modified today.
+- **Tomorrow** is for cards you want to postpone as they’ll automatically move to Today when the day changes.
+- **Archived** stores all archived cards.
+
+
+### Custom Categories
+Build your own categories with custom labels, colors, and icons.
+
+
+
+Icons currently appear only in the context menu and category list, not on the button itself.
+
+## Card Status System
+Assign statuses with custom names, colors, and text colors.
+
+
+
+### Color Inheritance
+Optionally inherit the status color into the card background.
+
+## Expiry
+Set expiry times with optional auto-archive and a time-left pill display.
+
+
+
+## Homepage
+
+### Dedicated Homepage View
+A full-page view with a card editor, pinned notes, and recent notes.
+
+
+
+You can replace your default new tab with this homepage.
+
+
+
+Notes can be pinned by right-clicking a tab and selecting “Pin tab to SideCards Homepage,” or by using a command. Icons are supported via the [Iconic plugin](https://github.com/gfxholo/iconic/).
+
+
+## Other Features
+
+### Auto-pair Brackets
+Automatically pairs `(`, `[`, `{`, `` ` ``, `=`, `%`, and `"` while typing.
+
+### Formatting Shortcuts
+Bold, italic, highlight, and comment wrapping work inside card editors.
+
+
+## Installation
+
+*SideCards is not yet available in Obsidian Community Plugins or BRAT.*
+
+1. **Clone the repository** and place the plugin folder in your vault’s `.obsidian/plugins/` directory. The folder structure should look like this:
+
+```
+sidecards
+├─ main.js
+├─ styles.css
+└─ manifest.json
+```
+
+Inside your vault, it should end up like:
+
+```
+.your-vault
+└─ .obsidian
+ └─ plugins
+ └─ sidecards
+ ├─ main.js
+ ├─ styles.css
+ └─ manifest.json
+```
+
+2. **Enable the plugin** in Obsidian.
+
+3. **Set a storage folder** on first launch. This is where your cards will be saved as notes.
+
+
+### Questions or Suggestions?
Create a new issue [here](https://github.com/Kazi-Aidah/sidecards/issues) to report bugs or request features!
\ No newline at end of file
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 149e5be..efa0bac 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,31 +1,31 @@
-import tsparser from "@typescript-eslint/parser";
-import { defineConfig } from "eslint/config";
-import obsidianmd from "eslint-plugin-obsidianmd";
-import globals from "globals";
-
-export default defineConfig([
- ...obsidianmd.configs.recommended,
-
- {
- files: ["src/**/*.ts"],
- languageOptions: {
- parser: tsparser,
- parserOptions: {
- project: "./tsconfig.json",
- tsconfigRootDir: import.meta.dirname,
- },
- globals: {
- ...globals.browser,
- ...globals.es2022,
- },
- },
- rules: {
- "@typescript-eslint/no-explicit-any": "off",
- "@typescript-eslint/no-unsafe-assignment": "off",
- "@typescript-eslint/no-unsafe-member-access": "off",
- "@typescript-eslint/no-unsafe-call": "off",
- "@typescript-eslint/no-unsafe-argument": "off",
- "@typescript-eslint/no-unsafe-return": "off",
- },
- },
+import tsparser from "@typescript-eslint/parser";
+import { defineConfig } from "eslint/config";
+import obsidianmd from "eslint-plugin-obsidianmd";
+import globals from "globals";
+
+export default defineConfig([
+ ...obsidianmd.configs.recommended,
+
+ {
+ files: ["src/**/*.ts"],
+ languageOptions: {
+ parser: tsparser,
+ parserOptions: {
+ project: "./tsconfig.json",
+ tsconfigRootDir: import.meta.dirname,
+ },
+ globals: {
+ ...globals.browser,
+ ...globals.es2022,
+ },
+ },
+ rules: {
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-unsafe-assignment": "off",
+ "@typescript-eslint/no-unsafe-member-access": "off",
+ "@typescript-eslint/no-unsafe-call": "off",
+ "@typescript-eslint/no-unsafe-argument": "off",
+ "@typescript-eslint/no-unsafe-return": "off",
+ },
+ },
]);
\ No newline at end of file
diff --git a/main.js b/main.js
index 2e2b937..b82fa2f 100644
--- a/main.js
+++ b/main.js
@@ -765,8 +765,9 @@ var InlineAutocomplete = class {
};
// src/views/components/Card.ts
-var _CardComponent = class {
+var _CardComponent = class extends import_obsidian3.Component {
constructor(container, card, store, app, plugin, settingsOverride) {
+ super();
this.container = container;
this.store = store;
this.app = app;
@@ -783,6 +784,7 @@ var _CardComponent = class {
this.scope = new import_obsidian3.Scope(this.app.scope);
this.setupMockEditor();
this.ensureGlobalMouseDownHandler();
+ this.load();
void this.render();
this.setupListeners();
}
@@ -1182,13 +1184,47 @@ var _CardComponent = class {
container.setAttribute("contenteditable", "false");
container.removeClass("is-editing");
const temp = document.createElement("div");
- await import_obsidian3.MarkdownRenderer.render(this.app, this.card.content, temp, this.card.notePath || "", this.app);
+ try {
+ await import_obsidian3.MarkdownRenderer.render(this.app, this.card.content, temp, this.card.notePath || "", this);
+ } catch (e) {
+ temp.textContent = this.card.content;
+ }
temp.querySelectorAll("mark").forEach((el) => el.addClass("cm-highlight"));
+ this.resolveImageEmbeds(temp);
while (temp.firstChild)
container.appendChild(temp.firstChild);
this.attachInternalLinkHandlers(container);
}
}
+ resolveImageEmbeds(container) {
+ const IMAGE_EXTS = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"]);
+ container.querySelectorAll("span.internal-embed").forEach((span) => {
+ var _a, _b;
+ try {
+ const src = span.getAttribute("src") || "";
+ const ext = (_b = (_a = src.split(".").pop()) == null ? void 0 : _a.toLowerCase()) != null ? _b : "";
+ if (!IMAGE_EXTS.has(ext))
+ return;
+ const file = this.app.metadataCache.getFirstLinkpathDest(src, this.card.notePath || "");
+ if (!file)
+ return;
+ const resourcePath = this.app.vault.getResourcePath(file);
+ const img = document.createElement("img");
+ img.src = resourcePath;
+ img.alt = src;
+ img.addClass("sc-embed-image");
+ const altAttr = span.getAttribute("alt") || "";
+ const sizeMatch = altAttr.match(/(\d+)(?:x(\d+))?/);
+ if (sizeMatch) {
+ img.style.width = `${sizeMatch[1]}px`;
+ if (sizeMatch[2])
+ img.style.height = `${sizeMatch[2]}px`;
+ }
+ span.replaceWith(img);
+ } catch (e) {
+ }
+ });
+ }
attachInternalLinkHandlers(container) {
const links = container.querySelectorAll("a.internal-link, a[data-href]");
links.forEach((link) => {
@@ -1605,6 +1641,7 @@ var _CardComponent = class {
}
this.stopExpiryTick();
this.unsubscribe.forEach((fn) => fn());
+ this.unload();
this.el.remove();
}
startExpiryTick() {
@@ -3436,6 +3473,25 @@ var CardStore = class {
if (!silent)
new import_obsidian5.Notice(`Imported ${files.length} notes from ${folder}`);
}
+ async switchStorageFolder(newFolder) {
+ const oldIds = Array.from(this.cards.keys());
+ this.cards.clear();
+ this.settings.cards = [];
+ await this.plugin.saveSettings();
+ for (const id of oldIds) {
+ this.eventBus.emit("card:deleted", id);
+ }
+ if (!newFolder || newFolder === "/")
+ return;
+ if (!await this.app.vault.adapter.exists(newFolder)) {
+ await this.app.vault.createFolder(newFolder);
+ }
+ await this.importNotesFromFolderToSettings(newFolder, true);
+ const allCards = this.getAll();
+ if (allCards.length > 0) {
+ this.eventBus.emit("card:added", allCards[0]);
+ }
+ }
async saveCards() {
await this.saveToStorage();
}
@@ -5870,7 +5926,7 @@ var SideCardsPlugin = class extends import_obsidian10.Plugin {
this.settings.storageFolder = val;
this.settings.tutorialShown = true;
await this.saveSettings();
- void this.store.importNotesFromFolderToSettings(val, true);
+ await this.store.switchStorageFolder(val);
}
modal.close();
})();
@@ -6011,9 +6067,14 @@ var SideCardsSettingTab = class extends import_obsidian10.PluginSettingTab {
});
new import_obsidian10.Setting(containerEl).setName("Storage folder").setDesc("Choose where to save notes created from cards.").addSearch((cb) => {
cb.setPlaceholder("Choose a folder").setValue(this.plugin.settings.storageFolder || "").onChange(async (value) => {
- this.plugin.settings.storageFolder = value;
+ const newFolder = value.trim();
+ const oldFolder = this.plugin.settings.storageFolder;
+ this.plugin.settings.storageFolder = newFolder;
this.plugin.settings.tutorialShown = true;
await this.plugin.saveSettings();
+ if (newFolder !== oldFolder) {
+ await this.plugin.store.switchStorageFolder(newFolder);
+ }
});
const folders = /* @__PURE__ */ new Set(["/"]);
this.app.vault.getAllLoadedFiles().forEach((file) => {
diff --git a/manifest.json b/manifest.json
index 9be787f..225b134 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,10 +1,10 @@
-{
- "id": "sidecards",
- "name": "SideCards",
- "description": "Quickly create cards in your sidebar, color, tag, and drag them straight into your notes.",
- "version": "0.2.0",
- "minAppVersion": "0.15.0",
- "author": "Kazi Aidah Haque",
- "authorUrl": "https://github.com/Kazi-Aidah",
- "isDesktopOnly": false
-}
+{
+ "id": "sidecards",
+ "name": "SideCards",
+ "description": "Quickly create cards in your sidebar, color, tag, and drag them straight into your notes.",
+ "version": "0.2.0",
+ "minAppVersion": "0.15.0",
+ "author": "Kazi Aidah Haque",
+ "authorUrl": "https://github.com/Kazi-Aidah",
+ "isDesktopOnly": false
+}
diff --git a/package-lock.json b/package-lock.json
index bf181c5..0ce6759 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
+ "@esbuild/linux-x64": "^0.17.3",
"@types/node": "^16.11.21",
"@typescript-eslint/eslint-plugin": "^8.57.1",
"@typescript-eslint/parser": "^8.57.1",
@@ -308,8 +309,6 @@
"x64"
],
"dev": true,
- "license": "MIT",
- "optional": true,
"os": [
"linux"
],
@@ -594,7 +593,6 @@
"integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -687,6 +685,7 @@
"integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -710,7 +709,8 @@
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@pkgr/core": {
"version": "0.1.2",
@@ -843,7 +843,6 @@
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.1",
@@ -1048,7 +1047,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1437,7 +1435,8 @@
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -1856,7 +1855,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -2298,7 +2296,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4666,7 +4663,8 @@
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/supports-color": {
"version": "7.2.0",
@@ -4773,7 +4771,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -4933,7 +4930,6 @@
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5008,7 +5004,8 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/which": {
"version": "2.0.2",
diff --git a/package.json b/package.json
index 5445935..03db9be 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"author": "Kazi Aidah Haque",
"license": "MIT",
"devDependencies": {
+ "@esbuild/linux-x64": "^0.17.3",
"@types/node": "^16.11.21",
"@typescript-eslint/eslint-plugin": "^8.57.1",
"@typescript-eslint/parser": "^8.57.1",
diff --git a/snippets/darken all cards except the other being hovered.css b/snippets/darken all cards except the other being hovered.css
index a08ac51..ce1a4d0 100644
--- a/snippets/darken all cards except the other being hovered.css
+++ b/snippets/darken all cards except the other being hovered.css
@@ -1,21 +1,21 @@
-/* --- DARKEN ALL OTHER CARDS EXCEPT THE ONE HOVERED UPON */
-/* styling for all cards */
-.sc-sidebar-card {
- transition: all 0.3s ease;
-}
-
-/* When a card is hovered, select its container,
- and then select all the OTHER cards in that container and darken them. */
-.sc-sidebar-cards-container:has(.sc-sidebar-card:hover) .sc-sidebar-card:not(:hover) {
- filter: brightness(0.6); /* blur(2px); */
- transition: all 0.3s ease;
-}
-
-/* Style the card that is being hovered */
-.sc-sidebar-cards-container .sc-sidebar-card:hover {
- filter: contrast(1.3) brightness(1);
- transform: translateY(-0px) scale(1.025);
- border-radius: 8px !important;
- outline: 3px solid #ffffff !important;
- transition: all 0.3s ease-out, outline 0s ease;
-}
+/* --- DARKEN ALL OTHER CARDS EXCEPT THE ONE HOVERED UPON */
+/* styling for all cards */
+.sc-sidebar-card {
+ transition: all 0.3s ease;
+}
+
+/* When a card is hovered, select its container,
+ and then select all the OTHER cards in that container and darken them. */
+.sc-sidebar-cards-container:has(.sc-sidebar-card:hover) .sc-sidebar-card:not(:hover) {
+ filter: brightness(0.6); /* blur(2px); */
+ transition: all 0.3s ease;
+}
+
+/* Style the card that is being hovered */
+.sc-sidebar-cards-container .sc-sidebar-card:hover {
+ filter: contrast(1.3) brightness(1);
+ transform: translateY(-0px) scale(1.025);
+ border-radius: 8px !important;
+ outline: 3px solid #ffffff !important;
+ transition: all 0.3s ease-out, outline 0s ease;
+}
diff --git a/snippets/sidecards filter button - wider active category.css b/snippets/sidecards filter button - wider active category.css
index 723e2bb..7ee511b 100644
--- a/snippets/sidecards filter button - wider active category.css
+++ b/snippets/sidecards filter button - wider active category.css
@@ -1,63 +1,63 @@
-/* --- FILTER BUTTONS: Wider Active Filter */
-
-/* topbar */
-.sc-sidebar-header {
- border-bottom-left-radius: 13px !important;
- border-bottom-right-radius: 13px !important;
- box-shadow: 0px 6px 10px #00000070; /* topbar shadow */
- z-index: 1000;
- padding: 10px;
-}
-
-.sc-sidebar-category-group {
- gap: 6px !important;
- width: 100% !important;
- display: flex;
- justify-content: none;
- overflow-x: auto;
- overscroll-behavior-x: contain;
- -webkit-overflow-scrolling: touch;
- padding: 0 14px;
- scroll-padding-inline: 8px;
- box-sizing: border-box;
- -ms-overflow-style: none;
- scrollbar-width: none;
- margin: 6px -10px !important;
-}
-
-.sc-sidebar-category-group::-webkit-scrollbar {
- display: none;
- width: 0;
- height: 0;
-}
-
-/* Default Category Buttons */
-.sc-category-btn {
- border-radius: 0px !important;
- padding: 0px 15px !important;
- flex: 0 0 auto;
- filter: brightness(0.9) !important;
- text-transform: uppercase;
- transition: all 0.2s ease;
-}
-
-/* Active Button Widening, Brightening & Border */
-.sc-category-btn.active {
- padding: 0px 25px !important;
- border: 1.7px solid #ffffff30 !important;
- transition: all 0.2s ease;
- filter: brightness(1.7) !important;
- transform: scale(1.02) !important;
- z-index: 2000 !important;
-}
-
-/* First & Last Category Button Border-Radius */
-.sc-category-btn:first-child {
- border-top-left-radius: 10px !important;
- border-bottom-left-radius: 10px !important;
-}
-
-.sc-category-btn:last-child {
- border-top-right-radius: 10px !important;
- border-bottom-right-radius: 10px !important;
-}
+/* --- FILTER BUTTONS: Wider Active Filter */
+
+/* topbar */
+.sc-sidebar-header {
+ border-bottom-left-radius: 13px !important;
+ border-bottom-right-radius: 13px !important;
+ box-shadow: 0px 6px 10px #00000070; /* topbar shadow */
+ z-index: 1000;
+ padding: 10px;
+}
+
+.sc-sidebar-category-group {
+ gap: 6px !important;
+ width: 100% !important;
+ display: flex;
+ justify-content: none;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ padding: 0 14px;
+ scroll-padding-inline: 8px;
+ box-sizing: border-box;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ margin: 6px -10px !important;
+}
+
+.sc-sidebar-category-group::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+/* Default Category Buttons */
+.sc-category-btn {
+ border-radius: 0px !important;
+ padding: 0px 15px !important;
+ flex: 0 0 auto;
+ filter: brightness(0.9) !important;
+ text-transform: uppercase;
+ transition: all 0.2s ease;
+}
+
+/* Active Button Widening, Brightening & Border */
+.sc-category-btn.active {
+ padding: 0px 25px !important;
+ border: 1.7px solid #ffffff30 !important;
+ transition: all 0.2s ease;
+ filter: brightness(1.7) !important;
+ transform: scale(1.02) !important;
+ z-index: 2000 !important;
+}
+
+/* First & Last Category Button Border-Radius */
+.sc-category-btn:first-child {
+ border-top-left-radius: 10px !important;
+ border-bottom-left-radius: 10px !important;
+}
+
+.sc-category-btn:last-child {
+ border-top-right-radius: 10px !important;
+ border-bottom-right-radius: 10px !important;
+}
diff --git a/snippets/sidecards filter buttons – take up max width.css b/snippets/sidecards filter buttons – take up max width.css
index 76260fd..ab98395 100644
--- a/snippets/sidecards filter buttons – take up max width.css
+++ b/snippets/sidecards filter buttons – take up max width.css
@@ -1,35 +1,35 @@
-/* --- FILTER BUTTONS: STRETCH TO MAX WIDTH */
-.sc-sidebar-category-group {
-gap: 6px !important;
-width: 100% !important;
-}
-
-.sc-category-btn {
-border-radius: 0px !important;
-padding: 0px 37px !important;
-width: 100% !important;
-/* box-shadow: none !important; */
-filter: brightness(0.9) !important; /* DARKEN ALL OTHERS */
-text-transform: uppercase; /* CAPITALIZE NAMES */
-}
-
-.sc-category-btn.active {
-border: 1.7px solid #ffffff30 !important;
-transition: all 100ms ease;
-filter: brightness(1.7) !important;
-/* box-shadow: 0px 0px 4px #ffffff !important; */
-/* uncomment box-shadow if you want glow */
-transform: scale(1.02) !important;
-z-index: 2000 !important;
-}
-
-.sc-category-btn:first-child {
-border-top-left-radius: 10px !important;
-border-bottom-left-radius: 10px !important;
-}
-
-.sc-category-btn:last-child {
-background-color: var(--background-primary) !important;
-border-top-right-radius: 10px !important;
-border-bottom-right-radius: 10px !important;
-}
+/* --- FILTER BUTTONS: STRETCH TO MAX WIDTH */
+.sc-sidebar-category-group {
+gap: 6px !important;
+width: 100% !important;
+}
+
+.sc-category-btn {
+border-radius: 0px !important;
+padding: 0px 37px !important;
+width: 100% !important;
+/* box-shadow: none !important; */
+filter: brightness(0.9) !important; /* DARKEN ALL OTHERS */
+text-transform: uppercase; /* CAPITALIZE NAMES */
+}
+
+.sc-category-btn.active {
+border: 1.7px solid #ffffff30 !important;
+transition: all 100ms ease;
+filter: brightness(1.7) !important;
+/* box-shadow: 0px 0px 4px #ffffff !important; */
+/* uncomment box-shadow if you want glow */
+transform: scale(1.02) !important;
+z-index: 2000 !important;
+}
+
+.sc-category-btn:first-child {
+border-top-left-radius: 10px !important;
+border-bottom-left-radius: 10px !important;
+}
+
+.sc-category-btn:last-child {
+background-color: var(--background-primary) !important;
+border-top-right-radius: 10px !important;
+border-bottom-right-radius: 10px !important;
+}
diff --git a/src/core/Plugin.ts b/src/core/Plugin.ts
index 2ae4dd5..8fdfc0c 100644
--- a/src/core/Plugin.ts
+++ b/src/core/Plugin.ts
@@ -521,7 +521,7 @@ export default class SideCardsPlugin extends Plugin {
this.settings.storageFolder = val;
this.settings.tutorialShown = true;
await this.saveSettings();
- void this.store.importNotesFromFolderToSettings(val, true);
+ await this.store.switchStorageFolder(val);
}
modal.close();
})();
@@ -681,9 +681,14 @@ class SideCardsSettingTab extends PluginSettingTab {
cb.setPlaceholder('Choose a folder')
.setValue(this.plugin.settings.storageFolder || '')
.onChange(async (value) => {
- this.plugin.settings.storageFolder = value;
+ const newFolder = value.trim();
+ const oldFolder = this.plugin.settings.storageFolder;
+ this.plugin.settings.storageFolder = newFolder;
this.plugin.settings.tutorialShown = true;
await this.plugin.saveSettings();
+ if (newFolder !== oldFolder) {
+ await this.plugin.store.switchStorageFolder(newFolder);
+ }
});
const folders = new Set(['/']);
this.app.vault.getAllLoadedFiles().forEach((file) => { if (file.parent) folders.add(file.parent.path); });
diff --git a/src/models/Card.ts b/src/models/Card.ts
index b1d042b..a9771e1 100644
--- a/src/models/Card.ts
+++ b/src/models/Card.ts
@@ -1,68 +1,68 @@
-
-export enum Status {
- TODO = 'todo',
- IN_PROGRESS = 'in-progress',
- DONE = 'done'
-}
-
-export interface CardStatus {
- name: string;
- color: string;
- textColor?: string;
- colorIndex?: number; // 1-10 = preset card color, absent = custom hex
-}
-
-export class Card {
- id: string;
- content: string;
- color: string;
- tags: string[];
- category: string | null;
- created: number; // Using timestamp for better serialization
- modified?: number;
- archived: boolean;
- pinned: boolean;
- notePath: string | null;
- expiresAt: number | null;
- status: CardStatus | null;
-
- constructor(data: Partial) {
- this.id = data.id || Math.random().toString(36).slice(2, 9);
- this.content = data.content || '';
- this.color = data.color || 'var(--card-color-1)';
- this.tags = data.tags || [];
- this.category = data.category || null;
- this.created = data.created || Date.now();
- this.modified = data.modified || this.created;
- this.archived = data.archived || false;
- this.pinned = data.pinned || false;
- this.notePath = data.notePath || null;
- this.expiresAt = data.expiresAt || null;
- this.status = data.status || null;
- }
-
- clone(): Card {
- return new Card({
- ...this,
- id: Math.random().toString(36).slice(2, 9),
- created: Date.now()
- });
- }
-
- toJSON() {
- return {
- id: this.id,
- content: this.content,
- color: this.color,
- tags: this.tags,
- category: this.category,
- created: this.created,
- modified: this.modified,
- archived: this.archived,
- pinned: this.pinned,
- notePath: this.notePath,
- expiresAt: this.expiresAt,
- status: this.status
- };
- }
-}
+
+export enum Status {
+ TODO = 'todo',
+ IN_PROGRESS = 'in-progress',
+ DONE = 'done'
+}
+
+export interface CardStatus {
+ name: string;
+ color: string;
+ textColor?: string;
+ colorIndex?: number; // 1-10 = preset card color, absent = custom hex
+}
+
+export class Card {
+ id: string;
+ content: string;
+ color: string;
+ tags: string[];
+ category: string | null;
+ created: number; // Using timestamp for better serialization
+ modified?: number;
+ archived: boolean;
+ pinned: boolean;
+ notePath: string | null;
+ expiresAt: number | null;
+ status: CardStatus | null;
+
+ constructor(data: Partial) {
+ this.id = data.id || Math.random().toString(36).slice(2, 9);
+ this.content = data.content || '';
+ this.color = data.color || 'var(--card-color-1)';
+ this.tags = data.tags || [];
+ this.category = data.category || null;
+ this.created = data.created || Date.now();
+ this.modified = data.modified || this.created;
+ this.archived = data.archived || false;
+ this.pinned = data.pinned || false;
+ this.notePath = data.notePath || null;
+ this.expiresAt = data.expiresAt || null;
+ this.status = data.status || null;
+ }
+
+ clone(): Card {
+ return new Card({
+ ...this,
+ id: Math.random().toString(36).slice(2, 9),
+ created: Date.now()
+ });
+ }
+
+ toJSON() {
+ return {
+ id: this.id,
+ content: this.content,
+ color: this.color,
+ tags: this.tags,
+ category: this.category,
+ created: this.created,
+ modified: this.modified,
+ archived: this.archived,
+ pinned: this.pinned,
+ notePath: this.notePath,
+ expiresAt: this.expiresAt,
+ status: this.status
+ };
+ }
+}
diff --git a/src/services/CardStore.ts b/src/services/CardStore.ts
index 74e16bd..9341bb1 100644
--- a/src/services/CardStore.ts
+++ b/src/services/CardStore.ts
@@ -308,6 +308,36 @@ export class CardStore {
if (!silent) new Notice(`Imported ${files.length} notes from ${folder}`);
}
+ async switchStorageFolder(newFolder: string): Promise {
+ // Collect IDs before clearing so we can notify views
+ const oldIds = Array.from(this.cards.keys());
+
+ // Clear all cards from the store without touching any files on disk
+ this.cards.clear();
+ this.settings.cards = [];
+ await (this.plugin as SideCardsPlugin).saveSettings();
+
+ // Notify views to remove all old cards
+ for (const id of oldIds) {
+ this.eventBus.emit('card:deleted', id);
+ }
+
+ if (!newFolder || newFolder === '/') return;
+
+ // Create the folder if it doesn't exist yet
+ if (!(await this.app.vault.adapter.exists(newFolder))) {
+ await this.app.vault.createFolder(newFolder);
+ }
+
+ await this.importNotesFromFolderToSettings(newFolder, true);
+
+ // Trigger a single re-render for all newly imported cards
+ const allCards = this.getAll();
+ if (allCards.length > 0) {
+ this.eventBus.emit('card:added', allCards[0]);
+ }
+ }
+
async saveCards(): Promise {
await this.saveToStorage();
}
diff --git a/src/utils/drag-drop.ts b/src/utils/drag-drop.ts
index 632bdfd..0c244e3 100644
--- a/src/utils/drag-drop.ts
+++ b/src/utils/drag-drop.ts
@@ -1,505 +1,505 @@
-import SideCardsPlugin from "../core/Plugin";
-
-/**
- Drag-to-reorder using FLIP.
- ------------------------------
- We briefly move items in the DOM to see where they’d land,
- let the browser calculate the layout, then move them back.
- The position changes are animated with transforms.
-
- drag-n-drop in grid layout is still messy ehm
-**/
-
+import SideCardsPlugin from "../core/Plugin";
+
+/**
+ Drag-to-reorder using FLIP.
+ ------------------------------
+ We briefly move items in the DOM to see where they’d land,
+ let the browser calculate the layout, then move them back.
+ The position changes are animated with transforms.
+
+ drag-n-drop in grid layout is still messy ehm
+**/
+
export function attachDragToReorder(
container: HTMLElement,
plugin: SideCardsPlugin,
getSortMode: () => string,
onReorder: (newIds: string[]) => Promise,
onPlaceholderMoved?: () => void
-): () => void {
- let ghost: HTMLElement | null = null;
- let draggedEl: HTMLElement | null = null;
- let offsetX = 0;
- let offsetY = 0;
- let active = false;
-
- let snapRects: Map = new Map();
- let originalOrder: HTMLElement[] = [];
- let currentTargetIndex = -1;
- let draggedIndex = -1;
-
- // Rects for the current targetIndex (used to avoid redundant FLIP reads)
- let lastFlipRects: Map = new Map();
-
- function getCards(): HTMLElement[] {
- return Array.from(container.querySelectorAll(':scope > .sc-card'));
- }
-
- function getCardEl(el: EventTarget | null): HTMLElement | null {
- if (!(el instanceof HTMLElement)) return null;
- if (el.closest('button, a, [contenteditable="true"], .sc-tag, .sc-copy-btn')) return null;
- return el.closest('.sc-card');
- }
-
- /** Compute target insert index from cursor position using snapshotted rects */
- function computeTargetIndex(clientX: number, clientY: number): number {
- const others = originalOrder.filter((_, i) => i !== draggedIndex);
- if (others.length === 0) return 0;
-
- let closest: HTMLElement | null = null;
- let closestDist = Infinity;
- for (const card of others) {
- const r = snapRects.get(card)!;
- const cx = r.left + r.width / 2;
- const cy = r.top + r.height / 2;
- const d = Math.hypot(clientX - cx, clientY - cy);
- if (d < closestDist) { closestDist = d; closest = card; }
- }
- if (!closest) return draggedIndex;
-
- const closestOrigIdx = originalOrder.indexOf(closest);
- const r = snapRects.get(closest)!;
-
- const isGrid = others.some(c => {
- if (c === closest) return false;
- return Math.abs(snapRects.get(c)!.top - r.top) < 10;
- });
-
- const insertBefore = isGrid
- ? clientX < r.left + r.width / 2
- : clientY < r.top + r.height / 2;
-
- if (insertBefore) {
- return closestOrigIdx <= draggedIndex ? closestOrigIdx : closestOrigIdx - 1;
- } else {
- return closestOrigIdx >= draggedIndex ? closestOrigIdx : closestOrigIdx + 1;
- }
- }
-
- /**
- * FLIP: temporarily reorder DOM to targetIdx, read real rects, revert.
- * Returns a map of card → new DOMRect in the target layout.
- */
- function readFlipRects(targetIdx: number): Map {
- // Build the new order array
- const newOrder = originalOrder.slice();
- newOrder.splice(draggedIndex, 1);
- newOrder.splice(targetIdx, 0, draggedEl!);
-
- // Suppress transitions during DOM manipulation
- originalOrder.forEach(c => {
- c.setCssProps({
- 'transition': 'none',
- 'transform': ''
- });
- });
-
- // Temporarily reorder DOM
- newOrder.forEach(card => container.appendChild(card));
-
- // Force layout read
- const rects = new Map();
- originalOrder.forEach(c => rects.set(c, c.getBoundingClientRect()));
-
- // Revert DOM to original order
- originalOrder.forEach(card => container.appendChild(card));
-
- return rects;
- }
-
- /** Apply FLIP animations: translate each card from snapRect → flipRect */
- function applyFlip(flipRects: Map) {
- // Step 1: instantly snap each card to its "from" position (no transition)
- // This is needed because readFlipRects cleared transforms — we must re-establish
- // the current visual position before animating to the new one.
- for (const card of originalOrder) {
- if (card === draggedEl) continue;
- card.setCssProps({
- 'transition': 'none',
- 'transform': card.style.transform || ''
- });
- }
-
- // Step 2: force a reflow so the browser registers the transition:none state
- void container.offsetHeight;
-
- // Step 3: set target transforms with transition — browser will animate from current → target
- for (const card of originalOrder) {
- if (card === draggedEl) continue;
-
- const from = snapRects.get(card)!;
- const to = flipRects.get(card)!;
- const dx = to.left - from.left;
- const dy = to.top - from.top;
-
- card.setCssProps({
- 'transition': 'transform 150ms cubic-bezier(0.25,0.46,0.45,0.94)',
- 'transform': (dx === 0 && dy === 0) ? '' : `translate(${dx}px,${dy}px)`
- });
- }
- }
-
- function clearTransforms() {
- getCards().forEach(c => {
- c.setCssProps({
- 'transition': '',
- 'transform': ''
- });
- });
- }
-
- let dragStartX = 0;
- let dragStartY = 0;
- let dragPending = false; // mousedown happened but threshold not yet crossed
- const DRAG_THRESHOLD = 5; // px
-
- // If a native HTML5 drag starts (e.g. drag-to-editor), cancel the custom reorder
- // immediately so the ghost never appears and mouseup cleanup is skipped.
- const onNativeDragStart = () => {
- if (!dragPending && !active) return;
- // Cancel pending/active custom drag without committing
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- ghost?.remove();
- ghost = null;
- if (draggedEl) {
- draggedEl.setCssProps({
- 'opacity': '',
- 'pointer-events': ''
- });
- }
- clearTransforms();
- draggedEl = null; active = false; dragPending = false;
- snapRects = new Map(); lastFlipRects = new Map();
- originalOrder = []; currentTargetIndex = -1; draggedIndex = -1;
- };
-
- const onMouseDown = (e: MouseEvent) => {
- if (getSortMode() !== 'manual') return;
- if (e.button !== 0) return;
- const card = getCardEl(e.target);
- if (!card) return;
-
- draggedEl = card;
- originalOrder = getCards();
- draggedIndex = originalOrder.indexOf(card);
- currentTargetIndex = draggedIndex;
- dragStartX = e.clientX;
- dragStartY = e.clientY;
- dragPending = true;
-
- // Snapshot rects eagerly so they're ready when drag activates
- snapRects = new Map();
- originalOrder.forEach(c => snapRects.set(c, c.getBoundingClientRect()));
-
- const rect = snapRects.get(card)!;
- offsetX = e.clientX - rect.left;
- offsetY = e.clientY - rect.top;
-
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- // Don't preventDefault here — let click events through until threshold crossed
- };
-
- function activateDrag(e: MouseEvent) {
- if (!draggedEl) return;
- dragPending = false;
- active = true;
-
- const rect = snapRects.get(draggedEl)!;
-
- // Hide source card in-place (keeps its grid slot)
- draggedEl.setCssProps({
- 'opacity': '0',
- 'pointer-events': 'none'
- });
-
- // Ghost: clone with resolved computed styles so CSS vars work on document.body
- ghost = draggedEl.cloneNode(true) as HTMLElement;
- ghost.classList.add('sc-drag-ghost');
- const cs = getComputedStyle(draggedEl);
- ghost.setCssProps({
- 'background': cs.background,
- 'background-color': cs.backgroundColor,
- 'border-color': cs.borderColor,
- 'border-width': cs.borderWidth,
- 'border-style': cs.borderStyle,
- 'border-radius': cs.borderRadius,
- 'color': cs.color,
- 'font-size': cs.fontSize,
- 'line-height': cs.lineHeight,
- 'padding': cs.padding,
- 'position': 'fixed',
- 'z-index': '9999',
- 'pointer-events': 'none',
- 'width': rect.width + 'px',
- 'height': rect.height + 'px',
- 'left': (e.clientX - offsetX) + 'px',
- 'top': (e.clientY - offsetY) + 'px',
- 'margin': '0',
- 'box-shadow': '0 8px 24px rgba(0,0,0,0.25)',
- 'opacity': '1',
- 'cursor': 'grabbing',
- 'transform': '',
- 'transition': ''
- });
- document.body.appendChild(ghost);
- }
- const onMouseMove = (e: MouseEvent) => {
- if (dragPending) {
- const dx = e.clientX - dragStartX;
- const dy = e.clientY - dragStartY;
- if (Math.hypot(dx, dy) >= DRAG_THRESHOLD) {
- activateDrag(e);
- } else {
- return;
- }
- }
-
- if (!active || !ghost || !draggedEl) return;
-
- ghost.setCssProps({
- 'left': (e.clientX - offsetX) + 'px',
- 'top': (e.clientY - offsetY) + 'px'
- });
-
- const newTarget = computeTargetIndex(e.clientX, e.clientY);
- if (newTarget !== currentTargetIndex) {
- currentTargetIndex = newTarget;
- lastFlipRects = readFlipRects(currentTargetIndex);
- applyFlip(lastFlipRects);
- }
- };
-
- const onMouseUp = () => {
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
-
- // Threshold never crossed — just a click, cancel silently
- if (dragPending) {
- dragPending = false;
- draggedEl = null;
- snapRects = new Map();
- originalOrder = [];
- return;
- }
-
- if (!active || !draggedEl) return;
- active = false;
-
- ghost?.remove();
- ghost = null;
-
- draggedEl.setCssProps({
- 'opacity': '',
- 'pointer-events': ''
- });
-
- // Commit: clear transforms, reorder DOM permanently
- clearTransforms();
-
- const newOrder = originalOrder.slice();
- newOrder.splice(draggedIndex, 1);
- newOrder.splice(currentTargetIndex, 0, draggedEl);
- newOrder.forEach(card => container.appendChild(card));
-
- const ids = newOrder.map(c => c.dataset.id ?? '').filter(Boolean);
- plugin.settings.manualOrder = ids;
- void onReorder(ids);
- onPlaceholderMoved?.();
-
- draggedEl = null;
- snapRects = new Map();
- lastFlipRects = new Map();
- originalOrder = [];
- currentTargetIndex = -1;
- draggedIndex = -1;
- };
-
- container.addEventListener('mousedown', onMouseDown);
- container.addEventListener('dragstart', onNativeDragStart);
-
- return () => {
- container.removeEventListener('mousedown', onMouseDown);
- container.removeEventListener('dragstart', onNativeDragStart);
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- ghost?.remove();
- if (draggedEl) {
- draggedEl.setCssProps({
- 'opacity': '',
- 'pointer-events': ''
- });
- }
- clearTransforms();
- ghost = null; draggedEl = null; active = false; dragPending = false;
- snapRects = new Map(); lastFlipRects = new Map();
- originalOrder = []; currentTargetIndex = -1; draggedIndex = -1;
- };
-}
-
-/**
- * Drag-to-reorder for the pinned notes list.
- * Items are vertical list rows (.sc-home-file-item).
- * Persists new order to plugin.settings.pinnedNotes.
- */
-export function attachPinnedListDragToReorder(
- container: HTMLElement,
- getPaths: () => string[],
- onReorder: (newPaths: string[]) => void | Promise
-): () => void {
- let ghost: HTMLElement | null = null;
- let draggedEl: HTMLElement | null = null;
- let draggedPath = '';
- let offsetY = 0;
- let active = false;
- let indicator: HTMLElement | null = null;
- let dropIndex = -1;
-
- function getItems(): HTMLElement[] {
- return Array.from(container.querySelectorAll(':scope > .sc-home-file-item'));
- }
-
- function getItemEl(el: EventTarget | null): HTMLElement | null {
- if (!(el instanceof HTMLElement)) return null;
- return el.closest('.sc-home-file-item');
- }
-
- function getOrCreateIndicator(): HTMLElement {
- if (!indicator) {
- indicator = document.createElement('div');
- indicator.className = 'sc-pinned-drop-indicator';
- indicator.setCssStyles({ position: 'absolute', left: '0', right: '0', height: '2px', background: 'var(--interactive-accent)', borderRadius: '2px', pointerEvents: 'none', zIndex: '100', display: 'none' });
- }
- return indicator;
- }
-
- function computeDropIndex(clientY: number): number {
- const items = getItems().filter(i => i !== draggedEl);
- if (items.length === 0) return 0;
- for (let i = 0; i < items.length; i++) {
- const r = items[i].getBoundingClientRect();
- if (clientY < r.top + r.height / 2) return i;
- }
- return items.length;
- }
-
- function positionIndicator(idx: number) {
- const ind = getOrCreateIndicator();
- const items = getItems().filter(i => i !== draggedEl);
- const containerRect = container.getBoundingClientRect();
-
- let top: number;
- if (items.length === 0) {
- top = 0;
- } else if (idx >= items.length) {
- const last = items[items.length - 1].getBoundingClientRect();
- top = last.bottom - containerRect.top;
- } else {
- const target = items[idx].getBoundingClientRect();
- top = target.top - containerRect.top;
- }
-
- ind.setCssProps({
- 'display': 'block',
- 'top': (top - 1) + 'px'
- });
-
- if (!ind.parentElement) {
- container.setCssProps({ 'position': 'relative' });
- container.appendChild(ind);
- }
- }
-
- const onMouseDown = (e: MouseEvent) => {
- if (e.button !== 0) return;
- const item = getItemEl(e.target);
- if (!item) return;
-
- draggedEl = item;
- draggedPath = item.dataset.path ?? '';
- if (!draggedPath) return;
-
- const rect = item.getBoundingClientRect();
- offsetY = e.clientY - rect.top;
-
- // Ghost
- ghost = item.cloneNode(true) as HTMLElement;
- ghost.classList.add('sc-drag-ghost');
- const cs = getComputedStyle(item);
- ghost.setCssStyles({
- position: 'fixed',
- zIndex: '9999',
- pointerEvents: 'none',
- width: rect.width + 'px',
- height: rect.height + 'px',
- left: rect.left + 'px',
- top: rect.top + 'px',
- background: cs.background,
- backgroundColor: cs.backgroundColor,
- borderRadius: cs.borderRadius,
- padding: cs.padding,
- fontSize: cs.fontSize,
- color: cs.color,
- boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
- opacity: '0.9',
- cursor: 'grabbing',
- });
- document.body.appendChild(ghost);
-
- item.setCssProps({ 'opacity': '0.3' });
- active = true;
- dropIndex = computeDropIndex(e.clientY);
- positionIndicator(dropIndex);
-
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- e.preventDefault();
- };
-
- const onMouseMove = (e: MouseEvent) => {
- if (!active || !ghost) return;
- ghost.setCssProps({ 'top': (e.clientY - offsetY) + 'px' });
- dropIndex = computeDropIndex(e.clientY);
- positionIndicator(dropIndex);
- };
-
- const onMouseUp = () => {
- if (!active) return;
- active = false;
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
-
- ghost?.remove();
- ghost = null;
- if (indicator) indicator.setCssProps({ 'display': 'none' });
- if (draggedEl) draggedEl.setCssProps({ 'opacity': '' });
-
- // Commit new order
- const paths = getPaths().slice();
- const fromIdx = paths.indexOf(draggedPath);
- if (fromIdx !== -1 && dropIndex !== -1) {
- paths.splice(fromIdx, 1);
- // dropIndex is relative to items excluding dragged — adjust for removal
- const insertAt = fromIdx < dropIndex ? dropIndex : dropIndex;
- paths.splice(insertAt, 0, draggedPath);
- void onReorder(paths);
- }
-
- draggedEl = null;
- draggedPath = '';
- dropIndex = -1;
- };
-
- container.addEventListener('mousedown', onMouseDown);
-
- return () => {
- container.removeEventListener('mousedown', onMouseDown);
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- ghost?.remove();
- indicator?.remove();
- if (draggedEl) draggedEl.setCssProps({ 'opacity': '' });
- ghost = null; draggedEl = null; active = false; indicator = null;
- };
-}
+): () => void {
+ let ghost: HTMLElement | null = null;
+ let draggedEl: HTMLElement | null = null;
+ let offsetX = 0;
+ let offsetY = 0;
+ let active = false;
+
+ let snapRects: Map = new Map();
+ let originalOrder: HTMLElement[] = [];
+ let currentTargetIndex = -1;
+ let draggedIndex = -1;
+
+ // Rects for the current targetIndex (used to avoid redundant FLIP reads)
+ let lastFlipRects: Map = new Map();
+
+ function getCards(): HTMLElement[] {
+ return Array.from(container.querySelectorAll(':scope > .sc-card'));
+ }
+
+ function getCardEl(el: EventTarget | null): HTMLElement | null {
+ if (!(el instanceof HTMLElement)) return null;
+ if (el.closest('button, a, [contenteditable="true"], .sc-tag, .sc-copy-btn')) return null;
+ return el.closest('.sc-card');
+ }
+
+ /** Compute target insert index from cursor position using snapshotted rects */
+ function computeTargetIndex(clientX: number, clientY: number): number {
+ const others = originalOrder.filter((_, i) => i !== draggedIndex);
+ if (others.length === 0) return 0;
+
+ let closest: HTMLElement | null = null;
+ let closestDist = Infinity;
+ for (const card of others) {
+ const r = snapRects.get(card)!;
+ const cx = r.left + r.width / 2;
+ const cy = r.top + r.height / 2;
+ const d = Math.hypot(clientX - cx, clientY - cy);
+ if (d < closestDist) { closestDist = d; closest = card; }
+ }
+ if (!closest) return draggedIndex;
+
+ const closestOrigIdx = originalOrder.indexOf(closest);
+ const r = snapRects.get(closest)!;
+
+ const isGrid = others.some(c => {
+ if (c === closest) return false;
+ return Math.abs(snapRects.get(c)!.top - r.top) < 10;
+ });
+
+ const insertBefore = isGrid
+ ? clientX < r.left + r.width / 2
+ : clientY < r.top + r.height / 2;
+
+ if (insertBefore) {
+ return closestOrigIdx <= draggedIndex ? closestOrigIdx : closestOrigIdx - 1;
+ } else {
+ return closestOrigIdx >= draggedIndex ? closestOrigIdx : closestOrigIdx + 1;
+ }
+ }
+
+ /**
+ * FLIP: temporarily reorder DOM to targetIdx, read real rects, revert.
+ * Returns a map of card → new DOMRect in the target layout.
+ */
+ function readFlipRects(targetIdx: number): Map {
+ // Build the new order array
+ const newOrder = originalOrder.slice();
+ newOrder.splice(draggedIndex, 1);
+ newOrder.splice(targetIdx, 0, draggedEl!);
+
+ // Suppress transitions during DOM manipulation
+ originalOrder.forEach(c => {
+ c.setCssProps({
+ 'transition': 'none',
+ 'transform': ''
+ });
+ });
+
+ // Temporarily reorder DOM
+ newOrder.forEach(card => container.appendChild(card));
+
+ // Force layout read
+ const rects = new Map();
+ originalOrder.forEach(c => rects.set(c, c.getBoundingClientRect()));
+
+ // Revert DOM to original order
+ originalOrder.forEach(card => container.appendChild(card));
+
+ return rects;
+ }
+
+ /** Apply FLIP animations: translate each card from snapRect → flipRect */
+ function applyFlip(flipRects: Map) {
+ // Step 1: instantly snap each card to its "from" position (no transition)
+ // This is needed because readFlipRects cleared transforms — we must re-establish
+ // the current visual position before animating to the new one.
+ for (const card of originalOrder) {
+ if (card === draggedEl) continue;
+ card.setCssProps({
+ 'transition': 'none',
+ 'transform': card.style.transform || ''
+ });
+ }
+
+ // Step 2: force a reflow so the browser registers the transition:none state
+ void container.offsetHeight;
+
+ // Step 3: set target transforms with transition — browser will animate from current → target
+ for (const card of originalOrder) {
+ if (card === draggedEl) continue;
+
+ const from = snapRects.get(card)!;
+ const to = flipRects.get(card)!;
+ const dx = to.left - from.left;
+ const dy = to.top - from.top;
+
+ card.setCssProps({
+ 'transition': 'transform 150ms cubic-bezier(0.25,0.46,0.45,0.94)',
+ 'transform': (dx === 0 && dy === 0) ? '' : `translate(${dx}px,${dy}px)`
+ });
+ }
+ }
+
+ function clearTransforms() {
+ getCards().forEach(c => {
+ c.setCssProps({
+ 'transition': '',
+ 'transform': ''
+ });
+ });
+ }
+
+ let dragStartX = 0;
+ let dragStartY = 0;
+ let dragPending = false; // mousedown happened but threshold not yet crossed
+ const DRAG_THRESHOLD = 5; // px
+
+ // If a native HTML5 drag starts (e.g. drag-to-editor), cancel the custom reorder
+ // immediately so the ghost never appears and mouseup cleanup is skipped.
+ const onNativeDragStart = () => {
+ if (!dragPending && !active) return;
+ // Cancel pending/active custom drag without committing
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ ghost?.remove();
+ ghost = null;
+ if (draggedEl) {
+ draggedEl.setCssProps({
+ 'opacity': '',
+ 'pointer-events': ''
+ });
+ }
+ clearTransforms();
+ draggedEl = null; active = false; dragPending = false;
+ snapRects = new Map(); lastFlipRects = new Map();
+ originalOrder = []; currentTargetIndex = -1; draggedIndex = -1;
+ };
+
+ const onMouseDown = (e: MouseEvent) => {
+ if (getSortMode() !== 'manual') return;
+ if (e.button !== 0) return;
+ const card = getCardEl(e.target);
+ if (!card) return;
+
+ draggedEl = card;
+ originalOrder = getCards();
+ draggedIndex = originalOrder.indexOf(card);
+ currentTargetIndex = draggedIndex;
+ dragStartX = e.clientX;
+ dragStartY = e.clientY;
+ dragPending = true;
+
+ // Snapshot rects eagerly so they're ready when drag activates
+ snapRects = new Map();
+ originalOrder.forEach(c => snapRects.set(c, c.getBoundingClientRect()));
+
+ const rect = snapRects.get(card)!;
+ offsetX = e.clientX - rect.left;
+ offsetY = e.clientY - rect.top;
+
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ // Don't preventDefault here — let click events through until threshold crossed
+ };
+
+ function activateDrag(e: MouseEvent) {
+ if (!draggedEl) return;
+ dragPending = false;
+ active = true;
+
+ const rect = snapRects.get(draggedEl)!;
+
+ // Hide source card in-place (keeps its grid slot)
+ draggedEl.setCssProps({
+ 'opacity': '0',
+ 'pointer-events': 'none'
+ });
+
+ // Ghost: clone with resolved computed styles so CSS vars work on document.body
+ ghost = draggedEl.cloneNode(true) as HTMLElement;
+ ghost.classList.add('sc-drag-ghost');
+ const cs = getComputedStyle(draggedEl);
+ ghost.setCssProps({
+ 'background': cs.background,
+ 'background-color': cs.backgroundColor,
+ 'border-color': cs.borderColor,
+ 'border-width': cs.borderWidth,
+ 'border-style': cs.borderStyle,
+ 'border-radius': cs.borderRadius,
+ 'color': cs.color,
+ 'font-size': cs.fontSize,
+ 'line-height': cs.lineHeight,
+ 'padding': cs.padding,
+ 'position': 'fixed',
+ 'z-index': '9999',
+ 'pointer-events': 'none',
+ 'width': rect.width + 'px',
+ 'height': rect.height + 'px',
+ 'left': (e.clientX - offsetX) + 'px',
+ 'top': (e.clientY - offsetY) + 'px',
+ 'margin': '0',
+ 'box-shadow': '0 8px 24px rgba(0,0,0,0.25)',
+ 'opacity': '1',
+ 'cursor': 'grabbing',
+ 'transform': '',
+ 'transition': ''
+ });
+ document.body.appendChild(ghost);
+ }
+ const onMouseMove = (e: MouseEvent) => {
+ if (dragPending) {
+ const dx = e.clientX - dragStartX;
+ const dy = e.clientY - dragStartY;
+ if (Math.hypot(dx, dy) >= DRAG_THRESHOLD) {
+ activateDrag(e);
+ } else {
+ return;
+ }
+ }
+
+ if (!active || !ghost || !draggedEl) return;
+
+ ghost.setCssProps({
+ 'left': (e.clientX - offsetX) + 'px',
+ 'top': (e.clientY - offsetY) + 'px'
+ });
+
+ const newTarget = computeTargetIndex(e.clientX, e.clientY);
+ if (newTarget !== currentTargetIndex) {
+ currentTargetIndex = newTarget;
+ lastFlipRects = readFlipRects(currentTargetIndex);
+ applyFlip(lastFlipRects);
+ }
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+
+ // Threshold never crossed — just a click, cancel silently
+ if (dragPending) {
+ dragPending = false;
+ draggedEl = null;
+ snapRects = new Map();
+ originalOrder = [];
+ return;
+ }
+
+ if (!active || !draggedEl) return;
+ active = false;
+
+ ghost?.remove();
+ ghost = null;
+
+ draggedEl.setCssProps({
+ 'opacity': '',
+ 'pointer-events': ''
+ });
+
+ // Commit: clear transforms, reorder DOM permanently
+ clearTransforms();
+
+ const newOrder = originalOrder.slice();
+ newOrder.splice(draggedIndex, 1);
+ newOrder.splice(currentTargetIndex, 0, draggedEl);
+ newOrder.forEach(card => container.appendChild(card));
+
+ const ids = newOrder.map(c => c.dataset.id ?? '').filter(Boolean);
+ plugin.settings.manualOrder = ids;
+ void onReorder(ids);
+ onPlaceholderMoved?.();
+
+ draggedEl = null;
+ snapRects = new Map();
+ lastFlipRects = new Map();
+ originalOrder = [];
+ currentTargetIndex = -1;
+ draggedIndex = -1;
+ };
+
+ container.addEventListener('mousedown', onMouseDown);
+ container.addEventListener('dragstart', onNativeDragStart);
+
+ return () => {
+ container.removeEventListener('mousedown', onMouseDown);
+ container.removeEventListener('dragstart', onNativeDragStart);
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ ghost?.remove();
+ if (draggedEl) {
+ draggedEl.setCssProps({
+ 'opacity': '',
+ 'pointer-events': ''
+ });
+ }
+ clearTransforms();
+ ghost = null; draggedEl = null; active = false; dragPending = false;
+ snapRects = new Map(); lastFlipRects = new Map();
+ originalOrder = []; currentTargetIndex = -1; draggedIndex = -1;
+ };
+}
+
+/**
+ * Drag-to-reorder for the pinned notes list.
+ * Items are vertical list rows (.sc-home-file-item).
+ * Persists new order to plugin.settings.pinnedNotes.
+ */
+export function attachPinnedListDragToReorder(
+ container: HTMLElement,
+ getPaths: () => string[],
+ onReorder: (newPaths: string[]) => void | Promise
+): () => void {
+ let ghost: HTMLElement | null = null;
+ let draggedEl: HTMLElement | null = null;
+ let draggedPath = '';
+ let offsetY = 0;
+ let active = false;
+ let indicator: HTMLElement | null = null;
+ let dropIndex = -1;
+
+ function getItems(): HTMLElement[] {
+ return Array.from(container.querySelectorAll(':scope > .sc-home-file-item'));
+ }
+
+ function getItemEl(el: EventTarget | null): HTMLElement | null {
+ if (!(el instanceof HTMLElement)) return null;
+ return el.closest('.sc-home-file-item');
+ }
+
+ function getOrCreateIndicator(): HTMLElement {
+ if (!indicator) {
+ indicator = document.createElement('div');
+ indicator.className = 'sc-pinned-drop-indicator';
+ indicator.setCssStyles({ position: 'absolute', left: '0', right: '0', height: '2px', background: 'var(--interactive-accent)', borderRadius: '2px', pointerEvents: 'none', zIndex: '100', display: 'none' });
+ }
+ return indicator;
+ }
+
+ function computeDropIndex(clientY: number): number {
+ const items = getItems().filter(i => i !== draggedEl);
+ if (items.length === 0) return 0;
+ for (let i = 0; i < items.length; i++) {
+ const r = items[i].getBoundingClientRect();
+ if (clientY < r.top + r.height / 2) return i;
+ }
+ return items.length;
+ }
+
+ function positionIndicator(idx: number) {
+ const ind = getOrCreateIndicator();
+ const items = getItems().filter(i => i !== draggedEl);
+ const containerRect = container.getBoundingClientRect();
+
+ let top: number;
+ if (items.length === 0) {
+ top = 0;
+ } else if (idx >= items.length) {
+ const last = items[items.length - 1].getBoundingClientRect();
+ top = last.bottom - containerRect.top;
+ } else {
+ const target = items[idx].getBoundingClientRect();
+ top = target.top - containerRect.top;
+ }
+
+ ind.setCssProps({
+ 'display': 'block',
+ 'top': (top - 1) + 'px'
+ });
+
+ if (!ind.parentElement) {
+ container.setCssProps({ 'position': 'relative' });
+ container.appendChild(ind);
+ }
+ }
+
+ const onMouseDown = (e: MouseEvent) => {
+ if (e.button !== 0) return;
+ const item = getItemEl(e.target);
+ if (!item) return;
+
+ draggedEl = item;
+ draggedPath = item.dataset.path ?? '';
+ if (!draggedPath) return;
+
+ const rect = item.getBoundingClientRect();
+ offsetY = e.clientY - rect.top;
+
+ // Ghost
+ ghost = item.cloneNode(true) as HTMLElement;
+ ghost.classList.add('sc-drag-ghost');
+ const cs = getComputedStyle(item);
+ ghost.setCssStyles({
+ position: 'fixed',
+ zIndex: '9999',
+ pointerEvents: 'none',
+ width: rect.width + 'px',
+ height: rect.height + 'px',
+ left: rect.left + 'px',
+ top: rect.top + 'px',
+ background: cs.background,
+ backgroundColor: cs.backgroundColor,
+ borderRadius: cs.borderRadius,
+ padding: cs.padding,
+ fontSize: cs.fontSize,
+ color: cs.color,
+ boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
+ opacity: '0.9',
+ cursor: 'grabbing',
+ });
+ document.body.appendChild(ghost);
+
+ item.setCssProps({ 'opacity': '0.3' });
+ active = true;
+ dropIndex = computeDropIndex(e.clientY);
+ positionIndicator(dropIndex);
+
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ e.preventDefault();
+ };
+
+ const onMouseMove = (e: MouseEvent) => {
+ if (!active || !ghost) return;
+ ghost.setCssProps({ 'top': (e.clientY - offsetY) + 'px' });
+ dropIndex = computeDropIndex(e.clientY);
+ positionIndicator(dropIndex);
+ };
+
+ const onMouseUp = () => {
+ if (!active) return;
+ active = false;
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+
+ ghost?.remove();
+ ghost = null;
+ if (indicator) indicator.setCssProps({ 'display': 'none' });
+ if (draggedEl) draggedEl.setCssProps({ 'opacity': '' });
+
+ // Commit new order
+ const paths = getPaths().slice();
+ const fromIdx = paths.indexOf(draggedPath);
+ if (fromIdx !== -1 && dropIndex !== -1) {
+ paths.splice(fromIdx, 1);
+ // dropIndex is relative to items excluding dragged — adjust for removal
+ const insertAt = fromIdx < dropIndex ? dropIndex : dropIndex;
+ paths.splice(insertAt, 0, draggedPath);
+ void onReorder(paths);
+ }
+
+ draggedEl = null;
+ draggedPath = '';
+ dropIndex = -1;
+ };
+
+ container.addEventListener('mousedown', onMouseDown);
+
+ return () => {
+ container.removeEventListener('mousedown', onMouseDown);
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ ghost?.remove();
+ indicator?.remove();
+ if (draggedEl) draggedEl.setCssProps({ 'opacity': '' });
+ ghost = null; draggedEl = null; active = false; indicator = null;
+ };
+}
diff --git a/src/views/CardSidebarView.ts b/src/views/CardSidebarView.ts
index 7f71d86..e143f3a 100644
--- a/src/views/CardSidebarView.ts
+++ b/src/views/CardSidebarView.ts
@@ -1,928 +1,928 @@
-
-import { ItemView, WorkspaceLeaf, Notice, Menu, TFile, setIcon, Scope, Editor, App } from "obsidian";
-import { CardStore } from "../services/CardStore";
-import { FilterService, FilterOptions } from "../services/FilterService";
-import { SortService, SortMode } from "../services/SortService";
-import { EventBus } from "../core/EventBus";
-import { CardComponent } from "./components/Card";
-import { flipAnimateAsync } from "../utils/animations";
-import { Card } from "../models/Card";
-import SideCardsPlugin from "../core/Plugin";
-import { InlineAutocomplete } from "./components/InlineAutocomplete";
-import { resolveAutoColor } from "../utils/dom";
-import { attachDragToReorder } from "../utils/drag-drop";
-
-interface AppWithInternals extends App {
- keymap: { pushScope: (scope: Scope) => void; popScope: (scope: Scope) => void };
- workspace: App['workspace'] & { activeEditor: { editor: Editor; editMode: boolean } | null };
-}
-
-export class CardSidebarView extends ItemView {
- private cardsContainer!: HTMLElement;
- private cardComponents: Map = new Map();
- private activeFilters: FilterOptions = { query: '', tags: [] };
- private currentSortMode: SortMode = 'manual';
- private sortAscending: boolean = true;
- private editorScope: Scope;
- private editor!: Editor;
- private owner!: { editor: Editor; editMode: boolean };
- private dragDropCleanup: (() => void) | null = null;
-
- constructor(
- leaf: WorkspaceLeaf,
- private plugin: SideCardsPlugin,
- private store: CardStore,
- private eventBus: EventBus,
- private filterService: FilterService,
- private sortService: SortService
- ) {
- super(leaf);
- this.editorScope = new Scope(this.app.scope);
- this.setupMockEditor();
- }
-
- private setupMockEditor() {
- this.editor = {
- getSelection: () => {
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return "";
- const selectedText = sel.toString();
- if (selectedText.length > 0) return selectedText;
- const wordRange = this.getWordRangeAtCaret(sel);
- return wordRange ? wordRange.toString() : "";
- },
- replaceSelection: (text: string) => {
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return;
- const currentRange = sel.getRangeAt(0);
- const range = currentRange.collapsed
- ? (this.getWordRangeAtCaret(sel) || currentRange)
- : currentRange;
- 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("*"),
- toggleHighlight: () => this.toggleMarkdownWrapper("=="),
- toggleComment: () => this.toggleMarkdownWrapper("%%", "%%", true),
- } as unknown as Editor;
-
- this.owner = {
- editor: this.editor,
- editMode: true,
- };
- }
-
- private isWordChar(char: string): boolean {
- return /[A-Za-z0-9_]/.test(char);
- }
-
- private getWordRangeAtCaret(selection: Selection): Range | null {
- if (!selection.rangeCount) return null;
- const baseRange = selection.getRangeAt(0);
- if (!baseRange.collapsed) return baseRange;
- const node = baseRange.startContainer;
- if (!(node instanceof Text)) return null;
- const text = node.data;
- if (!text) return null;
- const offset = baseRange.startOffset;
- const leftChar = offset > 0 ? text[offset - 1] : "";
- const rightChar = offset < text.length ? text[offset] : "";
- if (!this.isWordChar(leftChar) && !this.isWordChar(rightChar)) return null;
- let start = offset;
- let end = offset;
- while (start > 0 && this.isWordChar(text[start - 1])) start--;
- while (end < text.length && this.isWordChar(text[end])) end++;
- const wordRange = document.createRange();
- wordRange.setStart(node, start);
- wordRange.setEnd(node, end);
- return wordRange;
- }
-
- private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==" | "%%", closeWrapper?: string, includeInnerPadding = false) {
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return;
- const currentRange = sel.getRangeAt(0);
- const range = currentRange.collapsed
- ? (this.getWordRangeAtCaret(sel) || currentRange)
- : currentRange;
- const selectedText = range.toString();
- const endWrapper = closeWrapper ?? wrapper;
- if (selectedText.length === 0) {
- const text = wrapper + endWrapper;
- const node = document.createTextNode(text);
- range.insertNode(node);
- const cursorOffset = wrapper.length;
- range.setStart(node, cursorOffset);
- range.collapse(true);
- sel.removeAllRanges();
- sel.addRange(range);
- return;
- }
- const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(endWrapper);
- const newText = alreadyWrapped
- ? selectedText.slice(wrapper.length, selectedText.length - endWrapper.length)
- : includeInnerPadding
- ? `${wrapper} ${selectedText} ${endWrapper}`
- : `${wrapper}${selectedText}${endWrapper}`;
- sel.removeAllRanges();
- sel.addRange(range);
- this.editor.replaceSelection(newText);
- }
-
- private applySelectionWrapShortcut(event: KeyboardEvent, root: HTMLElement): boolean {
- if (this.plugin.settings.autoPairBrackets === false) return false;
- if (event.ctrlKey || event.metaKey || event.altKey) return false;
- const wrapMap: Record = {
- "[": ["[", "]"],
- "(": ["(", ")"],
- "{": ["{", "}"],
- "`": ["`", "`"],
- "%": ["%", "%"],
- "=": ["=", "="],
- '"': ['"', '"'],
- };
- const pair = wrapMap[event.key];
- if (!pair) return false;
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return false;
- const range = sel.getRangeAt(0);
- if (!root.contains(range.commonAncestorContainer)) return false;
-
- const selectedText = range.toString();
- const [open, close] = pair;
- let newText = `${open}${selectedText}${close}`;
-
- // Upgrade logic
- if (event.key === "%") {
- if (selectedText.startsWith("%") && selectedText.endsWith("%") && !selectedText.startsWith("%%")) {
- const inner = selectedText.slice(1, -1).trim();
- newText = `%% ${inner} %%`;
- }
- } else if (event.key === "=") {
- if (selectedText.startsWith("=") && selectedText.endsWith("=") && !selectedText.startsWith("==")) {
- const inner = selectedText.slice(1, -1);
- newText = `==${inner}==`;
- }
- } else if (event.key === "[") {
- if (selectedText.startsWith("[") && selectedText.endsWith("]") && !selectedText.startsWith("[[")) {
- const inner = selectedText.slice(1, -1);
- newText = `[[${inner}]]`;
- }
- }
-
- event.preventDefault();
- range.deleteContents();
- const node = document.createTextNode(newText);
- range.insertNode(node);
-
- const newRange = document.createRange();
- if (selectedText.length === 0) {
- newRange.setStart(node, open.length);
- newRange.collapse(true);
- } else {
- newRange.selectNode(node);
- }
- sel.removeAllRanges();
- sel.addRange(newRange);
- return true;
- }
-
- private getEffectiveHotkeys(commandId: string): Array<{ modifiers?: string[]; key?: string }> {
- const appAny = this.app as unknown as { hotkeyManager?: { getHotkeys?: (id: string) => Array<{ modifiers?: string[]; key?: string }>; customKeys?: Record> }; commands?: { commands?: Record }> } };
- const fromManager = appAny.hotkeyManager?.getHotkeys?.(commandId);
- if (Array.isArray(fromManager) && fromManager.length > 0) return fromManager;
- const custom = appAny.hotkeyManager?.customKeys?.[commandId];
- if (Array.isArray(custom) && custom.length > 0) return custom;
- const defaults = appAny.commands?.commands?.[commandId]?.hotkeys;
- if (Array.isArray(defaults) && defaults.length > 0) return defaults;
- return [];
- }
-
- private getFormattingCommandIds(kind: "bold" | "italic" | "highlight" | "comment"): string[] {
- const defaults: Record<"bold" | "italic" | "highlight" | "comment", string[]> = {
- bold: ["editor:toggle-bold"],
- italic: ["editor:toggle-italic", "editor:toggle-emphasis"],
- highlight: ["editor:toggle-highlight"],
- comment: ["editor:toggle-comment"],
- };
- const appAny = this.app as unknown as { commands?: { commands?: Record } };
- const commands = appAny.commands?.commands || {};
- const matcher: Record<"bold" | "italic" | "highlight" | "comment", RegExp> = {
- bold: /bold/i,
- italic: /italic|emphasis/i,
- highlight: /highlight/i,
- comment: /comment/i,
- };
- const discovered = Object.values(commands)
- .filter((cmd) => typeof cmd?.id === "string" && cmd.id.startsWith("editor:"))
- .filter((cmd) => matcher[kind].test(String(cmd?.name || "")))
- .map((cmd) => String(cmd.id));
- return Array.from(new Set([...defaults[kind], ...discovered]));
- }
-
- private eventMatchesHotkey(event: KeyboardEvent, hotkey: { modifiers?: string[]; key?: string }): boolean {
- const key = String(hotkey?.key || "").toLowerCase();
- if (!key) return false;
- const eventKey = String(event.key || "").toLowerCase();
- if (eventKey !== key) return false;
- const modifierSet = new Set((hotkey.modifiers || []).map(m => String(m).toLowerCase()));
- const hasMod = modifierSet.has("mod");
- const expectsCtrl = hasMod || modifierSet.has("ctrl");
- const expectsMeta = hasMod || modifierSet.has("meta");
- const expectsAlt = modifierSet.has("alt");
- const expectsShift = modifierSet.has("shift");
- if (expectsCtrl !== event.ctrlKey) return false;
- if (expectsMeta !== event.metaKey) return false;
- if (expectsAlt !== event.altKey) return false;
- if (expectsShift !== event.shiftKey) return false;
- return true;
- }
-
- private applyFormattingHotkey(event: KeyboardEvent, root: HTMLElement): boolean {
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return false;
- const range = sel.getRangeAt(0);
- if (!root.contains(range.commonAncestorContainer)) return false;
- const targets: Array<{ kind: "bold" | "italic" | "highlight" | "comment"; run: () => void }> = [
- { kind: "bold", run: () => this.toggleMarkdownWrapper("**") },
- { kind: "italic", run: () => this.toggleMarkdownWrapper("*") },
- { kind: "highlight", run: () => this.toggleMarkdownWrapper("==") },
- { kind: "comment", run: () => this.toggleMarkdownWrapper("%%", "%%", true) },
- ];
- for (const target of targets) {
- const commandIds = this.getFormattingCommandIds(target.kind);
- const hotkeys = commandIds.flatMap(id => this.getEffectiveHotkeys(id));
- if (!hotkeys.length) continue;
- if (!hotkeys.some(h => this.eventMatchesHotkey(event, h))) continue;
- event.preventDefault();
- event.stopPropagation();
- target.run();
- return true;
- }
- return false;
- }
-
- getViewType(): string {
- return 'card-sidebar';
- }
-
- getDisplayText(): string {
- return 'Card sidebar';
- }
-
- getIcon(): string {
- return 'rectangle-horizontal';
- }
-
- private _loadInProgress = false;
- private _loadingEl: HTMLElement | null = null;
- private _loadingTimeout: ReturnType | null = null;
- private _masonryObserver: ResizeObserver | null = null;
- private _masonryMutationObserver: MutationObserver | null = null;
- private _masonryTimeout: ReturnType | null = null;
-
- async onOpen(): Promise {
- const container = this.containerEl;
- container.empty();
- container.addClass('sc-sidebar-container');
- this.currentSortMode = (this.plugin.settings.sortMode || 'manual') as SortMode;
- this.sortAscending = typeof this.plugin.settings.sortAscending === 'boolean'
- ? !!this.plugin.settings.sortAscending
- : true;
-
- const mainContainer = container.createDiv('sc-sidebar-main');
-
- this.createHeader(mainContainer);
- this.createSearchBar(mainContainer);
-
- this.cardsContainer = mainContainer.createDiv('sc-sidebar-cards-container');
-
- // Apply scrollbar visibility
- this.applyScrollbarSetting();
-
- this.createInputBox(mainContainer);
-
- this.setupListeners();
- this.setupPositionDetection();
- this.setupLayoutObservers();
- this.registerVaultEvents();
-
- // Apply openCategoryOnLoad
- const openOn = this.plugin.settings.openCategoryOnLoad || 'all';
- const btn = mainContainer.querySelector(`.sc-category-btn[data-filter-value="${openOn}"]`);
- if (btn instanceof HTMLElement) btn.click();
-
- this.showLoadingOverlay();
- 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('sc-sidebar-loading');
-
- const box = this._loadingEl.createDiv('sc-sidebar-loading-inner');
-
- const spinner = box.createDiv('sc-sidebar-spinner');
- // Inline animation fallback (CSS @keyframes preferred but this works without it)
- spinner.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity });
-
- box.createDiv({ text: 'Loading cards...', cls: 'sc-sidebar-loading-text' });
-
- this._loadingTimeout = setTimeout(() => this.hideLoadingOverlay(), maxMs);
- }
-
- private hideLoadingOverlay(fadeMs = 0) {
- if (this._loadingTimeout) {
- clearTimeout(this._loadingTimeout);
- this._loadingTimeout = null;
- }
- if (this._loadingEl) {
- this._loadingEl.remove();
- this._loadingEl = null;
- }
- }
-
- private setupPositionDetection() {
- const detect = () => {
- let position = 'right';
- let current = this.containerEl;
- let depth = 0;
- while (current && depth < 10) {
- const cls = current.className || '';
- if (cls.includes('side-dock-left')) { position = 'left'; break; }
- if (cls.includes('side-dock-right')) { position = 'right'; break; }
- current = current.parentElement as HTMLElement;
- depth++;
- }
- if (this.plugin.settings.sidebarPosition !== position) {
- this.plugin.settings.sidebarPosition = position;
- void this.plugin.saveSettings();
- }
- };
- detect();
- const observer = new MutationObserver(detect);
- if (this.containerEl.parentElement) {
- observer.observe(this.containerEl.parentElement, { attributes: true, attributeFilter: ['class'] });
- }
- }
-
- private setupLayoutObservers() {
- if (typeof ResizeObserver !== 'undefined') {
- const ro = new ResizeObserver(() => {
- // Refresh masonry or layout if needed
- });
- ro.observe(this.cardsContainer);
- }
- }
-
- private registerVaultEvents() {
- this.plugin.registerEvent(this.app.vault.on('modify', async (file) => {
- if (!(file instanceof TFile)) return;
- // Skip if we're the ones writing this file (prevents expiry/color wipe race)
- if (this.store._syncingPaths?.has(file.path)) return;
- const pending = this.store._pendingTagWrites.get(file.path);
- if (pending) {
- await this.store.handlePendingTagReapply(file, pending);
- } else {
- await this.store.updateCardFromNotePath(file.path);
- }
- }));
- }
-
- private createHeader(container: HTMLElement): void {
- if (this.plugin.settings.disableFilterButtons) return;
-
- const header = container.createDiv('sc-sidebar-header');
- const filterGroup = header.createDiv('sc-category-group');
-
- const settings = this.plugin.settings;
- const cats: Array<{ id: string; label: string; showInMenu?: boolean }> =
- Array.isArray(settings.customCategories) ? settings.customCategories : [];
-
- // Build ordered chip list using allItemsOrder if available
- const defaultOrder = ['filter-all']
- .concat(!settings.hideTodayFilter ? ['filter-today'] : [])
- .concat(!settings.hideTomorrowFilter ? ['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.hideTodayFilter) chips.push({ type: 'category', label: 'Today', value: 'today' });
- return;
- }
- if (itemId === 'filter-tomorrow') {
- if (!settings.hideTomorrowFilter) 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('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.style.setProperty('--sc-btn-text-color', customColors.textColor);
- }
-
- btn.addEventListener('click', () => {
- void (async () => {
- 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');
- (b as HTMLElement).style.setProperty('--sc-btn-text-color', bColors.textColor);
- } else {
- (b as HTMLElement).style.removeProperty('color');
- (b as HTMLElement).style.removeProperty('--sc-btn-text-color');
- }
- });
- btn.addClass('active');
- if (chip.type === 'archived') {
- this.activeFilters.archivedOnly = true;
- this.activeFilters.category = undefined;
- } else if (chip.type === 'all') {
- this.activeFilters.archivedOnly = false;
- this.activeFilters.category = undefined;
- } else if (chip.type === 'category') {
- this.activeFilters.archivedOnly = false;
- this.activeFilters.category = chip.value;
- }
- await this.renderCards();
- })();
- });
- });
- }
-
- 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');
- }
- }
-
- private createSearchBar(container: HTMLElement): void {
- const wrapper = container.createDiv('sc-search-wrap');
- wrapper.toggleClass('sc-search-wrap--hidden', !this.plugin.settings.searchBarVisible);
- const row = wrapper.createDiv('sc-search-row');
-
- const iconSpan = row.createSpan({ cls: 'sc-search-input-icon' });
- try { setIcon(iconSpan, 'search'); } catch { /* icon may not exist */ }
-
- 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.toggleClass('sc-search-wrap--hidden', true);
- clearBtn.title = 'Clear search';
- clearBtn.addEventListener('click', () => {
- input.value = '';
- this.activeFilters.query = '';
- this.renderCardsDebounced();
- clearBtn.toggleClass('sc-search-wrap--hidden', true);
- input.focus();
- });
-
- input.addEventListener('input', () => {
- this.activeFilters.query = input.value || '';
- clearBtn.toggleClass('sc-search-wrap--hidden', !this.activeFilters.query);
- this.renderCardsDebounced();
- });
- }
-
- private createInputBox(container: HTMLElement): void {
- const inputContainer = container.createDiv('sc-sidebar-input-container');
-
- const editorEl = inputContainer.createDiv({ cls: 'sc-sidebar-input' });
-
- editorEl.setAttribute('contenteditable', 'true');
- editorEl.dataset.placeholder = 'Type here... (@category, #tag)';
- const updatePlaceholder = () => {
- if (!editorEl.textContent?.trim()) {
- editorEl.addClass('is-empty');
- } else {
- editorEl.removeClass('is-empty');
- }
- };
- updatePlaceholder();
- editorEl.addEventListener('input', updatePlaceholder);
-
- editorEl.addEventListener('focusin', () => {
- (this.app as unknown as AppWithInternals).keymap.pushScope(this.editorScope);
- (this.app as unknown as AppWithInternals).workspace.activeEditor = this.owner as any;
- });
-
- editorEl.addEventListener('blur', () => {
- (this.app as unknown as AppWithInternals).keymap.popScope(this.editorScope);
- if ((this.app as unknown as AppWithInternals).workspace.activeEditor === this.owner) {
- (this.app as unknown as AppWithInternals).workspace.activeEditor = null;
- }
- });
-
- // @category / #tag / [[file]] inline autocomplete
- new InlineAutocomplete(editorEl, this.store, this.app);
-
- editorEl.addEventListener('keydown', (e) => {
- if (this.applyFormattingHotkey(e, editorEl)) return;
- this.applySelectionWrapShortcut(e, editorEl);
-
- // Save key handling
- const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s+_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
- const saveKey = normalizeKey(this.plugin.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.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey)) {
- e.preventDefault();
- addButton.click();
- }
- });
-
- const buttonContainer = inputContainer.createDiv('sc-sidebar-button-container');
-
- 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('.sc-search-wrap');
- if (wrap) {
- const isHidden = wrap.hasClass('sc-search-wrap--hidden');
- wrap.toggleClass('sc-search-wrap--hidden', !isHidden);
- this.plugin.settings.searchBarVisible = isHidden;
- void 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', () => {
- void (async () => {
- 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', (e) => {
- const menu = new Menu();
- const modes: Array<{ key: SortMode; label: string }> = [
- { key: 'manual', label: 'Manual' },
- { key: 'created', label: 'Created Time' },
- { key: 'modified', label: 'Modified Time' },
- { key: 'alpha', label: 'A → Z' },
- { key: 'status', label: 'Status' }
- ];
- modes.forEach(m => {
- menu.addItem(item => {
- item.setTitle(m.label);
- if (this.currentSortMode === m.key) item.setChecked(true);
- item.onClick(async () => {
- this.currentSortMode = m.key;
- this.plugin.settings.sortMode = m.key;
- await this.plugin.saveSettings();
- await this.renderCards();
- });
- });
- });
- menu.addSeparator();
- menu.addItem(item => {
- item.setTitle(this.sortAscending ? 'Direction: Ascending' : 'Direction: Descending');
- item.onClick(async () => {
- this.sortAscending = !this.sortAscending;
- this.plugin.settings.sortAscending = this.sortAscending;
- await this.plugin.saveSettings();
- await this.renderCards();
- });
- });
- menu.showAtMouseEvent(e);
- });
-
- 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', () => {
- void (async () => {
- this.activeFilters.pinnedOnly = !this.activeFilters.pinnedOnly;
- await this.renderCards();
- })();
- });
-
- 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', () => {
- void (async () => {
- this.plugin.settings.verticalCardMode = !this.plugin.settings.verticalCardMode;
- await this.plugin.saveSettings();
- await flipAnimateAsync(this.cardsContainer, async () => {
- this.applyLayoutMode();
- }, {}, this.store.settings);
- })();
- });
-
- const moreBtn = buttonContainer.createEl('button');
- moreBtn.addClass('sc-icon-btn');
- try { setIcon(moreBtn, 'more-vertical'); } catch { moreBtn.textContent = '…'; }
- moreBtn.title = 'More';
- moreBtn.addEventListener('click', (e) => {
- const menu = new Menu();
- menu.addItem(item => {
- item.setTitle('Show tags')
- .setChecked(this.plugin.settings.sidebarShowTags ?? true)
- .onClick(async () => {
- const current = this.plugin.settings.sidebarShowTags ?? true;
- this.plugin.settings.sidebarShowTags = !current;
- await this.plugin.saveSettings();
- await this.renderCards();
- });
- });
- menu.addItem(item => {
- item.setTitle('Show timestamps')
- .setChecked(this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps)
- .onClick(async () => {
- const current = this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps;
- this.plugin.settings.sidebarShowTimestamps = !current;
- await this.plugin.saveSettings();
- await this.renderCards();
- });
- });
- menu.showAtMouseEvent(e);
- });
-
- const addButton = buttonContainer.createEl('button');
- addButton.addClass('sc-add-btn');
- addButton.textContent = 'Add card';
- addButton.addEventListener('click', () => {
- void (async () => {
- const content = editorEl.textContent?.trim();
- if (!content) return;
-
- // 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);
-
- // Strip @category and #tag tokens from the saved content
- const cleanContent = content.replace(/@[^\s#@,.]+/g, '').replace(/#[^\s#@,.]+/g, '').replace(/\s{2,}/g, ' ').trim();
-
- // Auto color
- const autoColor = resolveAutoColor(content, tags, this.plugin.settings);
- const color = autoColor || 'var(--card-color-1)';
-
- const card = new Card({
- content: cleanContent,
- tags,
- color,
- category: category === 'all' ? undefined : category
- });
- await this.store.add(card);
- editorEl.textContent = '';
- updatePlaceholder();
- })();
- });
- }
-
- private setupListeners(): void {
- this.eventBus.on('card:added', () => { void this.renderCards(); });
- this.eventBus.on('card:deleted', () => { void this.renderCards(); });
- this.eventBus.on('card:updated', () => { void this.renderCards(); });
- this.eventBus.on('filter:tag', (tag) => {
- if (this.activeFilters.tags.includes(tag)) {
- this.activeFilters.tags = this.activeFilters.tags.filter(t => t !== tag);
- } else {
- this.activeFilters.tags.push(tag);
- }
- void this.renderCards();
- });
-
- this.eventBus.on('card:contextmenu', ({ card, event }) => {
- const menu = new Menu();
- menu.addItem(item => {
- item.setTitle('Delete')
- .setIcon('trash')
- .onClick(() => { void this.store.delete(card.id); });
- });
- menu.addItem(item => {
- item.setTitle(card.archived ? 'Unarchive' : 'Archive')
- .setIcon('archive')
- .onClick(() => { void this.store.update(card.id, { archived: !card.archived }); });
- });
- menu.showAtMouseEvent(event);
- });
- }
-
- private renderTimeout: ReturnType | null = null;
- private renderCardsDebounced(): void {
- if (this.renderTimeout) clearTimeout(this.renderTimeout);
- this.renderTimeout = setTimeout(() => { void this.renderCards(); }, 300);
- }
-
- private async renderCards(isManualReload = false): Promise {
- const settings = { ...this.store.settings };
- const scrollTop = this.cardsContainer?.scrollTop || 0;
-
- await flipAnimateAsync(this.cardsContainer, async () => {
- // Clear existing components
- this.cardComponents.forEach(comp => comp.destroy());
- this.cardComponents.clear();
- this.cardsContainer.empty();
-
- // Filter and sort
- let cards = this.store.getAll();
- cards = this.filterService.filter(cards, this.activeFilters);
- cards = this.sortService.sort(cards, this.currentSortMode, this.sortAscending, this.app);
-
- // Render each card
- for (const card of cards) {
- const comp = new CardComponent(this.cardsContainer, card, this.store, this.app, this.plugin, {
- groupTags: this.plugin.settings.sidebarGroupTags ?? this.plugin.settings.groupTags,
- showTimestamps: this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps,
- showTags: this.plugin.settings.sidebarShowTags ?? true,
- });
- this.cardComponents.set(card.id, comp);
- }
-
- // 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);
-
- // Re-attach drag-to-reorder (cleans up previous listeners first)
- this.dragDropCleanup?.();
- this.dragDropCleanup = attachDragToReorder(
- this.cardsContainer,
- this.plugin,
- () => this.currentSortMode,
- async () => {
- await this.plugin.saveSettings();
- },
- () => { this.refreshMasonrySpans(); }
- );
- }
-
- onClose() {
- return Promise.resolve();
- }
-
- private applyLayoutMode(): void {
- if (!this.cardsContainer) return;
- const grid = !!this.plugin.settings.verticalCardMode;
- if (grid) {
- this.cardsContainer.addClass('grid-mode');
- this.cardsContainer.addClass('vertical-card-mode');
- this.setupMasonryObserver();
- this.refreshMasonrySpans();
- } else {
- this.cardsContainer.removeClass('grid-mode');
- this.cardsContainer.removeClass('vertical-card-mode');
- if (this._masonryObserver) {
- this._masonryObserver.disconnect();
- this._masonryObserver = null;
- }
- if (this._masonryMutationObserver) {
- this._masonryMutationObserver.disconnect();
- this._masonryMutationObserver = null;
- }
- this.cardsContainer.querySelectorAll('.sc-card').forEach((el) => {
- (el as HTMLElement).style.removeProperty('grid-row-end');
- });
- }
- }
-
- private setupMasonryObserver(): void {
- if (!this.cardsContainer) return;
- if (typeof ResizeObserver === 'undefined') {
- this.setupMasonryMutationObserver();
- return;
- }
- if (this._masonryObserver) this._masonryObserver.disconnect();
- this._masonryObserver = new ResizeObserver(() => {
- if (this._masonryTimeout) clearTimeout(this._masonryTimeout);
- this._masonryTimeout = setTimeout(() => this.refreshMasonrySpans(), 50);
- });
- this._masonryObserver.observe(this.cardsContainer);
- this.cardsContainer.querySelectorAll('.sc-card').forEach(el => {
- this._masonryObserver?.observe(el);
- });
- this.setupMasonryMutationObserver();
- }
-
- private refreshMasonrySpans(): void {
- 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.removeProperty('grid-row-end');
- const h = card.getBoundingClientRect().height;
- if (h > 0) {
- const span = Math.max(1, Math.ceil(h + 8));
- card.style.setProperty('grid-row-end', `span ${span}`);
- }
- });
- }
-
- private setupMasonryMutationObserver(): void {
- if (!this.cardsContainer || typeof MutationObserver === 'undefined') return;
- if (this._masonryMutationObserver) this._masonryMutationObserver.disconnect();
- let recalcTimeout: ReturnType | null = null;
- const debounced = () => {
- if (recalcTimeout) clearTimeout(recalcTimeout);
- recalcTimeout = setTimeout(() => this.refreshMasonrySpans(), 120);
- };
- this._masonryMutationObserver = new MutationObserver((mutations) => {
- for (const mutation of mutations) {
- if (
- mutation.type === 'childList' ||
- mutation.type === 'characterData' ||
- (mutation.type === 'attributes' && mutation.attributeName === 'class')
- ) {
- debounced();
- break;
- }
- }
- });
- this._masonryMutationObserver.observe(this.cardsContainer, {
- childList: true,
- subtree: true,
- characterData: true,
- attributes: true,
- attributeFilter: ['class']
- });
- }
-}
+
+import { ItemView, WorkspaceLeaf, Notice, Menu, TFile, setIcon, Scope, Editor, App } from "obsidian";
+import { CardStore } from "../services/CardStore";
+import { FilterService, FilterOptions } from "../services/FilterService";
+import { SortService, SortMode } from "../services/SortService";
+import { EventBus } from "../core/EventBus";
+import { CardComponent } from "./components/Card";
+import { flipAnimateAsync } from "../utils/animations";
+import { Card } from "../models/Card";
+import SideCardsPlugin from "../core/Plugin";
+import { InlineAutocomplete } from "./components/InlineAutocomplete";
+import { resolveAutoColor } from "../utils/dom";
+import { attachDragToReorder } from "../utils/drag-drop";
+
+interface AppWithInternals extends App {
+ keymap: { pushScope: (scope: Scope) => void; popScope: (scope: Scope) => void };
+ workspace: App['workspace'] & { activeEditor: { editor: Editor; editMode: boolean } | null };
+}
+
+export class CardSidebarView extends ItemView {
+ private cardsContainer!: HTMLElement;
+ private cardComponents: Map = new Map();
+ private activeFilters: FilterOptions = { query: '', tags: [] };
+ private currentSortMode: SortMode = 'manual';
+ private sortAscending: boolean = true;
+ private editorScope: Scope;
+ private editor!: Editor;
+ private owner!: { editor: Editor; editMode: boolean };
+ private dragDropCleanup: (() => void) | null = null;
+
+ constructor(
+ leaf: WorkspaceLeaf,
+ private plugin: SideCardsPlugin,
+ private store: CardStore,
+ private eventBus: EventBus,
+ private filterService: FilterService,
+ private sortService: SortService
+ ) {
+ super(leaf);
+ this.editorScope = new Scope(this.app.scope);
+ this.setupMockEditor();
+ }
+
+ private setupMockEditor() {
+ this.editor = {
+ getSelection: () => {
+ const sel = window.getSelection();
+ if (!sel || !sel.rangeCount) return "";
+ const selectedText = sel.toString();
+ if (selectedText.length > 0) return selectedText;
+ const wordRange = this.getWordRangeAtCaret(sel);
+ return wordRange ? wordRange.toString() : "";
+ },
+ replaceSelection: (text: string) => {
+ const sel = window.getSelection();
+ if (!sel || !sel.rangeCount) return;
+ const currentRange = sel.getRangeAt(0);
+ const range = currentRange.collapsed
+ ? (this.getWordRangeAtCaret(sel) || currentRange)
+ : currentRange;
+ 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("*"),
+ toggleHighlight: () => this.toggleMarkdownWrapper("=="),
+ toggleComment: () => this.toggleMarkdownWrapper("%%", "%%", true),
+ } as unknown as Editor;
+
+ this.owner = {
+ editor: this.editor,
+ editMode: true,
+ };
+ }
+
+ private isWordChar(char: string): boolean {
+ return /[A-Za-z0-9_]/.test(char);
+ }
+
+ private getWordRangeAtCaret(selection: Selection): Range | null {
+ if (!selection.rangeCount) return null;
+ const baseRange = selection.getRangeAt(0);
+ if (!baseRange.collapsed) return baseRange;
+ const node = baseRange.startContainer;
+ if (!(node instanceof Text)) return null;
+ const text = node.data;
+ if (!text) return null;
+ const offset = baseRange.startOffset;
+ const leftChar = offset > 0 ? text[offset - 1] : "";
+ const rightChar = offset < text.length ? text[offset] : "";
+ if (!this.isWordChar(leftChar) && !this.isWordChar(rightChar)) return null;
+ let start = offset;
+ let end = offset;
+ while (start > 0 && this.isWordChar(text[start - 1])) start--;
+ while (end < text.length && this.isWordChar(text[end])) end++;
+ const wordRange = document.createRange();
+ wordRange.setStart(node, start);
+ wordRange.setEnd(node, end);
+ return wordRange;
+ }
+
+ private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==" | "%%", closeWrapper?: string, includeInnerPadding = false) {
+ const sel = window.getSelection();
+ if (!sel || !sel.rangeCount) return;
+ const currentRange = sel.getRangeAt(0);
+ const range = currentRange.collapsed
+ ? (this.getWordRangeAtCaret(sel) || currentRange)
+ : currentRange;
+ const selectedText = range.toString();
+ const endWrapper = closeWrapper ?? wrapper;
+ if (selectedText.length === 0) {
+ const text = wrapper + endWrapper;
+ const node = document.createTextNode(text);
+ range.insertNode(node);
+ const cursorOffset = wrapper.length;
+ range.setStart(node, cursorOffset);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ return;
+ }
+ const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(endWrapper);
+ const newText = alreadyWrapped
+ ? selectedText.slice(wrapper.length, selectedText.length - endWrapper.length)
+ : includeInnerPadding
+ ? `${wrapper} ${selectedText} ${endWrapper}`
+ : `${wrapper}${selectedText}${endWrapper}`;
+ sel.removeAllRanges();
+ sel.addRange(range);
+ this.editor.replaceSelection(newText);
+ }
+
+ private applySelectionWrapShortcut(event: KeyboardEvent, root: HTMLElement): boolean {
+ if (this.plugin.settings.autoPairBrackets === false) return false;
+ if (event.ctrlKey || event.metaKey || event.altKey) return false;
+ const wrapMap: Record = {
+ "[": ["[", "]"],
+ "(": ["(", ")"],
+ "{": ["{", "}"],
+ "`": ["`", "`"],
+ "%": ["%", "%"],
+ "=": ["=", "="],
+ '"': ['"', '"'],
+ };
+ const pair = wrapMap[event.key];
+ if (!pair) return false;
+ const sel = window.getSelection();
+ if (!sel || !sel.rangeCount) return false;
+ const range = sel.getRangeAt(0);
+ if (!root.contains(range.commonAncestorContainer)) return false;
+
+ const selectedText = range.toString();
+ const [open, close] = pair;
+ let newText = `${open}${selectedText}${close}`;
+
+ // Upgrade logic
+ if (event.key === "%") {
+ if (selectedText.startsWith("%") && selectedText.endsWith("%") && !selectedText.startsWith("%%")) {
+ const inner = selectedText.slice(1, -1).trim();
+ newText = `%% ${inner} %%`;
+ }
+ } else if (event.key === "=") {
+ if (selectedText.startsWith("=") && selectedText.endsWith("=") && !selectedText.startsWith("==")) {
+ const inner = selectedText.slice(1, -1);
+ newText = `==${inner}==`;
+ }
+ } else if (event.key === "[") {
+ if (selectedText.startsWith("[") && selectedText.endsWith("]") && !selectedText.startsWith("[[")) {
+ const inner = selectedText.slice(1, -1);
+ newText = `[[${inner}]]`;
+ }
+ }
+
+ event.preventDefault();
+ range.deleteContents();
+ const node = document.createTextNode(newText);
+ range.insertNode(node);
+
+ const newRange = document.createRange();
+ if (selectedText.length === 0) {
+ newRange.setStart(node, open.length);
+ newRange.collapse(true);
+ } else {
+ newRange.selectNode(node);
+ }
+ sel.removeAllRanges();
+ sel.addRange(newRange);
+ return true;
+ }
+
+ private getEffectiveHotkeys(commandId: string): Array<{ modifiers?: string[]; key?: string }> {
+ const appAny = this.app as unknown as { hotkeyManager?: { getHotkeys?: (id: string) => Array<{ modifiers?: string[]; key?: string }>; customKeys?: Record> }; commands?: { commands?: Record }> } };
+ const fromManager = appAny.hotkeyManager?.getHotkeys?.(commandId);
+ if (Array.isArray(fromManager) && fromManager.length > 0) return fromManager;
+ const custom = appAny.hotkeyManager?.customKeys?.[commandId];
+ if (Array.isArray(custom) && custom.length > 0) return custom;
+ const defaults = appAny.commands?.commands?.[commandId]?.hotkeys;
+ if (Array.isArray(defaults) && defaults.length > 0) return defaults;
+ return [];
+ }
+
+ private getFormattingCommandIds(kind: "bold" | "italic" | "highlight" | "comment"): string[] {
+ const defaults: Record<"bold" | "italic" | "highlight" | "comment", string[]> = {
+ bold: ["editor:toggle-bold"],
+ italic: ["editor:toggle-italic", "editor:toggle-emphasis"],
+ highlight: ["editor:toggle-highlight"],
+ comment: ["editor:toggle-comment"],
+ };
+ const appAny = this.app as unknown as { commands?: { commands?: Record } };
+ const commands = appAny.commands?.commands || {};
+ const matcher: Record<"bold" | "italic" | "highlight" | "comment", RegExp> = {
+ bold: /bold/i,
+ italic: /italic|emphasis/i,
+ highlight: /highlight/i,
+ comment: /comment/i,
+ };
+ const discovered = Object.values(commands)
+ .filter((cmd) => typeof cmd?.id === "string" && cmd.id.startsWith("editor:"))
+ .filter((cmd) => matcher[kind].test(String(cmd?.name || "")))
+ .map((cmd) => String(cmd.id));
+ return Array.from(new Set([...defaults[kind], ...discovered]));
+ }
+
+ private eventMatchesHotkey(event: KeyboardEvent, hotkey: { modifiers?: string[]; key?: string }): boolean {
+ const key = String(hotkey?.key || "").toLowerCase();
+ if (!key) return false;
+ const eventKey = String(event.key || "").toLowerCase();
+ if (eventKey !== key) return false;
+ const modifierSet = new Set((hotkey.modifiers || []).map(m => String(m).toLowerCase()));
+ const hasMod = modifierSet.has("mod");
+ const expectsCtrl = hasMod || modifierSet.has("ctrl");
+ const expectsMeta = hasMod || modifierSet.has("meta");
+ const expectsAlt = modifierSet.has("alt");
+ const expectsShift = modifierSet.has("shift");
+ if (expectsCtrl !== event.ctrlKey) return false;
+ if (expectsMeta !== event.metaKey) return false;
+ if (expectsAlt !== event.altKey) return false;
+ if (expectsShift !== event.shiftKey) return false;
+ return true;
+ }
+
+ private applyFormattingHotkey(event: KeyboardEvent, root: HTMLElement): boolean {
+ const sel = window.getSelection();
+ if (!sel || !sel.rangeCount) return false;
+ const range = sel.getRangeAt(0);
+ if (!root.contains(range.commonAncestorContainer)) return false;
+ const targets: Array<{ kind: "bold" | "italic" | "highlight" | "comment"; run: () => void }> = [
+ { kind: "bold", run: () => this.toggleMarkdownWrapper("**") },
+ { kind: "italic", run: () => this.toggleMarkdownWrapper("*") },
+ { kind: "highlight", run: () => this.toggleMarkdownWrapper("==") },
+ { kind: "comment", run: () => this.toggleMarkdownWrapper("%%", "%%", true) },
+ ];
+ for (const target of targets) {
+ const commandIds = this.getFormattingCommandIds(target.kind);
+ const hotkeys = commandIds.flatMap(id => this.getEffectiveHotkeys(id));
+ if (!hotkeys.length) continue;
+ if (!hotkeys.some(h => this.eventMatchesHotkey(event, h))) continue;
+ event.preventDefault();
+ event.stopPropagation();
+ target.run();
+ return true;
+ }
+ return false;
+ }
+
+ getViewType(): string {
+ return 'card-sidebar';
+ }
+
+ getDisplayText(): string {
+ return 'Card sidebar';
+ }
+
+ getIcon(): string {
+ return 'rectangle-horizontal';
+ }
+
+ private _loadInProgress = false;
+ private _loadingEl: HTMLElement | null = null;
+ private _loadingTimeout: ReturnType | null = null;
+ private _masonryObserver: ResizeObserver | null = null;
+ private _masonryMutationObserver: MutationObserver | null = null;
+ private _masonryTimeout: ReturnType | null = null;
+
+ async onOpen(): Promise {
+ const container = this.containerEl;
+ container.empty();
+ container.addClass('sc-sidebar-container');
+ this.currentSortMode = (this.plugin.settings.sortMode || 'manual') as SortMode;
+ this.sortAscending = typeof this.plugin.settings.sortAscending === 'boolean'
+ ? !!this.plugin.settings.sortAscending
+ : true;
+
+ const mainContainer = container.createDiv('sc-sidebar-main');
+
+ this.createHeader(mainContainer);
+ this.createSearchBar(mainContainer);
+
+ this.cardsContainer = mainContainer.createDiv('sc-sidebar-cards-container');
+
+ // Apply scrollbar visibility
+ this.applyScrollbarSetting();
+
+ this.createInputBox(mainContainer);
+
+ this.setupListeners();
+ this.setupPositionDetection();
+ this.setupLayoutObservers();
+ this.registerVaultEvents();
+
+ // Apply openCategoryOnLoad
+ const openOn = this.plugin.settings.openCategoryOnLoad || 'all';
+ const btn = mainContainer.querySelector(`.sc-category-btn[data-filter-value="${openOn}"]`);
+ if (btn instanceof HTMLElement) btn.click();
+
+ this.showLoadingOverlay();
+ 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('sc-sidebar-loading');
+
+ const box = this._loadingEl.createDiv('sc-sidebar-loading-inner');
+
+ const spinner = box.createDiv('sc-sidebar-spinner');
+ // Inline animation fallback (CSS @keyframes preferred but this works without it)
+ spinner.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity });
+
+ box.createDiv({ text: 'Loading cards...', cls: 'sc-sidebar-loading-text' });
+
+ this._loadingTimeout = setTimeout(() => this.hideLoadingOverlay(), maxMs);
+ }
+
+ private hideLoadingOverlay(fadeMs = 0) {
+ if (this._loadingTimeout) {
+ clearTimeout(this._loadingTimeout);
+ this._loadingTimeout = null;
+ }
+ if (this._loadingEl) {
+ this._loadingEl.remove();
+ this._loadingEl = null;
+ }
+ }
+
+ private setupPositionDetection() {
+ const detect = () => {
+ let position = 'right';
+ let current = this.containerEl;
+ let depth = 0;
+ while (current && depth < 10) {
+ const cls = current.className || '';
+ if (cls.includes('side-dock-left')) { position = 'left'; break; }
+ if (cls.includes('side-dock-right')) { position = 'right'; break; }
+ current = current.parentElement as HTMLElement;
+ depth++;
+ }
+ if (this.plugin.settings.sidebarPosition !== position) {
+ this.plugin.settings.sidebarPosition = position;
+ void this.plugin.saveSettings();
+ }
+ };
+ detect();
+ const observer = new MutationObserver(detect);
+ if (this.containerEl.parentElement) {
+ observer.observe(this.containerEl.parentElement, { attributes: true, attributeFilter: ['class'] });
+ }
+ }
+
+ private setupLayoutObservers() {
+ if (typeof ResizeObserver !== 'undefined') {
+ const ro = new ResizeObserver(() => {
+ // Refresh masonry or layout if needed
+ });
+ ro.observe(this.cardsContainer);
+ }
+ }
+
+ private registerVaultEvents() {
+ this.plugin.registerEvent(this.app.vault.on('modify', async (file) => {
+ if (!(file instanceof TFile)) return;
+ // Skip if we're the ones writing this file (prevents expiry/color wipe race)
+ if (this.store._syncingPaths?.has(file.path)) return;
+ const pending = this.store._pendingTagWrites.get(file.path);
+ if (pending) {
+ await this.store.handlePendingTagReapply(file, pending);
+ } else {
+ await this.store.updateCardFromNotePath(file.path);
+ }
+ }));
+ }
+
+ private createHeader(container: HTMLElement): void {
+ if (this.plugin.settings.disableFilterButtons) return;
+
+ const header = container.createDiv('sc-sidebar-header');
+ const filterGroup = header.createDiv('sc-category-group');
+
+ const settings = this.plugin.settings;
+ const cats: Array<{ id: string; label: string; showInMenu?: boolean }> =
+ Array.isArray(settings.customCategories) ? settings.customCategories : [];
+
+ // Build ordered chip list using allItemsOrder if available
+ const defaultOrder = ['filter-all']
+ .concat(!settings.hideTodayFilter ? ['filter-today'] : [])
+ .concat(!settings.hideTomorrowFilter ? ['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.hideTodayFilter) chips.push({ type: 'category', label: 'Today', value: 'today' });
+ return;
+ }
+ if (itemId === 'filter-tomorrow') {
+ if (!settings.hideTomorrowFilter) 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('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.style.setProperty('--sc-btn-text-color', customColors.textColor);
+ }
+
+ btn.addEventListener('click', () => {
+ void (async () => {
+ 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');
+ (b as HTMLElement).style.setProperty('--sc-btn-text-color', bColors.textColor);
+ } else {
+ (b as HTMLElement).style.removeProperty('color');
+ (b as HTMLElement).style.removeProperty('--sc-btn-text-color');
+ }
+ });
+ btn.addClass('active');
+ if (chip.type === 'archived') {
+ this.activeFilters.archivedOnly = true;
+ this.activeFilters.category = undefined;
+ } else if (chip.type === 'all') {
+ this.activeFilters.archivedOnly = false;
+ this.activeFilters.category = undefined;
+ } else if (chip.type === 'category') {
+ this.activeFilters.archivedOnly = false;
+ this.activeFilters.category = chip.value;
+ }
+ await this.renderCards();
+ })();
+ });
+ });
+ }
+
+ 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');
+ }
+ }
+
+ private createSearchBar(container: HTMLElement): void {
+ const wrapper = container.createDiv('sc-search-wrap');
+ wrapper.toggleClass('sc-search-wrap--hidden', !this.plugin.settings.searchBarVisible);
+ const row = wrapper.createDiv('sc-search-row');
+
+ const iconSpan = row.createSpan({ cls: 'sc-search-input-icon' });
+ try { setIcon(iconSpan, 'search'); } catch { /* icon may not exist */ }
+
+ 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.toggleClass('sc-search-wrap--hidden', true);
+ clearBtn.title = 'Clear search';
+ clearBtn.addEventListener('click', () => {
+ input.value = '';
+ this.activeFilters.query = '';
+ this.renderCardsDebounced();
+ clearBtn.toggleClass('sc-search-wrap--hidden', true);
+ input.focus();
+ });
+
+ input.addEventListener('input', () => {
+ this.activeFilters.query = input.value || '';
+ clearBtn.toggleClass('sc-search-wrap--hidden', !this.activeFilters.query);
+ this.renderCardsDebounced();
+ });
+ }
+
+ private createInputBox(container: HTMLElement): void {
+ const inputContainer = container.createDiv('sc-sidebar-input-container');
+
+ const editorEl = inputContainer.createDiv({ cls: 'sc-sidebar-input' });
+
+ editorEl.setAttribute('contenteditable', 'true');
+ editorEl.dataset.placeholder = 'Type here... (@category, #tag)';
+ const updatePlaceholder = () => {
+ if (!editorEl.textContent?.trim()) {
+ editorEl.addClass('is-empty');
+ } else {
+ editorEl.removeClass('is-empty');
+ }
+ };
+ updatePlaceholder();
+ editorEl.addEventListener('input', updatePlaceholder);
+
+ editorEl.addEventListener('focusin', () => {
+ (this.app as unknown as AppWithInternals).keymap.pushScope(this.editorScope);
+ (this.app as unknown as AppWithInternals).workspace.activeEditor = this.owner as any;
+ });
+
+ editorEl.addEventListener('blur', () => {
+ (this.app as unknown as AppWithInternals).keymap.popScope(this.editorScope);
+ if ((this.app as unknown as AppWithInternals).workspace.activeEditor === this.owner) {
+ (this.app as unknown as AppWithInternals).workspace.activeEditor = null;
+ }
+ });
+
+ // @category / #tag / [[file]] inline autocomplete
+ new InlineAutocomplete(editorEl, this.store, this.app);
+
+ editorEl.addEventListener('keydown', (e) => {
+ if (this.applyFormattingHotkey(e, editorEl)) return;
+ this.applySelectionWrapShortcut(e, editorEl);
+
+ // Save key handling
+ const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s+_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
+ const saveKey = normalizeKey(this.plugin.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.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey)) {
+ e.preventDefault();
+ addButton.click();
+ }
+ });
+
+ const buttonContainer = inputContainer.createDiv('sc-sidebar-button-container');
+
+ 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('.sc-search-wrap');
+ if (wrap) {
+ const isHidden = wrap.hasClass('sc-search-wrap--hidden');
+ wrap.toggleClass('sc-search-wrap--hidden', !isHidden);
+ this.plugin.settings.searchBarVisible = isHidden;
+ void 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', () => {
+ void (async () => {
+ 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', (e) => {
+ const menu = new Menu();
+ const modes: Array<{ key: SortMode; label: string }> = [
+ { key: 'manual', label: 'Manual' },
+ { key: 'created', label: 'Created Time' },
+ { key: 'modified', label: 'Modified Time' },
+ { key: 'alpha', label: 'A → Z' },
+ { key: 'status', label: 'Status' }
+ ];
+ modes.forEach(m => {
+ menu.addItem(item => {
+ item.setTitle(m.label);
+ if (this.currentSortMode === m.key) item.setChecked(true);
+ item.onClick(async () => {
+ this.currentSortMode = m.key;
+ this.plugin.settings.sortMode = m.key;
+ await this.plugin.saveSettings();
+ await this.renderCards();
+ });
+ });
+ });
+ menu.addSeparator();
+ menu.addItem(item => {
+ item.setTitle(this.sortAscending ? 'Direction: Ascending' : 'Direction: Descending');
+ item.onClick(async () => {
+ this.sortAscending = !this.sortAscending;
+ this.plugin.settings.sortAscending = this.sortAscending;
+ await this.plugin.saveSettings();
+ await this.renderCards();
+ });
+ });
+ menu.showAtMouseEvent(e);
+ });
+
+ 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', () => {
+ void (async () => {
+ this.activeFilters.pinnedOnly = !this.activeFilters.pinnedOnly;
+ await this.renderCards();
+ })();
+ });
+
+ 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', () => {
+ void (async () => {
+ this.plugin.settings.verticalCardMode = !this.plugin.settings.verticalCardMode;
+ await this.plugin.saveSettings();
+ await flipAnimateAsync(this.cardsContainer, async () => {
+ this.applyLayoutMode();
+ }, {}, this.store.settings);
+ })();
+ });
+
+ const moreBtn = buttonContainer.createEl('button');
+ moreBtn.addClass('sc-icon-btn');
+ try { setIcon(moreBtn, 'more-vertical'); } catch { moreBtn.textContent = '…'; }
+ moreBtn.title = 'More';
+ moreBtn.addEventListener('click', (e) => {
+ const menu = new Menu();
+ menu.addItem(item => {
+ item.setTitle('Show tags')
+ .setChecked(this.plugin.settings.sidebarShowTags ?? true)
+ .onClick(async () => {
+ const current = this.plugin.settings.sidebarShowTags ?? true;
+ this.plugin.settings.sidebarShowTags = !current;
+ await this.plugin.saveSettings();
+ await this.renderCards();
+ });
+ });
+ menu.addItem(item => {
+ item.setTitle('Show timestamps')
+ .setChecked(this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps)
+ .onClick(async () => {
+ const current = this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps;
+ this.plugin.settings.sidebarShowTimestamps = !current;
+ await this.plugin.saveSettings();
+ await this.renderCards();
+ });
+ });
+ menu.showAtMouseEvent(e);
+ });
+
+ const addButton = buttonContainer.createEl('button');
+ addButton.addClass('sc-add-btn');
+ addButton.textContent = 'Add card';
+ addButton.addEventListener('click', () => {
+ void (async () => {
+ const content = editorEl.textContent?.trim();
+ if (!content) return;
+
+ // 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);
+
+ // Strip @category and #tag tokens from the saved content
+ const cleanContent = content.replace(/@[^\s#@,.]+/g, '').replace(/#[^\s#@,.]+/g, '').replace(/\s{2,}/g, ' ').trim();
+
+ // Auto color
+ const autoColor = resolveAutoColor(content, tags, this.plugin.settings);
+ const color = autoColor || 'var(--card-color-1)';
+
+ const card = new Card({
+ content: cleanContent,
+ tags,
+ color,
+ category: category === 'all' ? undefined : category
+ });
+ await this.store.add(card);
+ editorEl.textContent = '';
+ updatePlaceholder();
+ })();
+ });
+ }
+
+ private setupListeners(): void {
+ this.eventBus.on('card:added', () => { void this.renderCards(); });
+ this.eventBus.on('card:deleted', () => { void this.renderCards(); });
+ this.eventBus.on('card:updated', () => { void this.renderCards(); });
+ this.eventBus.on('filter:tag', (tag) => {
+ if (this.activeFilters.tags.includes(tag)) {
+ this.activeFilters.tags = this.activeFilters.tags.filter(t => t !== tag);
+ } else {
+ this.activeFilters.tags.push(tag);
+ }
+ void this.renderCards();
+ });
+
+ this.eventBus.on('card:contextmenu', ({ card, event }) => {
+ const menu = new Menu();
+ menu.addItem(item => {
+ item.setTitle('Delete')
+ .setIcon('trash')
+ .onClick(() => { void this.store.delete(card.id); });
+ });
+ menu.addItem(item => {
+ item.setTitle(card.archived ? 'Unarchive' : 'Archive')
+ .setIcon('archive')
+ .onClick(() => { void this.store.update(card.id, { archived: !card.archived }); });
+ });
+ menu.showAtMouseEvent(event);
+ });
+ }
+
+ private renderTimeout: ReturnType | null = null;
+ private renderCardsDebounced(): void {
+ if (this.renderTimeout) clearTimeout(this.renderTimeout);
+ this.renderTimeout = setTimeout(() => { void this.renderCards(); }, 300);
+ }
+
+ private async renderCards(isManualReload = false): Promise {
+ const settings = { ...this.store.settings };
+ const scrollTop = this.cardsContainer?.scrollTop || 0;
+
+ await flipAnimateAsync(this.cardsContainer, async () => {
+ // Clear existing components
+ this.cardComponents.forEach(comp => comp.destroy());
+ this.cardComponents.clear();
+ this.cardsContainer.empty();
+
+ // Filter and sort
+ let cards = this.store.getAll();
+ cards = this.filterService.filter(cards, this.activeFilters);
+ cards = this.sortService.sort(cards, this.currentSortMode, this.sortAscending, this.app);
+
+ // Render each card
+ for (const card of cards) {
+ const comp = new CardComponent(this.cardsContainer, card, this.store, this.app, this.plugin, {
+ groupTags: this.plugin.settings.sidebarGroupTags ?? this.plugin.settings.groupTags,
+ showTimestamps: this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps,
+ showTags: this.plugin.settings.sidebarShowTags ?? true,
+ });
+ this.cardComponents.set(card.id, comp);
+ }
+
+ // 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);
+
+ // Re-attach drag-to-reorder (cleans up previous listeners first)
+ this.dragDropCleanup?.();
+ this.dragDropCleanup = attachDragToReorder(
+ this.cardsContainer,
+ this.plugin,
+ () => this.currentSortMode,
+ async () => {
+ await this.plugin.saveSettings();
+ },
+ () => { this.refreshMasonrySpans(); }
+ );
+ }
+
+ onClose() {
+ return Promise.resolve();
+ }
+
+ private applyLayoutMode(): void {
+ if (!this.cardsContainer) return;
+ const grid = !!this.plugin.settings.verticalCardMode;
+ if (grid) {
+ this.cardsContainer.addClass('grid-mode');
+ this.cardsContainer.addClass('vertical-card-mode');
+ this.setupMasonryObserver();
+ this.refreshMasonrySpans();
+ } else {
+ this.cardsContainer.removeClass('grid-mode');
+ this.cardsContainer.removeClass('vertical-card-mode');
+ if (this._masonryObserver) {
+ this._masonryObserver.disconnect();
+ this._masonryObserver = null;
+ }
+ if (this._masonryMutationObserver) {
+ this._masonryMutationObserver.disconnect();
+ this._masonryMutationObserver = null;
+ }
+ this.cardsContainer.querySelectorAll('.sc-card').forEach((el) => {
+ (el as HTMLElement).style.removeProperty('grid-row-end');
+ });
+ }
+ }
+
+ private setupMasonryObserver(): void {
+ if (!this.cardsContainer) return;
+ if (typeof ResizeObserver === 'undefined') {
+ this.setupMasonryMutationObserver();
+ return;
+ }
+ if (this._masonryObserver) this._masonryObserver.disconnect();
+ this._masonryObserver = new ResizeObserver(() => {
+ if (this._masonryTimeout) clearTimeout(this._masonryTimeout);
+ this._masonryTimeout = setTimeout(() => this.refreshMasonrySpans(), 50);
+ });
+ this._masonryObserver.observe(this.cardsContainer);
+ this.cardsContainer.querySelectorAll('.sc-card').forEach(el => {
+ this._masonryObserver?.observe(el);
+ });
+ this.setupMasonryMutationObserver();
+ }
+
+ private refreshMasonrySpans(): void {
+ 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.removeProperty('grid-row-end');
+ const h = card.getBoundingClientRect().height;
+ if (h > 0) {
+ const span = Math.max(1, Math.ceil(h + 8));
+ card.style.setProperty('grid-row-end', `span ${span}`);
+ }
+ });
+ }
+
+ private setupMasonryMutationObserver(): void {
+ if (!this.cardsContainer || typeof MutationObserver === 'undefined') return;
+ if (this._masonryMutationObserver) this._masonryMutationObserver.disconnect();
+ let recalcTimeout: ReturnType | null = null;
+ const debounced = () => {
+ if (recalcTimeout) clearTimeout(recalcTimeout);
+ recalcTimeout = setTimeout(() => this.refreshMasonrySpans(), 120);
+ };
+ this._masonryMutationObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (
+ mutation.type === 'childList' ||
+ mutation.type === 'characterData' ||
+ (mutation.type === 'attributes' && mutation.attributeName === 'class')
+ ) {
+ debounced();
+ break;
+ }
+ }
+ });
+ this._masonryMutationObserver.observe(this.cardsContainer, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ attributes: true,
+ attributeFilter: ['class']
+ });
+ }
+}
diff --git a/src/views/HomeView.ts b/src/views/HomeView.ts
index b9688ba..c1867bb 100644
--- a/src/views/HomeView.ts
+++ b/src/views/HomeView.ts
@@ -1,1102 +1,1102 @@
-import { ItemView, WorkspaceLeaf, Menu, Notice, setIcon, Scope, Editor, TFile, Platform, App } 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";
-import { resolveAutoColor } from "../utils/dom";
-import { animateCardsEntrance } from "../utils/animations";
-import { InlineAutocomplete } from "./components/InlineAutocomplete";
-import { attachDragToReorder, attachPinnedListDragToReorder } from "../utils/drag-drop";
-
-interface AppWithInternals extends App {
- keymap: { pushScope: (scope: Scope) => void; popScope: (scope: Scope) => void };
- workspace: App['workspace'] & { activeEditor: { editor: Editor; editMode: boolean } | null };
-}
-
-type IconicIconEntry = { icon?: string; name?: string; value?: string; color?: string } | string;
-
-export class SideCardsHomeView extends ItemView {
- private selectedColor = 'var(--card-color-1)';
- private selectedTags: string[] = [];
- private filterType = '';
- private filterValue = '';
- private cardListEl: HTMLElement | null = null;
- private editorScope: Scope;
- private editor!: Editor;
- private owner!: { editor: Editor; editMode: boolean };
- private recentFiles: TFile[] = [];
- private pinnedFiles: TFile[] = [];
- private cardComponents: Map = new Map();
- private currentSortMode: SortMode = 'created';
- private sortAscending: boolean = false;
- private pinnedOnly: boolean = false;
- private dragDropCleanup: (() => void) | null = null;
- private currentSearchQuery: string = '';
- private iconicFileIconsCache: Record | null = null;
-
- constructor(
- leaf: WorkspaceLeaf,
- private plugin: SideCardsPlugin,
- private store: CardStore,
- private sortService: SortService
- ) {
- super(leaf);
- this.editorScope = new Scope(this.app.scope);
- this.setupMockEditor();
- }
-
- private setupMockEditor() {
- this.editor = {
- getSelection: () => {
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return "";
- const selectedText = sel.toString();
- if (selectedText.length > 0) return selectedText;
- const wordRange = this.getWordRangeAtCaret(sel);
- return wordRange ? wordRange.toString() : "";
- },
- replaceSelection: (text: string) => {
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return;
- const currentRange = sel.getRangeAt(0);
- const range = currentRange.collapsed
- ? (this.getWordRangeAtCaret(sel) || currentRange)
- : currentRange;
- 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("*"),
- toggleHighlight: () => this.toggleMarkdownWrapper("=="),
- toggleComment: () => this.toggleMarkdownWrapper("%%", "%%", true),
- } as unknown as Editor;
-
- this.owner = {
- editor: this.editor,
- editMode: true,
- };
- }
-
- private isWordChar(char: string): boolean {
- return /[A-Za-z0-9_]/.test(char);
- }
-
- private getWordRangeAtCaret(selection: Selection): Range | null {
- if (!selection.rangeCount) return null;
- const baseRange = selection.getRangeAt(0);
- if (!baseRange.collapsed) return baseRange;
- const node = baseRange.startContainer;
- if (!(node instanceof Text)) return null;
- const text = node.data;
- if (!text) return null;
- const offset = baseRange.startOffset;
- const leftChar = offset > 0 ? text[offset - 1] : "";
- const rightChar = offset < text.length ? text[offset] : "";
- if (!this.isWordChar(leftChar) && !this.isWordChar(rightChar)) return null;
- let start = offset;
- let end = offset;
- while (start > 0 && this.isWordChar(text[start - 1])) start--;
- while (end < text.length && this.isWordChar(text[end])) end++;
- const wordRange = document.createRange();
- wordRange.setStart(node, start);
- wordRange.setEnd(node, end);
- return wordRange;
- }
-
- private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==" | "%%", closeWrapper?: string, includeInnerPadding = false) {
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return;
- const currentRange = sel.getRangeAt(0);
- const range = currentRange.collapsed
- ? (this.getWordRangeAtCaret(sel) || currentRange)
- : currentRange;
- const selectedText = range.toString();
- const endWrapper = closeWrapper ?? wrapper;
- if (selectedText.length === 0) {
- const text = wrapper + endWrapper;
- const node = document.createTextNode(text);
- range.insertNode(node);
- const cursorOffset = wrapper.length;
- range.setStart(node, cursorOffset);
- range.collapse(true);
- sel.removeAllRanges();
- sel.addRange(range);
- return;
- }
- const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(endWrapper);
- const newText = alreadyWrapped
- ? selectedText.slice(wrapper.length, selectedText.length - endWrapper.length)
- : includeInnerPadding
- ? `${wrapper} ${selectedText} ${endWrapper}`
- : `${wrapper}${selectedText}${endWrapper}`;
- sel.removeAllRanges();
- sel.addRange(range);
- this.editor.replaceSelection(newText);
- }
-
- private handleKeyWrap(event: KeyboardEvent): { handled: boolean } {
- if (this.plugin.settings.autoPairBrackets === false) return { handled: false };
- if (event.ctrlKey || event.metaKey || event.altKey) return { handled: false };
-
- const key = event.key;
- const wrapMap: Record = {
- "[": ["[", "]"],
- "(": ["(", ")"],
- "{": ["{", "}"],
- "`": ["`", "`"],
- "%": ["%", "%"],
- "=": ["=", "="],
- '"': ['"', '"'],
- };
-
- const pair = wrapMap[key];
- if (!pair) return { handled: false };
-
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return { handled: false };
-
- const range = sel.getRangeAt(0);
- const editorEl = this.containerEl.querySelector('.sc-home-editor');
- if (!editorEl || !editorEl.contains(range.commonAncestorContainer)) return { handled: false };
-
- const selectedText = range.toString();
- const [open, close] = pair;
- let newText = `${open}${selectedText}${close}`;
-
- // Upgrade logic
- if (key === "%") {
- if (selectedText.startsWith("%") && selectedText.endsWith("%") && !selectedText.startsWith("%%")) {
- const inner = selectedText.slice(1, -1).trim();
- newText = `%% ${inner} %%`;
- }
- } else if (key === "=") {
- if (selectedText.startsWith("=") && selectedText.endsWith("=") && !selectedText.startsWith("==")) {
- const inner = selectedText.slice(1, -1);
- newText = `==${inner}==`;
- }
- } else if (key === "[") {
- if (selectedText.startsWith("[") && selectedText.endsWith("]") && !selectedText.startsWith("[[")) {
- const inner = selectedText.slice(1, -1);
- newText = `[[${inner}]]`;
- }
- }
-
- range.deleteContents();
- const node = document.createTextNode(newText);
- range.insertNode(node);
-
- const newRange = document.createRange();
- if (selectedText.length === 0) {
- // No selection: place cursor between the pair
- newRange.setStart(node, open.length);
- newRange.collapse(true);
- } else {
- // Had selection: select the wrapped text
- newRange.selectNode(node);
- }
- sel.removeAllRanges();
- sel.addRange(newRange);
-
- return { handled: true };
- }
-
- private getEffectiveHotkeys(commandId: string): Array<{ modifiers?: string[]; key?: string }> {
- const appAny = this.app as unknown as { hotkeyManager?: { getHotkeys?: (id: string) => Array<{ modifiers?: string[]; key?: string }>; customKeys?: Record> }; commands?: { commands?: Record }> } };
- const fromManager = appAny.hotkeyManager?.getHotkeys?.(commandId);
- if (Array.isArray(fromManager) && fromManager.length > 0) return fromManager;
- const custom = appAny.hotkeyManager?.customKeys?.[commandId];
- if (Array.isArray(custom) && custom.length > 0) return custom;
- const defaults = appAny.commands?.commands?.[commandId]?.hotkeys;
- if (Array.isArray(defaults) && defaults.length > 0) return defaults;
- return [];
- }
-
- private getFormattingCommandIds(kind: "bold" | "italic" | "highlight" | "comment"): string[] {
- const defaults: Record<"bold" | "italic" | "highlight" | "comment", string[]> = {
- bold: ["editor:toggle-bold", "custom-wrap-bold"],
- italic: ["editor:toggle-italic", "editor:toggle-emphasis", "custom-wrap-italic"],
- highlight: ["editor:toggle-highlight", "custom-wrap-highlight"],
- comment: ["editor:toggle-comment", "custom-wrap-comment"],
- };
- const appAny = this.app as unknown as { commands?: { commands?: Record } };
- const commands = appAny.commands?.commands || {};
- const matcher: Record<"bold" | "italic" | "highlight" | "comment", RegExp> = {
- bold: /bold/i,
- italic: /italic|emphasis/i,
- highlight: /highlight/i,
- comment: /comment/i,
- };
- const discovered = Object.values(commands)
- .filter((cmd) => typeof cmd?.id === "string" && cmd.id.startsWith("editor:"))
- .filter((cmd) => matcher[kind].test(String(cmd?.name || "")))
- .map((cmd) => String(cmd.id));
- return Array.from(new Set([...defaults[kind], ...discovered]));
- }
-
- private eventMatchesHotkey(event: KeyboardEvent, hotkey: { modifiers?: string[]; key?: string }): boolean {
- const key = String(hotkey?.key || "").toLowerCase();
- if (!key) return false;
- const eventKey = String(event.key || "").toLowerCase();
- if (eventKey !== key) return false;
- const modifierSet = new Set((hotkey.modifiers || []).map(m => String(m).toLowerCase()));
-
- const hasMod = modifierSet.has("mod");
- const expectsCtrl = modifierSet.has("ctrl") || (hasMod && !Platform.isMacOS);
- const expectsMeta = modifierSet.has("meta") || (hasMod && Platform.isMacOS);
- const expectsAlt = modifierSet.has("alt");
- const expectsShift = modifierSet.has("shift");
- if (expectsCtrl !== event.ctrlKey) return false;
- if (expectsMeta !== event.metaKey) return false;
- if (expectsAlt !== event.altKey) return false;
- if (expectsShift !== event.shiftKey) return false;
- return true;
- }
-
- private applyFormattingHotkey(event: KeyboardEvent, root: HTMLElement): boolean {
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount) return false;
- const range = sel.getRangeAt(0);
- if (!root.contains(range.commonAncestorContainer)) return false;
- const targets: Array<{ kind: "bold" | "italic" | "highlight" | "comment"; run: () => void }> = [
- { kind: "bold", run: () => this.toggleMarkdownWrapper("**") },
- { kind: "italic", run: () => this.toggleMarkdownWrapper("*") },
- { kind: "highlight", run: () => this.toggleMarkdownWrapper("==") },
- { kind: "comment", run: () => this.toggleMarkdownWrapper("%%", "%%", true) },
- ];
- for (const target of targets) {
- const commandIds = this.getFormattingCommandIds(target.kind);
- const hotkeys = commandIds.flatMap(id => this.getEffectiveHotkeys(id));
- if (!hotkeys.length) continue;
- if (!hotkeys.some(h => this.eventMatchesHotkey(event, h))) continue;
- event.preventDefault();
- event.stopPropagation();
- target.run();
- root.dispatchEvent(new Event('input'));
- return true;
- }
- return false;
- }
-
- getViewType(): string {
- return 'sidecards-home';
- }
-
- getDisplayText(): string {
- return 'Sidecards';
- }
-
- getIcon(): string {
- return 'home';
- }
-
- async onOpen(): Promise {
- const container = this.containerEl;
- container.empty();
- 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: 'sc-home-main' });
- // Apply user-configured max width via CSS variable so it overrides correctly
- const maxW = this.plugin.settings.homepageMaxWidth ?? 1000;
- container.style.setProperty('--sc-home-max-width', `${maxW}px`);
- const topMargin = this.plugin.settings.homepageTopMargin ?? 70;
- container.style.setProperty('--sc-home-top-margin', `${topMargin}px`);
-
- // Top Section
- const topSection = main.createDiv({ cls: 'sc-home-top' });
- topSection.createEl('h2', { text: this.plugin.settings.homepageName || 'SideCards', cls: 'sc-home-title' });
-
- const paletteRow = topSection.createDiv({ cls: 'sc-home-palette-row' });
-
- // Hide palette row entirely if both category and swatches are hidden
- if (this.plugin.settings.hideCategoryDropdown && this.plugin.settings.hideColorSwatches) {
- paletteRow.addClass('sc-hidden');
- }
-
- if (!this.plugin.settings.hideCategoryDropdown) {
- 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(() => {
- this.filterType = f.type;
- this.filterValue = f.value;
- categoryBtn.textContent = f.label;
- if (this.cardListEl) void this.renderCards(this.cardListEl);
- }));
- });
- menu.showAtMouseEvent(e);
- });
-
- const separator = paletteRow.createDiv({ cls: 'sc-home-separator' });
- separator.textContent = '|';
- }
-
- if (!this.plugin.settings.hideColorSwatches) {
-
- const colors = [
- { 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)' }
- ];
- const swatches: HTMLElement[] = [];
- colors.forEach((color) => {
- 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;
- if (this.selectedColor === color.var) swatch.addClass('is-selected');
-
- swatch.addEventListener('click', () => {
- swatches.forEach((s) => s.removeClass('is-selected'));
- swatch.addClass('is-selected');
- this.selectedColor = color.var;
- });
- swatches.push(swatch);
- });
- } // end hideColorSwatches check
-
- const editorEl = topSection.createDiv({ cls: 'sc-home-editor' });
- editorEl.setAttribute('contenteditable', 'true');
- editorEl.dataset.placeholder = 'Type here... (@category, #tag)';
-
- // @category / #tag / [[file]] inline autocomplete
- new InlineAutocomplete(editorEl, this.store, this.app);
-
- const updatePlaceholder = () => {
- if (!editorEl.textContent?.trim()) editorEl.addClass('is-empty');
- else editorEl.removeClass('is-empty');
- };
- updatePlaceholder();
- editorEl.addEventListener('input', updatePlaceholder);
- editorEl.addEventListener('focusin', () => {
- (this.app as unknown as AppWithInternals).keymap.pushScope(this.editorScope);
- (this.app as unknown as AppWithInternals).workspace.activeEditor = this.owner as any;
- });
- editorEl.addEventListener('blur', () => {
- (this.app as unknown as AppWithInternals).keymap.popScope(this.editorScope);
- if ((this.app as unknown as AppWithInternals).workspace.activeEditor === this.owner) (this.app as unknown as AppWithInternals).workspace.activeEditor = null;
- });
-
- // Keydown for input
- editorEl.addEventListener('keydown', (e) => {
- if (this.applyFormattingHotkey(e, editorEl)) return;
-
- const wrapResult = this.handleKeyWrap(e);
- if (wrapResult.handled) {
- e.preventDefault();
- e.stopPropagation();
- editorEl.dispatchEvent(new Event('input'));
- return;
- }
- 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';
- const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s+_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
- const saveKey = normalizeKey(this.plugin.settings.saveKey || 'enter');
- if (pressed === saveKey || (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey)) {
- e.preventDefault();
- void this.createCardFromHomeInput(editorEl, cardList);
- }
- });
-
- // Content Section (Two Columns)
- const contentGrid = main.createDiv({ cls: 'sc-home-grid' });
-
- const bothNotesHidden = this.plugin.settings.showPinnedNotes === false && this.plugin.settings.showRecentNotes === false;
- const notesOnRight = this.plugin.settings.notesPlacement === 'right';
-
- if (notesOnRight) contentGrid.addClass('notes-right');
-
- // Left Column: Notes
- const leftCol = contentGrid.createDiv({ cls: 'sc-home-left' });
- // Right Column: Cards
- const rightCol = contentGrid.createDiv({ cls: 'sc-home-right' });
-
- // Swap visual order via CSS order property (avoids grid width issues)
- if (notesOnRight) {
- leftCol.addClass('sc-home-left--right');
- rightCol.addClass('sc-home-right--left');
- }
-
- if (bothNotesHidden) {
- leftCol.addClass('sc-home-left--hidden');
- contentGrid.addClass('sc-home-grid--full');
- }
-
- let pinnedList: HTMLElement | null = null;
- let recentList: HTMLElement | null = null;
-
- if (this.plugin.settings.showPinnedNotes !== false) {
- leftCol.createEl('h3', { text: 'Pinned notes', cls: 'sc-home-section-title' });
- pinnedList = leftCol.createDiv({ cls: 'sc-home-file-list pinned' });
- await this.renderFileList(pinnedList, 'pinned');
- }
-
- if (this.plugin.settings.showRecentNotes !== false) {
- leftCol.createEl('h3', { text: 'Recent notes', cls: 'sc-home-section-title' });
- recentList = leftCol.createDiv({ cls: 'sc-home-file-list' });
- await this.renderFileList(recentList, 'recent');
- }
-
- const toolbar = rightCol.createDiv({ cls: 'sc-home-toolbar' });
- const cardList = rightCol.createDiv({ cls: 'sc-home-card-list' });
- this.cardListEl = cardList;
-
- // 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 ?? leftCol, recentList ?? leftCol);
-
- // Apply openCategoryOnLoad
- const openOn = this.plugin.settings.openCategoryOnLoad || 'all';
- const availableFilters = this.getAvailableFilters();
- const matchedFilter = availableFilters.find(f => f.value === openOn);
- if (matchedFilter) {
- this.filterType = matchedFilter.type;
- this.filterValue = matchedFilter.value;
- }
-
- this.renderCategoryBar(categoryBar, cardList);
-
- // Live-update when cards change
- const onCardChange = () => { void this.renderCards(cardList); };
- this.store.eventBus.on('card:added', onCardChange);
- this.store.eventBus.on('card:updated', onCardChange);
- this.store.eventBus.on('card:deleted', onCardChange);
-
- this.renderCards(cardList);
- }
-
- private async renderFileList(container: HTMLElement, type: 'recent' | 'pinned') {
- container.empty();
- const files = type === 'recent' ? this.getRecentFiles() : this.getPinnedFiles();
-
- if (files.length === 0) {
- container.createDiv({ text: `No ${type} notes found`, cls: 'sc-home-empty-msg' });
- return;
- }
-
- for (const file of files) {
- const item = container.createDiv({ cls: 'sc-home-file-item' });
- if (type === 'pinned') item.dataset.path = file.path;
- 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', () => {
- void this.app.workspace.getLeaf(false).openFile(file);
- });
-
- if (type === 'pinned') {
- item.addEventListener('contextmenu', (e) => {
- e.preventDefault();
- const menu = new Menu();
- menu.addItem(i => i
- /*eslint-disable-next-line obsidianmd/ui/sentence-case*/
- .setTitle('Unpin from SideCards').setIcon('pin-off').onClick(async () => {
- this.plugin.settings.pinnedNotes = (this.plugin.settings.pinnedNotes || []).filter(p => p !== file.path);
- await this.plugin.saveSettings();
- await this.renderFileList(container, 'pinned');
- }));
- menu.showAtMouseEvent(e);
- });
- }
- }
-
- if (type === 'pinned') {
- attachPinnedListDragToReorder(
- container,
- () => this.plugin.settings.pinnedNotes ?? [],
- async (newPaths) => {
- this.plugin.settings.pinnedNotes = newPaths;
- await this.plugin.saveSettings();
- await this.renderFileList(container, 'pinned');
- }
- );
- }
- }
-
- private async renderCustomFileIcon(iconEl: HTMLElement, file: TFile): Promise {
- const iconInfo = await this.getIconicFileIcon(file);
- if (!iconInfo) {
- try { setIcon(iconEl, 'file-text'); } catch { setIcon(iconEl, 'file'); }
- return;
- }
-
- const iconValue = String(iconInfo.icon || '').trim();
- if (!iconValue) {
- try { setIcon(iconEl, 'file-text'); } catch { setIcon(iconEl, 'file'); }
- return;
- }
-
- let rendered = false;
- const normalizedLucide = this.normalizeLucideIconName(iconValue);
- if (normalizedLucide) {
- try {
- setIcon(iconEl, normalizedLucide);
- rendered = true;
- } catch { /* icon not found, fall through */ }
- }
-
- if (!rendered && iconValue.includes('