mirror of
https://github.com/kazi-aidah/sidecards.git
synced 2026-07-22 06:42:13 +00:00
Fixed Storage card import and embeds
This commit is contained in:
parent
20bec7cec6
commit
b43826afc2
19 changed files with 4811 additions and 4650 deletions
36
LICENSE
36
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.
|
||||
|
|
|
|||
268
README.md
268
README.md
|
|
@ -1,135 +1,135 @@
|
|||
   
|
||||
|
||||
# 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.
|
||||
|
||||

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

|
||||
|
||||
### 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!
|
||||
|
|
@ -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
69
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) => {
|
||||
|
|
|
|||
|
|
@ -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
23
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
2769
styles.css
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue