Fixed Storage card import and embeds

This commit is contained in:
Kazi Aidah Haque 2026-03-24 16:45:28 +06:00
parent 20bec7cec6
commit b43826afc2
19 changed files with 4811 additions and 4650 deletions

36
LICENSE
View file

@ -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.

268
README.md
View file

@ -1,135 +1,135 @@
![SideCards Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&style=for-the-badge&label=downloads&labelColor=26233a&color=483699&query=%24%5B%22sidecards%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json) ![Stars](https://img.shields.io/github/stars/Kazi-Aidah/sidecards?style=for-the-badge&color=c4a7e7&labelColor=26233a) ![Version](https://img.shields.io/github/manifest-json/v/Kazi-Aidah/sidecards?style=for-the-badge&color=9ccfd8&labelColor=26233a) ![Last update](https://img.shields.io/github/last-commit/Kazi-Aidah/sidecards?style=for-the-badge&color=9fc387&labelColor=26233a)
# SideCards
Quickly create cards in your Obsidian sidebar; color, tag, and drag them straight into your notes.
<img alt="SideCards Banner" src="assets/SideCards-banner.png" />
---
## 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.
![Inline editing](assets/inline-editing.png)
### Drag to Editor
Drag any card into your markdown editor to insert its content.
![Drag into editor](assets/drag-into-editor.gif)
### 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.
![](assets/two-row-color-dots.png)
### Auto-color Rules
Automatically assign colors based on text content or tag matches.
![Auto-color settings](assets/settings-autocolor.png)
### Multiple Card Styles
Choose from 3 visual styles and adjust border radius, opacity, and border thickness to customize it to your liking!
![Card styling settings](assets/settings-card-styling.png)
## 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 theyll 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.
![Categories settings](assets/settings-categories.png)
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.
![Status menu](assets/status-menu.png)
### 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.
![](assets/card-expiry.png)
## Homepage
### Dedicated Homepage View
A full-page view with a card editor, pinned notes, and recent notes.
![Banner](assets/SideCards-banner.png)
You can replace your default new tab with this homepage.
![Pinned and recent notes](assets/pinned-recent-notes.png)
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 vaults `.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 Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&style=for-the-badge&label=downloads&labelColor=26233a&color=483699&query=%24%5B%22sidecards%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json) ![Stars](https://img.shields.io/github/stars/Kazi-Aidah/sidecards?style=for-the-badge&color=c4a7e7&labelColor=26233a) ![Version](https://img.shields.io/github/manifest-json/v/Kazi-Aidah/sidecards?style=for-the-badge&color=9ccfd8&labelColor=26233a) ![Last update](https://img.shields.io/github/last-commit/Kazi-Aidah/sidecards?style=for-the-badge&color=9fc387&labelColor=26233a)
# SideCards
Quickly create cards in your Obsidian sidebar; color, tag, and drag them straight into your notes.
<img alt="SideCards Banner" src="assets/SideCards-banner.png" />
---
## 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.
![Inline editing](assets/inline-editing.png)
### Drag to Editor
Drag any card into your markdown editor to insert its content.
![Drag into editor](assets/drag-into-editor.gif)
### 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.
![](assets/two-row-color-dots.png)
### Auto-color Rules
Automatically assign colors based on text content or tag matches.
![Auto-color settings](assets/settings-autocolor.png)
### Multiple Card Styles
Choose from 3 visual styles and adjust border radius, opacity, and border thickness to customize it to your liking!
![Card styling settings](assets/settings-card-styling.png)
## 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 theyll 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.
![Categories settings](assets/settings-categories.png)
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.
![Status menu](assets/status-menu.png)
### 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.
![](assets/card-expiry.png)
## Homepage
### Dedicated Homepage View
A full-page view with a card editor, pinned notes, and recent notes.
![Banner](assets/SideCards-banner.png)
You can replace your default new tab with this homepage.
![Pinned and recent notes](assets/pinned-recent-notes.png)
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 vaults `.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!

View file

@ -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",
},
},
]);

69
main.js
View file

@ -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) => {

View file

@ -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
}

23
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<string>(['/']);
this.app.vault.getAllLoadedFiles().forEach((file) => { if (file.parent) folders.add(file.parent.path); });

View file

@ -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<Card>) {
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<Card>) {
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
};
}
}

View file

@ -308,6 +308,36 @@ export class CardStore {
if (!silent) new Notice(`Imported ${files.length} notes from ${folder}`);
}
async switchStorageFolder(newFolder: string): Promise<void> {
// 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<void> {
await this.saveToStorage();
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ interface AppWithInternals extends App {
workspace: App['workspace'] & { activeEditor: { editor: Editor; editMode: boolean } | null };
}
export class CardComponent {
export class CardComponent extends Component {
public el: HTMLElement;
static activeEditor: CardComponent | null = null;
private static instanceCount = 0;
@ -43,12 +43,14 @@ export class CardComponent {
private plugin: Plugin,
private settingsOverride?: { groupTags?: boolean; showTimestamps?: boolean; showTags?: boolean }
) {
super();
CardComponent.instanceCount += 1;
this.card = card;
this.el = container.createDiv('sc-card');
this.scope = new Scope(this.app.scope);
this.setupMockEditor();
this.ensureGlobalMouseDownHandler();
this.load();
void this.render();
this.setupListeners();
}
@ -478,13 +480,52 @@ export class CardComponent {
container.setAttribute('contenteditable', 'false');
container.removeClass('is-editing');
const temp = document.createElement('div');
await MarkdownRenderer.render(this.app, this.card.content, temp, this.card.notePath || '', this.app as unknown as Component);
try {
await MarkdownRenderer.render(this.app, this.card.content, temp, this.card.notePath || '', this);
} catch {
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);
}
}
private resolveImageEmbeds(container: HTMLElement): void {
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']);
container.querySelectorAll('span.internal-embed').forEach((span) => {
try {
const src = span.getAttribute('src') || '';
const ext = src.split('.').pop()?.toLowerCase() ?? '';
if (!IMAGE_EXTS.has(ext)) return;
// Resolve via vault metadata cache (handles wikilink paths)
const file = this.app.metadataCache.getFirstLinkpathDest(src, this.card.notePath || '');
if (!file) return; // can't resolve — leave the span as-is
const resourcePath = this.app.vault.getResourcePath(file);
const img = document.createElement('img');
img.src = resourcePath;
img.alt = src;
img.addClass('sc-embed-image');
// Honour optional size from ![[image.png|300]] or ![[image.png|300x200]]
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 {
// leave embed span untouched on any error
}
});
}
private attachInternalLinkHandlers(container: HTMLElement): void {
const links = container.querySelectorAll('a.internal-link, a[data-href]');
links.forEach((link) => {
@ -938,6 +979,7 @@ export class CardComponent {
}
this.stopExpiryTick();
this.unsubscribe.forEach(fn => fn());
this.unload();
this.el.remove();
}

View file

@ -1,349 +1,349 @@
/**
* InlineAutocomplete
* Attaches to a contenteditable element and shows a dropdown when the user
* types @ (categories), # (tags), or [[ (file links) in the text.
*/
import { App, TFile, setIcon } from "obsidian";
import { CardStore } from "../../services/CardStore";
type SuggestionItem = { label: string; value: string; prefix: '@' | '#' | '[['; icon?: string; iconColor?: string };
export class InlineAutocomplete {
private dropdown: HTMLElement;
private selectedIndex = -1;
private items: SuggestionItem[] = [];
private triggerStart = -1;
private triggerChar: '@' | '#' | '[[' | null = null;
public isOpen = false;
constructor(
private editorEl: HTMLElement,
private store: CardStore,
private app?: App
) {
const parent = editorEl.parentElement!;
parent.addClass('sc-ac-parent');
this.dropdown = parent.createDiv('sc-inline-autocomplete');
editorEl.addEventListener('input', () => this.onInput());
editorEl.addEventListener('keydown', (e) => this.onKeyDown(e), true);
editorEl.addEventListener('blur', () => setTimeout(() => this.hide(), 150));
}
private getCaretInfo(): { text: string; offset: number } {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return { text: '', offset: 0 };
const range = sel.getRangeAt(0);
const preRange = document.createRange();
preRange.selectNodeContents(this.editorEl);
preRange.setEnd(range.startContainer, range.startOffset);
const text = preRange.toString();
return { text, offset: text.length };
}
private onInput(): void {
const { text } = this.getCaretInfo();
// Check for [[ trigger first (two-char trigger)
if (this.app) {
const doubleBracketIdx = text.lastIndexOf('[[');
if (doubleBracketIdx !== -1) {
const afterBracket = text.substring(doubleBracketIdx + 2);
// Only trigger if no closing ]] after the [[
if (!afterBracket.includes(']]')) {
const query = afterBracket.toLowerCase();
const suggestions = this.getFileSuggestions(query);
if (suggestions.length > 0) {
this.triggerStart = doubleBracketIdx;
this.triggerChar = '[[';
this.items = suggestions;
this.renderDropdown();
this.positionDropdown();
return;
}
}
}
}
// Check for @ or # triggers
let triggerIdx = -1;
let triggerChar: '@' | '#' | null = null;
for (let i = text.length - 1; i >= 0; i--) {
const ch = text[i];
if (ch === ' ' || ch === '\n') break;
if (ch === '@' || ch === '#') { triggerIdx = i; triggerChar = ch; break; }
}
if (triggerIdx === -1 || triggerChar === null) { this.hide(); return; }
const query = text.substring(triggerIdx + 1).toLowerCase();
this.triggerStart = triggerIdx;
this.triggerChar = triggerChar;
const suggestions = triggerChar === '@'
? this.getCategorySuggestions(query)
: this.getTagSuggestions(query);
if (suggestions.length === 0) { this.hide(); return; }
this.items = suggestions;
this.renderDropdown();
this.positionDropdown();
}
private getFileSuggestions(query: string): SuggestionItem[] {
if (!this.app) return [];
const files = this.app.vault.getFiles()
.filter((f: TFile) => f.name && !f.name.startsWith('.') && f.name.toLowerCase().includes(query))
.slice(0, 10);
return files.map((f: TFile) => {
const iconInfo = this.resolveIconicIcon(f);
return {
label: f.name,
value: f.name,
prefix: '[[' as const,
icon: iconInfo?.icon || 'file-text',
iconColor: iconInfo?.color,
};
});
}
/** Synchronously resolve iconic plugin icon for a file (no async needed for sync APIs). */
private resolveIconicIcon(file: TFile): { icon: string; color?: string } | null {
if (!this.app) return null;
type IconicEntry = { icon?: string; name?: string; value?: string; color?: string } | string;
type IconicPlugin = {
ruleManager?: { checkRuling?: (type: string, path: string) => { icon?: string; iconDefault?: string; color?: string } | null };
getFileItem?: (path: string) => { icon?: string; iconDefault?: string; color?: string } | null;
settings?: { fileIcons?: Record<string, IconicEntry> };
data?: { fileIcons?: Record<string, IconicEntry> };
fileIcons?: Record<string, IconicEntry>;
};
const iconicPlugin = (this.app as unknown as { plugins?: { getPlugin?: (id: string) => IconicPlugin | null } }).plugins?.getPlugin?.('iconic');
if (!iconicPlugin) return null;
const path = file.path;
try {
const ruled = iconicPlugin.ruleManager?.checkRuling?.('file', path);
if (ruled) {
const iconValue = ruled.icon ?? ruled.iconDefault;
if (iconValue) return { icon: String(iconValue), color: ruled.color ?? undefined };
}
} catch { /* ignore */ }
try {
const item = iconicPlugin.getFileItem?.(path);
if (item) {
const iconValue = item.icon ?? item.iconDefault;
if (iconValue) return { icon: String(iconValue), color: item.color ?? undefined };
}
} catch { /* ignore */ }
const entry =
iconicPlugin.settings?.fileIcons?.[path] ??
iconicPlugin.data?.fileIcons?.[path] ??
iconicPlugin.fileIcons?.[path];
if (!entry) return null;
if (typeof entry === 'string') return { icon: entry };
if (typeof entry === 'object') {
const iconValue = entry.icon ?? entry.name ?? entry.value;
if (iconValue) return { icon: String(iconValue), color: typeof entry.color === 'string' ? entry.color : undefined };
}
return null;
}
private getCategorySuggestions(query: string): SuggestionItem[] {
const settings = this.store.settings;
const cats: SuggestionItem[] = [];
const builtinIcons = settings.builtinCategoryIcons || {};
if (!settings.hideTodayFilter)
cats.push({ label: 'Today', value: 'today', prefix: '@', icon: builtinIcons['today'] || 'calendar-check' });
if (!settings.hideTomorrowFilter)
cats.push({ label: 'Tomorrow', value: 'tomorrow', prefix: '@', icon: builtinIcons['tomorrow'] || 'calendar-plus' });
if (settings.enableCustomCategories) {
(settings.customCategories || []).forEach(c => {
cats.push({ label: c.label, value: c.id || c.label, prefix: '@', icon: c.icon });
});
}
return query
? cats.filter(c => c.label.toLowerCase().startsWith(query) || c.value.toLowerCase().startsWith(query))
: cats.slice(0, 8);
}
private getTagSuggestions(query: string): SuggestionItem[] {
const tags = new Set<string>();
this.store.getAll().forEach(c => (c.tags || []).forEach(t => tags.add(t.toLowerCase())));
const all = Array.from(tags).sort();
const filtered = query ? all.filter(t => t.startsWith(query)) : all;
return filtered.slice(0, 8).map(t => ({ label: t, value: t, prefix: '#' as const }));
}
private renderDropdown(): void {
this.dropdown.empty();
this.selectedIndex = -1;
this.isOpen = true;
this.items.forEach((item, idx) => {
const row = this.dropdown.createDiv('sc-inline-ac-item');
if (item.prefix === '[[' || (item.prefix === '@' && item.icon)) {
const iconEl = row.createSpan('sc-inline-ac-icon');
const iconName = item.icon || (item.prefix === '[[' ? 'file-text' : 'at-sign');
try { setIcon(iconEl, iconName); } catch { iconEl.textContent = item.prefix; }
if (item.iconColor) {
const colorMap: Record<string, string> = {
red: 'var(--color-red)', orange: 'var(--color-orange)', yellow: 'var(--color-yellow)',
green: 'var(--color-green)', cyan: 'var(--color-cyan)', blue: 'var(--color-blue)',
purple: 'var(--color-purple)', pink: 'var(--color-pink)', magenta: 'var(--color-pink)',
gray: 'var(--color-base-70)', grey: 'var(--color-base-70)',
};
const normalized = item.iconColor.trim().toLowerCase();
iconEl.style.color = colorMap[normalized] ?? item.iconColor;
}
} else if (item.prefix === '@') {
row.createSpan({ cls: 'sc-inline-ac-badge', text: '@' });
} else {
row.createSpan({ cls: 'sc-inline-ac-badge', text: '#' });
}
row.createSpan({ text: item.label });
row.addEventListener('mousedown', (e) => { e.preventDefault(); this.selectItem(idx); });
row.addEventListener('mouseenter', () => { this.selectedIndex = idx; this.highlightSelected(); });
});
this.dropdown.addClass('is-visible');
}
private positionDropdown(): void {
const editorRect = this.editorEl.getBoundingClientRect();
const parentRect = this.editorEl.parentElement!.getBoundingClientRect();
let caretLeft = 0;
const sel = window.getSelection();
if (sel && sel.rangeCount) {
const range = sel.getRangeAt(0).cloneRange();
range.collapse(true);
const rect = range.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) caretLeft = rect.left - parentRect.left;
}
const bottomOffset = parentRect.bottom - editorRect.top + 4;
const leftOffset = Math.max(0, caretLeft);
// Default: above the editor
this.dropdown.setCssProps({
'--sc-ac-bottom': `${bottomOffset}px`,
'--sc-ac-left': `${leftOffset}px`,
});
this.dropdown.removeClass('ac-below');
this.dropdown.addClass('ac-above');
// After paint, flip below if it goes off-screen
requestAnimationFrame(() => {
const dropRect = this.dropdown.getBoundingClientRect();
if (dropRect.top < 0) {
const topOffset = editorRect.top - parentRect.top + editorRect.height + 4;
this.dropdown.setCssProps({ '--sc-ac-top': `${topOffset}px`, '--sc-ac-left': `${leftOffset}px` });
this.dropdown.removeClass('ac-above');
this.dropdown.addClass('ac-below');
}
});
}
private highlightSelected(): void {
const rows = this.dropdown.querySelectorAll('.sc-inline-ac-item');
rows.forEach((r, i) => r.toggleClass('is-selected', i === this.selectedIndex));
// Scroll selected item into view
if (this.selectedIndex >= 0 && rows[this.selectedIndex]) {
(rows[this.selectedIndex] as HTMLElement).scrollIntoView({ block: 'nearest' });
}
}
private selectItem(idx: number): void {
const item = this.items[idx];
if (!item) return;
const fullText = this.editorEl.textContent || '';
const caretOffset = this.getCaretInfo().offset;
if (item.prefix === '[[') {
// The text after [[ up to the caret is the partial query.
// The text after the caret may contain auto-paired ]] — strip those too.
const before = fullText.substring(0, this.triggerStart);
let after = fullText.substring(caretOffset);
// If auto-pairing inserted ]], remove them so we don't double up
if (after.startsWith(']]')) after = after.substring(2);
const replacement = '[[' + item.value + ']]';
this.editorEl.textContent = before + replacement + after;
this.setCaretAt(before.length + replacement.length);
} else {
const before = fullText.substring(0, this.triggerStart);
const after = fullText.substring(caretOffset);
const replacement = item.prefix + item.value + ' ';
this.editorEl.textContent = before + replacement + after;
this.setCaretAt(before.length + replacement.length);
}
this.editorEl.dispatchEvent(new Event('input'));
this.hide();
}
private setCaretAt(offset: number): void {
const sel = window.getSelection();
if (!sel) return;
let remaining = offset;
const walker = document.createTreeWalker(this.editorEl, NodeFilter.SHOW_TEXT);
let node: Text | null = null;
while (walker.nextNode()) {
const n = walker.currentNode as Text;
if (remaining <= n.length) { node = n; break; }
remaining -= n.length;
}
const range = document.createRange();
if (node) { range.setStart(node, remaining); }
else { range.selectNodeContents(this.editorEl); range.collapse(false); }
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
private onKeyDown(e: KeyboardEvent): void {
if (!this.isOpen) return;
if (e.key === 'ArrowDown') {
e.preventDefault(); e.stopPropagation();
this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
this.highlightSelected();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.stopPropagation();
this.selectedIndex = (this.selectedIndex - 1 + this.items.length) % this.items.length;
this.highlightSelected();
} else if (e.key === 'Enter') {
e.preventDefault(); e.stopImmediatePropagation();
if (this.selectedIndex >= 0) {
this.selectItem(this.selectedIndex);
} else {
this.hide();
}
} else if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation();
this.hide();
}
}
hide(): void {
this.dropdown.removeClass('is-visible', 'ac-above', 'ac-below');
this.isOpen = false;
this.items = [];
this.selectedIndex = -1;
this.triggerChar = null;
}
destroy(): void {
this.dropdown.remove();
}
}
/**
* InlineAutocomplete
* Attaches to a contenteditable element and shows a dropdown when the user
* types @ (categories), # (tags), or [[ (file links) in the text.
*/
import { App, TFile, setIcon } from "obsidian";
import { CardStore } from "../../services/CardStore";
type SuggestionItem = { label: string; value: string; prefix: '@' | '#' | '[['; icon?: string; iconColor?: string };
export class InlineAutocomplete {
private dropdown: HTMLElement;
private selectedIndex = -1;
private items: SuggestionItem[] = [];
private triggerStart = -1;
private triggerChar: '@' | '#' | '[[' | null = null;
public isOpen = false;
constructor(
private editorEl: HTMLElement,
private store: CardStore,
private app?: App
) {
const parent = editorEl.parentElement!;
parent.addClass('sc-ac-parent');
this.dropdown = parent.createDiv('sc-inline-autocomplete');
editorEl.addEventListener('input', () => this.onInput());
editorEl.addEventListener('keydown', (e) => this.onKeyDown(e), true);
editorEl.addEventListener('blur', () => setTimeout(() => this.hide(), 150));
}
private getCaretInfo(): { text: string; offset: number } {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return { text: '', offset: 0 };
const range = sel.getRangeAt(0);
const preRange = document.createRange();
preRange.selectNodeContents(this.editorEl);
preRange.setEnd(range.startContainer, range.startOffset);
const text = preRange.toString();
return { text, offset: text.length };
}
private onInput(): void {
const { text } = this.getCaretInfo();
// Check for [[ trigger first (two-char trigger)
if (this.app) {
const doubleBracketIdx = text.lastIndexOf('[[');
if (doubleBracketIdx !== -1) {
const afterBracket = text.substring(doubleBracketIdx + 2);
// Only trigger if no closing ]] after the [[
if (!afterBracket.includes(']]')) {
const query = afterBracket.toLowerCase();
const suggestions = this.getFileSuggestions(query);
if (suggestions.length > 0) {
this.triggerStart = doubleBracketIdx;
this.triggerChar = '[[';
this.items = suggestions;
this.renderDropdown();
this.positionDropdown();
return;
}
}
}
}
// Check for @ or # triggers
let triggerIdx = -1;
let triggerChar: '@' | '#' | null = null;
for (let i = text.length - 1; i >= 0; i--) {
const ch = text[i];
if (ch === ' ' || ch === '\n') break;
if (ch === '@' || ch === '#') { triggerIdx = i; triggerChar = ch; break; }
}
if (triggerIdx === -1 || triggerChar === null) { this.hide(); return; }
const query = text.substring(triggerIdx + 1).toLowerCase();
this.triggerStart = triggerIdx;
this.triggerChar = triggerChar;
const suggestions = triggerChar === '@'
? this.getCategorySuggestions(query)
: this.getTagSuggestions(query);
if (suggestions.length === 0) { this.hide(); return; }
this.items = suggestions;
this.renderDropdown();
this.positionDropdown();
}
private getFileSuggestions(query: string): SuggestionItem[] {
if (!this.app) return [];
const files = this.app.vault.getFiles()
.filter((f: TFile) => f.name && !f.name.startsWith('.') && f.name.toLowerCase().includes(query))
.slice(0, 10);
return files.map((f: TFile) => {
const iconInfo = this.resolveIconicIcon(f);
return {
label: f.name,
value: f.name,
prefix: '[[' as const,
icon: iconInfo?.icon || 'file-text',
iconColor: iconInfo?.color,
};
});
}
/** Synchronously resolve iconic plugin icon for a file (no async needed for sync APIs). */
private resolveIconicIcon(file: TFile): { icon: string; color?: string } | null {
if (!this.app) return null;
type IconicEntry = { icon?: string; name?: string; value?: string; color?: string } | string;
type IconicPlugin = {
ruleManager?: { checkRuling?: (type: string, path: string) => { icon?: string; iconDefault?: string; color?: string } | null };
getFileItem?: (path: string) => { icon?: string; iconDefault?: string; color?: string } | null;
settings?: { fileIcons?: Record<string, IconicEntry> };
data?: { fileIcons?: Record<string, IconicEntry> };
fileIcons?: Record<string, IconicEntry>;
};
const iconicPlugin = (this.app as unknown as { plugins?: { getPlugin?: (id: string) => IconicPlugin | null } }).plugins?.getPlugin?.('iconic');
if (!iconicPlugin) return null;
const path = file.path;
try {
const ruled = iconicPlugin.ruleManager?.checkRuling?.('file', path);
if (ruled) {
const iconValue = ruled.icon ?? ruled.iconDefault;
if (iconValue) return { icon: String(iconValue), color: ruled.color ?? undefined };
}
} catch { /* ignore */ }
try {
const item = iconicPlugin.getFileItem?.(path);
if (item) {
const iconValue = item.icon ?? item.iconDefault;
if (iconValue) return { icon: String(iconValue), color: item.color ?? undefined };
}
} catch { /* ignore */ }
const entry =
iconicPlugin.settings?.fileIcons?.[path] ??
iconicPlugin.data?.fileIcons?.[path] ??
iconicPlugin.fileIcons?.[path];
if (!entry) return null;
if (typeof entry === 'string') return { icon: entry };
if (typeof entry === 'object') {
const iconValue = entry.icon ?? entry.name ?? entry.value;
if (iconValue) return { icon: String(iconValue), color: typeof entry.color === 'string' ? entry.color : undefined };
}
return null;
}
private getCategorySuggestions(query: string): SuggestionItem[] {
const settings = this.store.settings;
const cats: SuggestionItem[] = [];
const builtinIcons = settings.builtinCategoryIcons || {};
if (!settings.hideTodayFilter)
cats.push({ label: 'Today', value: 'today', prefix: '@', icon: builtinIcons['today'] || 'calendar-check' });
if (!settings.hideTomorrowFilter)
cats.push({ label: 'Tomorrow', value: 'tomorrow', prefix: '@', icon: builtinIcons['tomorrow'] || 'calendar-plus' });
if (settings.enableCustomCategories) {
(settings.customCategories || []).forEach(c => {
cats.push({ label: c.label, value: c.id || c.label, prefix: '@', icon: c.icon });
});
}
return query
? cats.filter(c => c.label.toLowerCase().startsWith(query) || c.value.toLowerCase().startsWith(query))
: cats.slice(0, 8);
}
private getTagSuggestions(query: string): SuggestionItem[] {
const tags = new Set<string>();
this.store.getAll().forEach(c => (c.tags || []).forEach(t => tags.add(t.toLowerCase())));
const all = Array.from(tags).sort();
const filtered = query ? all.filter(t => t.startsWith(query)) : all;
return filtered.slice(0, 8).map(t => ({ label: t, value: t, prefix: '#' as const }));
}
private renderDropdown(): void {
this.dropdown.empty();
this.selectedIndex = -1;
this.isOpen = true;
this.items.forEach((item, idx) => {
const row = this.dropdown.createDiv('sc-inline-ac-item');
if (item.prefix === '[[' || (item.prefix === '@' && item.icon)) {
const iconEl = row.createSpan('sc-inline-ac-icon');
const iconName = item.icon || (item.prefix === '[[' ? 'file-text' : 'at-sign');
try { setIcon(iconEl, iconName); } catch { iconEl.textContent = item.prefix; }
if (item.iconColor) {
const colorMap: Record<string, string> = {
red: 'var(--color-red)', orange: 'var(--color-orange)', yellow: 'var(--color-yellow)',
green: 'var(--color-green)', cyan: 'var(--color-cyan)', blue: 'var(--color-blue)',
purple: 'var(--color-purple)', pink: 'var(--color-pink)', magenta: 'var(--color-pink)',
gray: 'var(--color-base-70)', grey: 'var(--color-base-70)',
};
const normalized = item.iconColor.trim().toLowerCase();
iconEl.style.color = colorMap[normalized] ?? item.iconColor;
}
} else if (item.prefix === '@') {
row.createSpan({ cls: 'sc-inline-ac-badge', text: '@' });
} else {
row.createSpan({ cls: 'sc-inline-ac-badge', text: '#' });
}
row.createSpan({ text: item.label });
row.addEventListener('mousedown', (e) => { e.preventDefault(); this.selectItem(idx); });
row.addEventListener('mouseenter', () => { this.selectedIndex = idx; this.highlightSelected(); });
});
this.dropdown.addClass('is-visible');
}
private positionDropdown(): void {
const editorRect = this.editorEl.getBoundingClientRect();
const parentRect = this.editorEl.parentElement!.getBoundingClientRect();
let caretLeft = 0;
const sel = window.getSelection();
if (sel && sel.rangeCount) {
const range = sel.getRangeAt(0).cloneRange();
range.collapse(true);
const rect = range.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) caretLeft = rect.left - parentRect.left;
}
const bottomOffset = parentRect.bottom - editorRect.top + 4;
const leftOffset = Math.max(0, caretLeft);
// Default: above the editor
this.dropdown.setCssProps({
'--sc-ac-bottom': `${bottomOffset}px`,
'--sc-ac-left': `${leftOffset}px`,
});
this.dropdown.removeClass('ac-below');
this.dropdown.addClass('ac-above');
// After paint, flip below if it goes off-screen
requestAnimationFrame(() => {
const dropRect = this.dropdown.getBoundingClientRect();
if (dropRect.top < 0) {
const topOffset = editorRect.top - parentRect.top + editorRect.height + 4;
this.dropdown.setCssProps({ '--sc-ac-top': `${topOffset}px`, '--sc-ac-left': `${leftOffset}px` });
this.dropdown.removeClass('ac-above');
this.dropdown.addClass('ac-below');
}
});
}
private highlightSelected(): void {
const rows = this.dropdown.querySelectorAll('.sc-inline-ac-item');
rows.forEach((r, i) => r.toggleClass('is-selected', i === this.selectedIndex));
// Scroll selected item into view
if (this.selectedIndex >= 0 && rows[this.selectedIndex]) {
(rows[this.selectedIndex] as HTMLElement).scrollIntoView({ block: 'nearest' });
}
}
private selectItem(idx: number): void {
const item = this.items[idx];
if (!item) return;
const fullText = this.editorEl.textContent || '';
const caretOffset = this.getCaretInfo().offset;
if (item.prefix === '[[') {
// The text after [[ up to the caret is the partial query.
// The text after the caret may contain auto-paired ]] — strip those too.
const before = fullText.substring(0, this.triggerStart);
let after = fullText.substring(caretOffset);
// If auto-pairing inserted ]], remove them so we don't double up
if (after.startsWith(']]')) after = after.substring(2);
const replacement = '[[' + item.value + ']]';
this.editorEl.textContent = before + replacement + after;
this.setCaretAt(before.length + replacement.length);
} else {
const before = fullText.substring(0, this.triggerStart);
const after = fullText.substring(caretOffset);
const replacement = item.prefix + item.value + ' ';
this.editorEl.textContent = before + replacement + after;
this.setCaretAt(before.length + replacement.length);
}
this.editorEl.dispatchEvent(new Event('input'));
this.hide();
}
private setCaretAt(offset: number): void {
const sel = window.getSelection();
if (!sel) return;
let remaining = offset;
const walker = document.createTreeWalker(this.editorEl, NodeFilter.SHOW_TEXT);
let node: Text | null = null;
while (walker.nextNode()) {
const n = walker.currentNode as Text;
if (remaining <= n.length) { node = n; break; }
remaining -= n.length;
}
const range = document.createRange();
if (node) { range.setStart(node, remaining); }
else { range.selectNodeContents(this.editorEl); range.collapse(false); }
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
private onKeyDown(e: KeyboardEvent): void {
if (!this.isOpen) return;
if (e.key === 'ArrowDown') {
e.preventDefault(); e.stopPropagation();
this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
this.highlightSelected();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.stopPropagation();
this.selectedIndex = (this.selectedIndex - 1 + this.items.length) % this.items.length;
this.highlightSelected();
} else if (e.key === 'Enter') {
e.preventDefault(); e.stopImmediatePropagation();
if (this.selectedIndex >= 0) {
this.selectItem(this.selectedIndex);
} else {
this.hide();
}
} else if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation();
this.hide();
}
}
hide(): void {
this.dropdown.removeClass('is-visible', 'ac-above', 'ac-below');
this.isOpen = false;
this.items = [];
this.selectedIndex = -1;
this.triggerChar = null;
}
destroy(): void {
this.dropdown.remove();
}
}

2769
styles.css

File diff suppressed because it is too large Load diff