mirror of
https://github.com/seventhxiv/obsidian-board-view.git
synced 2026-07-22 06:40:09 +00:00
Initialize
This commit is contained in:
commit
c4eb4151cd
33 changed files with 5044 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
23
.eslintrc
Normal file
23
.eslintrc
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
BIN
.github/assets/board-view-demonstration.gif
vendored
Normal file
BIN
.github/assets/board-view-demonstration.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 MiB |
34
.github/workflows/release.yml
vendored
Normal file
34
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
main.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
251
AGENTS.md
Normal file
251
AGENTS.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
|
||||
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
|
||||
- Types: `obsidian` type definitions.
|
||||
|
||||
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
- To use eslint install eslint from terminal: `npm install -g eslint`
|
||||
- To use eslint to analyze this project use this command: `eslint main.ts`
|
||||
- eslint will then create a report with suggestions for code improvement by file and line number.
|
||||
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
|
||||
|
||||
## File & folder conventions
|
||||
|
||||
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
|
||||
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
|
||||
- **Example file structure**:
|
||||
```
|
||||
src/
|
||||
main.ts # Plugin entry point, lifecycle management
|
||||
settings.ts # Settings interface and defaults
|
||||
commands/ # Command implementations
|
||||
command1.ts
|
||||
command2.ts
|
||||
ui/ # UI components, modals, views
|
||||
modal.ts
|
||||
view.ts
|
||||
utils/ # Utility functions, helpers
|
||||
helpers.ts
|
||||
constants.ts
|
||||
types.ts # TypeScript interfaces and types
|
||||
```
|
||||
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
|
||||
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
|
||||
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
|
||||
|
||||
## Manifest rules (`manifest.json`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- Keep `minAppVersion` accurate when using newer APIs.
|
||||
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
|
||||
```
|
||||
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||
```
|
||||
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
|
||||
|
||||
## Commands & settings
|
||||
|
||||
- Any user-facing commands should be added via `this.addCommand(...)`.
|
||||
- If the plugin has configuration, provide a settings tab and sensible defaults.
|
||||
- Persist settings using `this.loadData()` / `this.saveData()`.
|
||||
- Use stable command IDs; avoid renaming once released.
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
|
||||
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
|
||||
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
|
||||
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- Default to local/offline operation. Only make network requests when essential to the feature.
|
||||
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
|
||||
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
|
||||
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
|
||||
- Clearly disclose any external services used, data sent, and risks.
|
||||
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
|
||||
- Avoid deceptive patterns, ads, or spammy notifications.
|
||||
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- TypeScript with `"strict": true` preferred.
|
||||
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
|
||||
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
|
||||
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Add commands with stable IDs (don't rename once released).
|
||||
- Provide defaults and validation in settings.
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Organize code across multiple files
|
||||
|
||||
**main.ts** (minimal, lifecycle only):
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { MySettings, DEFAULT_SETTINGS } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MySettings;
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
registerCommands(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**settings.ts**:
|
||||
```ts
|
||||
export interface MySettings {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MySettings = {
|
||||
enabled: true,
|
||||
apiKey: "",
|
||||
};
|
||||
```
|
||||
|
||||
**commands/index.ts**:
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { doSomething } from "./my-command";
|
||||
|
||||
export function registerCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: "do-something",
|
||||
name: "Do something",
|
||||
callback: () => doSomething(plugin),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Add a command
|
||||
|
||||
```ts
|
||||
this.addCommand({
|
||||
id: "your-command-id",
|
||||
name: "Do the thing",
|
||||
callback: () => this.doTheThing(),
|
||||
});
|
||||
```
|
||||
|
||||
### Persist settings
|
||||
|
||||
```ts
|
||||
interface MySettings { enabled: boolean }
|
||||
const DEFAULT_SETTINGS: MySettings = { enabled: true };
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
```
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
|
||||
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
|
||||
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
|
||||
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
|
||||
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
5
LICENSE
Normal file
5
LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Copyright (C) 2020-2025 by Dynalist Inc.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
40
README.md
Normal file
40
README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Obsidian Board View
|
||||
|
||||
Interactive Board view for bases, use as Kanban or Gallery, with grouping for rows and columns.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
1. **Column and Row Grouping**
|
||||
2. **Drag and Drop**
|
||||
3. **Interactive Properties** - Are rendered natively, so compatible with most plugins that modify property interactions
|
||||
4. **Color Coding** - Assign colors to groups for quick visual navigation
|
||||
5. **Image** - Display property-based images on cards
|
||||
6. **Icons** - Display property-based icons on cards
|
||||
7. **New Note Button** - Create notes directly in any group with pre-filled properties
|
||||
8. **Hide/Reorder Groups**
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create a new Bases view
|
||||
2. Select "Board" layout
|
||||
3. Configure Board options (grouping, colors, etc.)
|
||||
4. Drag cards between groups to update note properties
|
||||
|
||||
## Installation
|
||||
|
||||
### From Obsidian Community Plugins
|
||||
|
||||
1. Open Settings → Community Plugins
|
||||
2. Search for "Board View"
|
||||
3. Click Install, then Enable
|
||||
|
||||
### BRAT Installation
|
||||
1. Download the community plugin `BRAT`
|
||||
2. Open the following URL in the browser: obsidian://brat?plugin=seventhxiv/obsidian-board-view.
|
||||
3. Click the "Add Plugin" button.
|
||||
|
||||
## Support
|
||||
|
||||
Found a bug or have a feature request? Please [open an issue](https://github.com/seventhxiv/board-view/issues) on GitHub.
|
||||
|
||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "board-view",
|
||||
"name": "Board View",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.10.3",
|
||||
"description": "Interactive board view for bases, use as Kanban or Gallery, with grouping for rows and columns.",
|
||||
"author": "Seventh",
|
||||
"authorUrl": "https://github.com/seventhxiv",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
2422
package-lock.json
generated
Normal file
2422
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "board-view",
|
||||
"version": "1.0.0",
|
||||
"description": "Interactive board view for bases, use as Kanban or Gallery, with grouping for rows and columns.",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Seventh",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
4
src/Base/PluginInterface.ts
Normal file
4
src/Base/PluginInterface.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export class PluginInterface {
|
||||
public static initialize() {
|
||||
}
|
||||
}
|
||||
18
src/Base/Services.ts
Normal file
18
src/Base/Services.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { PropertyManager } from 'Data/PropertyManager';
|
||||
import { App } from 'obsidian';
|
||||
import BoardViewPlugin from '../main';
|
||||
import { BoardViewSettings } from './Settings';
|
||||
|
||||
export default class Services {
|
||||
public static settings: BoardViewSettings;
|
||||
public static plugin: BoardViewPlugin;
|
||||
public static app: App;
|
||||
public static propertyManager: PropertyManager;
|
||||
|
||||
public static async initialize(plugin: BoardViewPlugin) {
|
||||
this.plugin = plugin;
|
||||
this.app = plugin.app;
|
||||
this.settings = plugin.settings;
|
||||
this.propertyManager = new PropertyManager();
|
||||
}
|
||||
}
|
||||
22
src/Base/Settings.ts
Normal file
22
src/Base/Settings.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { App, Plugin, PluginSettingTab } from 'obsidian';
|
||||
|
||||
export interface BoardViewSettings {
|
||||
columnColors: Record<string, string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: BoardViewSettings = {
|
||||
columnColors: {}
|
||||
};
|
||||
|
||||
export class BoardViewSettingTab extends PluginSettingTab {
|
||||
constructor(app: App, plugin: Plugin) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', { text: 'Board View settings' });
|
||||
}
|
||||
}
|
||||
76
src/Data/PropertyManager.ts
Normal file
76
src/Data/PropertyManager.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import Services from "Base/Services";
|
||||
import { TFile } from "obsidian";
|
||||
import { InternalApp, InternalMetadataCache } from "Types/Internal";
|
||||
|
||||
export enum PropertyNativeType {
|
||||
TEXT = "text",
|
||||
MULTITEXT = "multitext",
|
||||
NUMBER = "number",
|
||||
CHECKBOX = "checkbox",
|
||||
DATE = "date",
|
||||
TAGS = "tags",
|
||||
ALIASES = "aliases",
|
||||
}
|
||||
|
||||
export class PropertyManager {
|
||||
|
||||
getFile(file: string): TFile | null {
|
||||
return Services.app.vault.getFileByPath(file);
|
||||
}
|
||||
|
||||
getPropertyType(property: string): string | null {
|
||||
try {
|
||||
const properties = (Services.app as InternalApp).metadataTypeManager.properties;
|
||||
const propertyObject = properties[property.toLowerCase()] as Record<string, unknown>;
|
||||
const propertyType = propertyObject['widget'] || null;
|
||||
return propertyType as string;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getFrontmatterDate(file: TFile) {
|
||||
const cache = Services.app.metadataCache.getFileCache(file);
|
||||
return cache?.frontmatter?.myDate;
|
||||
}
|
||||
|
||||
async updateFrontmatter(file: TFile, key: string, value: unknown) {
|
||||
await Services.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
fm[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
public getPropertyValues(property: string): string[] {
|
||||
|
||||
let propertyValues = undefined;
|
||||
try {
|
||||
propertyValues = (Services.app.metadataCache as InternalMetadataCache).getFrontmatterPropertyValuesForKey(property);
|
||||
} catch (e) {
|
||||
console.log("Error getting property values", e);
|
||||
}
|
||||
|
||||
if (propertyValues != undefined) {
|
||||
return propertyValues;
|
||||
}
|
||||
|
||||
// Otherwise find values manually
|
||||
const values = new Set<string>();
|
||||
const allFiles = Services.app.vault.getMarkdownFiles();
|
||||
|
||||
allFiles.forEach(file => {
|
||||
const cache = Services.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cache?.frontmatter;
|
||||
if (!frontmatter) return;
|
||||
|
||||
const value = frontmatter[property];
|
||||
if (value !== undefined && value !== null) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => values.add(v));
|
||||
} else {
|
||||
values.add(value.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(values);
|
||||
}
|
||||
}
|
||||
29
src/Types/Internal.ts
Normal file
29
src/Types/Internal.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { App, Menu, MenuItem, MetadataCache, TFile, Value, Workspace, WorkspaceLeaf } from "obsidian";
|
||||
|
||||
export interface MetadataTypeManager {
|
||||
registeredTypeWidgets: Record<string, unknown>;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface InternalApp extends App {
|
||||
metadataTypeManager: MetadataTypeManager;
|
||||
}
|
||||
|
||||
export interface InternalWorkspace extends Workspace {
|
||||
iterateRootLeaves(callback: (leaf: WorkspaceLeaf) => boolean | void): void;
|
||||
_activeEditor?: {
|
||||
file?: TFile;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InternalMenuItem extends MenuItem {
|
||||
setSubmenu(): Menu;
|
||||
}
|
||||
|
||||
export interface InternalMetadataCache extends MetadataCache {
|
||||
getFrontmatterPropertyValuesForKey(key: string): string[];
|
||||
}
|
||||
|
||||
export interface PropertyData extends Value {
|
||||
data: unknown;
|
||||
}
|
||||
16
src/Utils.ts
Normal file
16
src/Utils.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
/**
|
||||
* Removes the BasesPropertyType prefix from a BasesPropertyId.
|
||||
* Assumes the format is "type:name" and returns "name".
|
||||
* If no colon is present, returns the original string.
|
||||
* @param propertyId The property ID to process.
|
||||
* @returns The property name without the type prefix.
|
||||
*/
|
||||
export function getPropertyKeyFromId(propertyId: string): string {
|
||||
if (!propertyId) return '';
|
||||
const parts = propertyId.split('.');
|
||||
if (parts.length > 1) {
|
||||
return parts.slice(1).join('.');
|
||||
}
|
||||
return propertyId;
|
||||
}
|
||||
202
src/Views/BoardDataBuilder.ts
Normal file
202
src/Views/BoardDataBuilder.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import Services from 'Base/Services';
|
||||
import { PropertyManager } from 'Data/PropertyManager';
|
||||
import { BasesQueryResult, BasesViewConfig } from 'obsidian';
|
||||
import { PropertyData } from 'Types/Internal';
|
||||
import { getPropertyKeyFromId } from 'Utils';
|
||||
import { BoardColumn, BoardItem, BoardRow, BoardViewData } from './BoardView';
|
||||
import { EMPTY_GROUP_VALUE } from './BoardViewRenderer';
|
||||
import { BoardOptions } from './OptionsExtractor';
|
||||
|
||||
export class BoardViewDataBuilder {
|
||||
constructor(
|
||||
private data: BasesQueryResult,
|
||||
private config: BasesViewConfig
|
||||
) { }
|
||||
|
||||
build(options: BoardOptions): BoardViewData {
|
||||
const items: Record<string, Record<string, BoardItem[]>> = {};
|
||||
const columns: BoardColumn[] = [];
|
||||
const rows: BoardRow[] = [];
|
||||
|
||||
const groupPropertyId = options.groupProperty;
|
||||
const subGroupPropertyId = options.subGroupProperty;
|
||||
const hiddenGroups = new Set(options.hiddenGroups || []);
|
||||
const hiddenSubGroups = new Set(options.hiddenSubGroups || []);
|
||||
|
||||
const boardViewData: BoardViewData = {
|
||||
groupPropertyId: groupPropertyId || '',
|
||||
subGroupPropertyId,
|
||||
columns,
|
||||
rows,
|
||||
items,
|
||||
cardOptions: options,
|
||||
cardProperties: this.config.getOrder(),
|
||||
columnColors: Services.settings.columnColors || {}
|
||||
};
|
||||
|
||||
// Use this.data.data (flat list of entries)
|
||||
const entries = this.data?.data || [];
|
||||
|
||||
// 1. Collect all unique group and subgroup values
|
||||
const groupValues = new Map<string, unknown>();
|
||||
const subGroupValues = new Map<string, unknown>();
|
||||
|
||||
// From data
|
||||
for (const entry of entries) {
|
||||
// Main Group
|
||||
let groupValStr = EMPTY_GROUP_VALUE;
|
||||
let groupValRaw: unknown = null;
|
||||
if (groupPropertyId) {
|
||||
const val = entry.getValue(groupPropertyId);
|
||||
if (val?.isTruthy()) {
|
||||
groupValRaw = (val as PropertyData).data;
|
||||
if (groupValRaw === undefined) {
|
||||
groupValRaw = val.toString();
|
||||
}
|
||||
groupValStr = val.toString();
|
||||
}
|
||||
}
|
||||
if (!groupValues.has(groupValStr)) {
|
||||
groupValues.set(groupValStr, groupValRaw);
|
||||
}
|
||||
|
||||
// Sub Group
|
||||
let subGroupValStr = EMPTY_GROUP_VALUE;
|
||||
let subGroupValRaw: unknown = null;
|
||||
if (subGroupPropertyId) {
|
||||
const val = entry.getValue(subGroupPropertyId);
|
||||
if (val?.isTruthy()) {
|
||||
subGroupValRaw = (val as PropertyData).data;
|
||||
if (subGroupValRaw === undefined) {
|
||||
subGroupValRaw = val.toString();
|
||||
}
|
||||
subGroupValStr = val.toString();
|
||||
}
|
||||
}
|
||||
if (!subGroupValues.has(subGroupValStr)) {
|
||||
subGroupValues.set(subGroupValStr, subGroupValRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// From vault (if not hiding empty)
|
||||
if (!options.hideEmptyGroups && groupPropertyId) {
|
||||
const propertyManager = new PropertyManager();
|
||||
const allValues = propertyManager.getPropertyValues(getPropertyKeyFromId(groupPropertyId));
|
||||
for (const val of allValues) {
|
||||
if (!groupValues.has(val)) {
|
||||
groupValues.set(val, val);
|
||||
}
|
||||
}
|
||||
if (!groupValues.has(EMPTY_GROUP_VALUE)) groupValues.set(EMPTY_GROUP_VALUE, null);
|
||||
}
|
||||
|
||||
if (!options.hideEmptySubGroups && subGroupPropertyId) {
|
||||
const propertyManager = new PropertyManager();
|
||||
const allValues = propertyManager.getPropertyValues(getPropertyKeyFromId(subGroupPropertyId));
|
||||
for (const val of allValues) {
|
||||
if (!subGroupValues.has(val)) {
|
||||
subGroupValues.set(val, val);
|
||||
}
|
||||
}
|
||||
if (!subGroupValues.has(EMPTY_GROUP_VALUE)) subGroupValues.set(EMPTY_GROUP_VALUE, null);
|
||||
}
|
||||
|
||||
// 2. Create Columns and Rows (Sorted)
|
||||
const sortedGroupKeys = this.sortGroups(Array.from(groupValues.keys()), options.groupOrder);
|
||||
for (const key of sortedGroupKeys) {
|
||||
if (!hiddenGroups.has(key)) {
|
||||
columns.push({
|
||||
id: key,
|
||||
title: key,
|
||||
rawValue: groupValues.get(key),
|
||||
count: 0
|
||||
});
|
||||
items[key] = {};
|
||||
}
|
||||
}
|
||||
|
||||
const sortedSubGroupKeys = this.sortGroups(Array.from(subGroupValues.keys()), options.subGroupOrder);
|
||||
for (const key of sortedSubGroupKeys) {
|
||||
if (!hiddenSubGroups.has(key)) {
|
||||
rows.push({
|
||||
id: key,
|
||||
title: key,
|
||||
rawValue: subGroupValues.get(key),
|
||||
count: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Populate Items
|
||||
for (const entry of entries) {
|
||||
// Main Group
|
||||
let groupId = EMPTY_GROUP_VALUE;
|
||||
if (groupPropertyId) {
|
||||
const val = entry.getValue(groupPropertyId);
|
||||
if (val?.isTruthy()) {
|
||||
groupId = val.toString();
|
||||
} else {
|
||||
groupId = EMPTY_GROUP_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
// Sub Group
|
||||
let subGroupId = EMPTY_GROUP_VALUE;
|
||||
if (subGroupPropertyId) {
|
||||
const val = entry.getValue(subGroupPropertyId);
|
||||
if (val?.isTruthy()) {
|
||||
subGroupId = val.toString();
|
||||
} else {
|
||||
subGroupId = EMPTY_GROUP_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if hidden
|
||||
if (hiddenGroups.has(groupId)) continue;
|
||||
if (subGroupPropertyId && hiddenSubGroups.has(subGroupId)) continue;
|
||||
|
||||
// Ensure structure exists
|
||||
if (!items[groupId]) items[groupId] = {};
|
||||
if (!items[groupId][subGroupId]) items[groupId][subGroupId] = [];
|
||||
|
||||
items[groupId][subGroupId].push({
|
||||
id: entry.file.path,
|
||||
groupId: groupId,
|
||||
subGroupId: subGroupId === 'default' ? undefined : subGroupId,
|
||||
data: entry
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Calculate Counts
|
||||
for (const column of columns) {
|
||||
let count = 0;
|
||||
if (items[column.id]) {
|
||||
for (const subGroupId in items[column.id]) {
|
||||
count += items[column.id][subGroupId].length;
|
||||
}
|
||||
}
|
||||
column.count = count;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
let count = 0;
|
||||
for (const column of columns) {
|
||||
if (items[column.id] && items[column.id][row.id]) {
|
||||
count += items[column.id][row.id].length;
|
||||
}
|
||||
}
|
||||
row.count = count;
|
||||
}
|
||||
|
||||
return boardViewData;
|
||||
}
|
||||
|
||||
private sortGroups(groups: string[], orderList: string[] | undefined): string[] {
|
||||
if (!orderList || orderList.length === 0) {
|
||||
return groups.sort();
|
||||
}
|
||||
const ordered = orderList.filter(g => groups.includes(g));
|
||||
const remaining = groups.filter(g => !orderList.includes(g)).sort();
|
||||
return [...ordered, ...remaining];
|
||||
}
|
||||
}
|
||||
71
src/Views/BoardNoteCreator.ts
Normal file
71
src/Views/BoardNoteCreator.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import Services from 'Base/Services';
|
||||
import { TFile } from 'obsidian';
|
||||
import { InternalWorkspace } from 'Types/Internal';
|
||||
import { getPropertyKeyFromId } from 'Utils';
|
||||
import { EMPTY_GROUP_VALUE } from './BoardViewRenderer';
|
||||
|
||||
export class BoardNoteCreator {
|
||||
private pendingNote: {
|
||||
groupValue: unknown;
|
||||
subGroupValue?: unknown;
|
||||
groupPropertyId?: string | null;
|
||||
subGroupPropertyId?: string | null;
|
||||
} | null = null;
|
||||
|
||||
async handleNewNoteClick(groupValue: unknown, subGroupValue?: unknown, groupPropertyId?: string | null, subGroupPropertyId?: string | null): Promise<void> {
|
||||
// Store the pending note info
|
||||
this.pendingNote = {
|
||||
groupValue,
|
||||
subGroupValue,
|
||||
groupPropertyId,
|
||||
subGroupPropertyId
|
||||
};
|
||||
|
||||
// Trigger native new button
|
||||
const newButton = document.querySelector('.bases-toolbar-new-item-menu .text-icon-button');
|
||||
if (newButton instanceof HTMLElement) {
|
||||
newButton.click();
|
||||
} else {
|
||||
console.warn('[BoardNoteCreator] New button not found');
|
||||
this.pendingNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
async processPendingNote(): Promise<void> {
|
||||
if (!this.pendingNote) return;
|
||||
|
||||
try {
|
||||
// Get the active editor's file
|
||||
const file = (Services.app.workspace as InternalWorkspace)._activeEditor?.file;
|
||||
|
||||
if (file instanceof TFile) {
|
||||
await this.assignGroupValues(
|
||||
file,
|
||||
this.pendingNote.groupValue,
|
||||
this.pendingNote.subGroupValue,
|
||||
this.pendingNote.groupPropertyId,
|
||||
this.pendingNote.subGroupPropertyId
|
||||
);
|
||||
} else {
|
||||
console.warn('[BoardNoteCreator] No active editor file found');
|
||||
}
|
||||
} finally {
|
||||
// Clear the pending note
|
||||
this.pendingNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async assignGroupValues(file: TFile, groupValue: unknown, subGroupValue?: unknown, groupPropertyId?: string | null, subGroupPropertyId?: string | null): Promise<void> {
|
||||
// Assign group value (skip if EMPTY_GROUP_VALUE or missing property ID)
|
||||
if (groupPropertyId && groupValue !== null && groupValue !== EMPTY_GROUP_VALUE) {
|
||||
const groupPropertyKey = getPropertyKeyFromId(groupPropertyId);
|
||||
await Services.propertyManager.updateFrontmatter(file, groupPropertyKey, groupValue);
|
||||
}
|
||||
|
||||
// Assign sub-group value (skip if EMPTY_GROUP_VALUE or missing property ID)
|
||||
if (subGroupPropertyId && subGroupValue !== undefined && subGroupValue !== null && subGroupValue !== EMPTY_GROUP_VALUE) {
|
||||
const subGroupPropertyKey = getPropertyKeyFromId(subGroupPropertyId);
|
||||
await Services.propertyManager.updateFrontmatter(file, subGroupPropertyKey, subGroupValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
428
src/Views/BoardView.ts
Normal file
428
src/Views/BoardView.ts
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import { BasesEntry, Menu, setIcon } from 'obsidian';
|
||||
import Sortable from 'sortablejs';
|
||||
import { InternalMenuItem } from 'Types/Internal';
|
||||
import { EMPTY_GROUP_VALUE } from './BoardViewRenderer';
|
||||
import { CardView } from './CardView';
|
||||
import { ColorManager } from './ColorManager';
|
||||
import { BoardOptions } from './OptionsExtractor';
|
||||
|
||||
export interface BoardItem {
|
||||
id: string;
|
||||
groupId: string;
|
||||
subGroupId?: string;
|
||||
data: BasesEntry;
|
||||
}
|
||||
|
||||
export interface BoardColumn {
|
||||
id: string;
|
||||
title: string;
|
||||
rawValue: unknown;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BoardRow {
|
||||
id: string;
|
||||
title: string;
|
||||
rawValue: unknown;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BoardViewData {
|
||||
groupPropertyId: string;
|
||||
subGroupPropertyId?: string | null;
|
||||
columns: BoardColumn[];
|
||||
rows: BoardRow[];
|
||||
items: Record<string, Record<string, BoardItem[]>>; // groupId -> subGroupId -> items
|
||||
cardOptions: BoardOptions;
|
||||
cardProperties: string[];
|
||||
columnColors: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BoardViewCallbacks {
|
||||
onCardDrop: (itemId: string, groupPropertyId: string, groupPropertyValue: unknown, subGroupPropertyId?: string | null, subGroupPropertyValue?: unknown) => void;
|
||||
onHideGroup: (groupValue: string) => void;
|
||||
onHideSubGroup: (subGroupValue: string) => void;
|
||||
onMoveGroup: (groupValue: string, direction: 'left' | 'right') => void;
|
||||
onMoveSubGroup: (subGroupValue: string, direction: 'up' | 'down') => void;
|
||||
onSetColumnColor: (groupValue: string, color: string, isSubGroup?: boolean) => void;
|
||||
onNewNoteClick: (groupValue: unknown, subGroupValue?: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
const COLUMN_COLORS = ColorManager.getColorNames().map(name =>
|
||||
name.charAt(0).toUpperCase() + name.slice(1)
|
||||
);
|
||||
|
||||
export class BoardView {
|
||||
private container: HTMLElement;
|
||||
private data: BoardViewData;
|
||||
private isDragging = false;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
public render(data: BoardViewData, callbacks: BoardViewCallbacks) {
|
||||
this.data = data;
|
||||
|
||||
// Skip render if currently dragging
|
||||
if (this.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture scroll position
|
||||
const existingWrapper = this.container.querySelector('.board-board-wrapper');
|
||||
let scrollTop = 0;
|
||||
let scrollLeft = 0;
|
||||
if (existingWrapper) {
|
||||
scrollTop = existingWrapper.scrollTop;
|
||||
scrollLeft = existingWrapper.scrollLeft;
|
||||
}
|
||||
|
||||
this.container.empty();
|
||||
|
||||
const wrapper = this.container.createDiv('board-board-wrapper');
|
||||
if (data.cardOptions.cardSize) {
|
||||
wrapper.classList.add(`card-size-${data.cardOptions.cardSize}`);
|
||||
}
|
||||
|
||||
// Render Column Headers
|
||||
const headerRow = wrapper.createDiv('board-column-headers');
|
||||
// Spacer for row headers if rows exist
|
||||
if (data.rows.length > 0) {
|
||||
headerRow.createDiv('board-column-header-spacer');
|
||||
}
|
||||
|
||||
for (const column of data.columns) {
|
||||
const header = headerRow.createDiv('board-column-header');
|
||||
|
||||
// Color circle before title (use grey as default)
|
||||
if (data.cardOptions.colorHeaders && data.groupPropertyId) {
|
||||
const circle = header.createSpan('board-header-color-circle');
|
||||
const colorName = this.getColorName(column.id) || 'grey';
|
||||
ColorManager.apply(circle, colorName.toLowerCase() as keyof typeof ColorManager.COLOR_MAP);
|
||||
}
|
||||
|
||||
// Chip for title (keeping implementation for future use)
|
||||
const chip = header.createSpan('board-header-chip');
|
||||
chip.textContent = column.title;
|
||||
|
||||
// Count badge
|
||||
const countBadge = header.createSpan('board-header-count');
|
||||
countBadge.textContent = `${column.count}`;
|
||||
|
||||
// Apply Color to chip (Group Colors) - DISABLED: Using circle instead
|
||||
// if (data.cardOptions.colorHeaders && data.groupPropertyId) {
|
||||
// const colorName = this.getColorName(column.id);
|
||||
// if (colorName) {
|
||||
// ColorManager.apply(chip, colorName.toLowerCase() as any);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Menu
|
||||
const menuBtn = header.createDiv('board-header-menu');
|
||||
setIcon(menuBtn, 'more-vertical');
|
||||
menuBtn.addEventListener('click', (evt) => {
|
||||
evt.stopPropagation();
|
||||
const menu = new Menu();
|
||||
|
||||
// Color Menu (Group)
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Color')
|
||||
.setIcon('palette');
|
||||
|
||||
const subMenu = (item as InternalMenuItem).setSubmenu();
|
||||
COLUMN_COLORS.forEach(colorName => {
|
||||
subMenu.addItem((subItem) => {
|
||||
subItem.setTitle(colorName)
|
||||
.onClick(() => callbacks.onSetColumnColor(column.id, colorName))
|
||||
.setIcon('circle');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Move left')
|
||||
.setIcon('arrow-left')
|
||||
.onClick(() => {
|
||||
callbacks.onMoveGroup(column.id, 'left');
|
||||
});
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Move right')
|
||||
.setIcon('arrow-right')
|
||||
.onClick(() => {
|
||||
callbacks.onMoveGroup(column.id, 'right');
|
||||
});
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Hide group')
|
||||
.setIcon('eye-off')
|
||||
.onClick(() => {
|
||||
callbacks.onHideGroup(column.id);
|
||||
});
|
||||
});
|
||||
menu.showAtMouseEvent(evt);
|
||||
});
|
||||
}
|
||||
|
||||
// Container for rows (allows for gallery view)
|
||||
const rowsContainer = wrapper.createDiv('board-rows-container');
|
||||
const isGallery = !data.groupPropertyId && !!data.subGroupPropertyId;
|
||||
|
||||
if (isGallery) {
|
||||
rowsContainer.classList.add('is-gallery');
|
||||
const defaultColumn = data.columns.find(c => c.id === EMPTY_GROUP_VALUE) || data.columns[0];
|
||||
|
||||
if (defaultColumn) {
|
||||
for (const row of data.rows) {
|
||||
this.renderRow(rowsContainer, row, [defaultColumn], data.items, callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// Standard Mode
|
||||
if (data.rows.length > 0) {
|
||||
for (const row of data.rows) {
|
||||
this.renderRow(rowsContainer, row, data.columns, data.items, callbacks);
|
||||
}
|
||||
} else {
|
||||
// Single row (default)
|
||||
this.renderRow(rowsContainer, null, data.columns, data.items, callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore scroll position
|
||||
if (scrollTop > 0 || scrollLeft > 0) {
|
||||
wrapper.scrollTop = scrollTop;
|
||||
wrapper.scrollLeft = scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
private renderRow(container: HTMLElement, row: BoardRow | null, columns: BoardColumn[], items: Record<string, Record<string, BoardItem[]>>, callbacks: BoardViewCallbacks) {
|
||||
const rowWrapper = container.createDiv('board-row-wrapper');
|
||||
|
||||
if (row) {
|
||||
const rowHeader = rowWrapper.createDiv('board-row-header-bar');
|
||||
const collapseIcon = rowHeader.createSpan('collapse-icon');
|
||||
setIcon(collapseIcon, 'chevron-down');
|
||||
|
||||
// Color circle before title (use grey as default)
|
||||
if (this.data.cardOptions.colorHeaders && this.data.subGroupPropertyId && !this.data.groupPropertyId) {
|
||||
const circle = rowHeader.createSpan('board-header-color-circle');
|
||||
const colorName = this.getColorName(row.id, true) || 'grey';
|
||||
ColorManager.apply(circle, colorName.toLowerCase() as keyof typeof ColorManager.COLOR_MAP);
|
||||
}
|
||||
|
||||
// Chip for title (keeping implementation for future use)
|
||||
const chip = rowHeader.createSpan('board-header-chip');
|
||||
chip.classList.add('board-row-title');
|
||||
chip.textContent = row.title;
|
||||
|
||||
// Count badge
|
||||
const countBadge = rowHeader.createSpan('board-header-count');
|
||||
countBadge.textContent = `${row.count}`;
|
||||
|
||||
// Apply Color to chip (Sub-Group Colors) - DISABLED: Using circle instead
|
||||
// if (this.data.cardOptions.colorHeaders && this.data.subGroupPropertyId && !this.data.groupPropertyId) {
|
||||
// const colorName = this.getColorName(row.id, true);
|
||||
// if (colorName) {
|
||||
// ColorManager.apply(chip, colorName.toLowerCase() as any);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Menu
|
||||
const menuBtn = rowHeader.createDiv('board-header-menu');
|
||||
setIcon(menuBtn, 'more-vertical');
|
||||
menuBtn.addEventListener('click', (evt) => {
|
||||
evt.stopPropagation();
|
||||
const menu = new Menu();
|
||||
|
||||
// Color Menu (Sub-Group) - Only show when no main group property exists
|
||||
if (!this.data.groupPropertyId) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Color')
|
||||
.setIcon('palette');
|
||||
|
||||
const subMenu = (item as InternalMenuItem).setSubmenu();
|
||||
COLUMN_COLORS.forEach(colorName => {
|
||||
subMenu.addItem((subItem) => {
|
||||
subItem.setTitle(colorName)
|
||||
.onClick(() => callbacks.onSetColumnColor(row.id, colorName, true))
|
||||
.setIcon('circle');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Move up')
|
||||
.setIcon('arrow-up')
|
||||
.onClick(() => {
|
||||
callbacks.onMoveSubGroup(row.id, 'up');
|
||||
});
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Move down')
|
||||
.setIcon('arrow-down')
|
||||
.onClick(() => {
|
||||
callbacks.onMoveSubGroup(row.id, 'down');
|
||||
});
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Hide sub-group')
|
||||
.setIcon('eye-off')
|
||||
.onClick(() => {
|
||||
callbacks.onHideSubGroup(row.id);
|
||||
});
|
||||
});
|
||||
menu.showAtMouseEvent(evt);
|
||||
});
|
||||
|
||||
rowHeader.addEventListener('click', () => {
|
||||
rowWrapper.classList.toggle('collapsed');
|
||||
});
|
||||
}
|
||||
|
||||
const rowEl = rowWrapper.createDiv('board-row');
|
||||
const rowCells = rowEl.createDiv('board-row-cells');
|
||||
|
||||
for (const column of columns) {
|
||||
const cell = rowCells.createDiv('board-cell');
|
||||
cell.setAttribute('data-group-id', column.id);
|
||||
if (row) {
|
||||
cell.setAttribute('data-subgroup-id', row.id);
|
||||
}
|
||||
|
||||
// Determine color source (Group > Sub-Group), default to grey
|
||||
let effectiveColorName = 'grey';
|
||||
if (this.data.groupPropertyId) {
|
||||
effectiveColorName = this.getColorName(column.id) || 'grey';
|
||||
} else if (this.data.subGroupPropertyId && row) {
|
||||
effectiveColorName = this.getColorName(row.id, true) || 'grey';
|
||||
}
|
||||
// Apply colors based on options
|
||||
if (this.data.cardOptions.colorCells) {
|
||||
ColorManager.apply(cell, effectiveColorName.toLowerCase() as keyof typeof ColorManager.COLOR_MAP);
|
||||
}
|
||||
|
||||
const cardColorMode = this.data.cardOptions.colorCards ? 'minimal' : 'none';
|
||||
|
||||
const cellItems = items[column.id] ? (items[column.id][row ? row.id : 'default'] || []) : [];
|
||||
|
||||
for (const item of cellItems) {
|
||||
const cardView = new CardView({
|
||||
options: this.data.cardOptions,
|
||||
properties: this.data.cardProperties,
|
||||
colorName: effectiveColorName,
|
||||
cardColorMode: cardColorMode
|
||||
});
|
||||
const cardEl = cardView.render(item.data);
|
||||
cardEl.setAttribute('data-item-id', item.id);
|
||||
|
||||
// Track mousedown on card to detect drag intention early
|
||||
cardEl.addEventListener('mousedown', (evt) => {
|
||||
// Only track left mouse button
|
||||
if (evt.button === 0) {
|
||||
this.isDragging = true;
|
||||
|
||||
// Set up one-time mouseup listener to clear flag if drag doesn't start
|
||||
const clearFlag = () => {
|
||||
setTimeout(() => {
|
||||
// Only clear if we're still in the "potential drag" state
|
||||
// SortableJS onStart will have taken over if a real drag happened
|
||||
if (this.isDragging) {
|
||||
this.isDragging = false;
|
||||
}
|
||||
}, 100);
|
||||
document.removeEventListener('mouseup', clearFlag);
|
||||
};
|
||||
document.addEventListener('mouseup', clearFlag);
|
||||
}
|
||||
});
|
||||
|
||||
cell.appendChild(cardEl);
|
||||
}
|
||||
|
||||
// Add "New Note" button
|
||||
const newNoteBtn = cell.createDiv('board-new-note-button');
|
||||
newNoteBtn.textContent = '+ New Note';
|
||||
newNoteBtn.addEventListener('click', async () => {
|
||||
await callbacks.onNewNoteClick(column.rawValue, row?.rawValue);
|
||||
});
|
||||
|
||||
this.setupSortable(cell, callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
private setupSortable(el: HTMLElement, callbacks: BoardViewCallbacks) {
|
||||
Sortable.create(el, {
|
||||
group: {
|
||||
name: 'board',
|
||||
pull: 'clone',
|
||||
put: true
|
||||
},
|
||||
animation: 150,
|
||||
sort: false, // Disable sorting to prevent items from shifting
|
||||
ghostClass: 'board-sortable-ghost',
|
||||
chosenClass: 'board-sortable-chosen',
|
||||
onStart: (evt) => {
|
||||
evt.from.classList.add('board-drag-source');
|
||||
},
|
||||
onMove: (evt) => {
|
||||
// Clear existing indicators
|
||||
document.querySelectorAll('.board-item-drop-target-top, .board-item-drop-target-bottom').forEach(el => {
|
||||
el.classList.remove('board-item-drop-target-top', 'board-item-drop-target-bottom');
|
||||
});
|
||||
|
||||
evt.related.classList.add('board-item-drop-target-top');
|
||||
},
|
||||
onEnd: (evt) => {
|
||||
this.isDragging = false;
|
||||
// Remove source class
|
||||
evt.from.classList.remove('board-drag-source');
|
||||
|
||||
// Cleanup indicators
|
||||
document.querySelectorAll('.board-item-drop-target-top, .board-item-drop-target-bottom').forEach(el => {
|
||||
el.classList.remove('board-item-drop-target-top', 'board-item-drop-target-bottom');
|
||||
});
|
||||
|
||||
// If dropped in the same list, do nothing (sort is false, so no reordering)
|
||||
if (evt.from === evt.to) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemEl = evt.item;
|
||||
const itemId = itemEl.getAttribute('data-item-id');
|
||||
|
||||
// When pull: 'clone', the itemEl might be the clone in the new list.
|
||||
// We remove it immediately because we rely on the data update to re-render the board.
|
||||
// This prevents visual duplication or incorrect state before re-render.
|
||||
if (itemEl && itemEl.parentNode) {
|
||||
itemEl.parentNode.removeChild(itemEl);
|
||||
}
|
||||
|
||||
const targetCell = evt.to as HTMLElement;
|
||||
const newGroupId = targetCell.getAttribute('data-group-id') || null;
|
||||
const newSubGroupId = targetCell.getAttribute('data-subgroup-id') || null;
|
||||
|
||||
if (itemId && newGroupId) {
|
||||
// Find column and row to get raw values
|
||||
const column = this.data.columns.find(c => c.id === newGroupId);
|
||||
const row = this.data.rows ? this.data.rows.find(r => r.id === newSubGroupId) : null;
|
||||
|
||||
const groupValue = column ? column.rawValue : newGroupId;
|
||||
const subGroupValue = row ? row.rawValue : newSubGroupId;
|
||||
|
||||
callbacks.onCardDrop(itemId, this.data.groupPropertyId, groupValue, this.data.subGroupPropertyId, subGroupValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getColorName(groupValue: string, isSubGroup = false): string {
|
||||
const propertyId = isSubGroup ? this.data.subGroupPropertyId : this.data.groupPropertyId;
|
||||
const key = `${propertyId}:${groupValue}`;
|
||||
const colorName = this.data.columnColors[key];
|
||||
return colorName || '';
|
||||
}
|
||||
}
|
||||
169
src/Views/BoardViewRenderer.ts
Normal file
169
src/Views/BoardViewRenderer.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import Services from 'Base/Services';
|
||||
import { getPropertyKeyFromId } from 'Utils';
|
||||
import { BASES_VIEW_ID } from 'main';
|
||||
import { BasesView, Notice, QueryController } from 'obsidian';
|
||||
import { BoardViewDataBuilder } from './BoardDataBuilder';
|
||||
import { BoardNoteCreator } from './BoardNoteCreator';
|
||||
import { BoardView, BoardViewCallbacks, BoardViewData } from './BoardView';
|
||||
import { BoardOptionKeys, BoardOptions, OptionsExtractor } from './OptionsExtractor';
|
||||
|
||||
export const EMPTY_GROUP_VALUE = 'Empty Group';
|
||||
|
||||
export class BoardViewRenderer extends BasesView {
|
||||
readonly type = BASES_VIEW_ID;
|
||||
private containerEl: HTMLElement;
|
||||
public controller: QueryController;
|
||||
private board: BoardView;
|
||||
private noteCreator: BoardNoteCreator;
|
||||
constructor(controller: QueryController, parentEl: HTMLElement) {
|
||||
super(controller);
|
||||
this.controller = controller;
|
||||
this.containerEl = parentEl.createDiv('board-view');
|
||||
const boardContainer = this.containerEl.createDiv('board-board-container');
|
||||
|
||||
// Initialize BoardView
|
||||
this.board = new BoardView(boardContainer);
|
||||
this.noteCreator = new BoardNoteCreator();
|
||||
}
|
||||
|
||||
// Called by Obsidian when Bases data changes
|
||||
public onDataUpdated(): void {
|
||||
this.noteCreator.processPendingNote();
|
||||
this.render();
|
||||
}
|
||||
|
||||
render(): void {
|
||||
|
||||
const options = this.extractOptions();
|
||||
const boardData = this.extractBoardData(options);
|
||||
|
||||
const callbacks: BoardViewCallbacks = {
|
||||
onCardDrop: this.handleCardDrop.bind(this),
|
||||
onHideGroup: this.hideGroup.bind(this),
|
||||
onHideSubGroup: this.hideSubGroup.bind(this),
|
||||
onMoveGroup: this.moveGroup.bind(this),
|
||||
onMoveSubGroup: this.moveSubGroup.bind(this),
|
||||
onSetColumnColor: this.setColumnColor.bind(this),
|
||||
onNewNoteClick: this.handleNewNoteClick.bind(this),
|
||||
};
|
||||
this.board.render(boardData, callbacks);
|
||||
}
|
||||
|
||||
private extractOptions(): BoardOptions {
|
||||
const optionsExtractor = new OptionsExtractor(this.config);
|
||||
const options = optionsExtractor.extract();
|
||||
return options;
|
||||
}
|
||||
|
||||
private extractBoardData(options: BoardOptions): BoardViewData {
|
||||
const boardViewDataBuilder = new BoardViewDataBuilder(this.data, this.config);
|
||||
const boardData = boardViewDataBuilder.build(options);
|
||||
return boardData;
|
||||
}
|
||||
|
||||
private async handleCardDrop(filePath: string, groupPropertyId: string, groupPropertyValue: unknown, subGroupPropertyId?: string | null, subGroupPropertyValue?: unknown) {
|
||||
console.log(`[BoardViewRenderer] Card dropped: ${filePath} -> Group: ${groupPropertyValue}, SubGroup: ${subGroupPropertyValue}`);
|
||||
|
||||
// If drop target has formula group, don't update frontmatter
|
||||
if (groupPropertyId.startsWith('formula.') || subGroupPropertyId?.startsWith('formula.')) {
|
||||
new Notice('Cannot drop card into formula group');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = Services.propertyManager.getFile(filePath);
|
||||
if (!file) return;
|
||||
|
||||
// Update Group
|
||||
if (groupPropertyId && groupPropertyValue !== undefined) {
|
||||
const groupPropertyKey = getPropertyKeyFromId(groupPropertyId);
|
||||
await Services.propertyManager.updateFrontmatter(file, groupPropertyKey, groupPropertyValue);
|
||||
}
|
||||
|
||||
// Update Sub Group
|
||||
if (subGroupPropertyId && subGroupPropertyValue !== undefined) {
|
||||
const subGroupPropertyKey = getPropertyKeyFromId(subGroupPropertyId);
|
||||
await Services.propertyManager.updateFrontmatter(file, subGroupPropertyKey, subGroupPropertyValue);
|
||||
}
|
||||
}
|
||||
|
||||
private hideGroup(groupValue: string) {
|
||||
const currentHidden = (this.config.get(BoardOptionKeys.HIDDEN_GROUPS) as string[]) || [];
|
||||
if (!currentHidden.includes(groupValue)) {
|
||||
this.config.set(BoardOptionKeys.HIDDEN_GROUPS, [...currentHidden, groupValue]);
|
||||
}
|
||||
}
|
||||
|
||||
private hideSubGroup(subGroupValue: string) {
|
||||
const currentHidden = (this.config.get(BoardOptionKeys.HIDDEN_SUB_GROUPS) as string[]) || [];
|
||||
if (!currentHidden.includes(subGroupValue)) {
|
||||
this.config.set(BoardOptionKeys.HIDDEN_SUB_GROUPS, [...currentHidden, subGroupValue]);
|
||||
}
|
||||
}
|
||||
|
||||
private moveGroup(groupValue: string, direction: 'left' | 'right') {
|
||||
const options = this.extractOptions();
|
||||
// Always reconstruct the current sort order from the board data
|
||||
const boardData = this.extractBoardData(options);
|
||||
const currentOrder = boardData.columns.map(c => c.id);
|
||||
|
||||
const index = currentOrder.indexOf(groupValue);
|
||||
if (index === -1) return;
|
||||
|
||||
const newOrder = [...currentOrder];
|
||||
if (direction === 'left') {
|
||||
if (index > 0) {
|
||||
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
|
||||
}
|
||||
} else {
|
||||
if (index < newOrder.length - 1) {
|
||||
[newOrder[index + 1], newOrder[index]] = [newOrder[index], newOrder[index + 1]];
|
||||
}
|
||||
}
|
||||
|
||||
this.config.set(BoardOptionKeys.GROUP_ORDER, newOrder);
|
||||
}
|
||||
|
||||
private moveSubGroup(subGroupValue: string, direction: 'up' | 'down') {
|
||||
const options = this.extractOptions();
|
||||
// Always reconstruct the current sort order from the board data
|
||||
const boardData = this.extractBoardData(options);
|
||||
const currentOrder = boardData.rows.map(r => r.id);
|
||||
|
||||
const index = currentOrder.indexOf(subGroupValue);
|
||||
if (index === -1) return;
|
||||
|
||||
const newOrder = [...currentOrder];
|
||||
if (direction === 'up') {
|
||||
if (index > 0) {
|
||||
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
|
||||
}
|
||||
} else {
|
||||
if (index < newOrder.length - 1) {
|
||||
[newOrder[index + 1], newOrder[index]] = [newOrder[index], newOrder[index + 1]];
|
||||
}
|
||||
}
|
||||
|
||||
this.config.set(BoardOptionKeys.SUB_GROUP_ORDER, newOrder);
|
||||
}
|
||||
|
||||
private async setColumnColor(groupValue: string, color: string, isSubGroup = false) {
|
||||
const options = this.extractOptions();
|
||||
const propertyId = isSubGroup ? options.subGroupProperty : options.groupProperty;
|
||||
if (!propertyId) return;
|
||||
const key = `${propertyId}:${groupValue}`;
|
||||
Services.settings.columnColors[key] = color;
|
||||
await Services.plugin.saveSettings();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async handleNewNoteClick(groupValue: unknown, subGroupValue?: unknown): Promise<void> {
|
||||
const options = this.extractOptions();
|
||||
await this.noteCreator.handleNewNoteClick(
|
||||
groupValue,
|
||||
subGroupValue,
|
||||
options.groupProperty,
|
||||
options.subGroupProperty
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
128
src/Views/CardView.ts
Normal file
128
src/Views/CardView.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { BasesEntry, Menu, WorkspaceLeaf } from 'obsidian';
|
||||
import { InternalWorkspace } from 'Types/Internal';
|
||||
import Services from '../Base/Services';
|
||||
import { ColorManager } from './ColorManager';
|
||||
import { BoardOptions } from './OptionsExtractor';
|
||||
import { PropertyView } from './PropertyView';
|
||||
|
||||
export interface CardViewContext {
|
||||
options: BoardOptions;
|
||||
properties: string[]; // properties to display (order)
|
||||
colorName?: string | null;
|
||||
cardColorMode?: 'none' | 'minimal' | 'full';
|
||||
}
|
||||
|
||||
export class CardView {
|
||||
private options: BoardOptions;
|
||||
private properties: string[];
|
||||
private colorName?: string | null;
|
||||
private cardColorMode: 'none' | 'minimal' | 'full';
|
||||
|
||||
constructor(ctx: CardViewContext) {
|
||||
this.options = ctx.options;
|
||||
|
||||
// Always ensure file.name is first in the properties list
|
||||
const otherProperties = ctx.properties.filter(p => p !== 'file.name');
|
||||
this.properties = ['file.name', ...otherProperties];
|
||||
|
||||
this.colorName = ctx.colorName;
|
||||
this.cardColorMode = ctx.cardColorMode || 'none';
|
||||
}
|
||||
|
||||
render(entry: BasesEntry): HTMLElement {
|
||||
const card = document.createElement('div');
|
||||
card.classList.add('board-card');
|
||||
if (this.options.cardSize) {
|
||||
card.classList.add(`card-size-${this.options.cardSize}`);
|
||||
}
|
||||
card.setAttribute('data-path', entry.file.path);
|
||||
|
||||
card.addEventListener('mouseup', async (evt) => {
|
||||
// Only handle left mouse button
|
||||
if (evt.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we clicked on an interactive property
|
||||
if ((evt.target as HTMLElement).closest('.metadata-property')) {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (this.options.openInSideView) {
|
||||
const workspace = Services.app.workspace;
|
||||
const activeLeaf = workspace.getLeaf(false);
|
||||
let targetLeaf: WorkspaceLeaf | null = null;
|
||||
|
||||
(workspace as InternalWorkspace).iterateRootLeaves((leaf: WorkspaceLeaf) => {
|
||||
if (leaf !== activeLeaf) {
|
||||
targetLeaf = leaf;
|
||||
}
|
||||
});
|
||||
|
||||
if (targetLeaf) {
|
||||
workspace.setActiveLeaf(targetLeaf, { focus: true });
|
||||
const newLeaf = workspace.getLeaf('tab');
|
||||
await newLeaf.openFile(entry.file);
|
||||
} else {
|
||||
const newLeaf = workspace.getLeaf('split');
|
||||
await newLeaf.openFile(entry.file);
|
||||
}
|
||||
} else {
|
||||
void Services.app.workspace.openLinkText(entry.file.path, '', true);
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('contextmenu', (evt) => {
|
||||
evt.preventDefault();
|
||||
const menu = new Menu();
|
||||
Services.app.workspace.trigger("file-menu", menu, entry.file, "board-card");
|
||||
menu.showAtPosition({ x: evt.pageX, y: evt.pageY });
|
||||
});
|
||||
|
||||
// Apply color based on mode
|
||||
if (this.colorName && this.cardColorMode !== 'none') {
|
||||
if (this.cardColorMode === 'full') {
|
||||
ColorManager.apply(card, this.colorName.toLowerCase() as keyof typeof ColorManager.COLOR_MAP);
|
||||
} else if (this.cardColorMode === 'minimal') {
|
||||
ColorManager.applyBorderLine(card, this.colorName.toLowerCase() as keyof typeof ColorManager.COLOR_MAP);
|
||||
}
|
||||
}
|
||||
|
||||
// optional image
|
||||
if (this.options.imageProperty) {
|
||||
const imageProperty = entry.getValue(this.options.imageProperty);
|
||||
if (imageProperty?.isTruthy()) {
|
||||
const data = imageProperty?.toString();
|
||||
const imgSrc = String(data);
|
||||
const img = document.createElement('img');
|
||||
img.classList.add('card-image');
|
||||
img.src = imgSrc; // TODO: validate local/remote paths
|
||||
card.appendChild(img);
|
||||
} else {
|
||||
// Placeholder
|
||||
if (!this.options.hideImagePlaceholder) {
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.classList.add('card-image', 'placeholder');
|
||||
card.appendChild(placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create PropertyView helper
|
||||
const propertyView = new PropertyView({
|
||||
options: this.options
|
||||
});
|
||||
|
||||
for (const prop of this.properties) {
|
||||
const propEl = propertyView.render(entry, prop as `note.${string}` | `formula.${string}` | `file.${string}`);
|
||||
if (propEl) {
|
||||
card.appendChild(propEl);
|
||||
}
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
102
src/Views/ColorManager.ts
Normal file
102
src/Views/ColorManager.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
type RGB = `rgb(${number}, ${number}, ${number})`;
|
||||
type RGBA = `rgba(${number}, ${number}, ${number}, ${number})`;
|
||||
|
||||
export interface LightDarkColor {
|
||||
light: RGB | RGBA;
|
||||
dark: RGB | RGBA;
|
||||
}
|
||||
|
||||
export class ColorManager {
|
||||
public static readonly COLOR_MAP: Record<string, LightDarkColor> = {
|
||||
grey: {
|
||||
light: "rgb(151, 151, 151)",
|
||||
dark: "rgb(99, 98, 93)"
|
||||
},
|
||||
purple: {
|
||||
light: "rgb(138, 92, 245)",
|
||||
dark: "rgb(107, 78, 129)"
|
||||
},
|
||||
brown: {
|
||||
light: "rgb(139, 69, 19)",
|
||||
dark: "rgba(122, 78, 44, 1)"
|
||||
},
|
||||
blue: {
|
||||
light: "rgb(0, 128, 213)",
|
||||
dark: "rgb(52, 97, 145)"
|
||||
},
|
||||
green: {
|
||||
light: "rgb(33, 148, 94)",
|
||||
dark: "rgb(55, 107, 78)"
|
||||
},
|
||||
yellow: {
|
||||
light: "rgb(200, 180, 80)",
|
||||
dark: "rgba(158, 122, 43, 1)"
|
||||
},
|
||||
red: {
|
||||
light: "rgb(214, 74, 64)",
|
||||
dark: "rgb(150, 74, 69)"
|
||||
}
|
||||
};
|
||||
|
||||
static apply(
|
||||
el: HTMLElement,
|
||||
name: keyof typeof ColorManager.COLOR_MAP,
|
||||
channel = "c"
|
||||
) {
|
||||
const color = this.COLOR_MAP[name];
|
||||
|
||||
if (!color) {
|
||||
console.warn(`[ColorManager] Unknown color: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
el.classList.add("has-plugin-color");
|
||||
|
||||
el.style.setProperty(`--${channel}-light`, color.light);
|
||||
el.style.setProperty(`--${channel}-dark`, color.dark);
|
||||
}
|
||||
|
||||
static applyBorderLine(
|
||||
el: HTMLElement,
|
||||
name: string,
|
||||
channel = "c"
|
||||
) {
|
||||
const color = this.COLOR_MAP[name];
|
||||
|
||||
if (!color) {
|
||||
console.warn(`[ColorManager] Unknown color: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
el.classList.add("has-plugin-color-border");
|
||||
|
||||
el.style.setProperty(`--${channel}-light`, color.light);
|
||||
el.style.setProperty(`--${channel}-dark`, color.dark);
|
||||
}
|
||||
|
||||
static applyCustom(
|
||||
el: HTMLElement,
|
||||
color: LightDarkColor,
|
||||
channel = "c"
|
||||
) {
|
||||
el.classList.add("has-plugin-color");
|
||||
|
||||
el.style.setProperty(`--${channel}-light`, color.light);
|
||||
el.style.setProperty(`--${channel}-dark`, color.dark);
|
||||
}
|
||||
|
||||
static toRGBA(rgb: RGB | string, alpha: number): string {
|
||||
const match = rgb.match(/\d+/g);
|
||||
|
||||
if (!match || match.length < 3) {
|
||||
throw new Error(`[ColorManager] Invalid RGB value: ${rgb}`);
|
||||
}
|
||||
|
||||
const [r, g, b] = match.map(Number);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
static getColorNames(): string[] {
|
||||
return Object.keys(this.COLOR_MAP);
|
||||
}
|
||||
}
|
||||
96
src/Views/OptionsExtractor.ts
Normal file
96
src/Views/OptionsExtractor.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { BasesPropertyId, BasesViewConfig } from 'obsidian';
|
||||
import { getPropertyKeyFromId } from 'Utils';
|
||||
import Services from '../Base/Services';
|
||||
|
||||
export const BoardOptionKeys = {
|
||||
GROUP_PROPERTY: 'groupProperty',
|
||||
SUB_GROUP_PROPERTY: 'subGroupProperty',
|
||||
IMAGE_PROPERTY: 'imageProperty',
|
||||
ICON_PROPERTY: 'iconProperty',
|
||||
GROUP_ORDER: 'groupOrder',
|
||||
SUB_GROUP_ORDER: 'subGroupOrder',
|
||||
HIDE_EMPTY_GROUPS: 'hideEmptyGroups',
|
||||
HIDE_EMPTY_SUB_GROUPS: 'hideEmptySubGroups',
|
||||
CARD_SIZE: 'cardSize',
|
||||
OPEN_IN_SIDE_VIEW: 'openInSideView',
|
||||
HIDDEN_GROUPS: 'hiddenGroups',
|
||||
HIDDEN_SUB_GROUPS: 'hiddenSubGroups',
|
||||
HIDE_IMAGE_PLACEHOLDER: 'hideImagePlaceholder',
|
||||
|
||||
// Color Options (simplified)
|
||||
COLOR_HEADERS: 'colorHeaders',
|
||||
COLOR_CELLS: 'colorCells',
|
||||
COLOR_CARDS: 'colorCards',
|
||||
} as const;
|
||||
|
||||
export interface BoardOptions {
|
||||
groupProperty?: BasesPropertyId | null;
|
||||
subGroupProperty?: BasesPropertyId | null;
|
||||
imageProperty?: BasesPropertyId | null;
|
||||
iconProperty?: BasesPropertyId | null;
|
||||
groupOrder?: string[];
|
||||
subGroupOrder?: string[];
|
||||
hideEmptyGroups?: boolean;
|
||||
hideEmptySubGroups?: boolean;
|
||||
cardSize?: 'small' | 'medium' | 'large';
|
||||
openInSideView?: boolean;
|
||||
hiddenGroups?: string[];
|
||||
hiddenSubGroups?: string[];
|
||||
hideImagePlaceholder?: boolean;
|
||||
|
||||
// Color Options
|
||||
colorHeaders?: boolean;
|
||||
colorCells?: boolean;
|
||||
colorCards?: boolean; // minimal mode only (left border)
|
||||
}
|
||||
|
||||
export class OptionsExtractor {
|
||||
constructor(private config: BasesViewConfig) { }
|
||||
|
||||
extract(): BoardOptions {
|
||||
const options: BoardOptions = {};
|
||||
let groupProperty = (this.config.get(BoardOptionKeys.GROUP_PROPERTY) as BasesPropertyId | null) || null;
|
||||
let subGroupProperty = (this.config.get(BoardOptionKeys.SUB_GROUP_PROPERTY) as BasesPropertyId | null) || null;
|
||||
|
||||
// Validate group property is still eligible
|
||||
if (groupProperty) {
|
||||
const propKey = getPropertyKeyFromId(groupProperty);
|
||||
if (!Services.plugin.isPropertyEligibleForGrouping(propKey)) {
|
||||
console.warn(`Group property '${groupProperty}' is no longer eligible for grouping. Clearing it.`);
|
||||
this.config.set(BoardOptionKeys.GROUP_PROPERTY, null);
|
||||
groupProperty = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate subgroup property is still eligible
|
||||
if (subGroupProperty) {
|
||||
const propKey = getPropertyKeyFromId(subGroupProperty);
|
||||
if (!Services.plugin.isPropertyEligibleForGrouping(propKey)) {
|
||||
console.warn(`Sub-group property '${subGroupProperty}' is no longer eligible for grouping. Clearing it.`);
|
||||
this.config.set(BoardOptionKeys.SUB_GROUP_PROPERTY, null);
|
||||
subGroupProperty = null;
|
||||
}
|
||||
}
|
||||
|
||||
options.groupProperty = groupProperty;
|
||||
options.subGroupProperty = subGroupProperty;
|
||||
options.imageProperty = (this.config.get(BoardOptionKeys.IMAGE_PROPERTY) as BasesPropertyId | null) || null;
|
||||
options.iconProperty = (this.config.get(BoardOptionKeys.ICON_PROPERTY) as BasesPropertyId | null) || null;
|
||||
options.groupOrder = (this.config.get(BoardOptionKeys.GROUP_ORDER) as string[]) || [];
|
||||
options.subGroupOrder = (this.config.get(BoardOptionKeys.SUB_GROUP_ORDER) as string[]) || [];
|
||||
options.hideEmptyGroups = (this.config.get(BoardOptionKeys.HIDE_EMPTY_GROUPS) as boolean) || false;
|
||||
options.hideEmptySubGroups = (this.config.get(BoardOptionKeys.HIDE_EMPTY_SUB_GROUPS) as boolean) || false;
|
||||
options.cardSize = (this.config.get(BoardOptionKeys.CARD_SIZE) as 'small' | 'medium' | 'large') || 'medium';
|
||||
options.openInSideView = (this.config.get(BoardOptionKeys.OPEN_IN_SIDE_VIEW) as boolean) || false;
|
||||
options.hiddenGroups = (this.config.get(BoardOptionKeys.HIDDEN_GROUPS) as string[]) || [];
|
||||
options.hiddenSubGroups = (this.config.get(BoardOptionKeys.HIDDEN_SUB_GROUPS) as string[]) || [];
|
||||
options.hideImagePlaceholder = (this.config.get(BoardOptionKeys.HIDE_IMAGE_PLACEHOLDER) as boolean) || false;
|
||||
|
||||
// Color Options
|
||||
options.colorHeaders = (this.config.get(BoardOptionKeys.COLOR_HEADERS) as boolean) || false;
|
||||
options.colorCells = (this.config.get(BoardOptionKeys.COLOR_CELLS) as boolean) || false;
|
||||
options.colorCards = (this.config.get(BoardOptionKeys.COLOR_CARDS) as boolean) || false;
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
113
src/Views/PropertyView.ts
Normal file
113
src/Views/PropertyView.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { PropertyNativeType } from 'Data/PropertyManager';
|
||||
import { InternalApp, PropertyData } from 'Types/Internal';
|
||||
import { BasesEntry, BasesPropertyId, RenderContext, setIcon } from 'obsidian';
|
||||
import Services from '../Base/Services';
|
||||
import { BoardOptions } from './OptionsExtractor';
|
||||
|
||||
export interface PropertyViewContext {
|
||||
options?: BoardOptions;
|
||||
}
|
||||
|
||||
export class PropertyView {
|
||||
private options?: BoardOptions;
|
||||
|
||||
constructor(ctx: PropertyViewContext) {
|
||||
this.options = ctx.options;
|
||||
}
|
||||
|
||||
render(entry: BasesEntry, propId: BasesPropertyId): HTMLElement | null {
|
||||
|
||||
const propertyName = propId.split(".").slice(1).join(".");
|
||||
const value = entry.getValue(propId);
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const propertyNativeType = Services.propertyManager.getPropertyType(propertyName);
|
||||
|
||||
const propEl = document.createElement('div');
|
||||
|
||||
if (propId === 'file.name') {
|
||||
|
||||
// Check if we need to render an icon
|
||||
const iconProperty = this.options?.iconProperty;
|
||||
const iconVal = iconProperty ? entry.getValue(iconProperty) : null;
|
||||
|
||||
if (iconVal?.isTruthy()) {
|
||||
// Create container for icon + file name
|
||||
propEl.classList.add('board-card-file-name-container');
|
||||
propEl.style.display = 'flex';
|
||||
propEl.style.alignItems = 'center';
|
||||
propEl.style.paddingLeft = '6px';
|
||||
|
||||
// Create icon element
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.classList.add('board-card-icon');
|
||||
iconEl.style.marginRight = '5px';
|
||||
iconEl.style.display = 'inline-flex';
|
||||
iconEl.style.alignItems = 'center';
|
||||
setIcon(iconEl, iconVal.toString());
|
||||
|
||||
// Create file name element
|
||||
const fileNameEl = document.createElement('div');
|
||||
fileNameEl.classList.add('metadata-property-value', 'card-prop', 'file-name');
|
||||
fileNameEl.classList.add('clickable');
|
||||
fileNameEl.style.textDecoration = 'none';
|
||||
fileNameEl.textContent = value.toString();
|
||||
|
||||
propEl.appendChild(iconEl);
|
||||
propEl.appendChild(fileNameEl);
|
||||
} else {
|
||||
// No icon, just render file name with padding
|
||||
propEl.classList.add('metadata-property-value', 'card-prop', 'file-name');
|
||||
propEl.classList.add('clickable');
|
||||
propEl.style.textDecoration = 'none';
|
||||
propEl.style.paddingLeft = '6px';
|
||||
propEl.textContent = value.toString();
|
||||
}
|
||||
|
||||
} else if (propId.startsWith('file.') || propId.startsWith('formula.')) {
|
||||
propEl.classList.add('metadata-property-value', 'card-prop');
|
||||
|
||||
value.renderTo(propEl, new RenderContext());
|
||||
|
||||
} else if (propId.startsWith('note.')) {
|
||||
// add parent to propEl with class metadata-property and data-property-key equal to PropertyNativeType
|
||||
propEl.classList.add('metadata-property');
|
||||
propEl.setAttribute('data-property-key', String(propertyNativeType));
|
||||
|
||||
const child = document.createElement('div');
|
||||
child.classList.add('metadata-property-value', 'card-prop');
|
||||
propEl.appendChild(child);
|
||||
|
||||
let propertyValue;
|
||||
|
||||
if (!value.isTruthy()) {
|
||||
propertyValue = null;
|
||||
} else if (propertyNativeType === PropertyNativeType.MULTITEXT || propertyNativeType === PropertyNativeType.TAGS) {
|
||||
propertyValue = (value as PropertyData).data;
|
||||
} else {
|
||||
propertyValue = value.toString();
|
||||
}
|
||||
|
||||
const context = {
|
||||
app: Services.app,
|
||||
blur: () => { },
|
||||
key: propertyName,
|
||||
onChange: (newValue: string) => {
|
||||
if (newValue === '' && !value.isTruthy()) {
|
||||
return;
|
||||
}
|
||||
void Services.propertyManager.updateFrontmatter(entry.file, propertyName, newValue);
|
||||
},
|
||||
sourcePath: entry.file.path,
|
||||
index: entry.file.name,
|
||||
};
|
||||
|
||||
if (propertyNativeType) {
|
||||
const widget = (Services.app as InternalApp).metadataTypeManager.registeredTypeWidgets[propertyNativeType];
|
||||
(widget as { render: (el: HTMLElement, value: unknown, context: unknown) => void; }).render(child, propertyValue, context);
|
||||
}
|
||||
}
|
||||
|
||||
return propEl;
|
||||
}
|
||||
}
|
||||
203
src/main.ts
Normal file
203
src/main.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { PluginInterface } from 'Base/PluginInterface';
|
||||
import { BoardViewSettingTab, BoardViewSettings, DEFAULT_SETTINGS } from 'Base/Settings';
|
||||
import { Plugin, QueryController } from 'obsidian';
|
||||
import { getPropertyKeyFromId } from 'Utils';
|
||||
import { BoardViewRenderer } from 'Views/BoardViewRenderer';
|
||||
import { BoardOptionKeys } from 'Views/OptionsExtractor';
|
||||
import Services from './Base/Services';
|
||||
|
||||
export const BASES_VIEW_ID = 'board-view';
|
||||
|
||||
export default class BoardViewPlugin extends Plugin {
|
||||
settings: BoardViewSettings;
|
||||
|
||||
async onload() {
|
||||
|
||||
// Load settings
|
||||
await this.loadSettings();
|
||||
|
||||
// Initialize services
|
||||
await Services.initialize(this);
|
||||
|
||||
// Initialize plugin UI (ribbon icons, commands)
|
||||
PluginInterface.initialize();
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new BoardViewSettingTab(this.app, this));
|
||||
|
||||
this.registerBasesView(BASES_VIEW_ID, {
|
||||
name: 'Board',
|
||||
icon: 'columns',
|
||||
factory: (controller: QueryController, containerEl: HTMLElement) => {
|
||||
return new BoardViewRenderer(controller, containerEl);
|
||||
},
|
||||
options: () => ([
|
||||
{
|
||||
type: 'group',
|
||||
displayName: 'Appearance',
|
||||
items: [
|
||||
{
|
||||
type: 'dropdown',
|
||||
displayName: 'Card size',
|
||||
key: BoardOptionKeys.CARD_SIZE,
|
||||
options: { 'small': 'Small (0.5x)', 'medium': 'Medium (0.75x)', 'large': 'Large (1x)' },
|
||||
default: 'medium',
|
||||
description: 'Scale the card size',
|
||||
},
|
||||
{
|
||||
type: 'property',
|
||||
displayName: 'Icon Property',
|
||||
key: BoardOptionKeys.ICON_PROPERTY,
|
||||
filter: (prop: string) => Services.plugin.isPropertyEligibleForGrouping(prop),
|
||||
default: '',
|
||||
description: 'Enter the property ID to display as card icon',
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
displayName: 'Open in side view',
|
||||
key: BoardOptionKeys.OPEN_IN_SIDE_VIEW,
|
||||
default: true,
|
||||
description: 'Open file.name in side view',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
displayName: 'Colors',
|
||||
items: [
|
||||
{
|
||||
type: 'toggle',
|
||||
displayName: 'Color Headers',
|
||||
key: BoardOptionKeys.COLOR_HEADERS,
|
||||
default: false,
|
||||
description: 'Apply color to headers (chips)',
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
displayName: 'Color Cells',
|
||||
key: BoardOptionKeys.COLOR_CELLS,
|
||||
default: false,
|
||||
description: 'Apply color to cells',
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
displayName: 'Color Cards',
|
||||
key: BoardOptionKeys.COLOR_CARDS,
|
||||
default: false,
|
||||
description: 'Apply color to cards (minimal border)',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
displayName: 'Group',
|
||||
items: [
|
||||
{
|
||||
type: 'property',
|
||||
displayName: 'Group property',
|
||||
key: BoardOptionKeys.GROUP_PROPERTY,
|
||||
filter: (prop: string) => Services.plugin.isPropertyEligibleForGrouping(prop),
|
||||
default: '',
|
||||
description: 'Enter the property ID to group columns by',
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
displayName: 'Hide empty groups',
|
||||
key: BoardOptionKeys.HIDE_EMPTY_GROUPS,
|
||||
default: false,
|
||||
description: 'Hide empty groups (columns)',
|
||||
},
|
||||
{
|
||||
type: 'multitext',
|
||||
displayName: 'Hidden groups',
|
||||
key: BoardOptionKeys.HIDDEN_GROUPS,
|
||||
default: [],
|
||||
description: 'List of hidden groups',
|
||||
},
|
||||
{
|
||||
type: 'multitext',
|
||||
displayName: 'Group order',
|
||||
key: BoardOptionKeys.GROUP_ORDER,
|
||||
default: [],
|
||||
description: 'Order of main group (columns)',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
displayName: 'Sub-Group',
|
||||
items: [
|
||||
{
|
||||
type: 'property',
|
||||
displayName: 'Sub-group property',
|
||||
key: BoardOptionKeys.SUB_GROUP_PROPERTY,
|
||||
filter: (prop: string) => Services.plugin.isPropertyEligibleForGrouping(prop),
|
||||
default: '',
|
||||
description: 'Enter the property ID to group rows by',
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
displayName: 'Hide empty sub groups',
|
||||
key: BoardOptionKeys.HIDE_EMPTY_SUB_GROUPS,
|
||||
default: false,
|
||||
description: 'Hide empty sub group (rows)',
|
||||
},
|
||||
{
|
||||
type: 'multitext',
|
||||
displayName: 'Hidden sub-groups',
|
||||
key: BoardOptionKeys.HIDDEN_SUB_GROUPS,
|
||||
default: [],
|
||||
description: 'List of hidden sub-groups',
|
||||
},
|
||||
{
|
||||
type: 'multitext',
|
||||
displayName: 'Sub Group order',
|
||||
key: BoardOptionKeys.SUB_GROUP_ORDER,
|
||||
default: [],
|
||||
description: 'Order of sub group (rows)',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
displayName: 'Image',
|
||||
items: [
|
||||
{
|
||||
type: 'property',
|
||||
displayName: 'Image property',
|
||||
key: BoardOptionKeys.IMAGE_PROPERTY,
|
||||
filter: (prop: string) => Services.plugin.isPropertyEligibleForGrouping(prop),
|
||||
default: '',
|
||||
description: 'Enter the property ID to display as card thumbnail',
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
displayName: 'Hide image placeholder',
|
||||
key: BoardOptionKeys.HIDE_IMAGE_PLACEHOLDER,
|
||||
default: true,
|
||||
description: 'Hide grey placeholder if image is missing',
|
||||
}
|
||||
]
|
||||
},
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
onunload() {
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
isPropertyEligibleForGrouping(prop: string) {
|
||||
if (prop.startsWith('file.')) return false;
|
||||
const propKey = getPropertyKeyFromId(prop);
|
||||
const type = Services.propertyManager.getPropertyType(propKey);
|
||||
return type !== 'tags' && type !== 'multitext' && type !== 'aliases';
|
||||
}
|
||||
}
|
||||
424
styles.css
Normal file
424
styles.css
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
body {
|
||||
--board-view-column-width: 280px;
|
||||
--board-view-column-width-small: 240px;
|
||||
--board-view-column-width-large: 310px;
|
||||
|
||||
--board-view-card-font-size-small: 0.9em;
|
||||
--board-view-card-font-size-large: 1.1em;
|
||||
|
||||
--board-view-plugin-image-height: 140px;
|
||||
--board-view-plugin-image-height-small: 112px;
|
||||
--board-view-plugin-image-height-large: 168px;
|
||||
}
|
||||
|
||||
.board-view {
|
||||
font-size: var(--font-ui-small);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.board-board-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.board-board-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.board-column-headers {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-column-header-spacer {
|
||||
min-width: 20px;
|
||||
/* Adjust based on collapse icon width? Or maybe 0 if row header is above */
|
||||
display: none;
|
||||
/* Since row header is now above, we don't need left spacer */
|
||||
}
|
||||
|
||||
.board-column-header {
|
||||
width: var(--board-view-column-width);
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
padding: 10px;
|
||||
background: var(--background-modifier-slight);
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.board-header-chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.board-header-color-circle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
/* margin-right: 8px; */
|
||||
}
|
||||
|
||||
.board-header-color-circle.has-plugin-color {
|
||||
background-color: color-mix(in srgb, var(--active-color) 100%, transparent);
|
||||
}
|
||||
|
||||
.board-header-count {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-faint);
|
||||
/* background: var(--background-modifier-border); */
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ColorManager theme switching for plugin colors */
|
||||
.theme-light .has-plugin-color {
|
||||
--active-color: var(--c-light);
|
||||
}
|
||||
|
||||
.theme-dark .has-plugin-color {
|
||||
--active-color: var(--c-dark);
|
||||
}
|
||||
|
||||
/* Card border line (minimal mode) */
|
||||
.theme-light .has-plugin-color-border {
|
||||
--active-color: var(--c-light);
|
||||
}
|
||||
|
||||
.theme-dark .has-plugin-color-border {
|
||||
--active-color: var(--c-dark);
|
||||
}
|
||||
|
||||
.board-card.has-plugin-color-border {
|
||||
border-left: 4px solid color-mix(in srgb, var(--active-color) 100%, transparent);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* Cells/headers use lower opacity via color-mix */
|
||||
.board-header-chip.has-plugin-color {
|
||||
background-color: color-mix(in srgb, var(--active-color) 100%, transparent);
|
||||
}
|
||||
|
||||
.board-cell.has-plugin-color {
|
||||
background-color: color-mix(in srgb, var(--active-color) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Cards use full color - not used */
|
||||
.board-card.has-plugin-color {
|
||||
background-color: color-mix(in srgb, var(--active-color) 0%, transparent);
|
||||
}
|
||||
|
||||
.board-header-menu {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.board-row-header-bar .board-header-menu {
|
||||
/* Override for row headers - position inline instead of absolute */
|
||||
position: relative;
|
||||
right: auto;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.board-header-menu:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.board-column-header:hover .board-header-menu,
|
||||
.board-row-header-bar:hover .board-header-menu {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-row-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.board-row-header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 4px 8px;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
/* Align with card columns - no left margin needed as we'll position within row-wrapper */
|
||||
width: fit-content;
|
||||
font-size: var(--font-ui-small);
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.board-row-title {
|
||||
/* font-size: 1.1em; */
|
||||
/* Match main header size (inherit or 1em) */
|
||||
font-size: 1em;
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.board-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.board-row-cells {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.board-cell {
|
||||
width: var(--board-view-column-width);
|
||||
flex-shrink: 0;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
/* background: var(--background-secondary); */
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.board-card {
|
||||
/* background: var(--background-primary); */
|
||||
width: 100%;
|
||||
background: var(--background-secondary);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Force grab cursor on properties to override input text cursor */
|
||||
.board-card .metadata-property,
|
||||
.board-card .metadata-property-value,
|
||||
.board-card .metadata-property-value * {
|
||||
cursor: pointer !important;
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.board-card .metadata-property-value .metadata-input-longtext {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.board-card .metadata-property-value .clickable-icon {
|
||||
padding: 0px;
|
||||
/* Remove padding on date icon which forces height */
|
||||
}
|
||||
|
||||
/* Card Sizes */
|
||||
.board-board-wrapper.card-size-small {
|
||||
--board-view-column-width: var(--board-view-column-width-small);
|
||||
}
|
||||
|
||||
.board-board-wrapper.card-size-large {
|
||||
--board-view-column-width: var(--board-view-column-width-large);
|
||||
}
|
||||
|
||||
.board-card.card-size-small {
|
||||
font-size: var(--board-view-card-font-size-small);
|
||||
}
|
||||
|
||||
.board-card.card-size-large {
|
||||
font-size: var(--board-view-card-font-size-large);
|
||||
}
|
||||
|
||||
.board-card .card-image {
|
||||
width: 100%;
|
||||
height: var(--board-view-plugin-image-height);
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.board-card.card-size-small .card-image {
|
||||
height: var(--board-view-plugin-image-height-small);
|
||||
}
|
||||
|
||||
.board-card.card-size-large .card-image {
|
||||
height: var(--board-view-plugin-image-height-large);
|
||||
}
|
||||
|
||||
.board-card .card-image.placeholder {
|
||||
background-color: var(--background-modifier-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.board-card .card-prop.clickable {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-row-wrapper.collapsed .board-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board-row-wrapper.collapsed .collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* SortableJS Ghost - Hide the placeholder (since we use blue lines) */
|
||||
.board-sortable-ghost {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
/* background-color: red; */
|
||||
}
|
||||
|
||||
/* BUT show the ghost in the source list so the original item doesn't disappear */
|
||||
.board-drag-source .board-sortable-ghost {
|
||||
opacity: 0.5;
|
||||
height: auto;
|
||||
padding: 8px;
|
||||
/* Restore padding */
|
||||
margin-bottom: 8px;
|
||||
/* Restore margin */
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
/* Restore border */
|
||||
overflow: visible;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* SortableJS Chosen - The original item stays in place and is dimmed */
|
||||
.board-sortable-chosen {
|
||||
opacity: 0.5;
|
||||
/* background-color: blue; */
|
||||
}
|
||||
|
||||
/* Drop Target Indicators */
|
||||
.board-item-drop-target-top {
|
||||
border-top: 2px solid var(--interactive-accent) !important;
|
||||
margin-top: -2px;
|
||||
/* position: absolute; */
|
||||
/* Prevent layout shift */
|
||||
}
|
||||
|
||||
.board-item-drop-target-bottom {
|
||||
border-bottom: 2px solid var(--interactive-accent) !important;
|
||||
margin-bottom: -2px;
|
||||
/* position: absolute; */
|
||||
/* Prevent layout shift */
|
||||
}
|
||||
|
||||
/* New Note Button */
|
||||
.board-new-note-button {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-faint);
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s, background-color 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.board-new-note-button:hover {
|
||||
opacity: 0.7;
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.board-card .file-name {
|
||||
font-size: var(--font-ui-medium);
|
||||
/* font-size: var(--table-text-size); */
|
||||
font-weight: calc(var(--font-weight) + var(--bold-modifier));
|
||||
}
|
||||
/* Gallery View for Sub-groups */
|
||||
.board-rows-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.board-rows-container.is-gallery {
|
||||
flex-direction: column; /* Keep rows stacked vertically */
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.board-rows-container.is-gallery .board-row-wrapper {
|
||||
width: 100%; /* Full width for each row */
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.board-rows-container.is-gallery .board-row-header-bar {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.board-rows-container.is-gallery .board-row {
|
||||
flex: 1;
|
||||
overflow: visible; /* Allow wrapping */
|
||||
}
|
||||
|
||||
.board-rows-container.is-gallery .board-row-cells {
|
||||
flex-direction: row; /* This is the key - make cells flow horizontally */
|
||||
}
|
||||
|
||||
.board-rows-container.is-gallery .board-cell {
|
||||
width: 100%; /* Take full width since there's only one cell */
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: row; /* Make cards flow horizontally */
|
||||
flex-wrap: wrap; /* Allow cards to wrap to next line */
|
||||
gap: 8px; /* Space between cards */
|
||||
}
|
||||
|
||||
/* In gallery mode, cards should have a fixed width */
|
||||
.board-rows-container.is-gallery .board-card {
|
||||
width: var(--board-view-column-width);
|
||||
flex: 0 0 var(--board-view-column-width);
|
||||
margin-bottom: 0; /* Remove margin since we use gap */
|
||||
}
|
||||
|
||||
/* New Note button in gallery mode */
|
||||
.board-rows-container.is-gallery .board-new-note-button {
|
||||
width: var(--board-view-column-width);
|
||||
flex: 0 0 var(--board-view-column-width);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src/",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue