From b43826afc21141e5093ab566ad586d8d35763f94 Mon Sep 17 00:00:00 2001 From: Kazi Aidah Haque Date: Tue, 24 Mar 2026 16:45:28 +0600 Subject: [PATCH] Fixed Storage card import and embeds --- LICENSE | 36 +- README.md | 268 +- eslint.config.mjs | 60 +- main.js | 69 +- manifest.json | 20 +- package-lock.json | 23 +- package.json | 1 + ...l cards except the other being hovered.css | 42 +- ... filter button - wider active category.css | 126 +- ...rds filter buttons – take up max width.css | 70 +- src/core/Plugin.ts | 9 +- src/models/Card.ts | 136 +- src/services/CardStore.ts | 30 + src/utils/drag-drop.ts | 998 +++--- src/views/CardSidebarView.ts | 1856 +++++------ src/views/HomeView.ts | 2204 ++++++------- src/views/components/Card.ts | 46 +- src/views/components/InlineAutocomplete.ts | 698 ++--- styles.css | 2769 +++++++++-------- 19 files changed, 4811 insertions(+), 4650 deletions(-) diff --git a/LICENSE b/LICENSE index ed0220c..be35d8b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ MIT License Copyright (c) 2026 Kazi Aidah Haque - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d603a5d..ffdbcc7 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,135 @@ -![SideCards 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. - -SideCards Banner - ---- - -## 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 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. - -![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 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 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. + +SideCards Banner + +--- + +## 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 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. + +![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 vault’s `.obsidian/plugins/` directory. The folder structure should look like this: + +``` +sidecards +├─ main.js +├─ styles.css +└─ manifest.json +``` + +Inside your vault, it should end up like: + +``` +.your-vault +└─ .obsidian + └─ plugins + └─ sidecards + ├─ main.js + ├─ styles.css + └─ manifest.json +``` + +2. **Enable the plugin** in Obsidian. + +3. **Set a storage folder** on first launch. This is where your cards will be saved as notes. + + +### Questions or Suggestions? Create a new issue [here](https://github.com/Kazi-Aidah/sidecards/issues) to report bugs or request features! \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 149e5be..efa0bac 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,31 +1,31 @@ -import tsparser from "@typescript-eslint/parser"; -import { defineConfig } from "eslint/config"; -import obsidianmd from "eslint-plugin-obsidianmd"; -import globals from "globals"; - -export default defineConfig([ - ...obsidianmd.configs.recommended, - - { - files: ["src/**/*.ts"], - languageOptions: { - parser: tsparser, - parserOptions: { - project: "./tsconfig.json", - tsconfigRootDir: import.meta.dirname, - }, - globals: { - ...globals.browser, - ...globals.es2022, - }, - }, - rules: { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-return": "off", - }, - }, +import tsparser from "@typescript-eslint/parser"; +import { defineConfig } from "eslint/config"; +import obsidianmd from "eslint-plugin-obsidianmd"; +import globals from "globals"; + +export default defineConfig([ + ...obsidianmd.configs.recommended, + + { + files: ["src/**/*.ts"], + languageOptions: { + parser: tsparser, + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + }, + globals: { + ...globals.browser, + ...globals.es2022, + }, + }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-return": "off", + }, + }, ]); \ No newline at end of file diff --git a/main.js b/main.js index 2e2b937..b82fa2f 100644 --- a/main.js +++ b/main.js @@ -765,8 +765,9 @@ var InlineAutocomplete = class { }; // src/views/components/Card.ts -var _CardComponent = class { +var _CardComponent = class extends import_obsidian3.Component { constructor(container, card, store, app, plugin, settingsOverride) { + super(); this.container = container; this.store = store; this.app = app; @@ -783,6 +784,7 @@ var _CardComponent = class { this.scope = new import_obsidian3.Scope(this.app.scope); this.setupMockEditor(); this.ensureGlobalMouseDownHandler(); + this.load(); void this.render(); this.setupListeners(); } @@ -1182,13 +1184,47 @@ var _CardComponent = class { container.setAttribute("contenteditable", "false"); container.removeClass("is-editing"); const temp = document.createElement("div"); - await import_obsidian3.MarkdownRenderer.render(this.app, this.card.content, temp, this.card.notePath || "", this.app); + try { + await import_obsidian3.MarkdownRenderer.render(this.app, this.card.content, temp, this.card.notePath || "", this); + } catch (e) { + temp.textContent = this.card.content; + } temp.querySelectorAll("mark").forEach((el) => el.addClass("cm-highlight")); + this.resolveImageEmbeds(temp); while (temp.firstChild) container.appendChild(temp.firstChild); this.attachInternalLinkHandlers(container); } } + resolveImageEmbeds(container) { + const IMAGE_EXTS = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"]); + container.querySelectorAll("span.internal-embed").forEach((span) => { + var _a, _b; + try { + const src = span.getAttribute("src") || ""; + const ext = (_b = (_a = src.split(".").pop()) == null ? void 0 : _a.toLowerCase()) != null ? _b : ""; + if (!IMAGE_EXTS.has(ext)) + return; + const file = this.app.metadataCache.getFirstLinkpathDest(src, this.card.notePath || ""); + if (!file) + return; + const resourcePath = this.app.vault.getResourcePath(file); + const img = document.createElement("img"); + img.src = resourcePath; + img.alt = src; + img.addClass("sc-embed-image"); + const altAttr = span.getAttribute("alt") || ""; + const sizeMatch = altAttr.match(/(\d+)(?:x(\d+))?/); + if (sizeMatch) { + img.style.width = `${sizeMatch[1]}px`; + if (sizeMatch[2]) + img.style.height = `${sizeMatch[2]}px`; + } + span.replaceWith(img); + } catch (e) { + } + }); + } attachInternalLinkHandlers(container) { const links = container.querySelectorAll("a.internal-link, a[data-href]"); links.forEach((link) => { @@ -1605,6 +1641,7 @@ var _CardComponent = class { } this.stopExpiryTick(); this.unsubscribe.forEach((fn) => fn()); + this.unload(); this.el.remove(); } startExpiryTick() { @@ -3436,6 +3473,25 @@ var CardStore = class { if (!silent) new import_obsidian5.Notice(`Imported ${files.length} notes from ${folder}`); } + async switchStorageFolder(newFolder) { + const oldIds = Array.from(this.cards.keys()); + this.cards.clear(); + this.settings.cards = []; + await this.plugin.saveSettings(); + for (const id of oldIds) { + this.eventBus.emit("card:deleted", id); + } + if (!newFolder || newFolder === "/") + return; + if (!await this.app.vault.adapter.exists(newFolder)) { + await this.app.vault.createFolder(newFolder); + } + await this.importNotesFromFolderToSettings(newFolder, true); + const allCards = this.getAll(); + if (allCards.length > 0) { + this.eventBus.emit("card:added", allCards[0]); + } + } async saveCards() { await this.saveToStorage(); } @@ -5870,7 +5926,7 @@ var SideCardsPlugin = class extends import_obsidian10.Plugin { this.settings.storageFolder = val; this.settings.tutorialShown = true; await this.saveSettings(); - void this.store.importNotesFromFolderToSettings(val, true); + await this.store.switchStorageFolder(val); } modal.close(); })(); @@ -6011,9 +6067,14 @@ var SideCardsSettingTab = class extends import_obsidian10.PluginSettingTab { }); new import_obsidian10.Setting(containerEl).setName("Storage folder").setDesc("Choose where to save notes created from cards.").addSearch((cb) => { cb.setPlaceholder("Choose a folder").setValue(this.plugin.settings.storageFolder || "").onChange(async (value) => { - this.plugin.settings.storageFolder = value; + const newFolder = value.trim(); + const oldFolder = this.plugin.settings.storageFolder; + this.plugin.settings.storageFolder = newFolder; this.plugin.settings.tutorialShown = true; await this.plugin.saveSettings(); + if (newFolder !== oldFolder) { + await this.plugin.store.switchStorageFolder(newFolder); + } }); const folders = /* @__PURE__ */ new Set(["/"]); this.app.vault.getAllLoadedFiles().forEach((file) => { diff --git a/manifest.json b/manifest.json index 9be787f..225b134 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ -{ - "id": "sidecards", - "name": "SideCards", - "description": "Quickly create cards in your sidebar, color, tag, and drag them straight into your notes.", - "version": "0.2.0", - "minAppVersion": "0.15.0", - "author": "Kazi Aidah Haque", - "authorUrl": "https://github.com/Kazi-Aidah", - "isDesktopOnly": false -} +{ + "id": "sidecards", + "name": "SideCards", + "description": "Quickly create cards in your sidebar, color, tag, and drag them straight into your notes.", + "version": "0.2.0", + "minAppVersion": "0.15.0", + "author": "Kazi Aidah Haque", + "authorUrl": "https://github.com/Kazi-Aidah", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json index bf181c5..0ce6759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { + "@esbuild/linux-x64": "^0.17.3", "@types/node": "^16.11.21", "@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/parser": "^8.57.1", @@ -308,8 +309,6 @@ "x64" ], "dev": true, - "license": "MIT", - "optional": true, "os": [ "linux" ], @@ -594,7 +593,6 @@ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -687,6 +685,7 @@ "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18" } @@ -710,7 +709,8 @@ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pkgr/core": { "version": "0.1.2", @@ -843,7 +843,6 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -1048,7 +1047,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1437,7 +1435,8 @@ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -1856,7 +1855,6 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -2298,7 +2296,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4666,7 +4663,8 @@ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/supports-color": { "version": "7.2.0", @@ -4773,7 +4771,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4933,7 +4930,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5008,7 +5004,8 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/which": { "version": "2.0.2", diff --git a/package.json b/package.json index 5445935..03db9be 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "author": "Kazi Aidah Haque", "license": "MIT", "devDependencies": { + "@esbuild/linux-x64": "^0.17.3", "@types/node": "^16.11.21", "@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/parser": "^8.57.1", diff --git a/snippets/darken all cards except the other being hovered.css b/snippets/darken all cards except the other being hovered.css index a08ac51..ce1a4d0 100644 --- a/snippets/darken all cards except the other being hovered.css +++ b/snippets/darken all cards except the other being hovered.css @@ -1,21 +1,21 @@ -/* --- DARKEN ALL OTHER CARDS EXCEPT THE ONE HOVERED UPON */ -/* styling for all cards */ -.sc-sidebar-card { - transition: all 0.3s ease; -} - -/* When a card is hovered, select its container, - and then select all the OTHER cards in that container and darken them. */ -.sc-sidebar-cards-container:has(.sc-sidebar-card:hover) .sc-sidebar-card:not(:hover) { - filter: brightness(0.6); /* blur(2px); */ - transition: all 0.3s ease; -} - -/* Style the card that is being hovered */ -.sc-sidebar-cards-container .sc-sidebar-card:hover { - filter: contrast(1.3) brightness(1); - transform: translateY(-0px) scale(1.025); - border-radius: 8px !important; - outline: 3px solid #ffffff !important; - transition: all 0.3s ease-out, outline 0s ease; -} +/* --- DARKEN ALL OTHER CARDS EXCEPT THE ONE HOVERED UPON */ +/* styling for all cards */ +.sc-sidebar-card { + transition: all 0.3s ease; +} + +/* When a card is hovered, select its container, + and then select all the OTHER cards in that container and darken them. */ +.sc-sidebar-cards-container:has(.sc-sidebar-card:hover) .sc-sidebar-card:not(:hover) { + filter: brightness(0.6); /* blur(2px); */ + transition: all 0.3s ease; +} + +/* Style the card that is being hovered */ +.sc-sidebar-cards-container .sc-sidebar-card:hover { + filter: contrast(1.3) brightness(1); + transform: translateY(-0px) scale(1.025); + border-radius: 8px !important; + outline: 3px solid #ffffff !important; + transition: all 0.3s ease-out, outline 0s ease; +} diff --git a/snippets/sidecards filter button - wider active category.css b/snippets/sidecards filter button - wider active category.css index 723e2bb..7ee511b 100644 --- a/snippets/sidecards filter button - wider active category.css +++ b/snippets/sidecards filter button - wider active category.css @@ -1,63 +1,63 @@ -/* --- FILTER BUTTONS: Wider Active Filter */ - -/* topbar */ -.sc-sidebar-header { - border-bottom-left-radius: 13px !important; - border-bottom-right-radius: 13px !important; - box-shadow: 0px 6px 10px #00000070; /* topbar shadow */ - z-index: 1000; - padding: 10px; -} - -.sc-sidebar-category-group { - gap: 6px !important; - width: 100% !important; - display: flex; - justify-content: none; - overflow-x: auto; - overscroll-behavior-x: contain; - -webkit-overflow-scrolling: touch; - padding: 0 14px; - scroll-padding-inline: 8px; - box-sizing: border-box; - -ms-overflow-style: none; - scrollbar-width: none; - margin: 6px -10px !important; -} - -.sc-sidebar-category-group::-webkit-scrollbar { - display: none; - width: 0; - height: 0; -} - -/* Default Category Buttons */ -.sc-category-btn { - border-radius: 0px !important; - padding: 0px 15px !important; - flex: 0 0 auto; - filter: brightness(0.9) !important; - text-transform: uppercase; - transition: all 0.2s ease; -} - -/* Active Button Widening, Brightening & Border */ -.sc-category-btn.active { - padding: 0px 25px !important; - border: 1.7px solid #ffffff30 !important; - transition: all 0.2s ease; - filter: brightness(1.7) !important; - transform: scale(1.02) !important; - z-index: 2000 !important; -} - -/* First & Last Category Button Border-Radius */ -.sc-category-btn:first-child { - border-top-left-radius: 10px !important; - border-bottom-left-radius: 10px !important; -} - -.sc-category-btn:last-child { - border-top-right-radius: 10px !important; - border-bottom-right-radius: 10px !important; -} +/* --- FILTER BUTTONS: Wider Active Filter */ + +/* topbar */ +.sc-sidebar-header { + border-bottom-left-radius: 13px !important; + border-bottom-right-radius: 13px !important; + box-shadow: 0px 6px 10px #00000070; /* topbar shadow */ + z-index: 1000; + padding: 10px; +} + +.sc-sidebar-category-group { + gap: 6px !important; + width: 100% !important; + display: flex; + justify-content: none; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + padding: 0 14px; + scroll-padding-inline: 8px; + box-sizing: border-box; + -ms-overflow-style: none; + scrollbar-width: none; + margin: 6px -10px !important; +} + +.sc-sidebar-category-group::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +/* Default Category Buttons */ +.sc-category-btn { + border-radius: 0px !important; + padding: 0px 15px !important; + flex: 0 0 auto; + filter: brightness(0.9) !important; + text-transform: uppercase; + transition: all 0.2s ease; +} + +/* Active Button Widening, Brightening & Border */ +.sc-category-btn.active { + padding: 0px 25px !important; + border: 1.7px solid #ffffff30 !important; + transition: all 0.2s ease; + filter: brightness(1.7) !important; + transform: scale(1.02) !important; + z-index: 2000 !important; +} + +/* First & Last Category Button Border-Radius */ +.sc-category-btn:first-child { + border-top-left-radius: 10px !important; + border-bottom-left-radius: 10px !important; +} + +.sc-category-btn:last-child { + border-top-right-radius: 10px !important; + border-bottom-right-radius: 10px !important; +} diff --git a/snippets/sidecards filter buttons – take up max width.css b/snippets/sidecards filter buttons – take up max width.css index 76260fd..ab98395 100644 --- a/snippets/sidecards filter buttons – take up max width.css +++ b/snippets/sidecards filter buttons – take up max width.css @@ -1,35 +1,35 @@ -/* --- FILTER BUTTONS: STRETCH TO MAX WIDTH */ -.sc-sidebar-category-group { -gap: 6px !important; -width: 100% !important; -} - -.sc-category-btn { -border-radius: 0px !important; -padding: 0px 37px !important; -width: 100% !important; -/* box-shadow: none !important; */ -filter: brightness(0.9) !important; /* DARKEN ALL OTHERS */ -text-transform: uppercase; /* CAPITALIZE NAMES */ -} - -.sc-category-btn.active { -border: 1.7px solid #ffffff30 !important; -transition: all 100ms ease; -filter: brightness(1.7) !important; -/* box-shadow: 0px 0px 4px #ffffff !important; */ -/* uncomment box-shadow if you want glow */ -transform: scale(1.02) !important; -z-index: 2000 !important; -} - -.sc-category-btn:first-child { -border-top-left-radius: 10px !important; -border-bottom-left-radius: 10px !important; -} - -.sc-category-btn:last-child { -background-color: var(--background-primary) !important; -border-top-right-radius: 10px !important; -border-bottom-right-radius: 10px !important; -} +/* --- FILTER BUTTONS: STRETCH TO MAX WIDTH */ +.sc-sidebar-category-group { +gap: 6px !important; +width: 100% !important; +} + +.sc-category-btn { +border-radius: 0px !important; +padding: 0px 37px !important; +width: 100% !important; +/* box-shadow: none !important; */ +filter: brightness(0.9) !important; /* DARKEN ALL OTHERS */ +text-transform: uppercase; /* CAPITALIZE NAMES */ +} + +.sc-category-btn.active { +border: 1.7px solid #ffffff30 !important; +transition: all 100ms ease; +filter: brightness(1.7) !important; +/* box-shadow: 0px 0px 4px #ffffff !important; */ +/* uncomment box-shadow if you want glow */ +transform: scale(1.02) !important; +z-index: 2000 !important; +} + +.sc-category-btn:first-child { +border-top-left-radius: 10px !important; +border-bottom-left-radius: 10px !important; +} + +.sc-category-btn:last-child { +background-color: var(--background-primary) !important; +border-top-right-radius: 10px !important; +border-bottom-right-radius: 10px !important; +} diff --git a/src/core/Plugin.ts b/src/core/Plugin.ts index 2ae4dd5..8fdfc0c 100644 --- a/src/core/Plugin.ts +++ b/src/core/Plugin.ts @@ -521,7 +521,7 @@ export default class SideCardsPlugin extends Plugin { this.settings.storageFolder = val; this.settings.tutorialShown = true; await this.saveSettings(); - void this.store.importNotesFromFolderToSettings(val, true); + await this.store.switchStorageFolder(val); } modal.close(); })(); @@ -681,9 +681,14 @@ class SideCardsSettingTab extends PluginSettingTab { cb.setPlaceholder('Choose a folder') .setValue(this.plugin.settings.storageFolder || '') .onChange(async (value) => { - this.plugin.settings.storageFolder = value; + const newFolder = value.trim(); + const oldFolder = this.plugin.settings.storageFolder; + this.plugin.settings.storageFolder = newFolder; this.plugin.settings.tutorialShown = true; await this.plugin.saveSettings(); + if (newFolder !== oldFolder) { + await this.plugin.store.switchStorageFolder(newFolder); + } }); const folders = new Set(['/']); this.app.vault.getAllLoadedFiles().forEach((file) => { if (file.parent) folders.add(file.parent.path); }); diff --git a/src/models/Card.ts b/src/models/Card.ts index b1d042b..a9771e1 100644 --- a/src/models/Card.ts +++ b/src/models/Card.ts @@ -1,68 +1,68 @@ - -export enum Status { - TODO = 'todo', - IN_PROGRESS = 'in-progress', - DONE = 'done' -} - -export interface CardStatus { - name: string; - color: string; - textColor?: string; - colorIndex?: number; // 1-10 = preset card color, absent = custom hex -} - -export class Card { - id: string; - content: string; - color: string; - tags: string[]; - category: string | null; - created: number; // Using timestamp for better serialization - modified?: number; - archived: boolean; - pinned: boolean; - notePath: string | null; - expiresAt: number | null; - status: CardStatus | null; - - constructor(data: Partial) { - this.id = data.id || Math.random().toString(36).slice(2, 9); - this.content = data.content || ''; - this.color = data.color || 'var(--card-color-1)'; - this.tags = data.tags || []; - this.category = data.category || null; - this.created = data.created || Date.now(); - this.modified = data.modified || this.created; - this.archived = data.archived || false; - this.pinned = data.pinned || false; - this.notePath = data.notePath || null; - this.expiresAt = data.expiresAt || null; - this.status = data.status || null; - } - - clone(): Card { - return new Card({ - ...this, - id: Math.random().toString(36).slice(2, 9), - created: Date.now() - }); - } - - toJSON() { - return { - id: this.id, - content: this.content, - color: this.color, - tags: this.tags, - category: this.category, - created: this.created, - modified: this.modified, - archived: this.archived, - pinned: this.pinned, - notePath: this.notePath, - expiresAt: this.expiresAt, - status: this.status - }; - } -} + +export enum Status { + TODO = 'todo', + IN_PROGRESS = 'in-progress', + DONE = 'done' +} + +export interface CardStatus { + name: string; + color: string; + textColor?: string; + colorIndex?: number; // 1-10 = preset card color, absent = custom hex +} + +export class Card { + id: string; + content: string; + color: string; + tags: string[]; + category: string | null; + created: number; // Using timestamp for better serialization + modified?: number; + archived: boolean; + pinned: boolean; + notePath: string | null; + expiresAt: number | null; + status: CardStatus | null; + + constructor(data: Partial) { + this.id = data.id || Math.random().toString(36).slice(2, 9); + this.content = data.content || ''; + this.color = data.color || 'var(--card-color-1)'; + this.tags = data.tags || []; + this.category = data.category || null; + this.created = data.created || Date.now(); + this.modified = data.modified || this.created; + this.archived = data.archived || false; + this.pinned = data.pinned || false; + this.notePath = data.notePath || null; + this.expiresAt = data.expiresAt || null; + this.status = data.status || null; + } + + clone(): Card { + return new Card({ + ...this, + id: Math.random().toString(36).slice(2, 9), + created: Date.now() + }); + } + + toJSON() { + return { + id: this.id, + content: this.content, + color: this.color, + tags: this.tags, + category: this.category, + created: this.created, + modified: this.modified, + archived: this.archived, + pinned: this.pinned, + notePath: this.notePath, + expiresAt: this.expiresAt, + status: this.status + }; + } +} diff --git a/src/services/CardStore.ts b/src/services/CardStore.ts index 74e16bd..9341bb1 100644 --- a/src/services/CardStore.ts +++ b/src/services/CardStore.ts @@ -308,6 +308,36 @@ export class CardStore { if (!silent) new Notice(`Imported ${files.length} notes from ${folder}`); } + async switchStorageFolder(newFolder: string): Promise { + // Collect IDs before clearing so we can notify views + const oldIds = Array.from(this.cards.keys()); + + // Clear all cards from the store without touching any files on disk + this.cards.clear(); + this.settings.cards = []; + await (this.plugin as SideCardsPlugin).saveSettings(); + + // Notify views to remove all old cards + for (const id of oldIds) { + this.eventBus.emit('card:deleted', id); + } + + if (!newFolder || newFolder === '/') return; + + // Create the folder if it doesn't exist yet + if (!(await this.app.vault.adapter.exists(newFolder))) { + await this.app.vault.createFolder(newFolder); + } + + await this.importNotesFromFolderToSettings(newFolder, true); + + // Trigger a single re-render for all newly imported cards + const allCards = this.getAll(); + if (allCards.length > 0) { + this.eventBus.emit('card:added', allCards[0]); + } + } + async saveCards(): Promise { await this.saveToStorage(); } diff --git a/src/utils/drag-drop.ts b/src/utils/drag-drop.ts index 632bdfd..0c244e3 100644 --- a/src/utils/drag-drop.ts +++ b/src/utils/drag-drop.ts @@ -1,505 +1,505 @@ -import SideCardsPlugin from "../core/Plugin"; - -/** - Drag-to-reorder using FLIP. - ------------------------------ - We briefly move items in the DOM to see where they’d land, - let the browser calculate the layout, then move them back. - The position changes are animated with transforms. - - drag-n-drop in grid layout is still messy ehm -**/ - +import SideCardsPlugin from "../core/Plugin"; + +/** + Drag-to-reorder using FLIP. + ------------------------------ + We briefly move items in the DOM to see where they’d land, + let the browser calculate the layout, then move them back. + The position changes are animated with transforms. + + drag-n-drop in grid layout is still messy ehm +**/ + export function attachDragToReorder( container: HTMLElement, plugin: SideCardsPlugin, getSortMode: () => string, onReorder: (newIds: string[]) => Promise, onPlaceholderMoved?: () => void -): () => void { - let ghost: HTMLElement | null = null; - let draggedEl: HTMLElement | null = null; - let offsetX = 0; - let offsetY = 0; - let active = false; - - let snapRects: Map = new Map(); - let originalOrder: HTMLElement[] = []; - let currentTargetIndex = -1; - let draggedIndex = -1; - - // Rects for the current targetIndex (used to avoid redundant FLIP reads) - let lastFlipRects: Map = new Map(); - - function getCards(): HTMLElement[] { - return Array.from(container.querySelectorAll(':scope > .sc-card')); - } - - function getCardEl(el: EventTarget | null): HTMLElement | null { - if (!(el instanceof HTMLElement)) return null; - if (el.closest('button, a, [contenteditable="true"], .sc-tag, .sc-copy-btn')) return null; - return el.closest('.sc-card'); - } - - /** Compute target insert index from cursor position using snapshotted rects */ - function computeTargetIndex(clientX: number, clientY: number): number { - const others = originalOrder.filter((_, i) => i !== draggedIndex); - if (others.length === 0) return 0; - - let closest: HTMLElement | null = null; - let closestDist = Infinity; - for (const card of others) { - const r = snapRects.get(card)!; - const cx = r.left + r.width / 2; - const cy = r.top + r.height / 2; - const d = Math.hypot(clientX - cx, clientY - cy); - if (d < closestDist) { closestDist = d; closest = card; } - } - if (!closest) return draggedIndex; - - const closestOrigIdx = originalOrder.indexOf(closest); - const r = snapRects.get(closest)!; - - const isGrid = others.some(c => { - if (c === closest) return false; - return Math.abs(snapRects.get(c)!.top - r.top) < 10; - }); - - const insertBefore = isGrid - ? clientX < r.left + r.width / 2 - : clientY < r.top + r.height / 2; - - if (insertBefore) { - return closestOrigIdx <= draggedIndex ? closestOrigIdx : closestOrigIdx - 1; - } else { - return closestOrigIdx >= draggedIndex ? closestOrigIdx : closestOrigIdx + 1; - } - } - - /** - * FLIP: temporarily reorder DOM to targetIdx, read real rects, revert. - * Returns a map of card → new DOMRect in the target layout. - */ - function readFlipRects(targetIdx: number): Map { - // Build the new order array - const newOrder = originalOrder.slice(); - newOrder.splice(draggedIndex, 1); - newOrder.splice(targetIdx, 0, draggedEl!); - - // Suppress transitions during DOM manipulation - originalOrder.forEach(c => { - c.setCssProps({ - 'transition': 'none', - 'transform': '' - }); - }); - - // Temporarily reorder DOM - newOrder.forEach(card => container.appendChild(card)); - - // Force layout read - const rects = new Map(); - originalOrder.forEach(c => rects.set(c, c.getBoundingClientRect())); - - // Revert DOM to original order - originalOrder.forEach(card => container.appendChild(card)); - - return rects; - } - - /** Apply FLIP animations: translate each card from snapRect → flipRect */ - function applyFlip(flipRects: Map) { - // Step 1: instantly snap each card to its "from" position (no transition) - // This is needed because readFlipRects cleared transforms — we must re-establish - // the current visual position before animating to the new one. - for (const card of originalOrder) { - if (card === draggedEl) continue; - card.setCssProps({ - 'transition': 'none', - 'transform': card.style.transform || '' - }); - } - - // Step 2: force a reflow so the browser registers the transition:none state - void container.offsetHeight; - - // Step 3: set target transforms with transition — browser will animate from current → target - for (const card of originalOrder) { - if (card === draggedEl) continue; - - const from = snapRects.get(card)!; - const to = flipRects.get(card)!; - const dx = to.left - from.left; - const dy = to.top - from.top; - - card.setCssProps({ - 'transition': 'transform 150ms cubic-bezier(0.25,0.46,0.45,0.94)', - 'transform': (dx === 0 && dy === 0) ? '' : `translate(${dx}px,${dy}px)` - }); - } - } - - function clearTransforms() { - getCards().forEach(c => { - c.setCssProps({ - 'transition': '', - 'transform': '' - }); - }); - } - - let dragStartX = 0; - let dragStartY = 0; - let dragPending = false; // mousedown happened but threshold not yet crossed - const DRAG_THRESHOLD = 5; // px - - // If a native HTML5 drag starts (e.g. drag-to-editor), cancel the custom reorder - // immediately so the ghost never appears and mouseup cleanup is skipped. - const onNativeDragStart = () => { - if (!dragPending && !active) return; - // Cancel pending/active custom drag without committing - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - ghost?.remove(); - ghost = null; - if (draggedEl) { - draggedEl.setCssProps({ - 'opacity': '', - 'pointer-events': '' - }); - } - clearTransforms(); - draggedEl = null; active = false; dragPending = false; - snapRects = new Map(); lastFlipRects = new Map(); - originalOrder = []; currentTargetIndex = -1; draggedIndex = -1; - }; - - const onMouseDown = (e: MouseEvent) => { - if (getSortMode() !== 'manual') return; - if (e.button !== 0) return; - const card = getCardEl(e.target); - if (!card) return; - - draggedEl = card; - originalOrder = getCards(); - draggedIndex = originalOrder.indexOf(card); - currentTargetIndex = draggedIndex; - dragStartX = e.clientX; - dragStartY = e.clientY; - dragPending = true; - - // Snapshot rects eagerly so they're ready when drag activates - snapRects = new Map(); - originalOrder.forEach(c => snapRects.set(c, c.getBoundingClientRect())); - - const rect = snapRects.get(card)!; - offsetX = e.clientX - rect.left; - offsetY = e.clientY - rect.top; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - // Don't preventDefault here — let click events through until threshold crossed - }; - - function activateDrag(e: MouseEvent) { - if (!draggedEl) return; - dragPending = false; - active = true; - - const rect = snapRects.get(draggedEl)!; - - // Hide source card in-place (keeps its grid slot) - draggedEl.setCssProps({ - 'opacity': '0', - 'pointer-events': 'none' - }); - - // Ghost: clone with resolved computed styles so CSS vars work on document.body - ghost = draggedEl.cloneNode(true) as HTMLElement; - ghost.classList.add('sc-drag-ghost'); - const cs = getComputedStyle(draggedEl); - ghost.setCssProps({ - 'background': cs.background, - 'background-color': cs.backgroundColor, - 'border-color': cs.borderColor, - 'border-width': cs.borderWidth, - 'border-style': cs.borderStyle, - 'border-radius': cs.borderRadius, - 'color': cs.color, - 'font-size': cs.fontSize, - 'line-height': cs.lineHeight, - 'padding': cs.padding, - 'position': 'fixed', - 'z-index': '9999', - 'pointer-events': 'none', - 'width': rect.width + 'px', - 'height': rect.height + 'px', - 'left': (e.clientX - offsetX) + 'px', - 'top': (e.clientY - offsetY) + 'px', - 'margin': '0', - 'box-shadow': '0 8px 24px rgba(0,0,0,0.25)', - 'opacity': '1', - 'cursor': 'grabbing', - 'transform': '', - 'transition': '' - }); - document.body.appendChild(ghost); - } - const onMouseMove = (e: MouseEvent) => { - if (dragPending) { - const dx = e.clientX - dragStartX; - const dy = e.clientY - dragStartY; - if (Math.hypot(dx, dy) >= DRAG_THRESHOLD) { - activateDrag(e); - } else { - return; - } - } - - if (!active || !ghost || !draggedEl) return; - - ghost.setCssProps({ - 'left': (e.clientX - offsetX) + 'px', - 'top': (e.clientY - offsetY) + 'px' - }); - - const newTarget = computeTargetIndex(e.clientX, e.clientY); - if (newTarget !== currentTargetIndex) { - currentTargetIndex = newTarget; - lastFlipRects = readFlipRects(currentTargetIndex); - applyFlip(lastFlipRects); - } - }; - - const onMouseUp = () => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - - // Threshold never crossed — just a click, cancel silently - if (dragPending) { - dragPending = false; - draggedEl = null; - snapRects = new Map(); - originalOrder = []; - return; - } - - if (!active || !draggedEl) return; - active = false; - - ghost?.remove(); - ghost = null; - - draggedEl.setCssProps({ - 'opacity': '', - 'pointer-events': '' - }); - - // Commit: clear transforms, reorder DOM permanently - clearTransforms(); - - const newOrder = originalOrder.slice(); - newOrder.splice(draggedIndex, 1); - newOrder.splice(currentTargetIndex, 0, draggedEl); - newOrder.forEach(card => container.appendChild(card)); - - const ids = newOrder.map(c => c.dataset.id ?? '').filter(Boolean); - plugin.settings.manualOrder = ids; - void onReorder(ids); - onPlaceholderMoved?.(); - - draggedEl = null; - snapRects = new Map(); - lastFlipRects = new Map(); - originalOrder = []; - currentTargetIndex = -1; - draggedIndex = -1; - }; - - container.addEventListener('mousedown', onMouseDown); - container.addEventListener('dragstart', onNativeDragStart); - - return () => { - container.removeEventListener('mousedown', onMouseDown); - container.removeEventListener('dragstart', onNativeDragStart); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - ghost?.remove(); - if (draggedEl) { - draggedEl.setCssProps({ - 'opacity': '', - 'pointer-events': '' - }); - } - clearTransforms(); - ghost = null; draggedEl = null; active = false; dragPending = false; - snapRects = new Map(); lastFlipRects = new Map(); - originalOrder = []; currentTargetIndex = -1; draggedIndex = -1; - }; -} - -/** - * Drag-to-reorder for the pinned notes list. - * Items are vertical list rows (.sc-home-file-item). - * Persists new order to plugin.settings.pinnedNotes. - */ -export function attachPinnedListDragToReorder( - container: HTMLElement, - getPaths: () => string[], - onReorder: (newPaths: string[]) => void | Promise -): () => void { - let ghost: HTMLElement | null = null; - let draggedEl: HTMLElement | null = null; - let draggedPath = ''; - let offsetY = 0; - let active = false; - let indicator: HTMLElement | null = null; - let dropIndex = -1; - - function getItems(): HTMLElement[] { - return Array.from(container.querySelectorAll(':scope > .sc-home-file-item')); - } - - function getItemEl(el: EventTarget | null): HTMLElement | null { - if (!(el instanceof HTMLElement)) return null; - return el.closest('.sc-home-file-item'); - } - - function getOrCreateIndicator(): HTMLElement { - if (!indicator) { - indicator = document.createElement('div'); - indicator.className = 'sc-pinned-drop-indicator'; - indicator.setCssStyles({ position: 'absolute', left: '0', right: '0', height: '2px', background: 'var(--interactive-accent)', borderRadius: '2px', pointerEvents: 'none', zIndex: '100', display: 'none' }); - } - return indicator; - } - - function computeDropIndex(clientY: number): number { - const items = getItems().filter(i => i !== draggedEl); - if (items.length === 0) return 0; - for (let i = 0; i < items.length; i++) { - const r = items[i].getBoundingClientRect(); - if (clientY < r.top + r.height / 2) return i; - } - return items.length; - } - - function positionIndicator(idx: number) { - const ind = getOrCreateIndicator(); - const items = getItems().filter(i => i !== draggedEl); - const containerRect = container.getBoundingClientRect(); - - let top: number; - if (items.length === 0) { - top = 0; - } else if (idx >= items.length) { - const last = items[items.length - 1].getBoundingClientRect(); - top = last.bottom - containerRect.top; - } else { - const target = items[idx].getBoundingClientRect(); - top = target.top - containerRect.top; - } - - ind.setCssProps({ - 'display': 'block', - 'top': (top - 1) + 'px' - }); - - if (!ind.parentElement) { - container.setCssProps({ 'position': 'relative' }); - container.appendChild(ind); - } - } - - const onMouseDown = (e: MouseEvent) => { - if (e.button !== 0) return; - const item = getItemEl(e.target); - if (!item) return; - - draggedEl = item; - draggedPath = item.dataset.path ?? ''; - if (!draggedPath) return; - - const rect = item.getBoundingClientRect(); - offsetY = e.clientY - rect.top; - - // Ghost - ghost = item.cloneNode(true) as HTMLElement; - ghost.classList.add('sc-drag-ghost'); - const cs = getComputedStyle(item); - ghost.setCssStyles({ - position: 'fixed', - zIndex: '9999', - pointerEvents: 'none', - width: rect.width + 'px', - height: rect.height + 'px', - left: rect.left + 'px', - top: rect.top + 'px', - background: cs.background, - backgroundColor: cs.backgroundColor, - borderRadius: cs.borderRadius, - padding: cs.padding, - fontSize: cs.fontSize, - color: cs.color, - boxShadow: '0 4px 16px rgba(0,0,0,0.2)', - opacity: '0.9', - cursor: 'grabbing', - }); - document.body.appendChild(ghost); - - item.setCssProps({ 'opacity': '0.3' }); - active = true; - dropIndex = computeDropIndex(e.clientY); - positionIndicator(dropIndex); - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - e.preventDefault(); - }; - - const onMouseMove = (e: MouseEvent) => { - if (!active || !ghost) return; - ghost.setCssProps({ 'top': (e.clientY - offsetY) + 'px' }); - dropIndex = computeDropIndex(e.clientY); - positionIndicator(dropIndex); - }; - - const onMouseUp = () => { - if (!active) return; - active = false; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - - ghost?.remove(); - ghost = null; - if (indicator) indicator.setCssProps({ 'display': 'none' }); - if (draggedEl) draggedEl.setCssProps({ 'opacity': '' }); - - // Commit new order - const paths = getPaths().slice(); - const fromIdx = paths.indexOf(draggedPath); - if (fromIdx !== -1 && dropIndex !== -1) { - paths.splice(fromIdx, 1); - // dropIndex is relative to items excluding dragged — adjust for removal - const insertAt = fromIdx < dropIndex ? dropIndex : dropIndex; - paths.splice(insertAt, 0, draggedPath); - void onReorder(paths); - } - - draggedEl = null; - draggedPath = ''; - dropIndex = -1; - }; - - container.addEventListener('mousedown', onMouseDown); - - return () => { - container.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - ghost?.remove(); - indicator?.remove(); - if (draggedEl) draggedEl.setCssProps({ 'opacity': '' }); - ghost = null; draggedEl = null; active = false; indicator = null; - }; -} +): () => void { + let ghost: HTMLElement | null = null; + let draggedEl: HTMLElement | null = null; + let offsetX = 0; + let offsetY = 0; + let active = false; + + let snapRects: Map = new Map(); + let originalOrder: HTMLElement[] = []; + let currentTargetIndex = -1; + let draggedIndex = -1; + + // Rects for the current targetIndex (used to avoid redundant FLIP reads) + let lastFlipRects: Map = new Map(); + + function getCards(): HTMLElement[] { + return Array.from(container.querySelectorAll(':scope > .sc-card')); + } + + function getCardEl(el: EventTarget | null): HTMLElement | null { + if (!(el instanceof HTMLElement)) return null; + if (el.closest('button, a, [contenteditable="true"], .sc-tag, .sc-copy-btn')) return null; + return el.closest('.sc-card'); + } + + /** Compute target insert index from cursor position using snapshotted rects */ + function computeTargetIndex(clientX: number, clientY: number): number { + const others = originalOrder.filter((_, i) => i !== draggedIndex); + if (others.length === 0) return 0; + + let closest: HTMLElement | null = null; + let closestDist = Infinity; + for (const card of others) { + const r = snapRects.get(card)!; + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const d = Math.hypot(clientX - cx, clientY - cy); + if (d < closestDist) { closestDist = d; closest = card; } + } + if (!closest) return draggedIndex; + + const closestOrigIdx = originalOrder.indexOf(closest); + const r = snapRects.get(closest)!; + + const isGrid = others.some(c => { + if (c === closest) return false; + return Math.abs(snapRects.get(c)!.top - r.top) < 10; + }); + + const insertBefore = isGrid + ? clientX < r.left + r.width / 2 + : clientY < r.top + r.height / 2; + + if (insertBefore) { + return closestOrigIdx <= draggedIndex ? closestOrigIdx : closestOrigIdx - 1; + } else { + return closestOrigIdx >= draggedIndex ? closestOrigIdx : closestOrigIdx + 1; + } + } + + /** + * FLIP: temporarily reorder DOM to targetIdx, read real rects, revert. + * Returns a map of card → new DOMRect in the target layout. + */ + function readFlipRects(targetIdx: number): Map { + // Build the new order array + const newOrder = originalOrder.slice(); + newOrder.splice(draggedIndex, 1); + newOrder.splice(targetIdx, 0, draggedEl!); + + // Suppress transitions during DOM manipulation + originalOrder.forEach(c => { + c.setCssProps({ + 'transition': 'none', + 'transform': '' + }); + }); + + // Temporarily reorder DOM + newOrder.forEach(card => container.appendChild(card)); + + // Force layout read + const rects = new Map(); + originalOrder.forEach(c => rects.set(c, c.getBoundingClientRect())); + + // Revert DOM to original order + originalOrder.forEach(card => container.appendChild(card)); + + return rects; + } + + /** Apply FLIP animations: translate each card from snapRect → flipRect */ + function applyFlip(flipRects: Map) { + // Step 1: instantly snap each card to its "from" position (no transition) + // This is needed because readFlipRects cleared transforms — we must re-establish + // the current visual position before animating to the new one. + for (const card of originalOrder) { + if (card === draggedEl) continue; + card.setCssProps({ + 'transition': 'none', + 'transform': card.style.transform || '' + }); + } + + // Step 2: force a reflow so the browser registers the transition:none state + void container.offsetHeight; + + // Step 3: set target transforms with transition — browser will animate from current → target + for (const card of originalOrder) { + if (card === draggedEl) continue; + + const from = snapRects.get(card)!; + const to = flipRects.get(card)!; + const dx = to.left - from.left; + const dy = to.top - from.top; + + card.setCssProps({ + 'transition': 'transform 150ms cubic-bezier(0.25,0.46,0.45,0.94)', + 'transform': (dx === 0 && dy === 0) ? '' : `translate(${dx}px,${dy}px)` + }); + } + } + + function clearTransforms() { + getCards().forEach(c => { + c.setCssProps({ + 'transition': '', + 'transform': '' + }); + }); + } + + let dragStartX = 0; + let dragStartY = 0; + let dragPending = false; // mousedown happened but threshold not yet crossed + const DRAG_THRESHOLD = 5; // px + + // If a native HTML5 drag starts (e.g. drag-to-editor), cancel the custom reorder + // immediately so the ghost never appears and mouseup cleanup is skipped. + const onNativeDragStart = () => { + if (!dragPending && !active) return; + // Cancel pending/active custom drag without committing + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + ghost?.remove(); + ghost = null; + if (draggedEl) { + draggedEl.setCssProps({ + 'opacity': '', + 'pointer-events': '' + }); + } + clearTransforms(); + draggedEl = null; active = false; dragPending = false; + snapRects = new Map(); lastFlipRects = new Map(); + originalOrder = []; currentTargetIndex = -1; draggedIndex = -1; + }; + + const onMouseDown = (e: MouseEvent) => { + if (getSortMode() !== 'manual') return; + if (e.button !== 0) return; + const card = getCardEl(e.target); + if (!card) return; + + draggedEl = card; + originalOrder = getCards(); + draggedIndex = originalOrder.indexOf(card); + currentTargetIndex = draggedIndex; + dragStartX = e.clientX; + dragStartY = e.clientY; + dragPending = true; + + // Snapshot rects eagerly so they're ready when drag activates + snapRects = new Map(); + originalOrder.forEach(c => snapRects.set(c, c.getBoundingClientRect())); + + const rect = snapRects.get(card)!; + offsetX = e.clientX - rect.left; + offsetY = e.clientY - rect.top; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + // Don't preventDefault here — let click events through until threshold crossed + }; + + function activateDrag(e: MouseEvent) { + if (!draggedEl) return; + dragPending = false; + active = true; + + const rect = snapRects.get(draggedEl)!; + + // Hide source card in-place (keeps its grid slot) + draggedEl.setCssProps({ + 'opacity': '0', + 'pointer-events': 'none' + }); + + // Ghost: clone with resolved computed styles so CSS vars work on document.body + ghost = draggedEl.cloneNode(true) as HTMLElement; + ghost.classList.add('sc-drag-ghost'); + const cs = getComputedStyle(draggedEl); + ghost.setCssProps({ + 'background': cs.background, + 'background-color': cs.backgroundColor, + 'border-color': cs.borderColor, + 'border-width': cs.borderWidth, + 'border-style': cs.borderStyle, + 'border-radius': cs.borderRadius, + 'color': cs.color, + 'font-size': cs.fontSize, + 'line-height': cs.lineHeight, + 'padding': cs.padding, + 'position': 'fixed', + 'z-index': '9999', + 'pointer-events': 'none', + 'width': rect.width + 'px', + 'height': rect.height + 'px', + 'left': (e.clientX - offsetX) + 'px', + 'top': (e.clientY - offsetY) + 'px', + 'margin': '0', + 'box-shadow': '0 8px 24px rgba(0,0,0,0.25)', + 'opacity': '1', + 'cursor': 'grabbing', + 'transform': '', + 'transition': '' + }); + document.body.appendChild(ghost); + } + const onMouseMove = (e: MouseEvent) => { + if (dragPending) { + const dx = e.clientX - dragStartX; + const dy = e.clientY - dragStartY; + if (Math.hypot(dx, dy) >= DRAG_THRESHOLD) { + activateDrag(e); + } else { + return; + } + } + + if (!active || !ghost || !draggedEl) return; + + ghost.setCssProps({ + 'left': (e.clientX - offsetX) + 'px', + 'top': (e.clientY - offsetY) + 'px' + }); + + const newTarget = computeTargetIndex(e.clientX, e.clientY); + if (newTarget !== currentTargetIndex) { + currentTargetIndex = newTarget; + lastFlipRects = readFlipRects(currentTargetIndex); + applyFlip(lastFlipRects); + } + }; + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + + // Threshold never crossed — just a click, cancel silently + if (dragPending) { + dragPending = false; + draggedEl = null; + snapRects = new Map(); + originalOrder = []; + return; + } + + if (!active || !draggedEl) return; + active = false; + + ghost?.remove(); + ghost = null; + + draggedEl.setCssProps({ + 'opacity': '', + 'pointer-events': '' + }); + + // Commit: clear transforms, reorder DOM permanently + clearTransforms(); + + const newOrder = originalOrder.slice(); + newOrder.splice(draggedIndex, 1); + newOrder.splice(currentTargetIndex, 0, draggedEl); + newOrder.forEach(card => container.appendChild(card)); + + const ids = newOrder.map(c => c.dataset.id ?? '').filter(Boolean); + plugin.settings.manualOrder = ids; + void onReorder(ids); + onPlaceholderMoved?.(); + + draggedEl = null; + snapRects = new Map(); + lastFlipRects = new Map(); + originalOrder = []; + currentTargetIndex = -1; + draggedIndex = -1; + }; + + container.addEventListener('mousedown', onMouseDown); + container.addEventListener('dragstart', onNativeDragStart); + + return () => { + container.removeEventListener('mousedown', onMouseDown); + container.removeEventListener('dragstart', onNativeDragStart); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + ghost?.remove(); + if (draggedEl) { + draggedEl.setCssProps({ + 'opacity': '', + 'pointer-events': '' + }); + } + clearTransforms(); + ghost = null; draggedEl = null; active = false; dragPending = false; + snapRects = new Map(); lastFlipRects = new Map(); + originalOrder = []; currentTargetIndex = -1; draggedIndex = -1; + }; +} + +/** + * Drag-to-reorder for the pinned notes list. + * Items are vertical list rows (.sc-home-file-item). + * Persists new order to plugin.settings.pinnedNotes. + */ +export function attachPinnedListDragToReorder( + container: HTMLElement, + getPaths: () => string[], + onReorder: (newPaths: string[]) => void | Promise +): () => void { + let ghost: HTMLElement | null = null; + let draggedEl: HTMLElement | null = null; + let draggedPath = ''; + let offsetY = 0; + let active = false; + let indicator: HTMLElement | null = null; + let dropIndex = -1; + + function getItems(): HTMLElement[] { + return Array.from(container.querySelectorAll(':scope > .sc-home-file-item')); + } + + function getItemEl(el: EventTarget | null): HTMLElement | null { + if (!(el instanceof HTMLElement)) return null; + return el.closest('.sc-home-file-item'); + } + + function getOrCreateIndicator(): HTMLElement { + if (!indicator) { + indicator = document.createElement('div'); + indicator.className = 'sc-pinned-drop-indicator'; + indicator.setCssStyles({ position: 'absolute', left: '0', right: '0', height: '2px', background: 'var(--interactive-accent)', borderRadius: '2px', pointerEvents: 'none', zIndex: '100', display: 'none' }); + } + return indicator; + } + + function computeDropIndex(clientY: number): number { + const items = getItems().filter(i => i !== draggedEl); + if (items.length === 0) return 0; + for (let i = 0; i < items.length; i++) { + const r = items[i].getBoundingClientRect(); + if (clientY < r.top + r.height / 2) return i; + } + return items.length; + } + + function positionIndicator(idx: number) { + const ind = getOrCreateIndicator(); + const items = getItems().filter(i => i !== draggedEl); + const containerRect = container.getBoundingClientRect(); + + let top: number; + if (items.length === 0) { + top = 0; + } else if (idx >= items.length) { + const last = items[items.length - 1].getBoundingClientRect(); + top = last.bottom - containerRect.top; + } else { + const target = items[idx].getBoundingClientRect(); + top = target.top - containerRect.top; + } + + ind.setCssProps({ + 'display': 'block', + 'top': (top - 1) + 'px' + }); + + if (!ind.parentElement) { + container.setCssProps({ 'position': 'relative' }); + container.appendChild(ind); + } + } + + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; + const item = getItemEl(e.target); + if (!item) return; + + draggedEl = item; + draggedPath = item.dataset.path ?? ''; + if (!draggedPath) return; + + const rect = item.getBoundingClientRect(); + offsetY = e.clientY - rect.top; + + // Ghost + ghost = item.cloneNode(true) as HTMLElement; + ghost.classList.add('sc-drag-ghost'); + const cs = getComputedStyle(item); + ghost.setCssStyles({ + position: 'fixed', + zIndex: '9999', + pointerEvents: 'none', + width: rect.width + 'px', + height: rect.height + 'px', + left: rect.left + 'px', + top: rect.top + 'px', + background: cs.background, + backgroundColor: cs.backgroundColor, + borderRadius: cs.borderRadius, + padding: cs.padding, + fontSize: cs.fontSize, + color: cs.color, + boxShadow: '0 4px 16px rgba(0,0,0,0.2)', + opacity: '0.9', + cursor: 'grabbing', + }); + document.body.appendChild(ghost); + + item.setCssProps({ 'opacity': '0.3' }); + active = true; + dropIndex = computeDropIndex(e.clientY); + positionIndicator(dropIndex); + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + e.preventDefault(); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!active || !ghost) return; + ghost.setCssProps({ 'top': (e.clientY - offsetY) + 'px' }); + dropIndex = computeDropIndex(e.clientY); + positionIndicator(dropIndex); + }; + + const onMouseUp = () => { + if (!active) return; + active = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + + ghost?.remove(); + ghost = null; + if (indicator) indicator.setCssProps({ 'display': 'none' }); + if (draggedEl) draggedEl.setCssProps({ 'opacity': '' }); + + // Commit new order + const paths = getPaths().slice(); + const fromIdx = paths.indexOf(draggedPath); + if (fromIdx !== -1 && dropIndex !== -1) { + paths.splice(fromIdx, 1); + // dropIndex is relative to items excluding dragged — adjust for removal + const insertAt = fromIdx < dropIndex ? dropIndex : dropIndex; + paths.splice(insertAt, 0, draggedPath); + void onReorder(paths); + } + + draggedEl = null; + draggedPath = ''; + dropIndex = -1; + }; + + container.addEventListener('mousedown', onMouseDown); + + return () => { + container.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + ghost?.remove(); + indicator?.remove(); + if (draggedEl) draggedEl.setCssProps({ 'opacity': '' }); + ghost = null; draggedEl = null; active = false; indicator = null; + }; +} diff --git a/src/views/CardSidebarView.ts b/src/views/CardSidebarView.ts index 7f71d86..e143f3a 100644 --- a/src/views/CardSidebarView.ts +++ b/src/views/CardSidebarView.ts @@ -1,928 +1,928 @@ - -import { ItemView, WorkspaceLeaf, Notice, Menu, TFile, setIcon, Scope, Editor, App } from "obsidian"; -import { CardStore } from "../services/CardStore"; -import { FilterService, FilterOptions } from "../services/FilterService"; -import { SortService, SortMode } from "../services/SortService"; -import { EventBus } from "../core/EventBus"; -import { CardComponent } from "./components/Card"; -import { flipAnimateAsync } from "../utils/animations"; -import { Card } from "../models/Card"; -import SideCardsPlugin from "../core/Plugin"; -import { InlineAutocomplete } from "./components/InlineAutocomplete"; -import { resolveAutoColor } from "../utils/dom"; -import { attachDragToReorder } from "../utils/drag-drop"; - -interface AppWithInternals extends App { - keymap: { pushScope: (scope: Scope) => void; popScope: (scope: Scope) => void }; - workspace: App['workspace'] & { activeEditor: { editor: Editor; editMode: boolean } | null }; -} - -export class CardSidebarView extends ItemView { - private cardsContainer!: HTMLElement; - private cardComponents: Map = new Map(); - private activeFilters: FilterOptions = { query: '', tags: [] }; - private currentSortMode: SortMode = 'manual'; - private sortAscending: boolean = true; - private editorScope: Scope; - private editor!: Editor; - private owner!: { editor: Editor; editMode: boolean }; - private dragDropCleanup: (() => void) | null = null; - - constructor( - leaf: WorkspaceLeaf, - private plugin: SideCardsPlugin, - private store: CardStore, - private eventBus: EventBus, - private filterService: FilterService, - private sortService: SortService - ) { - super(leaf); - this.editorScope = new Scope(this.app.scope); - this.setupMockEditor(); - } - - private setupMockEditor() { - this.editor = { - getSelection: () => { - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return ""; - const selectedText = sel.toString(); - if (selectedText.length > 0) return selectedText; - const wordRange = this.getWordRangeAtCaret(sel); - return wordRange ? wordRange.toString() : ""; - }, - replaceSelection: (text: string) => { - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return; - const currentRange = sel.getRangeAt(0); - const range = currentRange.collapsed - ? (this.getWordRangeAtCaret(sel) || currentRange) - : currentRange; - range.deleteContents(); - const node = document.createTextNode(text); - range.insertNode(node); - range.setStartAfter(node); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - }, - toggleBold: () => this.toggleMarkdownWrapper("**"), - toggleItalic: () => this.toggleMarkdownWrapper("*"), - toggleHighlight: () => this.toggleMarkdownWrapper("=="), - toggleComment: () => this.toggleMarkdownWrapper("%%", "%%", true), - } as unknown as Editor; - - this.owner = { - editor: this.editor, - editMode: true, - }; - } - - private isWordChar(char: string): boolean { - return /[A-Za-z0-9_]/.test(char); - } - - private getWordRangeAtCaret(selection: Selection): Range | null { - if (!selection.rangeCount) return null; - const baseRange = selection.getRangeAt(0); - if (!baseRange.collapsed) return baseRange; - const node = baseRange.startContainer; - if (!(node instanceof Text)) return null; - const text = node.data; - if (!text) return null; - const offset = baseRange.startOffset; - const leftChar = offset > 0 ? text[offset - 1] : ""; - const rightChar = offset < text.length ? text[offset] : ""; - if (!this.isWordChar(leftChar) && !this.isWordChar(rightChar)) return null; - let start = offset; - let end = offset; - while (start > 0 && this.isWordChar(text[start - 1])) start--; - while (end < text.length && this.isWordChar(text[end])) end++; - const wordRange = document.createRange(); - wordRange.setStart(node, start); - wordRange.setEnd(node, end); - return wordRange; - } - - private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==" | "%%", closeWrapper?: string, includeInnerPadding = false) { - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return; - const currentRange = sel.getRangeAt(0); - const range = currentRange.collapsed - ? (this.getWordRangeAtCaret(sel) || currentRange) - : currentRange; - const selectedText = range.toString(); - const endWrapper = closeWrapper ?? wrapper; - if (selectedText.length === 0) { - const text = wrapper + endWrapper; - const node = document.createTextNode(text); - range.insertNode(node); - const cursorOffset = wrapper.length; - range.setStart(node, cursorOffset); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - return; - } - const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(endWrapper); - const newText = alreadyWrapped - ? selectedText.slice(wrapper.length, selectedText.length - endWrapper.length) - : includeInnerPadding - ? `${wrapper} ${selectedText} ${endWrapper}` - : `${wrapper}${selectedText}${endWrapper}`; - sel.removeAllRanges(); - sel.addRange(range); - this.editor.replaceSelection(newText); - } - - private applySelectionWrapShortcut(event: KeyboardEvent, root: HTMLElement): boolean { - if (this.plugin.settings.autoPairBrackets === false) return false; - if (event.ctrlKey || event.metaKey || event.altKey) return false; - const wrapMap: Record = { - "[": ["[", "]"], - "(": ["(", ")"], - "{": ["{", "}"], - "`": ["`", "`"], - "%": ["%", "%"], - "=": ["=", "="], - '"': ['"', '"'], - }; - const pair = wrapMap[event.key]; - if (!pair) return false; - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return false; - const range = sel.getRangeAt(0); - if (!root.contains(range.commonAncestorContainer)) return false; - - const selectedText = range.toString(); - const [open, close] = pair; - let newText = `${open}${selectedText}${close}`; - - // Upgrade logic - if (event.key === "%") { - if (selectedText.startsWith("%") && selectedText.endsWith("%") && !selectedText.startsWith("%%")) { - const inner = selectedText.slice(1, -1).trim(); - newText = `%% ${inner} %%`; - } - } else if (event.key === "=") { - if (selectedText.startsWith("=") && selectedText.endsWith("=") && !selectedText.startsWith("==")) { - const inner = selectedText.slice(1, -1); - newText = `==${inner}==`; - } - } else if (event.key === "[") { - if (selectedText.startsWith("[") && selectedText.endsWith("]") && !selectedText.startsWith("[[")) { - const inner = selectedText.slice(1, -1); - newText = `[[${inner}]]`; - } - } - - event.preventDefault(); - range.deleteContents(); - const node = document.createTextNode(newText); - range.insertNode(node); - - const newRange = document.createRange(); - if (selectedText.length === 0) { - newRange.setStart(node, open.length); - newRange.collapse(true); - } else { - newRange.selectNode(node); - } - sel.removeAllRanges(); - sel.addRange(newRange); - return true; - } - - private getEffectiveHotkeys(commandId: string): Array<{ modifiers?: string[]; key?: string }> { - const appAny = this.app as unknown as { hotkeyManager?: { getHotkeys?: (id: string) => Array<{ modifiers?: string[]; key?: string }>; customKeys?: Record> }; commands?: { commands?: Record }> } }; - const fromManager = appAny.hotkeyManager?.getHotkeys?.(commandId); - if (Array.isArray(fromManager) && fromManager.length > 0) return fromManager; - const custom = appAny.hotkeyManager?.customKeys?.[commandId]; - if (Array.isArray(custom) && custom.length > 0) return custom; - const defaults = appAny.commands?.commands?.[commandId]?.hotkeys; - if (Array.isArray(defaults) && defaults.length > 0) return defaults; - return []; - } - - private getFormattingCommandIds(kind: "bold" | "italic" | "highlight" | "comment"): string[] { - const defaults: Record<"bold" | "italic" | "highlight" | "comment", string[]> = { - bold: ["editor:toggle-bold"], - italic: ["editor:toggle-italic", "editor:toggle-emphasis"], - highlight: ["editor:toggle-highlight"], - comment: ["editor:toggle-comment"], - }; - const appAny = this.app as unknown as { commands?: { commands?: Record } }; - const commands = appAny.commands?.commands || {}; - const matcher: Record<"bold" | "italic" | "highlight" | "comment", RegExp> = { - bold: /bold/i, - italic: /italic|emphasis/i, - highlight: /highlight/i, - comment: /comment/i, - }; - const discovered = Object.values(commands) - .filter((cmd) => typeof cmd?.id === "string" && cmd.id.startsWith("editor:")) - .filter((cmd) => matcher[kind].test(String(cmd?.name || ""))) - .map((cmd) => String(cmd.id)); - return Array.from(new Set([...defaults[kind], ...discovered])); - } - - private eventMatchesHotkey(event: KeyboardEvent, hotkey: { modifiers?: string[]; key?: string }): boolean { - const key = String(hotkey?.key || "").toLowerCase(); - if (!key) return false; - const eventKey = String(event.key || "").toLowerCase(); - if (eventKey !== key) return false; - const modifierSet = new Set((hotkey.modifiers || []).map(m => String(m).toLowerCase())); - const hasMod = modifierSet.has("mod"); - const expectsCtrl = hasMod || modifierSet.has("ctrl"); - const expectsMeta = hasMod || modifierSet.has("meta"); - const expectsAlt = modifierSet.has("alt"); - const expectsShift = modifierSet.has("shift"); - if (expectsCtrl !== event.ctrlKey) return false; - if (expectsMeta !== event.metaKey) return false; - if (expectsAlt !== event.altKey) return false; - if (expectsShift !== event.shiftKey) return false; - return true; - } - - private applyFormattingHotkey(event: KeyboardEvent, root: HTMLElement): boolean { - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return false; - const range = sel.getRangeAt(0); - if (!root.contains(range.commonAncestorContainer)) return false; - const targets: Array<{ kind: "bold" | "italic" | "highlight" | "comment"; run: () => void }> = [ - { kind: "bold", run: () => this.toggleMarkdownWrapper("**") }, - { kind: "italic", run: () => this.toggleMarkdownWrapper("*") }, - { kind: "highlight", run: () => this.toggleMarkdownWrapper("==") }, - { kind: "comment", run: () => this.toggleMarkdownWrapper("%%", "%%", true) }, - ]; - for (const target of targets) { - const commandIds = this.getFormattingCommandIds(target.kind); - const hotkeys = commandIds.flatMap(id => this.getEffectiveHotkeys(id)); - if (!hotkeys.length) continue; - if (!hotkeys.some(h => this.eventMatchesHotkey(event, h))) continue; - event.preventDefault(); - event.stopPropagation(); - target.run(); - return true; - } - return false; - } - - getViewType(): string { - return 'card-sidebar'; - } - - getDisplayText(): string { - return 'Card sidebar'; - } - - getIcon(): string { - return 'rectangle-horizontal'; - } - - private _loadInProgress = false; - private _loadingEl: HTMLElement | null = null; - private _loadingTimeout: ReturnType | null = null; - private _masonryObserver: ResizeObserver | null = null; - private _masonryMutationObserver: MutationObserver | null = null; - private _masonryTimeout: ReturnType | null = null; - - async onOpen(): Promise { - const container = this.containerEl; - container.empty(); - container.addClass('sc-sidebar-container'); - this.currentSortMode = (this.plugin.settings.sortMode || 'manual') as SortMode; - this.sortAscending = typeof this.plugin.settings.sortAscending === 'boolean' - ? !!this.plugin.settings.sortAscending - : true; - - const mainContainer = container.createDiv('sc-sidebar-main'); - - this.createHeader(mainContainer); - this.createSearchBar(mainContainer); - - this.cardsContainer = mainContainer.createDiv('sc-sidebar-cards-container'); - - // Apply scrollbar visibility - this.applyScrollbarSetting(); - - this.createInputBox(mainContainer); - - this.setupListeners(); - this.setupPositionDetection(); - this.setupLayoutObservers(); - this.registerVaultEvents(); - - // Apply openCategoryOnLoad - const openOn = this.plugin.settings.openCategoryOnLoad || 'all'; - const btn = mainContainer.querySelector(`.sc-category-btn[data-filter-value="${openOn}"]`); - if (btn instanceof HTMLElement) btn.click(); - - this.showLoadingOverlay(); - try { - await this.renderCards(); - } finally { - this.hideLoadingOverlay(300); - } - } - - private showLoadingOverlay(maxMs = 2000) { - const parent = this.cardsContainer || this.containerEl; - if (!parent || this._loadingEl) return; - - this._loadingEl = parent.createDiv('sc-sidebar-loading'); - - const box = this._loadingEl.createDiv('sc-sidebar-loading-inner'); - - const spinner = box.createDiv('sc-sidebar-spinner'); - // Inline animation fallback (CSS @keyframes preferred but this works without it) - spinner.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity }); - - box.createDiv({ text: 'Loading cards...', cls: 'sc-sidebar-loading-text' }); - - this._loadingTimeout = setTimeout(() => this.hideLoadingOverlay(), maxMs); - } - - private hideLoadingOverlay(fadeMs = 0) { - if (this._loadingTimeout) { - clearTimeout(this._loadingTimeout); - this._loadingTimeout = null; - } - if (this._loadingEl) { - this._loadingEl.remove(); - this._loadingEl = null; - } - } - - private setupPositionDetection() { - const detect = () => { - let position = 'right'; - let current = this.containerEl; - let depth = 0; - while (current && depth < 10) { - const cls = current.className || ''; - if (cls.includes('side-dock-left')) { position = 'left'; break; } - if (cls.includes('side-dock-right')) { position = 'right'; break; } - current = current.parentElement as HTMLElement; - depth++; - } - if (this.plugin.settings.sidebarPosition !== position) { - this.plugin.settings.sidebarPosition = position; - void this.plugin.saveSettings(); - } - }; - detect(); - const observer = new MutationObserver(detect); - if (this.containerEl.parentElement) { - observer.observe(this.containerEl.parentElement, { attributes: true, attributeFilter: ['class'] }); - } - } - - private setupLayoutObservers() { - if (typeof ResizeObserver !== 'undefined') { - const ro = new ResizeObserver(() => { - // Refresh masonry or layout if needed - }); - ro.observe(this.cardsContainer); - } - } - - private registerVaultEvents() { - this.plugin.registerEvent(this.app.vault.on('modify', async (file) => { - if (!(file instanceof TFile)) return; - // Skip if we're the ones writing this file (prevents expiry/color wipe race) - if (this.store._syncingPaths?.has(file.path)) return; - const pending = this.store._pendingTagWrites.get(file.path); - if (pending) { - await this.store.handlePendingTagReapply(file, pending); - } else { - await this.store.updateCardFromNotePath(file.path); - } - })); - } - - private createHeader(container: HTMLElement): void { - if (this.plugin.settings.disableFilterButtons) return; - - const header = container.createDiv('sc-sidebar-header'); - const filterGroup = header.createDiv('sc-category-group'); - - const settings = this.plugin.settings; - const cats: Array<{ id: string; label: string; showInMenu?: boolean }> = - Array.isArray(settings.customCategories) ? settings.customCategories : []; - - // Build ordered chip list using allItemsOrder if available - const defaultOrder = ['filter-all'] - .concat(!settings.hideTodayFilter ? ['filter-today'] : []) - .concat(!settings.hideTomorrowFilter ? ['filter-tomorrow'] : []) - .concat(!settings.hideArchivedFilterButton ? ['filter-archived'] : []) - .concat(cats.map(c => String(c.id || ''))); - - const combinedOrder = Array.isArray(settings.allItemsOrder) && settings.allItemsOrder.length > 0 - ? settings.allItemsOrder - : defaultOrder; - - const chips: Array<{ type: string; label: string; value: string }> = []; - - combinedOrder.forEach(itemId => { - if (!itemId) return; - if (itemId === 'filter-all') { - chips.push({ type: 'all', label: 'All', value: 'all' }); - return; - } - if (itemId === 'filter-today') { - if (!settings.hideTodayFilter) chips.push({ type: 'category', label: 'Today', value: 'today' }); - return; - } - if (itemId === 'filter-tomorrow') { - if (!settings.hideTomorrowFilter) chips.push({ type: 'category', label: 'Tomorrow', value: 'tomorrow' }); - return; - } - if (itemId === 'filter-archived') { - if (!settings.hideArchivedFilterButton) chips.push({ type: 'archived', label: 'Archived', value: 'archived' }); - return; - } - const cat = cats.find(c => String(c.id) === String(itemId)); - if (cat && cat.showInMenu !== false) { - chips.push({ type: 'category', label: cat.label || '', value: cat.id || cat.label || '' }); - } - }); - - // Ensure 'All' is always present - if (!chips.find(c => c.value === 'all')) { - chips.unshift({ type: 'all', label: 'All', value: 'all' }); - } - - chips.forEach(chip => { - const btn = filterGroup.createEl('button', { text: chip.label }); - btn.addClass('sc-category-btn'); - btn.dataset.filterType = chip.type; - btn.dataset.filterValue = chip.value; - - // Apply custom colors from filterColors settings - const colorKey = chip.value === 'all' ? 'all' - : chip.value === 'today' ? 'today' - : chip.value === 'tomorrow' ? 'tomorrow' - : chip.value === 'archived' ? 'archived' - : chip.value; - - const customColors = settings.filterColors?.[colorKey]; - if (customColors?.bgColor) btn.style.setProperty('background-color', customColors.bgColor, 'important'); - if (customColors?.textColor) { - btn.style.setProperty('color', customColors.textColor, 'important'); - btn.style.setProperty('--sc-btn-text-color', customColors.textColor); - } - - btn.addEventListener('click', () => { - void (async () => { - filterGroup.querySelectorAll('.sc-category-btn').forEach(b => { - (b as HTMLElement).removeClass('active'); - const bVal = (b as HTMLElement).dataset.filterValue || ''; - const bColors = settings.filterColors?.[bVal]; - if (bColors?.bgColor) (b as HTMLElement).style.setProperty('background-color', bColors.bgColor, 'important'); - else (b as HTMLElement).style.removeProperty('background-color'); - if (bColors?.textColor) { - (b as HTMLElement).style.setProperty('color', bColors.textColor, 'important'); - (b as HTMLElement).style.setProperty('--sc-btn-text-color', bColors.textColor); - } else { - (b as HTMLElement).style.removeProperty('color'); - (b as HTMLElement).style.removeProperty('--sc-btn-text-color'); - } - }); - btn.addClass('active'); - if (chip.type === 'archived') { - this.activeFilters.archivedOnly = true; - this.activeFilters.category = undefined; - } else if (chip.type === 'all') { - this.activeFilters.archivedOnly = false; - this.activeFilters.category = undefined; - } else if (chip.type === 'category') { - this.activeFilters.archivedOnly = false; - this.activeFilters.category = chip.value; - } - await this.renderCards(); - })(); - }); - }); - } - - private applyScrollbarSetting(): void { - if (!this.cardsContainer) return; - if (this.plugin.settings.hideScrollbar) { - this.cardsContainer.addClass('hide-scrollbar'); - // Also apply to parent if needed - this.containerEl.querySelector('.view-content')?.addClass('hide-scrollbar'); - } else { - this.cardsContainer.removeClass('hide-scrollbar'); - this.containerEl.querySelector('.view-content')?.removeClass('hide-scrollbar'); - } - } - - private createSearchBar(container: HTMLElement): void { - const wrapper = container.createDiv('sc-search-wrap'); - wrapper.toggleClass('sc-search-wrap--hidden', !this.plugin.settings.searchBarVisible); - const row = wrapper.createDiv('sc-search-row'); - - const iconSpan = row.createSpan({ cls: 'sc-search-input-icon' }); - try { setIcon(iconSpan, 'search'); } catch { /* icon may not exist */ } - - const input = row.createEl('input', { type: 'search', placeholder: 'Search cards…', cls: 'sc-search-input' }); - const clearBtn = row.createEl('button', { text: '✕', cls: 'sc-search-clear-btn' }); - clearBtn.toggleClass('sc-search-wrap--hidden', true); - clearBtn.title = 'Clear search'; - clearBtn.addEventListener('click', () => { - input.value = ''; - this.activeFilters.query = ''; - this.renderCardsDebounced(); - clearBtn.toggleClass('sc-search-wrap--hidden', true); - input.focus(); - }); - - input.addEventListener('input', () => { - this.activeFilters.query = input.value || ''; - clearBtn.toggleClass('sc-search-wrap--hidden', !this.activeFilters.query); - this.renderCardsDebounced(); - }); - } - - private createInputBox(container: HTMLElement): void { - const inputContainer = container.createDiv('sc-sidebar-input-container'); - - const editorEl = inputContainer.createDiv({ cls: 'sc-sidebar-input' }); - - editorEl.setAttribute('contenteditable', 'true'); - editorEl.dataset.placeholder = 'Type here... (@category, #tag)'; - const updatePlaceholder = () => { - if (!editorEl.textContent?.trim()) { - editorEl.addClass('is-empty'); - } else { - editorEl.removeClass('is-empty'); - } - }; - updatePlaceholder(); - editorEl.addEventListener('input', updatePlaceholder); - - editorEl.addEventListener('focusin', () => { - (this.app as unknown as AppWithInternals).keymap.pushScope(this.editorScope); - (this.app as unknown as AppWithInternals).workspace.activeEditor = this.owner as any; - }); - - editorEl.addEventListener('blur', () => { - (this.app as unknown as AppWithInternals).keymap.popScope(this.editorScope); - if ((this.app as unknown as AppWithInternals).workspace.activeEditor === this.owner) { - (this.app as unknown as AppWithInternals).workspace.activeEditor = null; - } - }); - - // @category / #tag / [[file]] inline autocomplete - new InlineAutocomplete(editorEl, this.store, this.app); - - editorEl.addEventListener('keydown', (e) => { - if (this.applyFormattingHotkey(e, editorEl)) return; - this.applySelectionWrapShortcut(e, editorEl); - - // Save key handling - const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s+_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, ''); - const saveKey = normalizeKey(this.plugin.settings.saveKey || 'enter'); - let pressed = ''; - if (e.ctrlKey) pressed += 'ctrl-'; - if (e.shiftKey) pressed += 'shift-'; - if (e.altKey) pressed += 'alt-'; - if (e.key && e.key.toLowerCase() === 'enter') pressed += 'enter'; - if (pressed === saveKey || (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey)) { - e.preventDefault(); - addButton.click(); - } - }); - - const buttonContainer = inputContainer.createDiv('sc-sidebar-button-container'); - - const searchBtn = buttonContainer.createEl('button'); - searchBtn.addClass('sc-icon-btn'); - try { setIcon(searchBtn, 'search'); } catch { searchBtn.textContent = 'Search'; } - searchBtn.title = 'Toggle search'; - searchBtn.addEventListener('click', () => { - const wrap = this.containerEl.querySelector('.sc-search-wrap'); - if (wrap) { - const isHidden = wrap.hasClass('sc-search-wrap--hidden'); - wrap.toggleClass('sc-search-wrap--hidden', !isHidden); - this.plugin.settings.searchBarVisible = isHidden; - void this.plugin.saveSettings(); - } - }); - - const reloadBtn = buttonContainer.createEl('button'); - reloadBtn.addClass('sc-icon-btn'); - reloadBtn.title = 'Reload cards'; - try { setIcon(reloadBtn, 'refresh-cw'); } catch { reloadBtn.textContent = 'Reload'; } - reloadBtn.addEventListener('click', () => { - void (async () => { - await this.renderCards(true); - new Notice('Cards reloaded'); - })(); - }); - - const sortBtn = buttonContainer.createEl('button'); - sortBtn.addClass('sc-icon-btn'); - sortBtn.title = 'Sort'; - try { setIcon(sortBtn, 'sort-desc'); } catch { sortBtn.textContent = 'Sort'; } - sortBtn.addEventListener('click', (e) => { - const menu = new Menu(); - const modes: Array<{ key: SortMode; label: string }> = [ - { key: 'manual', label: 'Manual' }, - { key: 'created', label: 'Created Time' }, - { key: 'modified', label: 'Modified Time' }, - { key: 'alpha', label: 'A → Z' }, - { key: 'status', label: 'Status' } - ]; - modes.forEach(m => { - menu.addItem(item => { - item.setTitle(m.label); - if (this.currentSortMode === m.key) item.setChecked(true); - item.onClick(async () => { - this.currentSortMode = m.key; - this.plugin.settings.sortMode = m.key; - await this.plugin.saveSettings(); - await this.renderCards(); - }); - }); - }); - menu.addSeparator(); - menu.addItem(item => { - item.setTitle(this.sortAscending ? 'Direction: Ascending' : 'Direction: Descending'); - item.onClick(async () => { - this.sortAscending = !this.sortAscending; - this.plugin.settings.sortAscending = this.sortAscending; - await this.plugin.saveSettings(); - await this.renderCards(); - }); - }); - menu.showAtMouseEvent(e); - }); - - const pinToggleBtn = buttonContainer.createEl('button'); - pinToggleBtn.addClass('sc-icon-btn'); - try { setIcon(pinToggleBtn, 'pin'); } catch { pinToggleBtn.textContent = 'Pin'; } - pinToggleBtn.title = 'Show pinned only'; - pinToggleBtn.addEventListener('click', () => { - void (async () => { - this.activeFilters.pinnedOnly = !this.activeFilters.pinnedOnly; - await this.renderCards(); - })(); - }); - - const gridToggleBtn = buttonContainer.createEl('button'); - gridToggleBtn.addClass('sc-icon-btn'); - try { setIcon(gridToggleBtn, 'layout-grid'); } catch { gridToggleBtn.textContent = 'Grid'; } - gridToggleBtn.title = 'Toggle grid layout'; - gridToggleBtn.addEventListener('click', () => { - void (async () => { - this.plugin.settings.verticalCardMode = !this.plugin.settings.verticalCardMode; - await this.plugin.saveSettings(); - await flipAnimateAsync(this.cardsContainer, async () => { - this.applyLayoutMode(); - }, {}, this.store.settings); - })(); - }); - - const moreBtn = buttonContainer.createEl('button'); - moreBtn.addClass('sc-icon-btn'); - try { setIcon(moreBtn, 'more-vertical'); } catch { moreBtn.textContent = '…'; } - moreBtn.title = 'More'; - moreBtn.addEventListener('click', (e) => { - const menu = new Menu(); - menu.addItem(item => { - item.setTitle('Show tags') - .setChecked(this.plugin.settings.sidebarShowTags ?? true) - .onClick(async () => { - const current = this.plugin.settings.sidebarShowTags ?? true; - this.plugin.settings.sidebarShowTags = !current; - await this.plugin.saveSettings(); - await this.renderCards(); - }); - }); - menu.addItem(item => { - item.setTitle('Show timestamps') - .setChecked(this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps) - .onClick(async () => { - const current = this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps; - this.plugin.settings.sidebarShowTimestamps = !current; - await this.plugin.saveSettings(); - await this.renderCards(); - }); - }); - menu.showAtMouseEvent(e); - }); - - const addButton = buttonContainer.createEl('button'); - addButton.addClass('sc-add-btn'); - addButton.textContent = 'Add card'; - addButton.addEventListener('click', () => { - void (async () => { - const content = editorEl.textContent?.trim(); - if (!content) return; - - // Extract tags (#tag) and categories (@category) - const tags: string[] = []; - const tagRegex = /#([^\s#@,.]+)/g; - let match; - while ((match = tagRegex.exec(content)) !== null) { - tags.push(match[1]); - } - - const catRegex = /@([^\s#@,.]+)/g; - const catMatch = catRegex.exec(content); - const category = catMatch ? catMatch[1] : (this.activeFilters.category || undefined); - - // Strip @category and #tag tokens from the saved content - const cleanContent = content.replace(/@[^\s#@,.]+/g, '').replace(/#[^\s#@,.]+/g, '').replace(/\s{2,}/g, ' ').trim(); - - // Auto color - const autoColor = resolveAutoColor(content, tags, this.plugin.settings); - const color = autoColor || 'var(--card-color-1)'; - - const card = new Card({ - content: cleanContent, - tags, - color, - category: category === 'all' ? undefined : category - }); - await this.store.add(card); - editorEl.textContent = ''; - updatePlaceholder(); - })(); - }); - } - - private setupListeners(): void { - this.eventBus.on('card:added', () => { void this.renderCards(); }); - this.eventBus.on('card:deleted', () => { void this.renderCards(); }); - this.eventBus.on('card:updated', () => { void this.renderCards(); }); - this.eventBus.on('filter:tag', (tag) => { - if (this.activeFilters.tags.includes(tag)) { - this.activeFilters.tags = this.activeFilters.tags.filter(t => t !== tag); - } else { - this.activeFilters.tags.push(tag); - } - void this.renderCards(); - }); - - this.eventBus.on('card:contextmenu', ({ card, event }) => { - const menu = new Menu(); - menu.addItem(item => { - item.setTitle('Delete') - .setIcon('trash') - .onClick(() => { void this.store.delete(card.id); }); - }); - menu.addItem(item => { - item.setTitle(card.archived ? 'Unarchive' : 'Archive') - .setIcon('archive') - .onClick(() => { void this.store.update(card.id, { archived: !card.archived }); }); - }); - menu.showAtMouseEvent(event); - }); - } - - private renderTimeout: ReturnType | null = null; - private renderCardsDebounced(): void { - if (this.renderTimeout) clearTimeout(this.renderTimeout); - this.renderTimeout = setTimeout(() => { void this.renderCards(); }, 300); - } - - private async renderCards(isManualReload = false): Promise { - const settings = { ...this.store.settings }; - const scrollTop = this.cardsContainer?.scrollTop || 0; - - await flipAnimateAsync(this.cardsContainer, async () => { - // Clear existing components - this.cardComponents.forEach(comp => comp.destroy()); - this.cardComponents.clear(); - this.cardsContainer.empty(); - - // Filter and sort - let cards = this.store.getAll(); - cards = this.filterService.filter(cards, this.activeFilters); - cards = this.sortService.sort(cards, this.currentSortMode, this.sortAscending, this.app); - - // Render each card - for (const card of cards) { - const comp = new CardComponent(this.cardsContainer, card, this.store, this.app, this.plugin, { - groupTags: this.plugin.settings.sidebarGroupTags ?? this.plugin.settings.groupTags, - showTimestamps: this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps, - showTags: this.plugin.settings.sidebarShowTags ?? true, - }); - this.cardComponents.set(card.id, comp); - } - - // Apply layout mode (Masonry) BEFORE measurements are taken by flipAnimateAsync - this.applyLayoutMode(); - - // Restore scroll position BEFORE measurements are taken - if (this.cardsContainer) { - this.cardsContainer.scrollTop = scrollTop; - } - }, {}, settings); - - // Re-attach drag-to-reorder (cleans up previous listeners first) - this.dragDropCleanup?.(); - this.dragDropCleanup = attachDragToReorder( - this.cardsContainer, - this.plugin, - () => this.currentSortMode, - async () => { - await this.plugin.saveSettings(); - }, - () => { this.refreshMasonrySpans(); } - ); - } - - onClose() { - return Promise.resolve(); - } - - private applyLayoutMode(): void { - if (!this.cardsContainer) return; - const grid = !!this.plugin.settings.verticalCardMode; - if (grid) { - this.cardsContainer.addClass('grid-mode'); - this.cardsContainer.addClass('vertical-card-mode'); - this.setupMasonryObserver(); - this.refreshMasonrySpans(); - } else { - this.cardsContainer.removeClass('grid-mode'); - this.cardsContainer.removeClass('vertical-card-mode'); - if (this._masonryObserver) { - this._masonryObserver.disconnect(); - this._masonryObserver = null; - } - if (this._masonryMutationObserver) { - this._masonryMutationObserver.disconnect(); - this._masonryMutationObserver = null; - } - this.cardsContainer.querySelectorAll('.sc-card').forEach((el) => { - (el as HTMLElement).style.removeProperty('grid-row-end'); - }); - } - } - - private setupMasonryObserver(): void { - if (!this.cardsContainer) return; - if (typeof ResizeObserver === 'undefined') { - this.setupMasonryMutationObserver(); - return; - } - if (this._masonryObserver) this._masonryObserver.disconnect(); - this._masonryObserver = new ResizeObserver(() => { - if (this._masonryTimeout) clearTimeout(this._masonryTimeout); - this._masonryTimeout = setTimeout(() => this.refreshMasonrySpans(), 50); - }); - this._masonryObserver.observe(this.cardsContainer); - this.cardsContainer.querySelectorAll('.sc-card').forEach(el => { - this._masonryObserver?.observe(el); - }); - this.setupMasonryMutationObserver(); - } - - private refreshMasonrySpans(): void { - if (!this.plugin.settings.verticalCardMode || !this.cardsContainer) return; - const cards = this.cardsContainer.querySelectorAll('.sc-card:not(.drag-spacer)'); - cards.forEach((el) => { - const card = el as HTMLElement; - card.style.removeProperty('grid-row-end'); - const h = card.getBoundingClientRect().height; - if (h > 0) { - const span = Math.max(1, Math.ceil(h + 8)); - card.style.setProperty('grid-row-end', `span ${span}`); - } - }); - } - - private setupMasonryMutationObserver(): void { - if (!this.cardsContainer || typeof MutationObserver === 'undefined') return; - if (this._masonryMutationObserver) this._masonryMutationObserver.disconnect(); - let recalcTimeout: ReturnType | null = null; - const debounced = () => { - if (recalcTimeout) clearTimeout(recalcTimeout); - recalcTimeout = setTimeout(() => this.refreshMasonrySpans(), 120); - }; - this._masonryMutationObserver = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if ( - mutation.type === 'childList' || - mutation.type === 'characterData' || - (mutation.type === 'attributes' && mutation.attributeName === 'class') - ) { - debounced(); - break; - } - } - }); - this._masonryMutationObserver.observe(this.cardsContainer, { - childList: true, - subtree: true, - characterData: true, - attributes: true, - attributeFilter: ['class'] - }); - } -} + +import { ItemView, WorkspaceLeaf, Notice, Menu, TFile, setIcon, Scope, Editor, App } from "obsidian"; +import { CardStore } from "../services/CardStore"; +import { FilterService, FilterOptions } from "../services/FilterService"; +import { SortService, SortMode } from "../services/SortService"; +import { EventBus } from "../core/EventBus"; +import { CardComponent } from "./components/Card"; +import { flipAnimateAsync } from "../utils/animations"; +import { Card } from "../models/Card"; +import SideCardsPlugin from "../core/Plugin"; +import { InlineAutocomplete } from "./components/InlineAutocomplete"; +import { resolveAutoColor } from "../utils/dom"; +import { attachDragToReorder } from "../utils/drag-drop"; + +interface AppWithInternals extends App { + keymap: { pushScope: (scope: Scope) => void; popScope: (scope: Scope) => void }; + workspace: App['workspace'] & { activeEditor: { editor: Editor; editMode: boolean } | null }; +} + +export class CardSidebarView extends ItemView { + private cardsContainer!: HTMLElement; + private cardComponents: Map = new Map(); + private activeFilters: FilterOptions = { query: '', tags: [] }; + private currentSortMode: SortMode = 'manual'; + private sortAscending: boolean = true; + private editorScope: Scope; + private editor!: Editor; + private owner!: { editor: Editor; editMode: boolean }; + private dragDropCleanup: (() => void) | null = null; + + constructor( + leaf: WorkspaceLeaf, + private plugin: SideCardsPlugin, + private store: CardStore, + private eventBus: EventBus, + private filterService: FilterService, + private sortService: SortService + ) { + super(leaf); + this.editorScope = new Scope(this.app.scope); + this.setupMockEditor(); + } + + private setupMockEditor() { + this.editor = { + getSelection: () => { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return ""; + const selectedText = sel.toString(); + if (selectedText.length > 0) return selectedText; + const wordRange = this.getWordRangeAtCaret(sel); + return wordRange ? wordRange.toString() : ""; + }, + replaceSelection: (text: string) => { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return; + const currentRange = sel.getRangeAt(0); + const range = currentRange.collapsed + ? (this.getWordRangeAtCaret(sel) || currentRange) + : currentRange; + range.deleteContents(); + const node = document.createTextNode(text); + range.insertNode(node); + range.setStartAfter(node); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + }, + toggleBold: () => this.toggleMarkdownWrapper("**"), + toggleItalic: () => this.toggleMarkdownWrapper("*"), + toggleHighlight: () => this.toggleMarkdownWrapper("=="), + toggleComment: () => this.toggleMarkdownWrapper("%%", "%%", true), + } as unknown as Editor; + + this.owner = { + editor: this.editor, + editMode: true, + }; + } + + private isWordChar(char: string): boolean { + return /[A-Za-z0-9_]/.test(char); + } + + private getWordRangeAtCaret(selection: Selection): Range | null { + if (!selection.rangeCount) return null; + const baseRange = selection.getRangeAt(0); + if (!baseRange.collapsed) return baseRange; + const node = baseRange.startContainer; + if (!(node instanceof Text)) return null; + const text = node.data; + if (!text) return null; + const offset = baseRange.startOffset; + const leftChar = offset > 0 ? text[offset - 1] : ""; + const rightChar = offset < text.length ? text[offset] : ""; + if (!this.isWordChar(leftChar) && !this.isWordChar(rightChar)) return null; + let start = offset; + let end = offset; + while (start > 0 && this.isWordChar(text[start - 1])) start--; + while (end < text.length && this.isWordChar(text[end])) end++; + const wordRange = document.createRange(); + wordRange.setStart(node, start); + wordRange.setEnd(node, end); + return wordRange; + } + + private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==" | "%%", closeWrapper?: string, includeInnerPadding = false) { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return; + const currentRange = sel.getRangeAt(0); + const range = currentRange.collapsed + ? (this.getWordRangeAtCaret(sel) || currentRange) + : currentRange; + const selectedText = range.toString(); + const endWrapper = closeWrapper ?? wrapper; + if (selectedText.length === 0) { + const text = wrapper + endWrapper; + const node = document.createTextNode(text); + range.insertNode(node); + const cursorOffset = wrapper.length; + range.setStart(node, cursorOffset); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return; + } + const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(endWrapper); + const newText = alreadyWrapped + ? selectedText.slice(wrapper.length, selectedText.length - endWrapper.length) + : includeInnerPadding + ? `${wrapper} ${selectedText} ${endWrapper}` + : `${wrapper}${selectedText}${endWrapper}`; + sel.removeAllRanges(); + sel.addRange(range); + this.editor.replaceSelection(newText); + } + + private applySelectionWrapShortcut(event: KeyboardEvent, root: HTMLElement): boolean { + if (this.plugin.settings.autoPairBrackets === false) return false; + if (event.ctrlKey || event.metaKey || event.altKey) return false; + const wrapMap: Record = { + "[": ["[", "]"], + "(": ["(", ")"], + "{": ["{", "}"], + "`": ["`", "`"], + "%": ["%", "%"], + "=": ["=", "="], + '"': ['"', '"'], + }; + const pair = wrapMap[event.key]; + if (!pair) return false; + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return false; + const range = sel.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return false; + + const selectedText = range.toString(); + const [open, close] = pair; + let newText = `${open}${selectedText}${close}`; + + // Upgrade logic + if (event.key === "%") { + if (selectedText.startsWith("%") && selectedText.endsWith("%") && !selectedText.startsWith("%%")) { + const inner = selectedText.slice(1, -1).trim(); + newText = `%% ${inner} %%`; + } + } else if (event.key === "=") { + if (selectedText.startsWith("=") && selectedText.endsWith("=") && !selectedText.startsWith("==")) { + const inner = selectedText.slice(1, -1); + newText = `==${inner}==`; + } + } else if (event.key === "[") { + if (selectedText.startsWith("[") && selectedText.endsWith("]") && !selectedText.startsWith("[[")) { + const inner = selectedText.slice(1, -1); + newText = `[[${inner}]]`; + } + } + + event.preventDefault(); + range.deleteContents(); + const node = document.createTextNode(newText); + range.insertNode(node); + + const newRange = document.createRange(); + if (selectedText.length === 0) { + newRange.setStart(node, open.length); + newRange.collapse(true); + } else { + newRange.selectNode(node); + } + sel.removeAllRanges(); + sel.addRange(newRange); + return true; + } + + private getEffectiveHotkeys(commandId: string): Array<{ modifiers?: string[]; key?: string }> { + const appAny = this.app as unknown as { hotkeyManager?: { getHotkeys?: (id: string) => Array<{ modifiers?: string[]; key?: string }>; customKeys?: Record> }; commands?: { commands?: Record }> } }; + const fromManager = appAny.hotkeyManager?.getHotkeys?.(commandId); + if (Array.isArray(fromManager) && fromManager.length > 0) return fromManager; + const custom = appAny.hotkeyManager?.customKeys?.[commandId]; + if (Array.isArray(custom) && custom.length > 0) return custom; + const defaults = appAny.commands?.commands?.[commandId]?.hotkeys; + if (Array.isArray(defaults) && defaults.length > 0) return defaults; + return []; + } + + private getFormattingCommandIds(kind: "bold" | "italic" | "highlight" | "comment"): string[] { + const defaults: Record<"bold" | "italic" | "highlight" | "comment", string[]> = { + bold: ["editor:toggle-bold"], + italic: ["editor:toggle-italic", "editor:toggle-emphasis"], + highlight: ["editor:toggle-highlight"], + comment: ["editor:toggle-comment"], + }; + const appAny = this.app as unknown as { commands?: { commands?: Record } }; + const commands = appAny.commands?.commands || {}; + const matcher: Record<"bold" | "italic" | "highlight" | "comment", RegExp> = { + bold: /bold/i, + italic: /italic|emphasis/i, + highlight: /highlight/i, + comment: /comment/i, + }; + const discovered = Object.values(commands) + .filter((cmd) => typeof cmd?.id === "string" && cmd.id.startsWith("editor:")) + .filter((cmd) => matcher[kind].test(String(cmd?.name || ""))) + .map((cmd) => String(cmd.id)); + return Array.from(new Set([...defaults[kind], ...discovered])); + } + + private eventMatchesHotkey(event: KeyboardEvent, hotkey: { modifiers?: string[]; key?: string }): boolean { + const key = String(hotkey?.key || "").toLowerCase(); + if (!key) return false; + const eventKey = String(event.key || "").toLowerCase(); + if (eventKey !== key) return false; + const modifierSet = new Set((hotkey.modifiers || []).map(m => String(m).toLowerCase())); + const hasMod = modifierSet.has("mod"); + const expectsCtrl = hasMod || modifierSet.has("ctrl"); + const expectsMeta = hasMod || modifierSet.has("meta"); + const expectsAlt = modifierSet.has("alt"); + const expectsShift = modifierSet.has("shift"); + if (expectsCtrl !== event.ctrlKey) return false; + if (expectsMeta !== event.metaKey) return false; + if (expectsAlt !== event.altKey) return false; + if (expectsShift !== event.shiftKey) return false; + return true; + } + + private applyFormattingHotkey(event: KeyboardEvent, root: HTMLElement): boolean { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return false; + const range = sel.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return false; + const targets: Array<{ kind: "bold" | "italic" | "highlight" | "comment"; run: () => void }> = [ + { kind: "bold", run: () => this.toggleMarkdownWrapper("**") }, + { kind: "italic", run: () => this.toggleMarkdownWrapper("*") }, + { kind: "highlight", run: () => this.toggleMarkdownWrapper("==") }, + { kind: "comment", run: () => this.toggleMarkdownWrapper("%%", "%%", true) }, + ]; + for (const target of targets) { + const commandIds = this.getFormattingCommandIds(target.kind); + const hotkeys = commandIds.flatMap(id => this.getEffectiveHotkeys(id)); + if (!hotkeys.length) continue; + if (!hotkeys.some(h => this.eventMatchesHotkey(event, h))) continue; + event.preventDefault(); + event.stopPropagation(); + target.run(); + return true; + } + return false; + } + + getViewType(): string { + return 'card-sidebar'; + } + + getDisplayText(): string { + return 'Card sidebar'; + } + + getIcon(): string { + return 'rectangle-horizontal'; + } + + private _loadInProgress = false; + private _loadingEl: HTMLElement | null = null; + private _loadingTimeout: ReturnType | null = null; + private _masonryObserver: ResizeObserver | null = null; + private _masonryMutationObserver: MutationObserver | null = null; + private _masonryTimeout: ReturnType | null = null; + + async onOpen(): Promise { + const container = this.containerEl; + container.empty(); + container.addClass('sc-sidebar-container'); + this.currentSortMode = (this.plugin.settings.sortMode || 'manual') as SortMode; + this.sortAscending = typeof this.plugin.settings.sortAscending === 'boolean' + ? !!this.plugin.settings.sortAscending + : true; + + const mainContainer = container.createDiv('sc-sidebar-main'); + + this.createHeader(mainContainer); + this.createSearchBar(mainContainer); + + this.cardsContainer = mainContainer.createDiv('sc-sidebar-cards-container'); + + // Apply scrollbar visibility + this.applyScrollbarSetting(); + + this.createInputBox(mainContainer); + + this.setupListeners(); + this.setupPositionDetection(); + this.setupLayoutObservers(); + this.registerVaultEvents(); + + // Apply openCategoryOnLoad + const openOn = this.plugin.settings.openCategoryOnLoad || 'all'; + const btn = mainContainer.querySelector(`.sc-category-btn[data-filter-value="${openOn}"]`); + if (btn instanceof HTMLElement) btn.click(); + + this.showLoadingOverlay(); + try { + await this.renderCards(); + } finally { + this.hideLoadingOverlay(300); + } + } + + private showLoadingOverlay(maxMs = 2000) { + const parent = this.cardsContainer || this.containerEl; + if (!parent || this._loadingEl) return; + + this._loadingEl = parent.createDiv('sc-sidebar-loading'); + + const box = this._loadingEl.createDiv('sc-sidebar-loading-inner'); + + const spinner = box.createDiv('sc-sidebar-spinner'); + // Inline animation fallback (CSS @keyframes preferred but this works without it) + spinner.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity }); + + box.createDiv({ text: 'Loading cards...', cls: 'sc-sidebar-loading-text' }); + + this._loadingTimeout = setTimeout(() => this.hideLoadingOverlay(), maxMs); + } + + private hideLoadingOverlay(fadeMs = 0) { + if (this._loadingTimeout) { + clearTimeout(this._loadingTimeout); + this._loadingTimeout = null; + } + if (this._loadingEl) { + this._loadingEl.remove(); + this._loadingEl = null; + } + } + + private setupPositionDetection() { + const detect = () => { + let position = 'right'; + let current = this.containerEl; + let depth = 0; + while (current && depth < 10) { + const cls = current.className || ''; + if (cls.includes('side-dock-left')) { position = 'left'; break; } + if (cls.includes('side-dock-right')) { position = 'right'; break; } + current = current.parentElement as HTMLElement; + depth++; + } + if (this.plugin.settings.sidebarPosition !== position) { + this.plugin.settings.sidebarPosition = position; + void this.plugin.saveSettings(); + } + }; + detect(); + const observer = new MutationObserver(detect); + if (this.containerEl.parentElement) { + observer.observe(this.containerEl.parentElement, { attributes: true, attributeFilter: ['class'] }); + } + } + + private setupLayoutObservers() { + if (typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(() => { + // Refresh masonry or layout if needed + }); + ro.observe(this.cardsContainer); + } + } + + private registerVaultEvents() { + this.plugin.registerEvent(this.app.vault.on('modify', async (file) => { + if (!(file instanceof TFile)) return; + // Skip if we're the ones writing this file (prevents expiry/color wipe race) + if (this.store._syncingPaths?.has(file.path)) return; + const pending = this.store._pendingTagWrites.get(file.path); + if (pending) { + await this.store.handlePendingTagReapply(file, pending); + } else { + await this.store.updateCardFromNotePath(file.path); + } + })); + } + + private createHeader(container: HTMLElement): void { + if (this.plugin.settings.disableFilterButtons) return; + + const header = container.createDiv('sc-sidebar-header'); + const filterGroup = header.createDiv('sc-category-group'); + + const settings = this.plugin.settings; + const cats: Array<{ id: string; label: string; showInMenu?: boolean }> = + Array.isArray(settings.customCategories) ? settings.customCategories : []; + + // Build ordered chip list using allItemsOrder if available + const defaultOrder = ['filter-all'] + .concat(!settings.hideTodayFilter ? ['filter-today'] : []) + .concat(!settings.hideTomorrowFilter ? ['filter-tomorrow'] : []) + .concat(!settings.hideArchivedFilterButton ? ['filter-archived'] : []) + .concat(cats.map(c => String(c.id || ''))); + + const combinedOrder = Array.isArray(settings.allItemsOrder) && settings.allItemsOrder.length > 0 + ? settings.allItemsOrder + : defaultOrder; + + const chips: Array<{ type: string; label: string; value: string }> = []; + + combinedOrder.forEach(itemId => { + if (!itemId) return; + if (itemId === 'filter-all') { + chips.push({ type: 'all', label: 'All', value: 'all' }); + return; + } + if (itemId === 'filter-today') { + if (!settings.hideTodayFilter) chips.push({ type: 'category', label: 'Today', value: 'today' }); + return; + } + if (itemId === 'filter-tomorrow') { + if (!settings.hideTomorrowFilter) chips.push({ type: 'category', label: 'Tomorrow', value: 'tomorrow' }); + return; + } + if (itemId === 'filter-archived') { + if (!settings.hideArchivedFilterButton) chips.push({ type: 'archived', label: 'Archived', value: 'archived' }); + return; + } + const cat = cats.find(c => String(c.id) === String(itemId)); + if (cat && cat.showInMenu !== false) { + chips.push({ type: 'category', label: cat.label || '', value: cat.id || cat.label || '' }); + } + }); + + // Ensure 'All' is always present + if (!chips.find(c => c.value === 'all')) { + chips.unshift({ type: 'all', label: 'All', value: 'all' }); + } + + chips.forEach(chip => { + const btn = filterGroup.createEl('button', { text: chip.label }); + btn.addClass('sc-category-btn'); + btn.dataset.filterType = chip.type; + btn.dataset.filterValue = chip.value; + + // Apply custom colors from filterColors settings + const colorKey = chip.value === 'all' ? 'all' + : chip.value === 'today' ? 'today' + : chip.value === 'tomorrow' ? 'tomorrow' + : chip.value === 'archived' ? 'archived' + : chip.value; + + const customColors = settings.filterColors?.[colorKey]; + if (customColors?.bgColor) btn.style.setProperty('background-color', customColors.bgColor, 'important'); + if (customColors?.textColor) { + btn.style.setProperty('color', customColors.textColor, 'important'); + btn.style.setProperty('--sc-btn-text-color', customColors.textColor); + } + + btn.addEventListener('click', () => { + void (async () => { + filterGroup.querySelectorAll('.sc-category-btn').forEach(b => { + (b as HTMLElement).removeClass('active'); + const bVal = (b as HTMLElement).dataset.filterValue || ''; + const bColors = settings.filterColors?.[bVal]; + if (bColors?.bgColor) (b as HTMLElement).style.setProperty('background-color', bColors.bgColor, 'important'); + else (b as HTMLElement).style.removeProperty('background-color'); + if (bColors?.textColor) { + (b as HTMLElement).style.setProperty('color', bColors.textColor, 'important'); + (b as HTMLElement).style.setProperty('--sc-btn-text-color', bColors.textColor); + } else { + (b as HTMLElement).style.removeProperty('color'); + (b as HTMLElement).style.removeProperty('--sc-btn-text-color'); + } + }); + btn.addClass('active'); + if (chip.type === 'archived') { + this.activeFilters.archivedOnly = true; + this.activeFilters.category = undefined; + } else if (chip.type === 'all') { + this.activeFilters.archivedOnly = false; + this.activeFilters.category = undefined; + } else if (chip.type === 'category') { + this.activeFilters.archivedOnly = false; + this.activeFilters.category = chip.value; + } + await this.renderCards(); + })(); + }); + }); + } + + private applyScrollbarSetting(): void { + if (!this.cardsContainer) return; + if (this.plugin.settings.hideScrollbar) { + this.cardsContainer.addClass('hide-scrollbar'); + // Also apply to parent if needed + this.containerEl.querySelector('.view-content')?.addClass('hide-scrollbar'); + } else { + this.cardsContainer.removeClass('hide-scrollbar'); + this.containerEl.querySelector('.view-content')?.removeClass('hide-scrollbar'); + } + } + + private createSearchBar(container: HTMLElement): void { + const wrapper = container.createDiv('sc-search-wrap'); + wrapper.toggleClass('sc-search-wrap--hidden', !this.plugin.settings.searchBarVisible); + const row = wrapper.createDiv('sc-search-row'); + + const iconSpan = row.createSpan({ cls: 'sc-search-input-icon' }); + try { setIcon(iconSpan, 'search'); } catch { /* icon may not exist */ } + + const input = row.createEl('input', { type: 'search', placeholder: 'Search cards…', cls: 'sc-search-input' }); + const clearBtn = row.createEl('button', { text: '✕', cls: 'sc-search-clear-btn' }); + clearBtn.toggleClass('sc-search-wrap--hidden', true); + clearBtn.title = 'Clear search'; + clearBtn.addEventListener('click', () => { + input.value = ''; + this.activeFilters.query = ''; + this.renderCardsDebounced(); + clearBtn.toggleClass('sc-search-wrap--hidden', true); + input.focus(); + }); + + input.addEventListener('input', () => { + this.activeFilters.query = input.value || ''; + clearBtn.toggleClass('sc-search-wrap--hidden', !this.activeFilters.query); + this.renderCardsDebounced(); + }); + } + + private createInputBox(container: HTMLElement): void { + const inputContainer = container.createDiv('sc-sidebar-input-container'); + + const editorEl = inputContainer.createDiv({ cls: 'sc-sidebar-input' }); + + editorEl.setAttribute('contenteditable', 'true'); + editorEl.dataset.placeholder = 'Type here... (@category, #tag)'; + const updatePlaceholder = () => { + if (!editorEl.textContent?.trim()) { + editorEl.addClass('is-empty'); + } else { + editorEl.removeClass('is-empty'); + } + }; + updatePlaceholder(); + editorEl.addEventListener('input', updatePlaceholder); + + editorEl.addEventListener('focusin', () => { + (this.app as unknown as AppWithInternals).keymap.pushScope(this.editorScope); + (this.app as unknown as AppWithInternals).workspace.activeEditor = this.owner as any; + }); + + editorEl.addEventListener('blur', () => { + (this.app as unknown as AppWithInternals).keymap.popScope(this.editorScope); + if ((this.app as unknown as AppWithInternals).workspace.activeEditor === this.owner) { + (this.app as unknown as AppWithInternals).workspace.activeEditor = null; + } + }); + + // @category / #tag / [[file]] inline autocomplete + new InlineAutocomplete(editorEl, this.store, this.app); + + editorEl.addEventListener('keydown', (e) => { + if (this.applyFormattingHotkey(e, editorEl)) return; + this.applySelectionWrapShortcut(e, editorEl); + + // Save key handling + const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s+_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const saveKey = normalizeKey(this.plugin.settings.saveKey || 'enter'); + let pressed = ''; + if (e.ctrlKey) pressed += 'ctrl-'; + if (e.shiftKey) pressed += 'shift-'; + if (e.altKey) pressed += 'alt-'; + if (e.key && e.key.toLowerCase() === 'enter') pressed += 'enter'; + if (pressed === saveKey || (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey)) { + e.preventDefault(); + addButton.click(); + } + }); + + const buttonContainer = inputContainer.createDiv('sc-sidebar-button-container'); + + const searchBtn = buttonContainer.createEl('button'); + searchBtn.addClass('sc-icon-btn'); + try { setIcon(searchBtn, 'search'); } catch { searchBtn.textContent = 'Search'; } + searchBtn.title = 'Toggle search'; + searchBtn.addEventListener('click', () => { + const wrap = this.containerEl.querySelector('.sc-search-wrap'); + if (wrap) { + const isHidden = wrap.hasClass('sc-search-wrap--hidden'); + wrap.toggleClass('sc-search-wrap--hidden', !isHidden); + this.plugin.settings.searchBarVisible = isHidden; + void this.plugin.saveSettings(); + } + }); + + const reloadBtn = buttonContainer.createEl('button'); + reloadBtn.addClass('sc-icon-btn'); + reloadBtn.title = 'Reload cards'; + try { setIcon(reloadBtn, 'refresh-cw'); } catch { reloadBtn.textContent = 'Reload'; } + reloadBtn.addEventListener('click', () => { + void (async () => { + await this.renderCards(true); + new Notice('Cards reloaded'); + })(); + }); + + const sortBtn = buttonContainer.createEl('button'); + sortBtn.addClass('sc-icon-btn'); + sortBtn.title = 'Sort'; + try { setIcon(sortBtn, 'sort-desc'); } catch { sortBtn.textContent = 'Sort'; } + sortBtn.addEventListener('click', (e) => { + const menu = new Menu(); + const modes: Array<{ key: SortMode; label: string }> = [ + { key: 'manual', label: 'Manual' }, + { key: 'created', label: 'Created Time' }, + { key: 'modified', label: 'Modified Time' }, + { key: 'alpha', label: 'A → Z' }, + { key: 'status', label: 'Status' } + ]; + modes.forEach(m => { + menu.addItem(item => { + item.setTitle(m.label); + if (this.currentSortMode === m.key) item.setChecked(true); + item.onClick(async () => { + this.currentSortMode = m.key; + this.plugin.settings.sortMode = m.key; + await this.plugin.saveSettings(); + await this.renderCards(); + }); + }); + }); + menu.addSeparator(); + menu.addItem(item => { + item.setTitle(this.sortAscending ? 'Direction: Ascending' : 'Direction: Descending'); + item.onClick(async () => { + this.sortAscending = !this.sortAscending; + this.plugin.settings.sortAscending = this.sortAscending; + await this.plugin.saveSettings(); + await this.renderCards(); + }); + }); + menu.showAtMouseEvent(e); + }); + + const pinToggleBtn = buttonContainer.createEl('button'); + pinToggleBtn.addClass('sc-icon-btn'); + try { setIcon(pinToggleBtn, 'pin'); } catch { pinToggleBtn.textContent = 'Pin'; } + pinToggleBtn.title = 'Show pinned only'; + pinToggleBtn.addEventListener('click', () => { + void (async () => { + this.activeFilters.pinnedOnly = !this.activeFilters.pinnedOnly; + await this.renderCards(); + })(); + }); + + const gridToggleBtn = buttonContainer.createEl('button'); + gridToggleBtn.addClass('sc-icon-btn'); + try { setIcon(gridToggleBtn, 'layout-grid'); } catch { gridToggleBtn.textContent = 'Grid'; } + gridToggleBtn.title = 'Toggle grid layout'; + gridToggleBtn.addEventListener('click', () => { + void (async () => { + this.plugin.settings.verticalCardMode = !this.plugin.settings.verticalCardMode; + await this.plugin.saveSettings(); + await flipAnimateAsync(this.cardsContainer, async () => { + this.applyLayoutMode(); + }, {}, this.store.settings); + })(); + }); + + const moreBtn = buttonContainer.createEl('button'); + moreBtn.addClass('sc-icon-btn'); + try { setIcon(moreBtn, 'more-vertical'); } catch { moreBtn.textContent = '…'; } + moreBtn.title = 'More'; + moreBtn.addEventListener('click', (e) => { + const menu = new Menu(); + menu.addItem(item => { + item.setTitle('Show tags') + .setChecked(this.plugin.settings.sidebarShowTags ?? true) + .onClick(async () => { + const current = this.plugin.settings.sidebarShowTags ?? true; + this.plugin.settings.sidebarShowTags = !current; + await this.plugin.saveSettings(); + await this.renderCards(); + }); + }); + menu.addItem(item => { + item.setTitle('Show timestamps') + .setChecked(this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps) + .onClick(async () => { + const current = this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps; + this.plugin.settings.sidebarShowTimestamps = !current; + await this.plugin.saveSettings(); + await this.renderCards(); + }); + }); + menu.showAtMouseEvent(e); + }); + + const addButton = buttonContainer.createEl('button'); + addButton.addClass('sc-add-btn'); + addButton.textContent = 'Add card'; + addButton.addEventListener('click', () => { + void (async () => { + const content = editorEl.textContent?.trim(); + if (!content) return; + + // Extract tags (#tag) and categories (@category) + const tags: string[] = []; + const tagRegex = /#([^\s#@,.]+)/g; + let match; + while ((match = tagRegex.exec(content)) !== null) { + tags.push(match[1]); + } + + const catRegex = /@([^\s#@,.]+)/g; + const catMatch = catRegex.exec(content); + const category = catMatch ? catMatch[1] : (this.activeFilters.category || undefined); + + // Strip @category and #tag tokens from the saved content + const cleanContent = content.replace(/@[^\s#@,.]+/g, '').replace(/#[^\s#@,.]+/g, '').replace(/\s{2,}/g, ' ').trim(); + + // Auto color + const autoColor = resolveAutoColor(content, tags, this.plugin.settings); + const color = autoColor || 'var(--card-color-1)'; + + const card = new Card({ + content: cleanContent, + tags, + color, + category: category === 'all' ? undefined : category + }); + await this.store.add(card); + editorEl.textContent = ''; + updatePlaceholder(); + })(); + }); + } + + private setupListeners(): void { + this.eventBus.on('card:added', () => { void this.renderCards(); }); + this.eventBus.on('card:deleted', () => { void this.renderCards(); }); + this.eventBus.on('card:updated', () => { void this.renderCards(); }); + this.eventBus.on('filter:tag', (tag) => { + if (this.activeFilters.tags.includes(tag)) { + this.activeFilters.tags = this.activeFilters.tags.filter(t => t !== tag); + } else { + this.activeFilters.tags.push(tag); + } + void this.renderCards(); + }); + + this.eventBus.on('card:contextmenu', ({ card, event }) => { + const menu = new Menu(); + menu.addItem(item => { + item.setTitle('Delete') + .setIcon('trash') + .onClick(() => { void this.store.delete(card.id); }); + }); + menu.addItem(item => { + item.setTitle(card.archived ? 'Unarchive' : 'Archive') + .setIcon('archive') + .onClick(() => { void this.store.update(card.id, { archived: !card.archived }); }); + }); + menu.showAtMouseEvent(event); + }); + } + + private renderTimeout: ReturnType | null = null; + private renderCardsDebounced(): void { + if (this.renderTimeout) clearTimeout(this.renderTimeout); + this.renderTimeout = setTimeout(() => { void this.renderCards(); }, 300); + } + + private async renderCards(isManualReload = false): Promise { + const settings = { ...this.store.settings }; + const scrollTop = this.cardsContainer?.scrollTop || 0; + + await flipAnimateAsync(this.cardsContainer, async () => { + // Clear existing components + this.cardComponents.forEach(comp => comp.destroy()); + this.cardComponents.clear(); + this.cardsContainer.empty(); + + // Filter and sort + let cards = this.store.getAll(); + cards = this.filterService.filter(cards, this.activeFilters); + cards = this.sortService.sort(cards, this.currentSortMode, this.sortAscending, this.app); + + // Render each card + for (const card of cards) { + const comp = new CardComponent(this.cardsContainer, card, this.store, this.app, this.plugin, { + groupTags: this.plugin.settings.sidebarGroupTags ?? this.plugin.settings.groupTags, + showTimestamps: this.plugin.settings.sidebarShowTimestamps ?? this.plugin.settings.showTimestamps, + showTags: this.plugin.settings.sidebarShowTags ?? true, + }); + this.cardComponents.set(card.id, comp); + } + + // Apply layout mode (Masonry) BEFORE measurements are taken by flipAnimateAsync + this.applyLayoutMode(); + + // Restore scroll position BEFORE measurements are taken + if (this.cardsContainer) { + this.cardsContainer.scrollTop = scrollTop; + } + }, {}, settings); + + // Re-attach drag-to-reorder (cleans up previous listeners first) + this.dragDropCleanup?.(); + this.dragDropCleanup = attachDragToReorder( + this.cardsContainer, + this.plugin, + () => this.currentSortMode, + async () => { + await this.plugin.saveSettings(); + }, + () => { this.refreshMasonrySpans(); } + ); + } + + onClose() { + return Promise.resolve(); + } + + private applyLayoutMode(): void { + if (!this.cardsContainer) return; + const grid = !!this.plugin.settings.verticalCardMode; + if (grid) { + this.cardsContainer.addClass('grid-mode'); + this.cardsContainer.addClass('vertical-card-mode'); + this.setupMasonryObserver(); + this.refreshMasonrySpans(); + } else { + this.cardsContainer.removeClass('grid-mode'); + this.cardsContainer.removeClass('vertical-card-mode'); + if (this._masonryObserver) { + this._masonryObserver.disconnect(); + this._masonryObserver = null; + } + if (this._masonryMutationObserver) { + this._masonryMutationObserver.disconnect(); + this._masonryMutationObserver = null; + } + this.cardsContainer.querySelectorAll('.sc-card').forEach((el) => { + (el as HTMLElement).style.removeProperty('grid-row-end'); + }); + } + } + + private setupMasonryObserver(): void { + if (!this.cardsContainer) return; + if (typeof ResizeObserver === 'undefined') { + this.setupMasonryMutationObserver(); + return; + } + if (this._masonryObserver) this._masonryObserver.disconnect(); + this._masonryObserver = new ResizeObserver(() => { + if (this._masonryTimeout) clearTimeout(this._masonryTimeout); + this._masonryTimeout = setTimeout(() => this.refreshMasonrySpans(), 50); + }); + this._masonryObserver.observe(this.cardsContainer); + this.cardsContainer.querySelectorAll('.sc-card').forEach(el => { + this._masonryObserver?.observe(el); + }); + this.setupMasonryMutationObserver(); + } + + private refreshMasonrySpans(): void { + if (!this.plugin.settings.verticalCardMode || !this.cardsContainer) return; + const cards = this.cardsContainer.querySelectorAll('.sc-card:not(.drag-spacer)'); + cards.forEach((el) => { + const card = el as HTMLElement; + card.style.removeProperty('grid-row-end'); + const h = card.getBoundingClientRect().height; + if (h > 0) { + const span = Math.max(1, Math.ceil(h + 8)); + card.style.setProperty('grid-row-end', `span ${span}`); + } + }); + } + + private setupMasonryMutationObserver(): void { + if (!this.cardsContainer || typeof MutationObserver === 'undefined') return; + if (this._masonryMutationObserver) this._masonryMutationObserver.disconnect(); + let recalcTimeout: ReturnType | null = null; + const debounced = () => { + if (recalcTimeout) clearTimeout(recalcTimeout); + recalcTimeout = setTimeout(() => this.refreshMasonrySpans(), 120); + }; + this._masonryMutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.type === 'childList' || + mutation.type === 'characterData' || + (mutation.type === 'attributes' && mutation.attributeName === 'class') + ) { + debounced(); + break; + } + } + }); + this._masonryMutationObserver.observe(this.cardsContainer, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ['class'] + }); + } +} diff --git a/src/views/HomeView.ts b/src/views/HomeView.ts index b9688ba..c1867bb 100644 --- a/src/views/HomeView.ts +++ b/src/views/HomeView.ts @@ -1,1102 +1,1102 @@ -import { ItemView, WorkspaceLeaf, Menu, Notice, setIcon, Scope, Editor, TFile, Platform, App } from "obsidian"; -import type SideCardsPlugin from "../core/Plugin"; -import { CardStore } from "../services/CardStore"; -import { Card } from "../models/Card"; -import { CardComponent } from "./components/Card"; -import { SortService, SortMode } from "../services/SortService"; -import { resolveAutoColor } from "../utils/dom"; -import { animateCardsEntrance } from "../utils/animations"; -import { InlineAutocomplete } from "./components/InlineAutocomplete"; -import { attachDragToReorder, attachPinnedListDragToReorder } from "../utils/drag-drop"; - -interface AppWithInternals extends App { - keymap: { pushScope: (scope: Scope) => void; popScope: (scope: Scope) => void }; - workspace: App['workspace'] & { activeEditor: { editor: Editor; editMode: boolean } | null }; -} - -type IconicIconEntry = { icon?: string; name?: string; value?: string; color?: string } | string; - -export class SideCardsHomeView extends ItemView { - private selectedColor = 'var(--card-color-1)'; - private selectedTags: string[] = []; - private filterType = ''; - private filterValue = ''; - private cardListEl: HTMLElement | null = null; - private editorScope: Scope; - private editor!: Editor; - private owner!: { editor: Editor; editMode: boolean }; - private recentFiles: TFile[] = []; - private pinnedFiles: TFile[] = []; - private cardComponents: Map = new Map(); - private currentSortMode: SortMode = 'created'; - private sortAscending: boolean = false; - private pinnedOnly: boolean = false; - private dragDropCleanup: (() => void) | null = null; - private currentSearchQuery: string = ''; - private iconicFileIconsCache: Record | null = null; - - constructor( - leaf: WorkspaceLeaf, - private plugin: SideCardsPlugin, - private store: CardStore, - private sortService: SortService - ) { - super(leaf); - this.editorScope = new Scope(this.app.scope); - this.setupMockEditor(); - } - - private setupMockEditor() { - this.editor = { - getSelection: () => { - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return ""; - const selectedText = sel.toString(); - if (selectedText.length > 0) return selectedText; - const wordRange = this.getWordRangeAtCaret(sel); - return wordRange ? wordRange.toString() : ""; - }, - replaceSelection: (text: string) => { - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return; - const currentRange = sel.getRangeAt(0); - const range = currentRange.collapsed - ? (this.getWordRangeAtCaret(sel) || currentRange) - : currentRange; - range.deleteContents(); - const node = document.createTextNode(text); - range.insertNode(node); - range.setStartAfter(node); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - }, - toggleBold: () => this.toggleMarkdownWrapper("**"), - toggleItalic: () => this.toggleMarkdownWrapper("*"), - toggleHighlight: () => this.toggleMarkdownWrapper("=="), - toggleComment: () => this.toggleMarkdownWrapper("%%", "%%", true), - } as unknown as Editor; - - this.owner = { - editor: this.editor, - editMode: true, - }; - } - - private isWordChar(char: string): boolean { - return /[A-Za-z0-9_]/.test(char); - } - - private getWordRangeAtCaret(selection: Selection): Range | null { - if (!selection.rangeCount) return null; - const baseRange = selection.getRangeAt(0); - if (!baseRange.collapsed) return baseRange; - const node = baseRange.startContainer; - if (!(node instanceof Text)) return null; - const text = node.data; - if (!text) return null; - const offset = baseRange.startOffset; - const leftChar = offset > 0 ? text[offset - 1] : ""; - const rightChar = offset < text.length ? text[offset] : ""; - if (!this.isWordChar(leftChar) && !this.isWordChar(rightChar)) return null; - let start = offset; - let end = offset; - while (start > 0 && this.isWordChar(text[start - 1])) start--; - while (end < text.length && this.isWordChar(text[end])) end++; - const wordRange = document.createRange(); - wordRange.setStart(node, start); - wordRange.setEnd(node, end); - return wordRange; - } - - private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==" | "%%", closeWrapper?: string, includeInnerPadding = false) { - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return; - const currentRange = sel.getRangeAt(0); - const range = currentRange.collapsed - ? (this.getWordRangeAtCaret(sel) || currentRange) - : currentRange; - const selectedText = range.toString(); - const endWrapper = closeWrapper ?? wrapper; - if (selectedText.length === 0) { - const text = wrapper + endWrapper; - const node = document.createTextNode(text); - range.insertNode(node); - const cursorOffset = wrapper.length; - range.setStart(node, cursorOffset); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - return; - } - const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(endWrapper); - const newText = alreadyWrapped - ? selectedText.slice(wrapper.length, selectedText.length - endWrapper.length) - : includeInnerPadding - ? `${wrapper} ${selectedText} ${endWrapper}` - : `${wrapper}${selectedText}${endWrapper}`; - sel.removeAllRanges(); - sel.addRange(range); - this.editor.replaceSelection(newText); - } - - private handleKeyWrap(event: KeyboardEvent): { handled: boolean } { - if (this.plugin.settings.autoPairBrackets === false) return { handled: false }; - if (event.ctrlKey || event.metaKey || event.altKey) return { handled: false }; - - const key = event.key; - const wrapMap: Record = { - "[": ["[", "]"], - "(": ["(", ")"], - "{": ["{", "}"], - "`": ["`", "`"], - "%": ["%", "%"], - "=": ["=", "="], - '"': ['"', '"'], - }; - - const pair = wrapMap[key]; - if (!pair) return { handled: false }; - - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return { handled: false }; - - const range = sel.getRangeAt(0); - const editorEl = this.containerEl.querySelector('.sc-home-editor'); - if (!editorEl || !editorEl.contains(range.commonAncestorContainer)) return { handled: false }; - - const selectedText = range.toString(); - const [open, close] = pair; - let newText = `${open}${selectedText}${close}`; - - // Upgrade logic - if (key === "%") { - if (selectedText.startsWith("%") && selectedText.endsWith("%") && !selectedText.startsWith("%%")) { - const inner = selectedText.slice(1, -1).trim(); - newText = `%% ${inner} %%`; - } - } else if (key === "=") { - if (selectedText.startsWith("=") && selectedText.endsWith("=") && !selectedText.startsWith("==")) { - const inner = selectedText.slice(1, -1); - newText = `==${inner}==`; - } - } else if (key === "[") { - if (selectedText.startsWith("[") && selectedText.endsWith("]") && !selectedText.startsWith("[[")) { - const inner = selectedText.slice(1, -1); - newText = `[[${inner}]]`; - } - } - - range.deleteContents(); - const node = document.createTextNode(newText); - range.insertNode(node); - - const newRange = document.createRange(); - if (selectedText.length === 0) { - // No selection: place cursor between the pair - newRange.setStart(node, open.length); - newRange.collapse(true); - } else { - // Had selection: select the wrapped text - newRange.selectNode(node); - } - sel.removeAllRanges(); - sel.addRange(newRange); - - return { handled: true }; - } - - private getEffectiveHotkeys(commandId: string): Array<{ modifiers?: string[]; key?: string }> { - const appAny = this.app as unknown as { hotkeyManager?: { getHotkeys?: (id: string) => Array<{ modifiers?: string[]; key?: string }>; customKeys?: Record> }; commands?: { commands?: Record }> } }; - const fromManager = appAny.hotkeyManager?.getHotkeys?.(commandId); - if (Array.isArray(fromManager) && fromManager.length > 0) return fromManager; - const custom = appAny.hotkeyManager?.customKeys?.[commandId]; - if (Array.isArray(custom) && custom.length > 0) return custom; - const defaults = appAny.commands?.commands?.[commandId]?.hotkeys; - if (Array.isArray(defaults) && defaults.length > 0) return defaults; - return []; - } - - private getFormattingCommandIds(kind: "bold" | "italic" | "highlight" | "comment"): string[] { - const defaults: Record<"bold" | "italic" | "highlight" | "comment", string[]> = { - bold: ["editor:toggle-bold", "custom-wrap-bold"], - italic: ["editor:toggle-italic", "editor:toggle-emphasis", "custom-wrap-italic"], - highlight: ["editor:toggle-highlight", "custom-wrap-highlight"], - comment: ["editor:toggle-comment", "custom-wrap-comment"], - }; - const appAny = this.app as unknown as { commands?: { commands?: Record } }; - const commands = appAny.commands?.commands || {}; - const matcher: Record<"bold" | "italic" | "highlight" | "comment", RegExp> = { - bold: /bold/i, - italic: /italic|emphasis/i, - highlight: /highlight/i, - comment: /comment/i, - }; - const discovered = Object.values(commands) - .filter((cmd) => typeof cmd?.id === "string" && cmd.id.startsWith("editor:")) - .filter((cmd) => matcher[kind].test(String(cmd?.name || ""))) - .map((cmd) => String(cmd.id)); - return Array.from(new Set([...defaults[kind], ...discovered])); - } - - private eventMatchesHotkey(event: KeyboardEvent, hotkey: { modifiers?: string[]; key?: string }): boolean { - const key = String(hotkey?.key || "").toLowerCase(); - if (!key) return false; - const eventKey = String(event.key || "").toLowerCase(); - if (eventKey !== key) return false; - const modifierSet = new Set((hotkey.modifiers || []).map(m => String(m).toLowerCase())); - - const hasMod = modifierSet.has("mod"); - const expectsCtrl = modifierSet.has("ctrl") || (hasMod && !Platform.isMacOS); - const expectsMeta = modifierSet.has("meta") || (hasMod && Platform.isMacOS); - const expectsAlt = modifierSet.has("alt"); - const expectsShift = modifierSet.has("shift"); - if (expectsCtrl !== event.ctrlKey) return false; - if (expectsMeta !== event.metaKey) return false; - if (expectsAlt !== event.altKey) return false; - if (expectsShift !== event.shiftKey) return false; - return true; - } - - private applyFormattingHotkey(event: KeyboardEvent, root: HTMLElement): boolean { - const sel = window.getSelection(); - if (!sel || !sel.rangeCount) return false; - const range = sel.getRangeAt(0); - if (!root.contains(range.commonAncestorContainer)) return false; - const targets: Array<{ kind: "bold" | "italic" | "highlight" | "comment"; run: () => void }> = [ - { kind: "bold", run: () => this.toggleMarkdownWrapper("**") }, - { kind: "italic", run: () => this.toggleMarkdownWrapper("*") }, - { kind: "highlight", run: () => this.toggleMarkdownWrapper("==") }, - { kind: "comment", run: () => this.toggleMarkdownWrapper("%%", "%%", true) }, - ]; - for (const target of targets) { - const commandIds = this.getFormattingCommandIds(target.kind); - const hotkeys = commandIds.flatMap(id => this.getEffectiveHotkeys(id)); - if (!hotkeys.length) continue; - if (!hotkeys.some(h => this.eventMatchesHotkey(event, h))) continue; - event.preventDefault(); - event.stopPropagation(); - target.run(); - root.dispatchEvent(new Event('input')); - return true; - } - return false; - } - - getViewType(): string { - return 'sidecards-home'; - } - - getDisplayText(): string { - return 'Sidecards'; - } - - getIcon(): string { - return 'home'; - } - - async onOpen(): Promise { - const container = this.containerEl; - container.empty(); - container.addClass('sc-home-container'); - this.currentSortMode = (this.plugin.settings.sortMode as SortMode) || 'created'; - this.sortAscending = typeof this.plugin.settings.sortAscending === 'boolean' ? this.plugin.settings.sortAscending : false; - - const main = container.createDiv({ cls: 'sc-home-main' }); - // Apply user-configured max width via CSS variable so it overrides correctly - const maxW = this.plugin.settings.homepageMaxWidth ?? 1000; - container.style.setProperty('--sc-home-max-width', `${maxW}px`); - const topMargin = this.plugin.settings.homepageTopMargin ?? 70; - container.style.setProperty('--sc-home-top-margin', `${topMargin}px`); - - // Top Section - const topSection = main.createDiv({ cls: 'sc-home-top' }); - topSection.createEl('h2', { text: this.plugin.settings.homepageName || 'SideCards', cls: 'sc-home-title' }); - - const paletteRow = topSection.createDiv({ cls: 'sc-home-palette-row' }); - - // Hide palette row entirely if both category and swatches are hidden - if (this.plugin.settings.hideCategoryDropdown && this.plugin.settings.hideColorSwatches) { - paletteRow.addClass('sc-hidden'); - } - - if (!this.plugin.settings.hideCategoryDropdown) { - const categoryBtn = paletteRow.createEl('button', { text: 'Category', cls: 'sc-home-category-btn' }); - categoryBtn.addEventListener('click', (e) => { - const menu = new Menu(); - this.getAvailableFilters().forEach((f) => { - menu.addItem(item => item.setTitle(f.label).onClick(() => { - this.filterType = f.type; - this.filterValue = f.value; - categoryBtn.textContent = f.label; - if (this.cardListEl) void this.renderCards(this.cardListEl); - })); - }); - menu.showAtMouseEvent(e); - }); - - const separator = paletteRow.createDiv({ cls: 'sc-home-separator' }); - separator.textContent = '|'; - } - - if (!this.plugin.settings.hideColorSwatches) { - - const colors = [ - { name: 'gray', var: 'var(--card-color-1)' }, - { name: 'red', var: 'var(--card-color-2)' }, - { name: 'orange', var: 'var(--card-color-3)' }, - { name: 'yellow', var: 'var(--card-color-4)' }, - { name: 'green', var: 'var(--card-color-5)' }, - { name: 'blue', var: 'var(--card-color-6)' }, - { name: 'purple', var: 'var(--card-color-7)' }, - { name: 'magenta', var: 'var(--card-color-8)' }, - { name: 'pink', var: 'var(--card-color-9)' }, - { name: 'brown', var: 'var(--card-color-10)' } - ]; - const swatches: HTMLElement[] = []; - colors.forEach((color) => { - const swatch = paletteRow.createDiv({ cls: 'sc-home-color-dot' }); - const root = document.documentElement; - const computedColor = getComputedStyle(root).getPropertyValue(color.var.replace('var(', '').replace(')', '')); - swatch.style.backgroundColor = computedColor.trim() || color.var; - if (this.selectedColor === color.var) swatch.addClass('is-selected'); - - swatch.addEventListener('click', () => { - swatches.forEach((s) => s.removeClass('is-selected')); - swatch.addClass('is-selected'); - this.selectedColor = color.var; - }); - swatches.push(swatch); - }); - } // end hideColorSwatches check - - const editorEl = topSection.createDiv({ cls: 'sc-home-editor' }); - editorEl.setAttribute('contenteditable', 'true'); - editorEl.dataset.placeholder = 'Type here... (@category, #tag)'; - - // @category / #tag / [[file]] inline autocomplete - new InlineAutocomplete(editorEl, this.store, this.app); - - const updatePlaceholder = () => { - if (!editorEl.textContent?.trim()) editorEl.addClass('is-empty'); - else editorEl.removeClass('is-empty'); - }; - updatePlaceholder(); - editorEl.addEventListener('input', updatePlaceholder); - editorEl.addEventListener('focusin', () => { - (this.app as unknown as AppWithInternals).keymap.pushScope(this.editorScope); - (this.app as unknown as AppWithInternals).workspace.activeEditor = this.owner as any; - }); - editorEl.addEventListener('blur', () => { - (this.app as unknown as AppWithInternals).keymap.popScope(this.editorScope); - if ((this.app as unknown as AppWithInternals).workspace.activeEditor === this.owner) (this.app as unknown as AppWithInternals).workspace.activeEditor = null; - }); - - // Keydown for input - editorEl.addEventListener('keydown', (e) => { - if (this.applyFormattingHotkey(e, editorEl)) return; - - const wrapResult = this.handleKeyWrap(e); - if (wrapResult.handled) { - e.preventDefault(); - e.stopPropagation(); - editorEl.dispatchEvent(new Event('input')); - return; - } - let pressed = ''; - if (e.ctrlKey) pressed += 'ctrl-'; - if (e.shiftKey) pressed += 'shift-'; - if (e.altKey) pressed += 'alt-'; - if (e.key && e.key.toLowerCase() === 'enter') pressed += 'enter'; - const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s+_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, ''); - const saveKey = normalizeKey(this.plugin.settings.saveKey || 'enter'); - if (pressed === saveKey || (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey)) { - e.preventDefault(); - void this.createCardFromHomeInput(editorEl, cardList); - } - }); - - // Content Section (Two Columns) - const contentGrid = main.createDiv({ cls: 'sc-home-grid' }); - - const bothNotesHidden = this.plugin.settings.showPinnedNotes === false && this.plugin.settings.showRecentNotes === false; - const notesOnRight = this.plugin.settings.notesPlacement === 'right'; - - if (notesOnRight) contentGrid.addClass('notes-right'); - - // Left Column: Notes - const leftCol = contentGrid.createDiv({ cls: 'sc-home-left' }); - // Right Column: Cards - const rightCol = contentGrid.createDiv({ cls: 'sc-home-right' }); - - // Swap visual order via CSS order property (avoids grid width issues) - if (notesOnRight) { - leftCol.addClass('sc-home-left--right'); - rightCol.addClass('sc-home-right--left'); - } - - if (bothNotesHidden) { - leftCol.addClass('sc-home-left--hidden'); - contentGrid.addClass('sc-home-grid--full'); - } - - let pinnedList: HTMLElement | null = null; - let recentList: HTMLElement | null = null; - - if (this.plugin.settings.showPinnedNotes !== false) { - leftCol.createEl('h3', { text: 'Pinned notes', cls: 'sc-home-section-title' }); - pinnedList = leftCol.createDiv({ cls: 'sc-home-file-list pinned' }); - await this.renderFileList(pinnedList, 'pinned'); - } - - if (this.plugin.settings.showRecentNotes !== false) { - leftCol.createEl('h3', { text: 'Recent notes', cls: 'sc-home-section-title' }); - recentList = leftCol.createDiv({ cls: 'sc-home-file-list' }); - await this.renderFileList(recentList, 'recent'); - } - - const toolbar = rightCol.createDiv({ cls: 'sc-home-toolbar' }); - const cardList = rightCol.createDiv({ cls: 'sc-home-card-list' }); - this.cardListEl = cardList; - - // Category Buttons Bar (Create before card list) - const categoryBar = rightCol.createDiv({ cls: 'sc-home-category-bar' }); - rightCol.insertBefore(categoryBar, cardList); - - this.renderToolbar(toolbar, editorEl, cardList, pinnedList ?? leftCol, recentList ?? leftCol); - - // Apply openCategoryOnLoad - const openOn = this.plugin.settings.openCategoryOnLoad || 'all'; - const availableFilters = this.getAvailableFilters(); - const matchedFilter = availableFilters.find(f => f.value === openOn); - if (matchedFilter) { - this.filterType = matchedFilter.type; - this.filterValue = matchedFilter.value; - } - - this.renderCategoryBar(categoryBar, cardList); - - // Live-update when cards change - const onCardChange = () => { void this.renderCards(cardList); }; - this.store.eventBus.on('card:added', onCardChange); - this.store.eventBus.on('card:updated', onCardChange); - this.store.eventBus.on('card:deleted', onCardChange); - - this.renderCards(cardList); - } - - private async renderFileList(container: HTMLElement, type: 'recent' | 'pinned') { - container.empty(); - const files = type === 'recent' ? this.getRecentFiles() : this.getPinnedFiles(); - - if (files.length === 0) { - container.createDiv({ text: `No ${type} notes found`, cls: 'sc-home-empty-msg' }); - return; - } - - for (const file of files) { - const item = container.createDiv({ cls: 'sc-home-file-item' }); - if (type === 'pinned') item.dataset.path = file.path; - const iconSpan = item.createSpan({ cls: 'sc-home-file-icon' }); - await this.renderCustomFileIcon(iconSpan, file); - item.createSpan({ text: file.basename, cls: 'sc-home-file-name' }); - - item.addEventListener('click', () => { - void this.app.workspace.getLeaf(false).openFile(file); - }); - - if (type === 'pinned') { - item.addEventListener('contextmenu', (e) => { - e.preventDefault(); - const menu = new Menu(); - menu.addItem(i => i - /*eslint-disable-next-line obsidianmd/ui/sentence-case*/ - .setTitle('Unpin from SideCards').setIcon('pin-off').onClick(async () => { - this.plugin.settings.pinnedNotes = (this.plugin.settings.pinnedNotes || []).filter(p => p !== file.path); - await this.plugin.saveSettings(); - await this.renderFileList(container, 'pinned'); - })); - menu.showAtMouseEvent(e); - }); - } - } - - if (type === 'pinned') { - attachPinnedListDragToReorder( - container, - () => this.plugin.settings.pinnedNotes ?? [], - async (newPaths) => { - this.plugin.settings.pinnedNotes = newPaths; - await this.plugin.saveSettings(); - await this.renderFileList(container, 'pinned'); - } - ); - } - } - - private async renderCustomFileIcon(iconEl: HTMLElement, file: TFile): Promise { - const iconInfo = await this.getIconicFileIcon(file); - if (!iconInfo) { - try { setIcon(iconEl, 'file-text'); } catch { setIcon(iconEl, 'file'); } - return; - } - - const iconValue = String(iconInfo.icon || '').trim(); - if (!iconValue) { - try { setIcon(iconEl, 'file-text'); } catch { setIcon(iconEl, 'file'); } - return; - } - - let rendered = false; - const normalizedLucide = this.normalizeLucideIconName(iconValue); - if (normalizedLucide) { - try { - setIcon(iconEl, normalizedLucide); - rendered = true; - } catch { /* icon not found, fall through */ } - } - - if (!rendered && iconValue.includes(' = { - 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 = iconInfo.color.trim().toLowerCase(); - iconEl.style.color = colorNameToVar[normalized] ?? iconInfo.color; - } - } - - private normalizeLucideIconName(rawIcon: string): string | null { - const cleaned = rawIcon.trim(); - if (!cleaned) return null; - if (cleaned.includes(' { - 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 }; - data?: { fileIcons?: Record }; - fileIcons?: Record; - loadData?: () => Promise<{ fileIcons?: Record }>; - }; - const iconicPlugin = ((this.app as unknown as { plugins?: { getPlugin?: (id: string) => IconicPlugin | null } }).plugins?.getPlugin?.('iconic')) as IconicPlugin | null; - if (!iconicPlugin) return null; - - const path = file.path; - - // Try checkRuling first — this resolves rulebook icons - 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 { /* ruleManager may not exist */ } - - // Try the public getFileItem API - try { - const item = iconicPlugin.getFileItem?.(path); - if (item) { - // Check icon first, then iconDefault (set by iconic for files with no custom icon) - const iconValue = item.icon ?? item.iconDefault; - if (iconValue) { - return { icon: String(iconValue), color: item.color ?? undefined }; - } - } - } catch { /* API may not exist */ } - - const immediateEntry = this.resolveIconicIconEntry( - iconicPlugin.settings?.fileIcons?.[path] - ?? iconicPlugin.data?.fileIcons?.[path] - ?? iconicPlugin.fileIcons?.[path] - ); - if (immediateEntry) return immediateEntry; - - if (this.iconicFileIconsCache === null) { - try { - const loaded = await iconicPlugin.loadData?.(); - this.iconicFileIconsCache = loaded?.fileIcons && typeof loaded.fileIcons === 'object' - ? loaded.fileIcons - : {}; - } catch { - this.iconicFileIconsCache = {}; - } - } - - const cache = this.iconicFileIconsCache || {}; - return this.resolveIconicIconEntry(cache[path]); - } - - private getRecentFiles(): TFile[] { - const limit = this.plugin.settings.recentNotesLimit ?? 5; - return this.app.vault.getMarkdownFiles() - .sort((a, b) => b.stat.mtime - a.stat.mtime) - .slice(0, limit); - } - - private getPinnedFiles(): TFile[] { - const pinned: TFile[] = []; - if (this.plugin.settings.pinnedNotes) { - this.plugin.settings.pinnedNotes.forEach(path => { - const file = this.app.vault.getAbstractFileByPath(path); - if (file instanceof TFile) pinned.push(file); - }); - } - return pinned; - } - - public async refreshPinnedNotes() { - const pinnedList = this.containerEl.querySelector('.sc-home-file-list.pinned'); - if (pinnedList) { - await this.renderFileList(pinnedList as HTMLElement, 'pinned'); - } - } - - public async refresh() { - const main = this.containerEl.querySelector('.sc-home-main') as HTMLElement; - if (!main) return; - - // Refresh title and max width - const titleEl = main.querySelector('.sc-home-title'); - if (titleEl) titleEl.textContent = this.plugin.settings.homepageName || 'Sidecards'; - const maxW = this.plugin.settings.homepageMaxWidth ?? 1000; - this.containerEl.style.setProperty('--sc-home-max-width', `${maxW}px`); - const topMargin = this.plugin.settings.homepageTopMargin ?? 70; - this.containerEl.style.setProperty('--sc-home-top-margin', `${topMargin}px`); - - // Refresh category bar - const categoryBar = main.querySelector('.sc-home-category-bar') as HTMLElement; - if (categoryBar) { - this.renderCategoryBar(categoryBar, this.cardListEl!); - } - - // Refresh file lists - const pinnedList = main.querySelector('.sc-home-file-list.pinned') as HTMLElement; - if (pinnedList) await this.renderFileList(pinnedList, 'pinned'); - const recentList = main.querySelector('.sc-home-file-list:not(.pinned)') as HTMLElement; - if (recentList) await this.renderFileList(recentList, 'recent'); - - // Refresh cards - if (this.cardListEl) this.renderCards(this.cardListEl); - } - - private async refreshHomeContent(cardList: HTMLElement, pinnedList: HTMLElement, recentList: HTMLElement): Promise { - this.iconicFileIconsCache = null; - await this.renderFileList(pinnedList, 'pinned'); - await this.renderFileList(recentList, 'recent'); - this.renderCards(cardList); - } - - private renderToolbar(container: HTMLElement, editorEl: HTMLElement, cardList: HTMLElement, pinnedList: HTMLElement, recentList: HTMLElement) { - container.empty(); - - const leftActions = container.createDiv({ cls: 'sc-home-toolbar-left' }); - const searchWrap = container.createDiv({ cls: 'sc-home-search-wrap' }); - const rightActions = container.createDiv({ cls: 'sc-home-toolbar-right' }); - - // Left Actions - const sortBtn = leftActions.createEl('button', { cls: 'sc-icon-btn' }); - setIcon(sortBtn, 'sort-desc'); - sortBtn.title = 'Sort'; - sortBtn.addEventListener('click', (e) => { - const menu = new Menu(); - - const modes: { label: string, mode: SortMode }[] = [ - { label: 'Manual', mode: 'manual' }, - { label: 'Created Time', mode: 'created' }, - { label: 'Modified Time', mode: 'modified' }, - { label: 'A → Z', mode: 'alpha' }, - { label: 'Status', mode: 'status' } - ]; - - modes.forEach(({ label, mode }) => { - menu.addItem(item => { - item.setTitle(label) - .setChecked(this.currentSortMode === mode) - .onClick(async () => { - this.currentSortMode = mode; - this.plugin.settings.sortMode = mode; - await this.plugin.saveSettings(); - this.renderCards(cardList); - }); - }); - }); - - menu.addSeparator(); - - menu.addItem(item => { - item.setTitle('Direction: ' + (this.sortAscending ? 'Ascending' : 'Descending')) - .onClick(async () => { - this.sortAscending = !this.sortAscending; - this.plugin.settings.sortAscending = this.sortAscending; - await this.plugin.saveSettings(); - this.renderCards(cardList); - }); - }); - - menu.showAtMouseEvent(e); - }); - - const refreshBtn = leftActions.createEl('button', { cls: 'sc-icon-btn' }); - setIcon(refreshBtn, 'refresh-cw'); - refreshBtn.title = 'Refresh'; - refreshBtn.addEventListener('click', () => { - void this.refreshHomeContent(cardList, pinnedList, recentList); - }); - - // Search Bar - const searchIcon = searchWrap.createSpan({ cls: 'sc-home-search-icon' }); - setIcon(searchIcon, 'search'); - const searchInput = searchWrap.createEl('input', { cls: 'sc-home-search-input', placeholder: 'Search cards...' }); - searchInput.addEventListener('input', () => { - this.currentSearchQuery = searchInput.value; - void this.renderCards(cardList); - }); - - // Right Actions - const pinBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' }); - setIcon(pinBtn, 'pin'); - pinBtn.title = 'Pinned'; - pinBtn.addEventListener('click', () => { - pinBtn.toggleClass('active', !pinBtn.hasClass('active')); - this.pinnedOnly = pinBtn.hasClass('active'); - void this.renderCards(cardList); - }); - - const moreBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' }); - setIcon(moreBtn, 'more-vertical'); - moreBtn.title = 'More'; - moreBtn.addEventListener('click', (e) => { - const menu = new Menu(); - menu.addItem(item => { - item.setTitle('Show tags') - .setChecked(this.plugin.settings.homeShowTags ?? true) - .onClick(async () => { - const current = this.plugin.settings.homeShowTags ?? true; - this.plugin.settings.homeShowTags = !current; - await this.plugin.saveSettings(); - void this.renderCards(cardList); - }); - }); - menu.addItem(item => { - item.setTitle('Show timestamps') - .setChecked(this.plugin.settings.homeShowTimestamps ?? this.plugin.settings.showTimestamps) - .onClick(async () => { - const current = this.plugin.settings.homeShowTimestamps ?? this.plugin.settings.showTimestamps; - this.plugin.settings.homeShowTimestamps = !current; - await this.plugin.saveSettings(); - void this.renderCards(cardList); - }); - }); - menu.showAtMouseEvent(e); - }); - - const sidebarBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' }); - setIcon(sidebarBtn, 'panel-right'); - sidebarBtn.title = 'Open sidebar'; - sidebarBtn.addEventListener('click', () => { - void this.plugin.activateView(); - }); - } - - private renderCategoryBar(container: HTMLElement, cardList: HTMLElement) { - container.empty(); - const filters = this.getAvailableFilters(); - - filters.forEach(f => { - const btn = container.createEl('button', { text: f.label, cls: 'sc-category-btn' }); - if (this.filterValue === f.value) btn.addClass('active'); - - const colorKey = f.value === 'all' ? 'all' - : f.value === 'today' ? 'today' - : f.value === 'tomorrow' ? 'tomorrow' - : f.value === 'archived' ? 'archived' - : f.value; - btn.dataset.filterValue = f.value; - btn.dataset.filterColorKey = colorKey; - - const customColors = this.plugin.settings.filterColors?.[colorKey]; - if (customColors?.bgColor) btn.style.setProperty('background-color', customColors.bgColor, 'important'); - if (customColors?.textColor) { - btn.style.setProperty('color', customColors.textColor, 'important'); - btn.style.setProperty('--sc-btn-text-color', customColors.textColor); - } - - btn.addEventListener('click', () => { - container.querySelectorAll('.sc-category-btn').forEach(b => { - (b as HTMLElement).removeClass('active'); - const bColorKey = (b as HTMLElement).dataset.filterColorKey || ''; - const bColors = this.plugin.settings.filterColors?.[bColorKey]; - if (bColors?.bgColor) (b as HTMLElement).style.setProperty('background-color', bColors.bgColor, 'important'); - else (b as HTMLElement).style.removeProperty('background-color'); - if (bColors?.textColor) { - (b as HTMLElement).style.setProperty('color', bColors.textColor, 'important'); - (b as HTMLElement).style.setProperty('--sc-btn-text-color', bColors.textColor); - } else { - (b as HTMLElement).style.removeProperty('color'); - (b as HTMLElement).style.removeProperty('--sc-btn-text-color'); - } - }); - btn.addClass('active'); - this.filterType = f.type; - this.filterValue = f.value; - void this.renderCards(cardList); - }); - }); - } - - private cardRenderGen = 0; - - private renderCards(container: HTMLElement): void { - const gen = ++this.cardRenderGen; - container.empty(); - this.cardComponents.forEach(c => c.destroy()); - this.cardComponents.clear(); - - let cards = this.store.getAll(); - - if (this.pinnedOnly) { - cards = cards.filter(c => c.pinned); - } - - if (this.currentSearchQuery) { - cards = cards.filter(c => c.content.toLowerCase().includes(this.currentSearchQuery.toLowerCase())); - } - - const category = this.filterValue; - if (category && category !== 'all') { - if (category === 'archived') { - cards = cards.filter(c => c.archived); - } else if (category === 'today') { - cards = cards.filter(c => (c.category || '').toLowerCase() === 'today' && !c.archived); - } else if (category === 'tomorrow') { - cards = cards.filter(c => (c.category || '').toLowerCase() === 'tomorrow' && !c.archived); - } else { - // Resolve id → label for custom categories - const customCats = this.plugin.settings.customCategories || []; - const matched = customCats.find(c => c.id === category || c.label === category); - const categoryLabel = matched ? matched.label : category; - cards = cards.filter(c => (c.category === categoryLabel || c.category === category) && !c.archived); - } - } else if (!category || category === 'all') { - // "All" shows non-archived cards only - cards = cards.filter(c => !c.archived); - } - - cards = this.sortService.sort(cards, this.currentSortMode, this.sortAscending, this.app); - - // Render in chunks — bail out if a newer renderCards call has started - const CHUNK_SIZE = 10; - const renderChunk = (startIdx: number) => { - if (gen !== this.cardRenderGen) return; // stale, abort - const chunk = cards.slice(startIdx, startIdx + CHUNK_SIZE); - if (chunk.length === 0) return; - chunk.forEach(card => { - const comp = new CardComponent(container, card, this.store, this.app, this.plugin, { - groupTags: this.plugin.settings.homeGroupTags ?? this.plugin.settings.groupTags, - showTimestamps: this.plugin.settings.homeShowTimestamps ?? this.plugin.settings.showTimestamps, - showTags: this.plugin.settings.homeShowTags ?? true, - }); - this.cardComponents.set(card.id, comp); - }); - if (startIdx + CHUNK_SIZE < cards.length) { - requestAnimationFrame(() => renderChunk(startIdx + CHUNK_SIZE)); - } - }; - - renderChunk(0); - // Trigger entrance animation after first chunk is painted - requestAnimationFrame(() => animateCardsEntrance(container, {}, this.plugin.settings)); - - // Re-attach drag-to-reorder - this.dragDropCleanup?.(); - this.dragDropCleanup = attachDragToReorder( - container, - this.plugin, - () => this.currentSortMode, - async () => { - await this.plugin.saveSettings(); - } - ); - } - - private getAvailableFilters(): Array<{ type: string; label: string; value: string }> { - const settings = this.plugin.settings; - const cats = Array.isArray(settings.customCategories) ? settings.customCategories : []; - - // Build default order matching the sidebar - const defaultOrder = ['filter-all'] - .concat(!settings.hideTodayFilter ? ['filter-today'] : []) - .concat(!settings.hideTomorrowFilter ? ['filter-tomorrow'] : []) - .concat(!settings.hideArchivedFilterButton ? ['filter-archived'] : []) - .concat(cats.map(c => String(c.id || ''))); - - const combinedOrder = Array.isArray(settings.allItemsOrder) && settings.allItemsOrder.length > 0 - ? settings.allItemsOrder - : defaultOrder; - - const filters: Array<{ type: string; label: string; value: string }> = []; - - combinedOrder.forEach(itemId => { - if (!itemId) return; - if (itemId === 'filter-all') { - filters.push({ type: 'all', label: 'All', value: 'all' }); - return; - } - if (itemId === 'filter-today') { - if (!settings.hideTodayFilter) filters.push({ type: 'category', label: 'Today', value: 'today' }); - return; - } - if (itemId === 'filter-tomorrow') { - if (!settings.hideTomorrowFilter) filters.push({ type: 'category', label: 'Tomorrow', value: 'tomorrow' }); - return; - } - if (itemId === 'filter-archived') { - if (!settings.hideArchivedFilterButton) filters.push({ type: 'archived', label: 'Archived', value: 'archived' }); - return; - } - if (settings.enableCustomCategories) { - const cat = cats.find(c => String(c.id) === String(itemId)); - if (cat && cat.showInMenu !== false) { - filters.push({ type: 'category', label: cat.label || '', value: cat.id || cat.label || '' }); - } - } - }); - - // Ensure 'All' is always present - if (!filters.find(f => f.value === 'all')) { - filters.unshift({ type: 'all', label: 'All', value: 'all' }); - } - - return filters; - } - - private getAllUsedTags(): string[] { - try { - const tags = new Set(); - const allCards = this.store.getAll(); - allCards.forEach(c => c.tags.forEach(t => tags.add(String(t).toLowerCase()))); - return Array.from(tags).sort(); - } catch { - return []; - } - } - - private async createCardFromHomeInput(editorEl: HTMLElement, cardList: HTMLElement) { - const content = editorEl.textContent?.trim(); - if (!content) return; - - // Extract tags (#tag) and categories (@category) - const tags: string[] = []; - const tagRegex = /#([^\s#@,.]+)/g; - let match; - while ((match = tagRegex.exec(content)) !== null) { - tags.push(match[1]); - } - - const catRegex = /@([^\s#@,.]+)/g; - const catMatch = catRegex.exec(content); - const category = catMatch ? catMatch[1] : (this.filterValue === 'all' ? undefined : this.filterValue); - - // Strip @category and #tag tokens from the saved content - const cleanContent = content.replace(/@[^\s#@,.]+/g, '').replace(/#[^\s#@,.]+/g, '').replace(/\s{2,}/g, ' ').trim(); - - const allTags = [...new Set([...tags, ...this.selectedTags])]; - const autoColor = resolveAutoColor(content, allTags, this.plugin.settings); - const effectiveColor = autoColor || this.selectedColor; - - const card = new Card({ - content: cleanContent, - color: effectiveColor, - tags: allTags, - category - }); - await this.store.add(card); - editorEl.textContent = ''; - editorEl.dispatchEvent(new Event('input')); // trigger placeholder - - // Clear selection state - this.selectedTags = []; - - new Notice('Card created'); - } - onClose() { - return Promise.resolve(); - } -} +import { ItemView, WorkspaceLeaf, Menu, Notice, setIcon, Scope, Editor, TFile, Platform, App } from "obsidian"; +import type SideCardsPlugin from "../core/Plugin"; +import { CardStore } from "../services/CardStore"; +import { Card } from "../models/Card"; +import { CardComponent } from "./components/Card"; +import { SortService, SortMode } from "../services/SortService"; +import { resolveAutoColor } from "../utils/dom"; +import { animateCardsEntrance } from "../utils/animations"; +import { InlineAutocomplete } from "./components/InlineAutocomplete"; +import { attachDragToReorder, attachPinnedListDragToReorder } from "../utils/drag-drop"; + +interface AppWithInternals extends App { + keymap: { pushScope: (scope: Scope) => void; popScope: (scope: Scope) => void }; + workspace: App['workspace'] & { activeEditor: { editor: Editor; editMode: boolean } | null }; +} + +type IconicIconEntry = { icon?: string; name?: string; value?: string; color?: string } | string; + +export class SideCardsHomeView extends ItemView { + private selectedColor = 'var(--card-color-1)'; + private selectedTags: string[] = []; + private filterType = ''; + private filterValue = ''; + private cardListEl: HTMLElement | null = null; + private editorScope: Scope; + private editor!: Editor; + private owner!: { editor: Editor; editMode: boolean }; + private recentFiles: TFile[] = []; + private pinnedFiles: TFile[] = []; + private cardComponents: Map = new Map(); + private currentSortMode: SortMode = 'created'; + private sortAscending: boolean = false; + private pinnedOnly: boolean = false; + private dragDropCleanup: (() => void) | null = null; + private currentSearchQuery: string = ''; + private iconicFileIconsCache: Record | null = null; + + constructor( + leaf: WorkspaceLeaf, + private plugin: SideCardsPlugin, + private store: CardStore, + private sortService: SortService + ) { + super(leaf); + this.editorScope = new Scope(this.app.scope); + this.setupMockEditor(); + } + + private setupMockEditor() { + this.editor = { + getSelection: () => { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return ""; + const selectedText = sel.toString(); + if (selectedText.length > 0) return selectedText; + const wordRange = this.getWordRangeAtCaret(sel); + return wordRange ? wordRange.toString() : ""; + }, + replaceSelection: (text: string) => { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return; + const currentRange = sel.getRangeAt(0); + const range = currentRange.collapsed + ? (this.getWordRangeAtCaret(sel) || currentRange) + : currentRange; + range.deleteContents(); + const node = document.createTextNode(text); + range.insertNode(node); + range.setStartAfter(node); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + }, + toggleBold: () => this.toggleMarkdownWrapper("**"), + toggleItalic: () => this.toggleMarkdownWrapper("*"), + toggleHighlight: () => this.toggleMarkdownWrapper("=="), + toggleComment: () => this.toggleMarkdownWrapper("%%", "%%", true), + } as unknown as Editor; + + this.owner = { + editor: this.editor, + editMode: true, + }; + } + + private isWordChar(char: string): boolean { + return /[A-Za-z0-9_]/.test(char); + } + + private getWordRangeAtCaret(selection: Selection): Range | null { + if (!selection.rangeCount) return null; + const baseRange = selection.getRangeAt(0); + if (!baseRange.collapsed) return baseRange; + const node = baseRange.startContainer; + if (!(node instanceof Text)) return null; + const text = node.data; + if (!text) return null; + const offset = baseRange.startOffset; + const leftChar = offset > 0 ? text[offset - 1] : ""; + const rightChar = offset < text.length ? text[offset] : ""; + if (!this.isWordChar(leftChar) && !this.isWordChar(rightChar)) return null; + let start = offset; + let end = offset; + while (start > 0 && this.isWordChar(text[start - 1])) start--; + while (end < text.length && this.isWordChar(text[end])) end++; + const wordRange = document.createRange(); + wordRange.setStart(node, start); + wordRange.setEnd(node, end); + return wordRange; + } + + private toggleMarkdownWrapper(wrapper: "**" | "*" | "~~" | "==" | "%%", closeWrapper?: string, includeInnerPadding = false) { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return; + const currentRange = sel.getRangeAt(0); + const range = currentRange.collapsed + ? (this.getWordRangeAtCaret(sel) || currentRange) + : currentRange; + const selectedText = range.toString(); + const endWrapper = closeWrapper ?? wrapper; + if (selectedText.length === 0) { + const text = wrapper + endWrapper; + const node = document.createTextNode(text); + range.insertNode(node); + const cursorOffset = wrapper.length; + range.setStart(node, cursorOffset); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return; + } + const alreadyWrapped = selectedText.startsWith(wrapper) && selectedText.endsWith(endWrapper); + const newText = alreadyWrapped + ? selectedText.slice(wrapper.length, selectedText.length - endWrapper.length) + : includeInnerPadding + ? `${wrapper} ${selectedText} ${endWrapper}` + : `${wrapper}${selectedText}${endWrapper}`; + sel.removeAllRanges(); + sel.addRange(range); + this.editor.replaceSelection(newText); + } + + private handleKeyWrap(event: KeyboardEvent): { handled: boolean } { + if (this.plugin.settings.autoPairBrackets === false) return { handled: false }; + if (event.ctrlKey || event.metaKey || event.altKey) return { handled: false }; + + const key = event.key; + const wrapMap: Record = { + "[": ["[", "]"], + "(": ["(", ")"], + "{": ["{", "}"], + "`": ["`", "`"], + "%": ["%", "%"], + "=": ["=", "="], + '"': ['"', '"'], + }; + + const pair = wrapMap[key]; + if (!pair) return { handled: false }; + + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return { handled: false }; + + const range = sel.getRangeAt(0); + const editorEl = this.containerEl.querySelector('.sc-home-editor'); + if (!editorEl || !editorEl.contains(range.commonAncestorContainer)) return { handled: false }; + + const selectedText = range.toString(); + const [open, close] = pair; + let newText = `${open}${selectedText}${close}`; + + // Upgrade logic + if (key === "%") { + if (selectedText.startsWith("%") && selectedText.endsWith("%") && !selectedText.startsWith("%%")) { + const inner = selectedText.slice(1, -1).trim(); + newText = `%% ${inner} %%`; + } + } else if (key === "=") { + if (selectedText.startsWith("=") && selectedText.endsWith("=") && !selectedText.startsWith("==")) { + const inner = selectedText.slice(1, -1); + newText = `==${inner}==`; + } + } else if (key === "[") { + if (selectedText.startsWith("[") && selectedText.endsWith("]") && !selectedText.startsWith("[[")) { + const inner = selectedText.slice(1, -1); + newText = `[[${inner}]]`; + } + } + + range.deleteContents(); + const node = document.createTextNode(newText); + range.insertNode(node); + + const newRange = document.createRange(); + if (selectedText.length === 0) { + // No selection: place cursor between the pair + newRange.setStart(node, open.length); + newRange.collapse(true); + } else { + // Had selection: select the wrapped text + newRange.selectNode(node); + } + sel.removeAllRanges(); + sel.addRange(newRange); + + return { handled: true }; + } + + private getEffectiveHotkeys(commandId: string): Array<{ modifiers?: string[]; key?: string }> { + const appAny = this.app as unknown as { hotkeyManager?: { getHotkeys?: (id: string) => Array<{ modifiers?: string[]; key?: string }>; customKeys?: Record> }; commands?: { commands?: Record }> } }; + const fromManager = appAny.hotkeyManager?.getHotkeys?.(commandId); + if (Array.isArray(fromManager) && fromManager.length > 0) return fromManager; + const custom = appAny.hotkeyManager?.customKeys?.[commandId]; + if (Array.isArray(custom) && custom.length > 0) return custom; + const defaults = appAny.commands?.commands?.[commandId]?.hotkeys; + if (Array.isArray(defaults) && defaults.length > 0) return defaults; + return []; + } + + private getFormattingCommandIds(kind: "bold" | "italic" | "highlight" | "comment"): string[] { + const defaults: Record<"bold" | "italic" | "highlight" | "comment", string[]> = { + bold: ["editor:toggle-bold", "custom-wrap-bold"], + italic: ["editor:toggle-italic", "editor:toggle-emphasis", "custom-wrap-italic"], + highlight: ["editor:toggle-highlight", "custom-wrap-highlight"], + comment: ["editor:toggle-comment", "custom-wrap-comment"], + }; + const appAny = this.app as unknown as { commands?: { commands?: Record } }; + const commands = appAny.commands?.commands || {}; + const matcher: Record<"bold" | "italic" | "highlight" | "comment", RegExp> = { + bold: /bold/i, + italic: /italic|emphasis/i, + highlight: /highlight/i, + comment: /comment/i, + }; + const discovered = Object.values(commands) + .filter((cmd) => typeof cmd?.id === "string" && cmd.id.startsWith("editor:")) + .filter((cmd) => matcher[kind].test(String(cmd?.name || ""))) + .map((cmd) => String(cmd.id)); + return Array.from(new Set([...defaults[kind], ...discovered])); + } + + private eventMatchesHotkey(event: KeyboardEvent, hotkey: { modifiers?: string[]; key?: string }): boolean { + const key = String(hotkey?.key || "").toLowerCase(); + if (!key) return false; + const eventKey = String(event.key || "").toLowerCase(); + if (eventKey !== key) return false; + const modifierSet = new Set((hotkey.modifiers || []).map(m => String(m).toLowerCase())); + + const hasMod = modifierSet.has("mod"); + const expectsCtrl = modifierSet.has("ctrl") || (hasMod && !Platform.isMacOS); + const expectsMeta = modifierSet.has("meta") || (hasMod && Platform.isMacOS); + const expectsAlt = modifierSet.has("alt"); + const expectsShift = modifierSet.has("shift"); + if (expectsCtrl !== event.ctrlKey) return false; + if (expectsMeta !== event.metaKey) return false; + if (expectsAlt !== event.altKey) return false; + if (expectsShift !== event.shiftKey) return false; + return true; + } + + private applyFormattingHotkey(event: KeyboardEvent, root: HTMLElement): boolean { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return false; + const range = sel.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return false; + const targets: Array<{ kind: "bold" | "italic" | "highlight" | "comment"; run: () => void }> = [ + { kind: "bold", run: () => this.toggleMarkdownWrapper("**") }, + { kind: "italic", run: () => this.toggleMarkdownWrapper("*") }, + { kind: "highlight", run: () => this.toggleMarkdownWrapper("==") }, + { kind: "comment", run: () => this.toggleMarkdownWrapper("%%", "%%", true) }, + ]; + for (const target of targets) { + const commandIds = this.getFormattingCommandIds(target.kind); + const hotkeys = commandIds.flatMap(id => this.getEffectiveHotkeys(id)); + if (!hotkeys.length) continue; + if (!hotkeys.some(h => this.eventMatchesHotkey(event, h))) continue; + event.preventDefault(); + event.stopPropagation(); + target.run(); + root.dispatchEvent(new Event('input')); + return true; + } + return false; + } + + getViewType(): string { + return 'sidecards-home'; + } + + getDisplayText(): string { + return 'Sidecards'; + } + + getIcon(): string { + return 'home'; + } + + async onOpen(): Promise { + const container = this.containerEl; + container.empty(); + container.addClass('sc-home-container'); + this.currentSortMode = (this.plugin.settings.sortMode as SortMode) || 'created'; + this.sortAscending = typeof this.plugin.settings.sortAscending === 'boolean' ? this.plugin.settings.sortAscending : false; + + const main = container.createDiv({ cls: 'sc-home-main' }); + // Apply user-configured max width via CSS variable so it overrides correctly + const maxW = this.plugin.settings.homepageMaxWidth ?? 1000; + container.style.setProperty('--sc-home-max-width', `${maxW}px`); + const topMargin = this.plugin.settings.homepageTopMargin ?? 70; + container.style.setProperty('--sc-home-top-margin', `${topMargin}px`); + + // Top Section + const topSection = main.createDiv({ cls: 'sc-home-top' }); + topSection.createEl('h2', { text: this.plugin.settings.homepageName || 'SideCards', cls: 'sc-home-title' }); + + const paletteRow = topSection.createDiv({ cls: 'sc-home-palette-row' }); + + // Hide palette row entirely if both category and swatches are hidden + if (this.plugin.settings.hideCategoryDropdown && this.plugin.settings.hideColorSwatches) { + paletteRow.addClass('sc-hidden'); + } + + if (!this.plugin.settings.hideCategoryDropdown) { + const categoryBtn = paletteRow.createEl('button', { text: 'Category', cls: 'sc-home-category-btn' }); + categoryBtn.addEventListener('click', (e) => { + const menu = new Menu(); + this.getAvailableFilters().forEach((f) => { + menu.addItem(item => item.setTitle(f.label).onClick(() => { + this.filterType = f.type; + this.filterValue = f.value; + categoryBtn.textContent = f.label; + if (this.cardListEl) void this.renderCards(this.cardListEl); + })); + }); + menu.showAtMouseEvent(e); + }); + + const separator = paletteRow.createDiv({ cls: 'sc-home-separator' }); + separator.textContent = '|'; + } + + if (!this.plugin.settings.hideColorSwatches) { + + const colors = [ + { name: 'gray', var: 'var(--card-color-1)' }, + { name: 'red', var: 'var(--card-color-2)' }, + { name: 'orange', var: 'var(--card-color-3)' }, + { name: 'yellow', var: 'var(--card-color-4)' }, + { name: 'green', var: 'var(--card-color-5)' }, + { name: 'blue', var: 'var(--card-color-6)' }, + { name: 'purple', var: 'var(--card-color-7)' }, + { name: 'magenta', var: 'var(--card-color-8)' }, + { name: 'pink', var: 'var(--card-color-9)' }, + { name: 'brown', var: 'var(--card-color-10)' } + ]; + const swatches: HTMLElement[] = []; + colors.forEach((color) => { + const swatch = paletteRow.createDiv({ cls: 'sc-home-color-dot' }); + const root = document.documentElement; + const computedColor = getComputedStyle(root).getPropertyValue(color.var.replace('var(', '').replace(')', '')); + swatch.style.backgroundColor = computedColor.trim() || color.var; + if (this.selectedColor === color.var) swatch.addClass('is-selected'); + + swatch.addEventListener('click', () => { + swatches.forEach((s) => s.removeClass('is-selected')); + swatch.addClass('is-selected'); + this.selectedColor = color.var; + }); + swatches.push(swatch); + }); + } // end hideColorSwatches check + + const editorEl = topSection.createDiv({ cls: 'sc-home-editor' }); + editorEl.setAttribute('contenteditable', 'true'); + editorEl.dataset.placeholder = 'Type here... (@category, #tag)'; + + // @category / #tag / [[file]] inline autocomplete + new InlineAutocomplete(editorEl, this.store, this.app); + + const updatePlaceholder = () => { + if (!editorEl.textContent?.trim()) editorEl.addClass('is-empty'); + else editorEl.removeClass('is-empty'); + }; + updatePlaceholder(); + editorEl.addEventListener('input', updatePlaceholder); + editorEl.addEventListener('focusin', () => { + (this.app as unknown as AppWithInternals).keymap.pushScope(this.editorScope); + (this.app as unknown as AppWithInternals).workspace.activeEditor = this.owner as any; + }); + editorEl.addEventListener('blur', () => { + (this.app as unknown as AppWithInternals).keymap.popScope(this.editorScope); + if ((this.app as unknown as AppWithInternals).workspace.activeEditor === this.owner) (this.app as unknown as AppWithInternals).workspace.activeEditor = null; + }); + + // Keydown for input + editorEl.addEventListener('keydown', (e) => { + if (this.applyFormattingHotkey(e, editorEl)) return; + + const wrapResult = this.handleKeyWrap(e); + if (wrapResult.handled) { + e.preventDefault(); + e.stopPropagation(); + editorEl.dispatchEvent(new Event('input')); + return; + } + let pressed = ''; + if (e.ctrlKey) pressed += 'ctrl-'; + if (e.shiftKey) pressed += 'shift-'; + if (e.altKey) pressed += 'alt-'; + if (e.key && e.key.toLowerCase() === 'enter') pressed += 'enter'; + const normalizeKey = (v: string) => String(v || '').toLowerCase().replace(/[\s+_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const saveKey = normalizeKey(this.plugin.settings.saveKey || 'enter'); + if (pressed === saveKey || (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey)) { + e.preventDefault(); + void this.createCardFromHomeInput(editorEl, cardList); + } + }); + + // Content Section (Two Columns) + const contentGrid = main.createDiv({ cls: 'sc-home-grid' }); + + const bothNotesHidden = this.plugin.settings.showPinnedNotes === false && this.plugin.settings.showRecentNotes === false; + const notesOnRight = this.plugin.settings.notesPlacement === 'right'; + + if (notesOnRight) contentGrid.addClass('notes-right'); + + // Left Column: Notes + const leftCol = contentGrid.createDiv({ cls: 'sc-home-left' }); + // Right Column: Cards + const rightCol = contentGrid.createDiv({ cls: 'sc-home-right' }); + + // Swap visual order via CSS order property (avoids grid width issues) + if (notesOnRight) { + leftCol.addClass('sc-home-left--right'); + rightCol.addClass('sc-home-right--left'); + } + + if (bothNotesHidden) { + leftCol.addClass('sc-home-left--hidden'); + contentGrid.addClass('sc-home-grid--full'); + } + + let pinnedList: HTMLElement | null = null; + let recentList: HTMLElement | null = null; + + if (this.plugin.settings.showPinnedNotes !== false) { + leftCol.createEl('h3', { text: 'Pinned notes', cls: 'sc-home-section-title' }); + pinnedList = leftCol.createDiv({ cls: 'sc-home-file-list pinned' }); + await this.renderFileList(pinnedList, 'pinned'); + } + + if (this.plugin.settings.showRecentNotes !== false) { + leftCol.createEl('h3', { text: 'Recent notes', cls: 'sc-home-section-title' }); + recentList = leftCol.createDiv({ cls: 'sc-home-file-list' }); + await this.renderFileList(recentList, 'recent'); + } + + const toolbar = rightCol.createDiv({ cls: 'sc-home-toolbar' }); + const cardList = rightCol.createDiv({ cls: 'sc-home-card-list' }); + this.cardListEl = cardList; + + // Category Buttons Bar (Create before card list) + const categoryBar = rightCol.createDiv({ cls: 'sc-home-category-bar' }); + rightCol.insertBefore(categoryBar, cardList); + + this.renderToolbar(toolbar, editorEl, cardList, pinnedList ?? leftCol, recentList ?? leftCol); + + // Apply openCategoryOnLoad + const openOn = this.plugin.settings.openCategoryOnLoad || 'all'; + const availableFilters = this.getAvailableFilters(); + const matchedFilter = availableFilters.find(f => f.value === openOn); + if (matchedFilter) { + this.filterType = matchedFilter.type; + this.filterValue = matchedFilter.value; + } + + this.renderCategoryBar(categoryBar, cardList); + + // Live-update when cards change + const onCardChange = () => { void this.renderCards(cardList); }; + this.store.eventBus.on('card:added', onCardChange); + this.store.eventBus.on('card:updated', onCardChange); + this.store.eventBus.on('card:deleted', onCardChange); + + this.renderCards(cardList); + } + + private async renderFileList(container: HTMLElement, type: 'recent' | 'pinned') { + container.empty(); + const files = type === 'recent' ? this.getRecentFiles() : this.getPinnedFiles(); + + if (files.length === 0) { + container.createDiv({ text: `No ${type} notes found`, cls: 'sc-home-empty-msg' }); + return; + } + + for (const file of files) { + const item = container.createDiv({ cls: 'sc-home-file-item' }); + if (type === 'pinned') item.dataset.path = file.path; + const iconSpan = item.createSpan({ cls: 'sc-home-file-icon' }); + await this.renderCustomFileIcon(iconSpan, file); + item.createSpan({ text: file.basename, cls: 'sc-home-file-name' }); + + item.addEventListener('click', () => { + void this.app.workspace.getLeaf(false).openFile(file); + }); + + if (type === 'pinned') { + item.addEventListener('contextmenu', (e) => { + e.preventDefault(); + const menu = new Menu(); + menu.addItem(i => i + /*eslint-disable-next-line obsidianmd/ui/sentence-case*/ + .setTitle('Unpin from SideCards').setIcon('pin-off').onClick(async () => { + this.plugin.settings.pinnedNotes = (this.plugin.settings.pinnedNotes || []).filter(p => p !== file.path); + await this.plugin.saveSettings(); + await this.renderFileList(container, 'pinned'); + })); + menu.showAtMouseEvent(e); + }); + } + } + + if (type === 'pinned') { + attachPinnedListDragToReorder( + container, + () => this.plugin.settings.pinnedNotes ?? [], + async (newPaths) => { + this.plugin.settings.pinnedNotes = newPaths; + await this.plugin.saveSettings(); + await this.renderFileList(container, 'pinned'); + } + ); + } + } + + private async renderCustomFileIcon(iconEl: HTMLElement, file: TFile): Promise { + const iconInfo = await this.getIconicFileIcon(file); + if (!iconInfo) { + try { setIcon(iconEl, 'file-text'); } catch { setIcon(iconEl, 'file'); } + return; + } + + const iconValue = String(iconInfo.icon || '').trim(); + if (!iconValue) { + try { setIcon(iconEl, 'file-text'); } catch { setIcon(iconEl, 'file'); } + return; + } + + let rendered = false; + const normalizedLucide = this.normalizeLucideIconName(iconValue); + if (normalizedLucide) { + try { + setIcon(iconEl, normalizedLucide); + rendered = true; + } catch { /* icon not found, fall through */ } + } + + if (!rendered && iconValue.includes(' = { + 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 = iconInfo.color.trim().toLowerCase(); + iconEl.style.color = colorNameToVar[normalized] ?? iconInfo.color; + } + } + + private normalizeLucideIconName(rawIcon: string): string | null { + const cleaned = rawIcon.trim(); + if (!cleaned) return null; + if (cleaned.includes(' { + 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 }; + data?: { fileIcons?: Record }; + fileIcons?: Record; + loadData?: () => Promise<{ fileIcons?: Record }>; + }; + const iconicPlugin = ((this.app as unknown as { plugins?: { getPlugin?: (id: string) => IconicPlugin | null } }).plugins?.getPlugin?.('iconic')) as IconicPlugin | null; + if (!iconicPlugin) return null; + + const path = file.path; + + // Try checkRuling first — this resolves rulebook icons + 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 { /* ruleManager may not exist */ } + + // Try the public getFileItem API + try { + const item = iconicPlugin.getFileItem?.(path); + if (item) { + // Check icon first, then iconDefault (set by iconic for files with no custom icon) + const iconValue = item.icon ?? item.iconDefault; + if (iconValue) { + return { icon: String(iconValue), color: item.color ?? undefined }; + } + } + } catch { /* API may not exist */ } + + const immediateEntry = this.resolveIconicIconEntry( + iconicPlugin.settings?.fileIcons?.[path] + ?? iconicPlugin.data?.fileIcons?.[path] + ?? iconicPlugin.fileIcons?.[path] + ); + if (immediateEntry) return immediateEntry; + + if (this.iconicFileIconsCache === null) { + try { + const loaded = await iconicPlugin.loadData?.(); + this.iconicFileIconsCache = loaded?.fileIcons && typeof loaded.fileIcons === 'object' + ? loaded.fileIcons + : {}; + } catch { + this.iconicFileIconsCache = {}; + } + } + + const cache = this.iconicFileIconsCache || {}; + return this.resolveIconicIconEntry(cache[path]); + } + + private getRecentFiles(): TFile[] { + const limit = this.plugin.settings.recentNotesLimit ?? 5; + return this.app.vault.getMarkdownFiles() + .sort((a, b) => b.stat.mtime - a.stat.mtime) + .slice(0, limit); + } + + private getPinnedFiles(): TFile[] { + const pinned: TFile[] = []; + if (this.plugin.settings.pinnedNotes) { + this.plugin.settings.pinnedNotes.forEach(path => { + const file = this.app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) pinned.push(file); + }); + } + return pinned; + } + + public async refreshPinnedNotes() { + const pinnedList = this.containerEl.querySelector('.sc-home-file-list.pinned'); + if (pinnedList) { + await this.renderFileList(pinnedList as HTMLElement, 'pinned'); + } + } + + public async refresh() { + const main = this.containerEl.querySelector('.sc-home-main') as HTMLElement; + if (!main) return; + + // Refresh title and max width + const titleEl = main.querySelector('.sc-home-title'); + if (titleEl) titleEl.textContent = this.plugin.settings.homepageName || 'Sidecards'; + const maxW = this.plugin.settings.homepageMaxWidth ?? 1000; + this.containerEl.style.setProperty('--sc-home-max-width', `${maxW}px`); + const topMargin = this.plugin.settings.homepageTopMargin ?? 70; + this.containerEl.style.setProperty('--sc-home-top-margin', `${topMargin}px`); + + // Refresh category bar + const categoryBar = main.querySelector('.sc-home-category-bar') as HTMLElement; + if (categoryBar) { + this.renderCategoryBar(categoryBar, this.cardListEl!); + } + + // Refresh file lists + const pinnedList = main.querySelector('.sc-home-file-list.pinned') as HTMLElement; + if (pinnedList) await this.renderFileList(pinnedList, 'pinned'); + const recentList = main.querySelector('.sc-home-file-list:not(.pinned)') as HTMLElement; + if (recentList) await this.renderFileList(recentList, 'recent'); + + // Refresh cards + if (this.cardListEl) this.renderCards(this.cardListEl); + } + + private async refreshHomeContent(cardList: HTMLElement, pinnedList: HTMLElement, recentList: HTMLElement): Promise { + this.iconicFileIconsCache = null; + await this.renderFileList(pinnedList, 'pinned'); + await this.renderFileList(recentList, 'recent'); + this.renderCards(cardList); + } + + private renderToolbar(container: HTMLElement, editorEl: HTMLElement, cardList: HTMLElement, pinnedList: HTMLElement, recentList: HTMLElement) { + container.empty(); + + const leftActions = container.createDiv({ cls: 'sc-home-toolbar-left' }); + const searchWrap = container.createDiv({ cls: 'sc-home-search-wrap' }); + const rightActions = container.createDiv({ cls: 'sc-home-toolbar-right' }); + + // Left Actions + const sortBtn = leftActions.createEl('button', { cls: 'sc-icon-btn' }); + setIcon(sortBtn, 'sort-desc'); + sortBtn.title = 'Sort'; + sortBtn.addEventListener('click', (e) => { + const menu = new Menu(); + + const modes: { label: string, mode: SortMode }[] = [ + { label: 'Manual', mode: 'manual' }, + { label: 'Created Time', mode: 'created' }, + { label: 'Modified Time', mode: 'modified' }, + { label: 'A → Z', mode: 'alpha' }, + { label: 'Status', mode: 'status' } + ]; + + modes.forEach(({ label, mode }) => { + menu.addItem(item => { + item.setTitle(label) + .setChecked(this.currentSortMode === mode) + .onClick(async () => { + this.currentSortMode = mode; + this.plugin.settings.sortMode = mode; + await this.plugin.saveSettings(); + this.renderCards(cardList); + }); + }); + }); + + menu.addSeparator(); + + menu.addItem(item => { + item.setTitle('Direction: ' + (this.sortAscending ? 'Ascending' : 'Descending')) + .onClick(async () => { + this.sortAscending = !this.sortAscending; + this.plugin.settings.sortAscending = this.sortAscending; + await this.plugin.saveSettings(); + this.renderCards(cardList); + }); + }); + + menu.showAtMouseEvent(e); + }); + + const refreshBtn = leftActions.createEl('button', { cls: 'sc-icon-btn' }); + setIcon(refreshBtn, 'refresh-cw'); + refreshBtn.title = 'Refresh'; + refreshBtn.addEventListener('click', () => { + void this.refreshHomeContent(cardList, pinnedList, recentList); + }); + + // Search Bar + const searchIcon = searchWrap.createSpan({ cls: 'sc-home-search-icon' }); + setIcon(searchIcon, 'search'); + const searchInput = searchWrap.createEl('input', { cls: 'sc-home-search-input', placeholder: 'Search cards...' }); + searchInput.addEventListener('input', () => { + this.currentSearchQuery = searchInput.value; + void this.renderCards(cardList); + }); + + // Right Actions + const pinBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' }); + setIcon(pinBtn, 'pin'); + pinBtn.title = 'Pinned'; + pinBtn.addEventListener('click', () => { + pinBtn.toggleClass('active', !pinBtn.hasClass('active')); + this.pinnedOnly = pinBtn.hasClass('active'); + void this.renderCards(cardList); + }); + + const moreBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' }); + setIcon(moreBtn, 'more-vertical'); + moreBtn.title = 'More'; + moreBtn.addEventListener('click', (e) => { + const menu = new Menu(); + menu.addItem(item => { + item.setTitle('Show tags') + .setChecked(this.plugin.settings.homeShowTags ?? true) + .onClick(async () => { + const current = this.plugin.settings.homeShowTags ?? true; + this.plugin.settings.homeShowTags = !current; + await this.plugin.saveSettings(); + void this.renderCards(cardList); + }); + }); + menu.addItem(item => { + item.setTitle('Show timestamps') + .setChecked(this.plugin.settings.homeShowTimestamps ?? this.plugin.settings.showTimestamps) + .onClick(async () => { + const current = this.plugin.settings.homeShowTimestamps ?? this.plugin.settings.showTimestamps; + this.plugin.settings.homeShowTimestamps = !current; + await this.plugin.saveSettings(); + void this.renderCards(cardList); + }); + }); + menu.showAtMouseEvent(e); + }); + + const sidebarBtn = rightActions.createEl('button', { cls: 'sc-icon-btn' }); + setIcon(sidebarBtn, 'panel-right'); + sidebarBtn.title = 'Open sidebar'; + sidebarBtn.addEventListener('click', () => { + void this.plugin.activateView(); + }); + } + + private renderCategoryBar(container: HTMLElement, cardList: HTMLElement) { + container.empty(); + const filters = this.getAvailableFilters(); + + filters.forEach(f => { + const btn = container.createEl('button', { text: f.label, cls: 'sc-category-btn' }); + if (this.filterValue === f.value) btn.addClass('active'); + + const colorKey = f.value === 'all' ? 'all' + : f.value === 'today' ? 'today' + : f.value === 'tomorrow' ? 'tomorrow' + : f.value === 'archived' ? 'archived' + : f.value; + btn.dataset.filterValue = f.value; + btn.dataset.filterColorKey = colorKey; + + const customColors = this.plugin.settings.filterColors?.[colorKey]; + if (customColors?.bgColor) btn.style.setProperty('background-color', customColors.bgColor, 'important'); + if (customColors?.textColor) { + btn.style.setProperty('color', customColors.textColor, 'important'); + btn.style.setProperty('--sc-btn-text-color', customColors.textColor); + } + + btn.addEventListener('click', () => { + container.querySelectorAll('.sc-category-btn').forEach(b => { + (b as HTMLElement).removeClass('active'); + const bColorKey = (b as HTMLElement).dataset.filterColorKey || ''; + const bColors = this.plugin.settings.filterColors?.[bColorKey]; + if (bColors?.bgColor) (b as HTMLElement).style.setProperty('background-color', bColors.bgColor, 'important'); + else (b as HTMLElement).style.removeProperty('background-color'); + if (bColors?.textColor) { + (b as HTMLElement).style.setProperty('color', bColors.textColor, 'important'); + (b as HTMLElement).style.setProperty('--sc-btn-text-color', bColors.textColor); + } else { + (b as HTMLElement).style.removeProperty('color'); + (b as HTMLElement).style.removeProperty('--sc-btn-text-color'); + } + }); + btn.addClass('active'); + this.filterType = f.type; + this.filterValue = f.value; + void this.renderCards(cardList); + }); + }); + } + + private cardRenderGen = 0; + + private renderCards(container: HTMLElement): void { + const gen = ++this.cardRenderGen; + container.empty(); + this.cardComponents.forEach(c => c.destroy()); + this.cardComponents.clear(); + + let cards = this.store.getAll(); + + if (this.pinnedOnly) { + cards = cards.filter(c => c.pinned); + } + + if (this.currentSearchQuery) { + cards = cards.filter(c => c.content.toLowerCase().includes(this.currentSearchQuery.toLowerCase())); + } + + const category = this.filterValue; + if (category && category !== 'all') { + if (category === 'archived') { + cards = cards.filter(c => c.archived); + } else if (category === 'today') { + cards = cards.filter(c => (c.category || '').toLowerCase() === 'today' && !c.archived); + } else if (category === 'tomorrow') { + cards = cards.filter(c => (c.category || '').toLowerCase() === 'tomorrow' && !c.archived); + } else { + // Resolve id → label for custom categories + const customCats = this.plugin.settings.customCategories || []; + const matched = customCats.find(c => c.id === category || c.label === category); + const categoryLabel = matched ? matched.label : category; + cards = cards.filter(c => (c.category === categoryLabel || c.category === category) && !c.archived); + } + } else if (!category || category === 'all') { + // "All" shows non-archived cards only + cards = cards.filter(c => !c.archived); + } + + cards = this.sortService.sort(cards, this.currentSortMode, this.sortAscending, this.app); + + // Render in chunks — bail out if a newer renderCards call has started + const CHUNK_SIZE = 10; + const renderChunk = (startIdx: number) => { + if (gen !== this.cardRenderGen) return; // stale, abort + const chunk = cards.slice(startIdx, startIdx + CHUNK_SIZE); + if (chunk.length === 0) return; + chunk.forEach(card => { + const comp = new CardComponent(container, card, this.store, this.app, this.plugin, { + groupTags: this.plugin.settings.homeGroupTags ?? this.plugin.settings.groupTags, + showTimestamps: this.plugin.settings.homeShowTimestamps ?? this.plugin.settings.showTimestamps, + showTags: this.plugin.settings.homeShowTags ?? true, + }); + this.cardComponents.set(card.id, comp); + }); + if (startIdx + CHUNK_SIZE < cards.length) { + requestAnimationFrame(() => renderChunk(startIdx + CHUNK_SIZE)); + } + }; + + renderChunk(0); + // Trigger entrance animation after first chunk is painted + requestAnimationFrame(() => animateCardsEntrance(container, {}, this.plugin.settings)); + + // Re-attach drag-to-reorder + this.dragDropCleanup?.(); + this.dragDropCleanup = attachDragToReorder( + container, + this.plugin, + () => this.currentSortMode, + async () => { + await this.plugin.saveSettings(); + } + ); + } + + private getAvailableFilters(): Array<{ type: string; label: string; value: string }> { + const settings = this.plugin.settings; + const cats = Array.isArray(settings.customCategories) ? settings.customCategories : []; + + // Build default order matching the sidebar + const defaultOrder = ['filter-all'] + .concat(!settings.hideTodayFilter ? ['filter-today'] : []) + .concat(!settings.hideTomorrowFilter ? ['filter-tomorrow'] : []) + .concat(!settings.hideArchivedFilterButton ? ['filter-archived'] : []) + .concat(cats.map(c => String(c.id || ''))); + + const combinedOrder = Array.isArray(settings.allItemsOrder) && settings.allItemsOrder.length > 0 + ? settings.allItemsOrder + : defaultOrder; + + const filters: Array<{ type: string; label: string; value: string }> = []; + + combinedOrder.forEach(itemId => { + if (!itemId) return; + if (itemId === 'filter-all') { + filters.push({ type: 'all', label: 'All', value: 'all' }); + return; + } + if (itemId === 'filter-today') { + if (!settings.hideTodayFilter) filters.push({ type: 'category', label: 'Today', value: 'today' }); + return; + } + if (itemId === 'filter-tomorrow') { + if (!settings.hideTomorrowFilter) filters.push({ type: 'category', label: 'Tomorrow', value: 'tomorrow' }); + return; + } + if (itemId === 'filter-archived') { + if (!settings.hideArchivedFilterButton) filters.push({ type: 'archived', label: 'Archived', value: 'archived' }); + return; + } + if (settings.enableCustomCategories) { + const cat = cats.find(c => String(c.id) === String(itemId)); + if (cat && cat.showInMenu !== false) { + filters.push({ type: 'category', label: cat.label || '', value: cat.id || cat.label || '' }); + } + } + }); + + // Ensure 'All' is always present + if (!filters.find(f => f.value === 'all')) { + filters.unshift({ type: 'all', label: 'All', value: 'all' }); + } + + return filters; + } + + private getAllUsedTags(): string[] { + try { + const tags = new Set(); + const allCards = this.store.getAll(); + allCards.forEach(c => c.tags.forEach(t => tags.add(String(t).toLowerCase()))); + return Array.from(tags).sort(); + } catch { + return []; + } + } + + private async createCardFromHomeInput(editorEl: HTMLElement, cardList: HTMLElement) { + const content = editorEl.textContent?.trim(); + if (!content) return; + + // Extract tags (#tag) and categories (@category) + const tags: string[] = []; + const tagRegex = /#([^\s#@,.]+)/g; + let match; + while ((match = tagRegex.exec(content)) !== null) { + tags.push(match[1]); + } + + const catRegex = /@([^\s#@,.]+)/g; + const catMatch = catRegex.exec(content); + const category = catMatch ? catMatch[1] : (this.filterValue === 'all' ? undefined : this.filterValue); + + // Strip @category and #tag tokens from the saved content + const cleanContent = content.replace(/@[^\s#@,.]+/g, '').replace(/#[^\s#@,.]+/g, '').replace(/\s{2,}/g, ' ').trim(); + + const allTags = [...new Set([...tags, ...this.selectedTags])]; + const autoColor = resolveAutoColor(content, allTags, this.plugin.settings); + const effectiveColor = autoColor || this.selectedColor; + + const card = new Card({ + content: cleanContent, + color: effectiveColor, + tags: allTags, + category + }); + await this.store.add(card); + editorEl.textContent = ''; + editorEl.dispatchEvent(new Event('input')); // trigger placeholder + + // Clear selection state + this.selectedTags = []; + + new Notice('Card created'); + } + onClose() { + return Promise.resolve(); + } +} diff --git a/src/views/components/Card.ts b/src/views/components/Card.ts index 5814dc1..715e3d4 100644 --- a/src/views/components/Card.ts +++ b/src/views/components/Card.ts @@ -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(); } diff --git a/src/views/components/InlineAutocomplete.ts b/src/views/components/InlineAutocomplete.ts index d738cbb..00bace1 100644 --- a/src/views/components/InlineAutocomplete.ts +++ b/src/views/components/InlineAutocomplete.ts @@ -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 }; - data?: { fileIcons?: Record }; - fileIcons?: Record; - }; - 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(); - 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 = { - 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 }; + data?: { fileIcons?: Record }; + fileIcons?: Record; + }; + 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(); + 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 = { + 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(); + } +} diff --git a/styles.css b/styles.css index 46d65be..d06104a 100644 --- a/styles.css +++ b/styles.css @@ -1,1372 +1,1397 @@ -/* --- == SC == --- */ - -.workspace-leaf:has(.sc-home-container) .view-content, -.workspace-leaf:has(.sc-home-container) .view-header { - display: none; -} - -.sc-home-category-bar { - margin: 0 !important; - gap: 10px !important; -} - -.sc-color-dots.two-row { - gap: 8px !important; - margin-bottom: 6px !important; -} - -.sidecards-home, .sc-home-main { - margin-top: var(--sc-home-top-margin, 70px) !important; - transition: all 0.2s ease; -} - - .sc-home-right, - .sc-home-toolbar, - .sc-home-category-bar, - .sc-home-card-list { - width: 100% !important; - min-width: 0; - } - -.sc-sidebar-button-container { - padding-bottom: var(--sc-button-padding-bottom, 26px) !important; -} - -.sc-content p { - margin: 0; -} - -.sc-content blockquote { - border-left: 3px solid var(--text-accent); - margin: 4px 0; - padding: 2px 0 2px 10px; - color: var(--text-muted); - font-style: italic; - background: transparent; -} - -.sc-sidebar-header .sc-category-group { - background: var(--background-primary); - display: flex; - gap: 8px; - flex-wrap: nowrap; - overflow-x: auto; - padding: 10px; - scrollbar-width: none; - scroll-behavior: smooth; -} - -.sc-sidebar-header .sc-category-group::-webkit-scrollbar { - display: none; -} - -.sc-category-btn { - /* border: 2px solid var(--background-modifier-border); */ - border-radius: var(--button-radius); - background: var(--background-primary); - color: var(--text-muted); - font-size: 11px; - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; - transition: border-width 0s ease; -} -.sc-category-btn:hover { - filter: brightness(1.2); -} - -.sc-category-btn.active { - border: 2px solid var(--sc-btn-text-color, var(--interactive-accent)); - transition: border-width 0s ease; -} - -.sc-search-wrap { - width: 100%; - padding: 10px; - background: var(--background-primary); - border-bottom: 1px solid var(--background-modifier-border); - padding-top: 0px; -} - -.sc-search-row { - position: relative; - display: flex; - align-items: center; - width: 100%; -} - -.sc-search-input-icon { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - pointer-events: none; - color: var(--text-muted); - display: flex; - align-items: center; - justify-content: center; - z-index: 2; - width: 16px; - height: 16px; -} - -.sc-search-input { - flex: 1; - width: 100%; - padding: 8px 36px 8px 34px !important; - border: 1px solid var(--background-modifier-border); - border-radius: var(--input-radius); - background: var(--background-primary-alt); - color: var(--text-normal); - font-size: 14px; - transition: all 0.2s ease; - line-height: 1.4; -} - -.sc-search-input::placeholder { - color: var(--text-muted); -} - -.sc-search-input:focus { - outline: none; - border-color: var(--interactive-accent); - background: var(--background-primary); - box-shadow: 0 0 0 2px rgba(var(--interactive-accent), 0.1); -} - -.sc-sidebar-input { - width: 100%; - min-height: 36px; - max-height: 200px; - padding: 8px; - border: 1px solid var(--background-modifier-border); - border-radius: var(--input-radius); - background-color: var(--background-modifier-form-field); - overflow-y: auto; - white-space: pre-wrap; - position: relative; -} - -.sc-sidebar-input.is-empty::before { - content: attr(data-placeholder); - color: var(--text-muted); - position: absolute; - pointer-events: none; - opacity: 0.6; -} - -/* Home View Layout */ -.sc-home-container { - height: 100%; - overflow-y: auto; - background-color: var(--background-primary); -} - -.sc-home-main { - width: 100%; - max-width: var(--sc-home-max-width, 1000px); - margin: 0 auto; - padding: 40px 20px; - box-sizing: border-box; -} - -.sc-home-top { - margin-bottom: 40px; -} - -.sc-home-title { - font-size: 28px; - font-weight: 700; - margin: 0 0 15px 0; - color: var(--text-normal); -} - -.sc-home-palette-row { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; -} - -.sc-home-category-btn { - background-color: var(--background-modifier-form-field); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - padding: 4px 12px; - font-size: 13px; - color: var(--text-muted); - cursor: pointer; -} - -.sc-home-separator { - color: var(--background-modifier-border); - margin: 0 4px; - font-size: 18px; - opacity: 0.5; -} - -.sc-home-color-dot { - width: 24px; - height: 24px; - border-radius: 50%; - cursor: pointer; - border: 2px solid transparent; - transition: transform 0.1s ease, border-color 0.1s ease; -} - -.sc-home-color-dot:hover { - transform: scale(1.15); -} - -.sc-home-color-dot.is-selected { - border-color: var(--text-accent); -} - -.sc-home-editor { - width: 100%; - min-height: 120px; - padding: 16px; - border: 1px solid var(--background-modifier-border); - border-radius: var(--input-radius); - background-color: var(--background-modifier-form-field); - font-size: 16px; - line-height: 1.5; - white-space: pre-wrap; - position: relative; -} - -.sc-home-editor.is-empty::before { - content: attr(data-placeholder); - color: var(--text-muted); - position: absolute; - pointer-events: none; - opacity: 0.5; -} - -.sc-home-grid { - display: grid; - grid-template-columns: minmax(0, 280px) 1fr; - gap: 32px; - align-items: start; - gap: 40px; -} - -.sc-home-section-title { - font-size: 18px; - font-weight: 600; - margin: 0 0 16px 0; - color: var(--text-normal); -} - -.sc-home-file-list { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 32px; -} - -.sc-home-file-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - background-color: var(--background-modifier-form-field); - /* border-radius: 6px; */ - border-radius: var(--input-radius); - cursor: grab; - transition: background-color 0.1s ease; -} - -.sc-home-file-item:hover { - background-color: var(--background-modifier-hover); -} - -.sc-home-file-list:not(.pinned) .sc-home-file-item { - cursor: pointer; -} - -.sc-home-file-icon { - color: var(--text-muted); - display: flex; - align-items: center; -} - -.sc-home-file-name { - font-size: 14px; - color: var(--text-normal); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.sc-home-empty-msg { - font-size: 13px; - color: var(--text-muted); - font-style: italic; - padding: 4px 12px; -} - -/* Right Column Toolbar */ -.sc-home-toolbar { - display: flex; - align-items: center; - margin-bottom: 12px; - padding-bottom: 12px; - border-bottom: 1px solid var(--background-modifier-border); - gap: 8px; -} - -.sc-home-toolbar-left, -.sc-home-toolbar-right { - display: flex; - align-items: center; - gap: 8px; -} - -.sc-home-search-wrap { - flex: 1; - position: relative; - display: flex; - align-items: center; - background-color: var(--background-modifier-form-field); - border: 1px solid var(--background-modifier-border); - border-radius: var(--input-radius); - padding: 0 8px; -} - -.sc-home-search-icon { - color: var(--text-muted); - display: flex; - align-items: center; -} - -.sc-home-search-input { - color: var(--text-muted); - background: transparent !important; - border: none !important; - box-shadow: none !important; - padding: 6px 8px !important; - font-size: 13px; - width: 100%; -} - -.sc-home-search-input::placeholder { - color: var(--text-muted); -} - -.sc-home-category-bar { - display: flex; - gap: 4px; - flex-wrap: nowrap; - overflow-x: auto; - padding-bottom: 16px; - margin-bottom: 16px; - scrollbar-width: none; -} - -.sc-home-category-bar::-webkit-scrollbar { - display: none; -} - -.sc-home-card-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.sc-home-card-list .sc-card { - margin: 0; - width: 100%; -} - - -.workspace-leaf-content .sc-home-container { - container-type: inline-size; -} - -@container (max-width: 768px) { - .sc-home-left { - display: none; - } - - .sc-home-grid { - grid-template-columns: 1fr; - } - - .sc-home-main { - padding: 20px 12px; - } - - .sidecards-home, .sc-home-main { - margin: 50px 10px !important; - transition: all 0.2s ease; - } - - .sc-home-right, - .sc-home-toolbar, - .sc-home-category-bar, - .sc-home-card-list { - width: 100% !important; - min-width: 0; - } -} - -.sc-search-clear-btn { - font-size: 20px; - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - padding: 4px 10px; - border-radius: 100%; - border: none !important; - box-shadow: none !important; - color: var(--text-error) !important; - background: transparent !important; - /* background-color: transparent !important; */ - cursor: pointer; - transition: all 0.2s ease; - font-size: 14px; - display: flex; - align-items: center; - justify-content: center; - z-index: 2; - opacity: 0.6; -} - -.sc-search-clear-btn:hover { - color: var(--text-normal); - opacity: 1; -} - -.sc-sidebar-cards-container { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 8px; - padding: 8px; - scrollbar-width: auto; - -ms-overflow-style: auto; -} - -.sc-sidebar-cards-container.hide-scrollbar { - scrollbar-width: none; - -ms-overflow-style: none; -} - -.sc-sidebar-cards-container.hide-scrollbar::-webkit-scrollbar { - display: none; - width: 0; - height: 0; -} - -.sc-sidebar-cards-container.grid-mode { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - grid-auto-rows: 1px; - gap: 0px 6px; - align-items: start; -} - -.sc-sidebar-cards-container.grid-mode .sc-card { - margin: 0 !important; - align-self: start; - height: auto; - padding-bottom: 14px; -} - -.sc-card { - background: var(--background-primary); - padding: 12px; - position: relative; - /* font-size: 14px; */ - line-height: 1.4; - border: 1px solid var(--background-modifier-border); - border-radius: var(--card-border-radius, 6px); -} - -.sc-card .sc-copy-btn { - color: var(--text-normal); - position: absolute; - top: 6px; - right: 6px; - width: 24px; - height: 24px; - padding: 2px; - border-radius: 4px; - cursor: pointer; - display: none; - align-items: center; - justify-content: center; - opacity: 0.7; - transition: all 0.2s ease; -} - -.sc-card:hover .sc-copy-btn { - display: flex; - opacity: 1; -} - -.sc-card .sc-copy-btn:hover { - color: var(--color-accent); -} - -.sc-card .sc-copy-btn:active { - transform: scale(0.8); -} - -.sc-category-row { - border-radius: var(--setting-items-radius) !important; -} - -.sc-tags { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 4px; -} - -.sc-tag { - font-size: 11px; - color: var(--text-accent); - background-color: hsla(var(--interactive-accent-hsl), 0.2); - border-radius: var(--tag-radius); - padding: 1px 5px; - cursor: pointer; - transition: color 0.15s ease; -} - -/* .sc-tag:hover { - color: var(--text-accent); -} */ - -.sc-card .sc-content.is-editing { - outline: none; - background: var(--background-primary-alt); - padding: 4px; - border-radius: 4px; - min-height: 1.4em; - white-space: pre-wrap; -} - -.sc-card .sc-content { - word-break: break-word; - overflow-wrap: break-word; -} - -.sc-timestamp { - font-size: small; - color: var(--text-normal) !important; - opacity: 0.7 !important; - margin-top: 4px; -} - -/* .sc-expiry-pill::before { - content: "Expires in "; -} */ - -.sc-expiry-pill { - font-size: small; - color: var(--text-normal); - opacity: 0.7; - margin-bottom: -2px; -} - - -.sc-footer { - margin-top: 4px; -} - -.sc-color-dots { - display: flex; - gap: 4px; - flex-wrap: nowrap; - padding: 2px 0; -} - -/* .sc-color-dots.two-row { - flex-wrap: wrap; - width: 114px; -} */ - -.sc-color-dot { - width: 22px !important; - height: 22px !important; - border-radius: 10px !important; - cursor: pointer; - flex-shrink: 0; - transition: transform 0.1s ease; -} -de -.sc-home-color-dot { - border-radius: 10px !important; - border: 1px solid var(--background-primary); -} - -.sc-icon-btn { - padding: 6px; - background-color: var(--background-modifier-form-field); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - color: var(--text-muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.1s ease; -} - -.sc-icon-btn:hover { - background-color: var(--background-modifier-hover); - color: var(--text-normal); -} - -.sc-icon-btn svg { - width: 16px; - height: 16px; -} - -.sc-color-dot:hover { - transform: scale(1.15); -} - - -@media (max-width: 768px) { - .sidecards-home-palette-row { - gap: 4px !important; - } - .sidecards-home-category-btn { - font-size: 10px; - } - .sidecards-home-separator { - margin: 0px 2px !important; - } -} - -.sc-status-pill { - display: inline; - background-color: var(--background-modifier-hover); - color: var(--text-normal); - padding: 2px 8px; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - white-space: nowrap; -} - -.sc-pill-bar { - margin-bottom: 6px !important; -} - -/* Modal Styles */ -.sc-quick-card-modal .sc-modal-title { - margin-top: 0; - margin-bottom: 20px; -} - -.sc-quick-card-modal .sc-modal-section-title { - font-size: 13px; - font-weight: 600; - margin: 16px 0 8px 0; - color: var(--text-muted); -} - -.sc-quick-card-modal .sc-modal-textarea { - width: 100%; - min-height: 120px; - padding: 10px; - border-radius: var(--input-radius); - border: 1px solid var(--background-modifier-border); - background-color: var(--background-modifier-form-field); - font-family: var(--font-text); - resize: vertical; - white-space: pre-wrap; - position: relative; -} - -.sc-quick-card-modal .sc-modal-textarea.is-empty::before { - content: attr(data-placeholder); - color: var(--text-muted); - position: absolute; - pointer-events: none; - opacity: 0.6; -} - -.sc-quick-card-modal .sc-modal-color-container { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.sc-quick-card-modal .sc-modal-color-swatch { - width: 24px; - height: 24px; - border-radius: 10px; - cursor: pointer; - border: 2px solid transparent; - transition: transform 0.1s ease; -} - -.sc-quick-card-modal .sc-modal-color-swatch:hover { - transform: scale(1.2); -} - -.sc-quick-card-modal .sc-modal-color-swatch.is-selected { - border-color: var(--text-accent); -} - -.sc-quick-card-modal .sc-modal-tags-wrapper { - position: relative; -} - -.sc-quick-card-modal .sc-modal-tags-input { - width: 100%; - padding: 8px; - border-radius: var(--input-radius); - border: 1px solid var(--background-modifier-border); -} - -.sc-quick-card-modal .sc-modal-tags-autocomplete { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: var(--input-radius); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - z-index: 100; - margin-top: 4px; - max-height: 200px; - overflow-y: auto; -} - -.sc-quick-card-modal .sc-modal-autocomplete-item { - padding: 8px 12px; - cursor: pointer; - border-bottom: 1px solid var(--background-modifier-border); -} - -.sc-quick-card-modal .sc-modal-autocomplete-item:last-child { - border-bottom: none; -} - -.sc-quick-card-modal .sc-modal-autocomplete-item:hover, -.sc-quick-card-modal .sc-modal-autocomplete-item.is-selected { - background: var(--background-modifier-hover); -} - -.sc-quick-card-modal .sc-modal-select { - width: 100%; - padding: 8px; - border-radius: var(--input-radius); - border: 1px solid var(--background-modifier-border); -} - -.sc-quick-card-modal .sc-modal-buttons { - display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 24px; -} - -.sidecards-home-separator { - height: 24px; - width: 1px; - background-color: var(--background-modifier-border); -} - -/* --- Utility classes replacing inline styles --- */ - -/* sc-sidebar-main: flex column, full height */ -.sc-sidebar-main { - display: flex; - flex-direction: column; - height: 100%; -} - -/* sc-sidebar-cards-container positional */ -.sc-sidebar-cards-container { - position: relative; -} - -/* sc-sidebar-input-container: sticky bottom input area */ -.sc-sidebar-input-container { - padding: 8px; - border-top: 1px solid var(--background-modifier-border); - background: var(--background-primary); - position: sticky; - bottom: 0; -} - -/* sc-sidebar-button-container: action buttons row */ -.sc-sidebar-button-container { - display: flex; - gap: 8px; - justify-content: flex-end; - margin-top: 8px; -} - -/* sc-sidebar-loading: full-cover loading overlay */ -.sc-sidebar-loading { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--background-primary); - z-index: 9999; -} - -.sc-sidebar-loading-inner { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -/* sc-sidebar-spinner */ -.sc-sidebar-spinner { - width: 36px; - height: 36px; - border: 4px solid var(--background-modifier-border); - border-top-color: var(--interactive-accent); - border-radius: 50%; -} - -/* sc-category-group: filter chip row */ -.sc-category-group { - display: flex; - gap: 8px; - overflow-x: auto; - flex-wrap: nowrap; - white-space: nowrap; -} - -/* sc-search-wrap visibility helpers */ -.sc-search-wrap--hidden { - display: none; -} - -/* sc-search-clear-btn visibility */ -.sc-search-clear-btn--hidden { - display: none; -} - -/* Home view: notes column hidden */ -.sc-home-left--hidden { - display: none; -} - -/* Home view: single column when notes hidden */ -.sc-home-grid--full { - grid-template-columns: 1fr; -} - -/* Home view: notes on right side */ -.sc-home-grid.notes-right { - grid-template-columns: 1fr minmax(0, 280px); -} - -.sc-home-grid.notes-right .sc-home-left { - order: 2; -} -.sc-home-grid.notes-right .sc-home-right { - order: 1; -} - -/* Home search input font */ -.sc-home-search-input { - font-family: var(--font-interface); -} - -/* Color swatch grid (two-row context menu) */ -.sc-color-dots.two-row { - display: grid; - grid-template-columns: repeat(5, 18px); - grid-auto-rows: 18px; - column-gap: 8px; - row-gap: 8px; - width: fit-content; -} - -/* Individual color swatch in context menu */ -.sc-color-dot-swatch { - width: 18px; - height: 18px; - border-radius: 4px; - cursor: pointer; - flex-shrink: 0; -} - -/* Masonry break-inside for style 2 */ -.sc-card.sc-style-2-masonry { - break-inside: avoid; -} - -/* Timestamp display variants */ -.sc-timestamp--block { - display: block; - margin-bottom: 3px; -} - -.sc-timestamp--inline { - display: inline-block; -} - -.sc-timestamp--inline-spaced { - display: inline-block; - margin-left: 8px; -} - -/* Generic hidden utility */ -.sc-hidden { - display: none !important; -} - -/* Settings tab utility classes */ -.sc-preview-wrapper { - margin-bottom: 24px; - padding: 20px; - border-radius: 8px; - background-color: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - display: flex; - flex-direction: column; - align-items: center; -} - -.sc-settings-preview-card { - width: 100%; - max-width: 300px; - margin: 0; - pointer-events: none; -} - -.sc-cats-container { - margin-top: 8px; -} - -.sc-full-width { - width: 100% !important; -} - -/* Category row controls */ -.sc-cat-row-controls { - flex: 1; - justify-content: flex-start; -} - -.sc-drag-handle { - cursor: grab; - color: var(--text-muted); - margin-right: 10px; -} - -.sc-round-btn { - border-radius: 50%; - width: 32px; - min-width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - border: none; - margin-right: 10px; -} - -.sc-round-btn--disabled { - opacity: 0.5; - cursor: default; -} - -.sc-round-btn--green { - color: var(--color-green); - background-color: rgba(var(--color-green-rgb), 0.2); -} - -.sc-round-btn--red { - color: var(--color-red); - background-color: rgba(var(--color-red-rgb), 0.2); -} - -.sc-color-picker-btn { - width: 24px; - height: 24px; - padding: 0; - border: none; - border-radius: 50%; - background: none; - cursor: pointer; - overflow: hidden; -} - -.sc-drag-over-top { - border-top: 2px solid var(--color-accent) !important; -} - -.sc-drag-over-bottom { - border-bottom: 2px solid var(--color-accent) !important; -} - -.sc-dragging { - opacity: 0.5; -} - -.sc-cat-name-input { - width: 100%; -} - -.sc-cat-name-input--disabled { - opacity: 0.6; -} - -.sc-reset-btn-small { - padding: 2px 8px; - font-size: 11px; -} - -.sc-spacer-32 { - width: 32px; -} - -/* Add row / add button containers */ -.sc-add-row { - display: flex; - justify-content: flex-end; - margin-top: 12px; - margin-bottom: 24px; -} - -/* Auto color rule row */ -.sc-rule-row-controls { - flex: 1; - justify-content: flex-start; -} - -.sc-rule-text-input { - width: 100%; - max-width: none; -} - -.sc-dropdown-normal { - color: var(--text-normal); -} - -/* Status config */ -.sc-status-row-controls { - flex: 1; - justify-content: flex-start; -} - -.sc-status-text-input { - width: 100%; - max-width: none; -} - -.sc-color-picker-inline { - padding: 0; -} - -/* FolderSuggest dropdown */ -.sc-folder-suggest { - display: none; - position: absolute; - z-index: 1000; - left: 0; - width: 100%; - background-color: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - max-height: 200px; - overflow-y: auto; -} - -.sc-folder-suggest-item { - padding: 6px 10px; - cursor: pointer; -} - -.sc-folder-suggest-item:hover { - background-color: var(--background-modifier-hover); -} - -/* Icon picker modal */ -.sc-icon-picker-content { - display: flex; - flex-direction: column; - gap: 8px; -} - -.sc-icon-picker-search { - width: 100%; - margin-bottom: 8px; -} - -.sc-icon-picker-list { - display: grid; - grid-template-columns: repeat(auto-fill, 32px); - gap: 4px; - max-height: 300px; - overflow-y: auto; -} - -.sc-icon-picker-footer { - display: flex; - justify-content: flex-end; - margin-top: 8px; -} - -.sc-icon-picker-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - border: none; - border-radius: 4px; - cursor: pointer; - background: none; - box-shadow: none; -} - -/* Icon btn plus state */ -.sc-icon-btn--plus { - font-size: 20px; -} - - -.sc-ac-parent { - position: relative; -} - -/* ── Inline Autocomplete (@category / #tag) ─────────────────────────── */ -.sc-inline-autocomplete { - position: absolute; - z-index: 9999; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - min-width: 160px; - max-height: 180px; - overflow-y: auto; - display: none; -} - -.sc-inline-autocomplete.is-visible { - display: block; -} - -/* Position variants — set via CSS custom properties on the element */ -.sc-inline-autocomplete.ac-above { - bottom: var(--sc-ac-bottom, 4px); - top: auto; - left: var(--sc-ac-left, 0px); -} - -.sc-inline-autocomplete.ac-below { - top: var(--sc-ac-top, 4px); - bottom: auto; - left: var(--sc-ac-left, 0px); -} - -.sc-inline-ac-item { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - cursor: pointer; - font-size: 13px; - border-bottom: 1px solid var(--background-modifier-border); -} - -.sc-inline-ac-item:last-child { border-bottom: none; } - -.sc-inline-ac-item:hover, -.sc-inline-ac-item.is-selected { - background: var(--background-modifier-hover); -} - -.sc-inline-ac-badge { - font-weight: 700; - color: var(--text-accent); - font-size: 12px; - min-width: 12px; -} - -/* ── DateTimeModal ───────────────────────────────────────────────────── */ -.sc-datetime-modal .modal-title { - margin-bottom: 0; -} - -.sc-datetime-modal .sc-dt-mode-row { - display: flex; - gap: 16px; - margin-bottom: 14px; -} - -.sc-dt-radio-label { - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; - font-size: 14px; -} - -.sc-dt-panel { margin-bottom: 8px; } - -.sc-dt-slots { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; } - -.sc-dt-slot-row { - display: flex; - align-items: center; - gap: 6px; -} - -.sc-dt-slot-num { - width: 70px; - padding: 4px 6px; - border-radius: 4px; - border: 1px solid var(--background-modifier-border); - color: var(--text-normal); -} - -.sc-dt-slot-unit { - flex: 1; - padding: 4px 6px; - border-radius: 4px; - border: 1px solid var(--background-modifier-border); - color: var(--text-normal); -} - -.sc-dt-slot-remove { - padding: 2px 7px; - border-radius: 4px; - opacity: 0.6; - cursor: pointer; -} - -.sc-dt-slot-remove:hover { opacity: 1; } - -.sc-dt-add-slot-btn { - font-size: 12px; - padding: 4px 10px; - margin-bottom: 10px; - border-radius: 4px; - cursor: pointer; -} - -.sc-dt-presets { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 4px; -} - -.sc-dt-preset-btn { - font-size: 12px; - padding: 3px 9px; - border-radius: 12px; - border: 1px solid var(--background-modifier-border); - cursor: pointer; - background: var(--background-secondary); -} - -.sc-dt-preset-btn:hover { background: var(--background-modifier-hover); } - -.sc-dt-exact-input { - width: 100%; - padding: 6px 8px; - margin-bottom: 8px; - border-radius: 4px; - border: 1px solid var(--background-modifier-border); - color: var(--text-normal); -} - -.sc-dt-quick-row { display: flex; gap: 8px; margin-bottom: 4px; } - -.sc-dt-quick-btn { - font-size: 12px; - padding: 4px 10px; - border-radius: 4px; - cursor: pointer; -} - -.sc-dt-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 14px; -} - -/* --- Setup modal --- */ - -.sc-setup-modal .modal-content { - overflow: visible !important; -} - -.sc-setup-modal .modal-title { - margin-bottom: -16px; -} - -.sc-setup-folder-row { - position: relative; - margin-bottom: 12px; -} - -.sc-setup-folder-input { - width: 100%; - box-sizing: border-box; -} - -.sc-setup-folder-suggest { - display: none; - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 1000; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: var(--radius-s); - max-height: 200px; - overflow-y: auto; - box-shadow: var(--shadow-s); -} - -.sc-setup-folder-suggest.is-visible { - display: block; -} - -.sc-setup-folder-item { - padding: 6px 10px; - cursor: pointer; - font-size: var(--font-ui-small); - color: var(--text-normal); -} - -.sc-setup-folder-item:hover { - background: var(--background-modifier-hover); -} - -.sc-setup-btn-row { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 12px; -} - -/* Drag-to-reorder */ -.sc-drag-ghost { - position: fixed; - z-index: 9999; - pointer-events: none; - opacity: 1; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); - border-radius: var(--radius-m); - cursor: grabbing; -} +/* --- == SC == --- */ + +.workspace-leaf:has(.sc-home-container) .view-content, +.workspace-leaf:has(.sc-home-container) .view-header { + display: none; +} + +.sc-home-category-bar { + margin: 0 !important; + gap: 10px !important; +} + +.sc-color-dots.two-row { + gap: 8px !important; + margin-bottom: 6px !important; +} + +.sidecards-home, .sc-home-main { + margin-top: var(--sc-home-top-margin, 70px) !important; + transition: all 0.2s ease; +} + + .sc-home-right, + .sc-home-toolbar, + .sc-home-category-bar, + .sc-home-card-list { + width: 100% !important; + min-width: 0; + } + +.sc-sidebar-button-container { + padding-bottom: var(--sc-button-padding-bottom, 26px) !important; +} + +.sc-content p { + margin: 0; +} + +.sc-content blockquote { + border-left: 3px solid var(--text-accent); + margin: 4px 0; + padding: 2px 0 2px 10px; + color: var(--text-muted); + font-style: italic; + background: transparent; +} + +.sc-sidebar-header .sc-category-group { + background: var(--background-primary); + display: flex; + gap: 8px; + flex-wrap: nowrap; + overflow-x: auto; + padding: 10px; + scrollbar-width: none; + scroll-behavior: smooth; +} + +.sc-sidebar-header .sc-category-group::-webkit-scrollbar { + display: none; +} + +.sc-category-btn { + /* border: 2px solid var(--background-modifier-border); */ + border-radius: var(--button-radius); + background: var(--background-primary); + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + transition: border-width 0s ease; +} +.sc-category-btn:hover { + filter: brightness(1.2); +} + +.sc-category-btn.active { + border: 2px solid var(--sc-btn-text-color, var(--interactive-accent)); + transition: border-width 0s ease; +} + +.sc-search-wrap { + width: 100%; + padding: 10px; + background: var(--background-primary); + border-bottom: 1px solid var(--background-modifier-border); + padding-top: 0px; +} + +.sc-search-row { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.sc-search-input-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + width: 16px; + height: 16px; +} + +.sc-search-input { + flex: 1; + width: 100%; + padding: 8px 36px 8px 34px !important; + border: 1px solid var(--background-modifier-border); + border-radius: var(--input-radius); + background: var(--background-primary-alt); + color: var(--text-normal); + font-size: 14px; + transition: all 0.2s ease; + line-height: 1.4; +} + +.sc-search-input::placeholder { + color: var(--text-muted); +} + +.sc-search-input:focus { + outline: none; + border-color: var(--interactive-accent); + background: var(--background-primary); + box-shadow: 0 0 0 2px rgba(var(--interactive-accent), 0.1); +} + +.sc-sidebar-input { + width: 100%; + min-height: 36px; + max-height: 200px; + padding: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: var(--input-radius); + background-color: var(--background-modifier-form-field); + overflow-y: auto; + white-space: pre-wrap; + position: relative; +} + +.sc-sidebar-input.is-empty::before { + content: attr(data-placeholder); + color: var(--text-muted); + position: absolute; + pointer-events: none; + opacity: 0.6; +} + +/* Home View Layout */ +.sc-home-container { + height: 100%; + overflow-y: auto; + background-color: var(--background-primary); +} + +.sc-home-main { + width: 100%; + max-width: var(--sc-home-max-width, 1000px); + margin: 0 auto; + padding: 40px 20px; + box-sizing: border-box; +} + +.sc-home-top { + margin-bottom: 40px; +} + +.sc-home-title { + font-size: 28px; + font-weight: 700; + margin: 0 0 15px 0; + color: var(--text-normal); +} + +.sc-home-palette-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.sc-home-category-btn { + background-color: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 4px 12px; + font-size: 13px; + color: var(--text-muted); + cursor: pointer; +} + +.sc-home-separator { + color: var(--background-modifier-border); + margin: 0 4px; + font-size: 18px; + opacity: 0.5; +} + +.sc-home-color-dot { + width: 24px; + height: 24px; + border-radius: 50%; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.1s ease, border-color 0.1s ease; +} + +.sc-home-color-dot:hover { + transform: scale(1.15); +} + +.sc-home-color-dot.is-selected { + border-color: var(--text-accent); +} + +.sc-home-editor { + width: 100%; + min-height: 120px; + padding: 16px; + border: 1px solid var(--background-modifier-border); + border-radius: var(--input-radius); + background-color: var(--background-modifier-form-field); + font-size: 16px; + line-height: 1.5; + white-space: pre-wrap; + position: relative; +} + +.sc-home-editor.is-empty::before { + content: attr(data-placeholder); + color: var(--text-muted); + position: absolute; + pointer-events: none; + opacity: 0.5; +} + +.sc-home-grid { + display: grid; + grid-template-columns: minmax(0, 280px) 1fr; + gap: 32px; + align-items: start; + gap: 40px; +} + +.sc-home-section-title { + font-size: 18px; + font-weight: 600; + margin: 0 0 16px 0; + color: var(--text-normal); +} + +.sc-home-file-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 32px; +} + +.sc-home-file-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background-color: var(--background-modifier-form-field); + /* border-radius: 6px; */ + border-radius: var(--input-radius); + cursor: grab; + transition: background-color 0.1s ease; +} + +.sc-home-file-item:hover { + background-color: var(--background-modifier-hover); +} + +.sc-home-file-list:not(.pinned) .sc-home-file-item { + cursor: pointer; +} + +.sc-home-file-icon { + color: var(--text-muted); + display: flex; + align-items: center; +} + +.sc-home-file-name { + font-size: 14px; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sc-home-empty-msg { + font-size: 13px; + color: var(--text-muted); + font-style: italic; + padding: 4px 12px; +} + +/* Right Column Toolbar */ +.sc-home-toolbar { + display: flex; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--background-modifier-border); + gap: 8px; +} + +.sc-home-toolbar-left, +.sc-home-toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.sc-home-search-wrap { + flex: 1; + position: relative; + display: flex; + align-items: center; + background-color: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + border-radius: var(--input-radius); + padding: 0 8px; +} + +.sc-home-search-icon { + color: var(--text-muted); + display: flex; + align-items: center; +} + +.sc-home-search-input { + color: var(--text-muted); + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 6px 8px !important; + font-size: 13px; + width: 100%; +} + +.sc-home-search-input::placeholder { + color: var(--text-muted); +} + +.sc-home-category-bar { + display: flex; + gap: 4px; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 16px; + margin-bottom: 16px; + scrollbar-width: none; +} + +.sc-home-category-bar::-webkit-scrollbar { + display: none; +} + +.sc-home-card-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sc-home-card-list .sc-card { + margin: 0; + width: 100%; +} + + +.workspace-leaf-content .sc-home-container { + container-type: inline-size; +} + +@container (max-width: 768px) { + .sc-home-left { + display: none; + } + + .sc-home-grid { + grid-template-columns: 1fr; + } + + .sc-home-main { + padding: 20px 12px; + } + + .sidecards-home, .sc-home-main { + margin: 50px 10px !important; + transition: all 0.2s ease; + } + + .sc-home-right, + .sc-home-toolbar, + .sc-home-category-bar, + .sc-home-card-list { + width: 100% !important; + min-width: 0; + } +} + +.sc-search-clear-btn { + font-size: 20px; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + padding: 4px 10px; + border-radius: 100%; + border: none !important; + box-shadow: none !important; + color: var(--text-error) !important; + background: transparent !important; + /* background-color: transparent !important; */ + cursor: pointer; + transition: all 0.2s ease; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + opacity: 0.6; +} + +.sc-search-clear-btn:hover { + color: var(--text-normal); + opacity: 1; +} + +.sc-sidebar-cards-container { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + scrollbar-width: auto; + -ms-overflow-style: auto; +} + +.sc-sidebar-cards-container.hide-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.sc-sidebar-cards-container.hide-scrollbar::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.sc-sidebar-cards-container.grid-mode { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-auto-rows: 1px; + gap: 0px 6px; + align-items: start; +} + +.sc-sidebar-cards-container.grid-mode .sc-card { + margin: 0 !important; + align-self: start; + height: auto; + padding-bottom: 14px; +} + +.sc-card { + background: var(--background-primary); + padding: 12px; + position: relative; + /* font-size: 14px; */ + line-height: 1.4; + border: 1px solid var(--background-modifier-border); + border-radius: var(--card-border-radius, 6px); +} + +.sc-card .sc-copy-btn { + color: var(--text-normal); + position: absolute; + top: 6px; + right: 6px; + width: 24px; + height: 24px; + padding: 2px; + border-radius: 4px; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + opacity: 0.7; + transition: all 0.2s ease; +} + +.sc-card:hover .sc-copy-btn { + display: flex; + opacity: 1; +} + +.sc-card .sc-copy-btn:hover { + color: var(--color-accent); +} + +.sc-card .sc-copy-btn:active { + transform: scale(0.8); +} + +.sc-category-row { + border-radius: var(--setting-items-radius) !important; +} + +.sc-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.sc-tag { + font-size: 11px; + color: var(--text-accent); + background-color: hsla(var(--interactive-accent-hsl), 0.2); + border-radius: var(--tag-radius); + padding: 1px 5px; + cursor: pointer; + transition: color 0.15s ease; +} + +/* .sc-tag:hover { + color: var(--text-accent); +} */ + +.sc-card .sc-content.is-editing { + outline: none; + background: var(--background-primary-alt); + padding: 4px; + border-radius: 4px; + min-height: 1.4em; + white-space: pre-wrap; +} + +.sc-card .sc-content { + word-break: break-word; + overflow-wrap: break-word; +} + +.sc-card .sc-content img.sc-embed-image { + max-width: 100%; + height: auto; + display: block; + border-radius: 4px; + margin: 4px 0; +} + +.sc-card .sc-content .internal-embed { + display: block; + /* border: 1px solid var(--background-modifier-border); */ + border-radius: 4px; + padding: 6px 8px; + margin: 4px 0; + font-size: 0.9em; + overflow: hidden; +} + +.sc-card .sc-content .internal-embed .markdown-embed-title { + font-weight: 600; + margin-bottom: 4px; + font-size: 0.85em; + opacity: 0.7; +} + +.sc-timestamp { + font-size: small; + color: var(--text-normal) !important; + opacity: 0.7 !important; + margin-top: 4px; +} + +/* .sc-expiry-pill::before { + content: "Expires in "; +} */ + +.sc-expiry-pill { + font-size: small; + color: var(--text-normal); + opacity: 0.7; + margin-bottom: -2px; +} + + +.sc-footer { + margin-top: 4px; +} + +.sc-color-dots { + display: flex; + gap: 4px; + flex-wrap: nowrap; + padding: 2px 0; +} + +/* .sc-color-dots.two-row { + flex-wrap: wrap; + width: 114px; +} */ + +.sc-color-dot { + width: 22px !important; + height: 22px !important; + border-radius: 10px !important; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.1s ease; +} +de +.sc-home-color-dot { + border-radius: 10px !important; + border: 1px solid var(--background-primary); +} + +.sc-icon-btn { + padding: 6px; + background-color: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s ease; +} + +.sc-icon-btn:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); +} + +.sc-icon-btn svg { + width: 16px; + height: 16px; +} + +.sc-color-dot:hover { + transform: scale(1.15); +} + + +@media (max-width: 768px) { + .sidecards-home-palette-row { + gap: 4px !important; + } + .sidecards-home-category-btn { + font-size: 10px; + } + .sidecards-home-separator { + margin: 0px 2px !important; + } +} + +.sc-status-pill { + display: inline; + background-color: var(--background-modifier-hover); + color: var(--text-normal); + padding: 2px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.sc-pill-bar { + margin-bottom: 6px !important; +} + +/* Modal Styles */ +.sc-quick-card-modal .sc-modal-title { + margin-top: 0; + margin-bottom: 20px; +} + +.sc-quick-card-modal .sc-modal-section-title { + font-size: 13px; + font-weight: 600; + margin: 16px 0 8px 0; + color: var(--text-muted); +} + +.sc-quick-card-modal .sc-modal-textarea { + width: 100%; + min-height: 120px; + padding: 10px; + border-radius: var(--input-radius); + border: 1px solid var(--background-modifier-border); + background-color: var(--background-modifier-form-field); + font-family: var(--font-text); + resize: vertical; + white-space: pre-wrap; + position: relative; +} + +.sc-quick-card-modal .sc-modal-textarea.is-empty::before { + content: attr(data-placeholder); + color: var(--text-muted); + position: absolute; + pointer-events: none; + opacity: 0.6; +} + +.sc-quick-card-modal .sc-modal-color-container { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.sc-quick-card-modal .sc-modal-color-swatch { + width: 24px; + height: 24px; + border-radius: 10px; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.1s ease; +} + +.sc-quick-card-modal .sc-modal-color-swatch:hover { + transform: scale(1.2); +} + +.sc-quick-card-modal .sc-modal-color-swatch.is-selected { + border-color: var(--text-accent); +} + +.sc-quick-card-modal .sc-modal-tags-wrapper { + position: relative; +} + +.sc-quick-card-modal .sc-modal-tags-input { + width: 100%; + padding: 8px; + border-radius: var(--input-radius); + border: 1px solid var(--background-modifier-border); +} + +.sc-quick-card-modal .sc-modal-tags-autocomplete { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--input-radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 100; + margin-top: 4px; + max-height: 200px; + overflow-y: auto; +} + +.sc-quick-card-modal .sc-modal-autocomplete-item { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--background-modifier-border); +} + +.sc-quick-card-modal .sc-modal-autocomplete-item:last-child { + border-bottom: none; +} + +.sc-quick-card-modal .sc-modal-autocomplete-item:hover, +.sc-quick-card-modal .sc-modal-autocomplete-item.is-selected { + background: var(--background-modifier-hover); +} + +.sc-quick-card-modal .sc-modal-select { + width: 100%; + padding: 8px; + border-radius: var(--input-radius); + border: 1px solid var(--background-modifier-border); +} + +.sc-quick-card-modal .sc-modal-buttons { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; +} + +.sidecards-home-separator { + height: 24px; + width: 1px; + background-color: var(--background-modifier-border); +} + +/* --- Utility classes replacing inline styles --- */ + +/* sc-sidebar-main: flex column, full height */ +.sc-sidebar-main { + display: flex; + flex-direction: column; + height: 100%; +} + +/* sc-sidebar-cards-container positional */ +.sc-sidebar-cards-container { + position: relative; +} + +/* sc-sidebar-input-container: sticky bottom input area */ +.sc-sidebar-input-container { + padding: 8px; + border-top: 1px solid var(--background-modifier-border); + background: var(--background-primary); + position: sticky; + bottom: 0; +} + +/* sc-sidebar-button-container: action buttons row */ +.sc-sidebar-button-container { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; +} + +/* sc-sidebar-loading: full-cover loading overlay */ +.sc-sidebar-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--background-primary); + z-index: 9999; +} + +.sc-sidebar-loading-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +/* sc-sidebar-spinner */ +.sc-sidebar-spinner { + width: 36px; + height: 36px; + border: 4px solid var(--background-modifier-border); + border-top-color: var(--interactive-accent); + border-radius: 50%; +} + +/* sc-category-group: filter chip row */ +.sc-category-group { + display: flex; + gap: 8px; + overflow-x: auto; + flex-wrap: nowrap; + white-space: nowrap; +} + +/* sc-search-wrap visibility helpers */ +.sc-search-wrap--hidden { + display: none; +} + +/* sc-search-clear-btn visibility */ +.sc-search-clear-btn--hidden { + display: none; +} + +/* Home view: notes column hidden */ +.sc-home-left--hidden { + display: none; +} + +/* Home view: single column when notes hidden */ +.sc-home-grid--full { + grid-template-columns: 1fr; +} + +/* Home view: notes on right side */ +.sc-home-grid.notes-right { + grid-template-columns: 1fr minmax(0, 280px); +} + +.sc-home-grid.notes-right .sc-home-left { + order: 2; +} +.sc-home-grid.notes-right .sc-home-right { + order: 1; +} + +/* Home search input font */ +.sc-home-search-input { + font-family: var(--font-interface); +} + +/* Color swatch grid (two-row context menu) */ +.sc-color-dots.two-row { + display: grid; + grid-template-columns: repeat(5, 18px); + grid-auto-rows: 18px; + column-gap: 8px; + row-gap: 8px; + width: fit-content; +} + +/* Individual color swatch in context menu */ +.sc-color-dot-swatch { + width: 18px; + height: 18px; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +/* Masonry break-inside for style 2 */ +.sc-card.sc-style-2-masonry { + break-inside: avoid; +} + +/* Timestamp display variants */ +.sc-timestamp--block { + display: block; + margin-bottom: 3px; +} + +.sc-timestamp--inline { + display: inline-block; +} + +.sc-timestamp--inline-spaced { + display: inline-block; + margin-left: 8px; +} + +/* Generic hidden utility */ +.sc-hidden { + display: none !important; +} + +/* Settings tab utility classes */ +.sc-preview-wrapper { + margin-bottom: 24px; + padding: 20px; + border-radius: 8px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + display: flex; + flex-direction: column; + align-items: center; +} + +.sc-settings-preview-card { + width: 100%; + max-width: 300px; + margin: 0; + pointer-events: none; +} + +.sc-cats-container { + margin-top: 8px; +} + +.sc-full-width { + width: 100% !important; +} + +/* Category row controls */ +.sc-cat-row-controls { + flex: 1; + justify-content: flex-start; +} + +.sc-drag-handle { + cursor: grab; + color: var(--text-muted); + margin-right: 10px; +} + +.sc-round-btn { + border-radius: 50%; + width: 32px; + min-width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + margin-right: 10px; +} + +.sc-round-btn--disabled { + opacity: 0.5; + cursor: default; +} + +.sc-round-btn--green { + color: var(--color-green); + background-color: rgba(var(--color-green-rgb), 0.2); +} + +.sc-round-btn--red { + color: var(--color-red); + background-color: rgba(var(--color-red-rgb), 0.2); +} + +.sc-color-picker-btn { + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 50%; + background: none; + cursor: pointer; + overflow: hidden; +} + +.sc-drag-over-top { + border-top: 2px solid var(--color-accent) !important; +} + +.sc-drag-over-bottom { + border-bottom: 2px solid var(--color-accent) !important; +} + +.sc-dragging { + opacity: 0.5; +} + +.sc-cat-name-input { + width: 100%; +} + +.sc-cat-name-input--disabled { + opacity: 0.6; +} + +.sc-reset-btn-small { + padding: 2px 8px; + font-size: 11px; +} + +.sc-spacer-32 { + width: 32px; +} + +/* Add row / add button containers */ +.sc-add-row { + display: flex; + justify-content: flex-end; + margin-top: 12px; + margin-bottom: 24px; +} + +/* Auto color rule row */ +.sc-rule-row-controls { + flex: 1; + justify-content: flex-start; +} + +.sc-rule-text-input { + width: 100%; + max-width: none; +} + +.sc-dropdown-normal { + color: var(--text-normal); +} + +/* Status config */ +.sc-status-row-controls { + flex: 1; + justify-content: flex-start; +} + +.sc-status-text-input { + width: 100%; + max-width: none; +} + +.sc-color-picker-inline { + padding: 0; +} + +/* FolderSuggest dropdown */ +.sc-folder-suggest { + display: none; + position: absolute; + z-index: 1000; + left: 0; + width: 100%; + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + max-height: 200px; + overflow-y: auto; +} + +.sc-folder-suggest-item { + padding: 6px 10px; + cursor: pointer; +} + +.sc-folder-suggest-item:hover { + background-color: var(--background-modifier-hover); +} + +/* Icon picker modal */ +.sc-icon-picker-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sc-icon-picker-search { + width: 100%; + margin-bottom: 8px; +} + +.sc-icon-picker-list { + display: grid; + grid-template-columns: repeat(auto-fill, 32px); + gap: 4px; + max-height: 300px; + overflow-y: auto; +} + +.sc-icon-picker-footer { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +.sc-icon-picker-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 4px; + cursor: pointer; + background: none; + box-shadow: none; +} + +/* Icon btn plus state */ +.sc-icon-btn--plus { + font-size: 20px; +} + + +.sc-ac-parent { + position: relative; +} + +/* ── Inline Autocomplete (@category / #tag) ─────────────────────────── */ +.sc-inline-autocomplete { + position: absolute; + z-index: 9999; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + min-width: 160px; + max-height: 180px; + overflow-y: auto; + display: none; +} + +.sc-inline-autocomplete.is-visible { + display: block; +} + +/* Position variants — set via CSS custom properties on the element */ +.sc-inline-autocomplete.ac-above { + bottom: var(--sc-ac-bottom, 4px); + top: auto; + left: var(--sc-ac-left, 0px); +} + +.sc-inline-autocomplete.ac-below { + top: var(--sc-ac-top, 4px); + bottom: auto; + left: var(--sc-ac-left, 0px); +} + +.sc-inline-ac-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + font-size: 13px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.sc-inline-ac-item:last-child { border-bottom: none; } + +.sc-inline-ac-item:hover, +.sc-inline-ac-item.is-selected { + background: var(--background-modifier-hover); +} + +.sc-inline-ac-badge { + font-weight: 700; + color: var(--text-accent); + font-size: 12px; + min-width: 12px; +} + +/* ── DateTimeModal ───────────────────────────────────────────────────── */ +.sc-datetime-modal .modal-title { + margin-bottom: 0; +} + +.sc-datetime-modal .sc-dt-mode-row { + display: flex; + gap: 16px; + margin-bottom: 14px; +} + +.sc-dt-radio-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 14px; +} + +.sc-dt-panel { margin-bottom: 8px; } + +.sc-dt-slots { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; } + +.sc-dt-slot-row { + display: flex; + align-items: center; + gap: 6px; +} + +.sc-dt-slot-num { + width: 70px; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); + color: var(--text-normal); +} + +.sc-dt-slot-unit { + flex: 1; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); + color: var(--text-normal); +} + +.sc-dt-slot-remove { + padding: 2px 7px; + border-radius: 4px; + opacity: 0.6; + cursor: pointer; +} + +.sc-dt-slot-remove:hover { opacity: 1; } + +.sc-dt-add-slot-btn { + font-size: 12px; + padding: 4px 10px; + margin-bottom: 10px; + border-radius: 4px; + cursor: pointer; +} + +.sc-dt-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 4px; +} + +.sc-dt-preset-btn { + font-size: 12px; + padding: 3px 9px; + border-radius: 12px; + border: 1px solid var(--background-modifier-border); + cursor: pointer; + background: var(--background-secondary); +} + +.sc-dt-preset-btn:hover { background: var(--background-modifier-hover); } + +.sc-dt-exact-input { + width: 100%; + padding: 6px 8px; + margin-bottom: 8px; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); + color: var(--text-normal); +} + +.sc-dt-quick-row { display: flex; gap: 8px; margin-bottom: 4px; } + +.sc-dt-quick-btn { + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; +} + +.sc-dt-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; +} + +/* --- Setup modal --- */ + +.sc-setup-modal .modal-content { + overflow: visible !important; +} + +.sc-setup-modal .modal-title { + margin-bottom: -16px; +} + +.sc-setup-folder-row { + position: relative; + margin-bottom: 12px; +} + +.sc-setup-folder-input { + width: 100%; + box-sizing: border-box; +} + +.sc-setup-folder-suggest { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + max-height: 200px; + overflow-y: auto; + box-shadow: var(--shadow-s); +} + +.sc-setup-folder-suggest.is-visible { + display: block; +} + +.sc-setup-folder-item { + padding: 6px 10px; + cursor: pointer; + font-size: var(--font-ui-small); + color: var(--text-normal); +} + +.sc-setup-folder-item:hover { + background: var(--background-modifier-hover); +} + +.sc-setup-btn-row { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +/* Drag-to-reorder */ +.sc-drag-ghost { + position: fixed; + z-index: 9999; + pointer-events: none; + opacity: 1; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + border-radius: var(--radius-m); + cursor: grabbing; +}