mirror of
https://github.com/zadam/trilium.git
synced 2026-02-02 03:44:24 +01:00
Merge remote-tracking branch 'origin/main' into feat/add-ocr-capabilities
This commit is contained in:
commit
99fa5d89e7
40
.github/instructions/nx.instructions.md
vendored
Normal file
40
.github/instructions/nx.instructions.md
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
applyTo: '**'
|
||||
---
|
||||
|
||||
// This file is automatically generated by Nx Console
|
||||
|
||||
You are in an nx workspace using Nx 21.3.7 and pnpm as the package manager.
|
||||
|
||||
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
|
||||
|
||||
# General Guidelines
|
||||
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
|
||||
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
|
||||
|
||||
# Generation Guidelines
|
||||
If the user wants to generate something, use the following flow:
|
||||
|
||||
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
|
||||
- get the available generators using the 'nx_generators' tool
|
||||
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
|
||||
- get generator details using the 'nx_generator_schema' tool
|
||||
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
|
||||
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
|
||||
- open the generator UI using the 'nx_open_generate_ui' tool
|
||||
- wait for the user to finish the generator
|
||||
- read the generator log file using the 'nx_read_generator_log' tool
|
||||
- use the information provided in the log file to answer the user's question or continue with what they were doing
|
||||
|
||||
# Running Tasks Guidelines
|
||||
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
|
||||
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
|
||||
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
|
||||
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
|
||||
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
|
||||
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
|
||||
|
||||
|
||||
|
||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"servers": {
|
||||
"nx-mcp": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:9461/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@ -28,5 +28,13 @@
|
||||
"typescript.validate.enable": true,
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"docs/**/*.html": true,
|
||||
"docs/**/*.png": true,
|
||||
"apps/server/src/assets/doc_notes/**": true,
|
||||
"apps/edit-docs/demo/**": true
|
||||
},
|
||||
"nxConsole.generateAiAgentRules": true
|
||||
}
|
||||
161
CLAUDE.md
Normal file
161
CLAUDE.md
Normal file
@ -0,0 +1,161 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using NX, with multiple applications and shared packages.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup
|
||||
- `pnpm install` - Install all dependencies
|
||||
- `corepack enable` - Enable pnpm if not available
|
||||
|
||||
### Running Applications
|
||||
- `pnpm run server:start` - Start development server (http://localhost:8080)
|
||||
- `pnpm nx run server:serve` - Alternative server start command
|
||||
- `pnpm nx run desktop:serve` - Run desktop Electron app
|
||||
- `pnpm run server:start-prod` - Run server in production mode
|
||||
|
||||
### Building
|
||||
- `pnpm nx build <project>` - Build specific project (server, client, desktop, etc.)
|
||||
- `pnpm run client:build` - Build client application
|
||||
- `pnpm run server:build` - Build server application
|
||||
- `pnpm run electron:build` - Build desktop application
|
||||
|
||||
### Testing
|
||||
- `pnpm test:all` - Run all tests (parallel + sequential)
|
||||
- `pnpm test:parallel` - Run tests that can run in parallel
|
||||
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
|
||||
- `pnpm nx test <project>` - Run tests for specific project
|
||||
- `pnpm coverage` - Generate coverage reports
|
||||
|
||||
### Linting & Type Checking
|
||||
- `pnpm nx run <project>:lint` - Lint specific project
|
||||
- `pnpm nx run <project>:typecheck` - Type check specific project
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Monorepo Structure
|
||||
- **apps/**: Runnable applications
|
||||
- `client/` - Frontend application (shared by server and desktop)
|
||||
- `server/` - Node.js server with web interface
|
||||
- `desktop/` - Electron desktop application
|
||||
- `web-clipper/` - Browser extension for saving web content
|
||||
- Additional tools: `db-compare`, `dump-db`, `edit-docs`
|
||||
|
||||
- **packages/**: Shared libraries
|
||||
- `commons/` - Shared interfaces and utilities
|
||||
- `ckeditor5/` - Custom rich text editor with Trilium-specific plugins
|
||||
- `codemirror/` - Code editor customizations
|
||||
- `highlightjs/` - Syntax highlighting
|
||||
- Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid`
|
||||
|
||||
### Core Architecture Patterns
|
||||
|
||||
#### Three-Layer Cache System
|
||||
- **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`)
|
||||
- **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`)
|
||||
- **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`)
|
||||
|
||||
#### Entity System
|
||||
Core entities are defined in `apps/server/src/becca/entities/`:
|
||||
- `BNote` - Notes with content and metadata
|
||||
- `BBranch` - Hierarchical relationships between notes (allows multiple parents)
|
||||
- `BAttribute` - Key-value metadata attached to notes
|
||||
- `BRevision` - Note version history
|
||||
- `BOption` - Application configuration
|
||||
|
||||
#### Widget-Based UI
|
||||
Frontend uses a widget system (`apps/client/src/widgets/`):
|
||||
- `BasicWidget` - Base class for all UI components
|
||||
- `NoteContextAwareWidget` - Widgets that respond to note changes
|
||||
- `RightPanelWidget` - Widgets displayed in the right panel
|
||||
- Type-specific widgets in `type_widgets/` directory
|
||||
|
||||
#### API Architecture
|
||||
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
|
||||
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
|
||||
- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`)
|
||||
|
||||
### Key Files for Understanding Architecture
|
||||
|
||||
1. **Application Entry Points**:
|
||||
- `apps/server/src/main.ts` - Server startup
|
||||
- `apps/client/src/desktop.ts` - Client initialization
|
||||
|
||||
2. **Core Services**:
|
||||
- `apps/server/src/becca/becca.ts` - Backend data management
|
||||
- `apps/client/src/services/froca.ts` - Frontend data synchronization
|
||||
- `apps/server/src/services/backend_script_api.ts` - Scripting API
|
||||
|
||||
3. **Database Schema**:
|
||||
- `apps/server/src/assets/db/schema.sql` - Core database structure
|
||||
|
||||
4. **Configuration**:
|
||||
- `nx.json` - NX workspace configuration
|
||||
- `package.json` - Project dependencies and scripts
|
||||
|
||||
## Note Types and Features
|
||||
|
||||
Trilium supports multiple note types, each with specialized widgets:
|
||||
- **Text**: Rich text with CKEditor5 (markdown import/export)
|
||||
- **Code**: Syntax-highlighted code editing with CodeMirror
|
||||
- **File**: Binary file attachments
|
||||
- **Image**: Image display with editing capabilities
|
||||
- **Canvas**: Drawing/diagramming with Excalidraw
|
||||
- **Mermaid**: Diagram generation
|
||||
- **Relation Map**: Visual note relationship mapping
|
||||
- **Web View**: Embedded web pages
|
||||
- **Doc/Book**: Hierarchical documentation structure
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Testing Strategy
|
||||
- Server tests run sequentially due to shared database
|
||||
- Client tests can run in parallel
|
||||
- E2E tests use Playwright for both server and desktop apps
|
||||
- Build validation tests check artifact integrity
|
||||
|
||||
### Scripting System
|
||||
Trilium provides powerful user scripting capabilities:
|
||||
- Frontend scripts run in browser context
|
||||
- Backend scripts run in Node.js context with full API access
|
||||
- Script API documentation available in `docs/Script API/`
|
||||
|
||||
### Internationalization
|
||||
- Translation files in `apps/client/src/translations/`
|
||||
- Supported languages: English, German, Spanish, French, Romanian, Chinese
|
||||
|
||||
### Security Considerations
|
||||
- Per-note encryption with granular protected sessions
|
||||
- CSRF protection for API endpoints
|
||||
- OpenID and TOTP authentication support
|
||||
- Sanitization of user-generated content
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding New Note Types
|
||||
1. Create widget in `apps/client/src/widgets/type_widgets/`
|
||||
2. Register in `apps/client/src/services/note_types.ts`
|
||||
3. Add backend handling in `apps/server/src/services/notes.ts`
|
||||
|
||||
### Extending Search
|
||||
- Search expressions handled in `apps/server/src/services/search/`
|
||||
- Add new search operators in search context files
|
||||
|
||||
### Custom CKEditor Plugins
|
||||
- Create new package in `packages/` following existing plugin structure
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
|
||||
## Build System Notes
|
||||
- Uses NX for monorepo management with build caching
|
||||
- Vite for fast development builds
|
||||
- ESBuild for production optimization
|
||||
- pnpm workspaces for dependency management
|
||||
- Docker support with multi-stage builds
|
||||
@ -36,12 +36,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.54.1",
|
||||
"@stylistic/eslint-plugin": "5.1.0",
|
||||
"@stylistic/eslint-plugin": "5.2.2",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.16.4",
|
||||
"@types/node": "22.16.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.31.0",
|
||||
"eslint": "9.32.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"esm": "3.2.25",
|
||||
"jsdoc": "4.0.4",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.1",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@ -10,7 +10,7 @@
|
||||
"url": "https://github.com/TriliumNext/Notes"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.31.0",
|
||||
"@eslint/js": "9.32.0",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.18",
|
||||
"@fullcalendar/daygrid": "6.1.18",
|
||||
@ -18,6 +18,7 @@
|
||||
"@fullcalendar/list": "6.1.18",
|
||||
"@fullcalendar/multimonth": "6.1.18",
|
||||
"@fullcalendar/timegrid": "6.1.18",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.2",
|
||||
"@mermaid-js/layout-elk": "0.1.8",
|
||||
"@mind-elixir/node-menu": "5.0.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
@ -46,9 +47,9 @@
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.0.0",
|
||||
"mermaid": "11.8.1",
|
||||
"mind-elixir": "5.0.2",
|
||||
"marked": "16.1.1",
|
||||
"mermaid": "11.9.0",
|
||||
"mind-elixir": "5.0.4",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.26.9",
|
||||
@ -64,7 +65,7 @@
|
||||
"@types/leaflet": "1.9.20",
|
||||
"@types/leaflet-gpx": "1.3.7",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/tabulator-tables": "6.2.7",
|
||||
"@types/tabulator-tables": "6.2.8",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"happy-dom": "18.0.1",
|
||||
"script-loader": "0.7.2",
|
||||
|
||||
@ -287,6 +287,10 @@ export type CommandMappings = {
|
||||
columnToEdit?: ColumnComponent;
|
||||
referenceColumn?: ColumnComponent;
|
||||
direction?: "before" | "after";
|
||||
type?: "label" | "relation";
|
||||
};
|
||||
deleteTableColumn: CommandData & {
|
||||
columnToDelete?: ColumnComponent;
|
||||
};
|
||||
|
||||
buildTouchBar: CommandData & {
|
||||
|
||||
@ -325,8 +325,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some book types must always display a note list, even if no children.
|
||||
if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) {
|
||||
// Collections must always display a note list, even if no children.
|
||||
const viewType = note.getLabelValue("viewType") ?? "grid";
|
||||
if (!["list", "grid"].includes(viewType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -265,6 +265,11 @@ class FNote {
|
||||
return noteIds.flat();
|
||||
}
|
||||
|
||||
async getSubtreeNotes() {
|
||||
const noteIds = await this.getSubtreeNoteIds();
|
||||
return this.froca.getNotes(noteIds);
|
||||
}
|
||||
|
||||
async getChildNotes() {
|
||||
return await this.froca.getNotes(this.children);
|
||||
}
|
||||
|
||||
@ -26,6 +26,11 @@ export interface MenuCommandItem<T> {
|
||||
title: string;
|
||||
command?: T;
|
||||
type?: string;
|
||||
/**
|
||||
* The icon to display in the menu item.
|
||||
*
|
||||
* If not set, no icon is displayed and the item will appear shifted slightly to the left if there are other items with icons. To avoid this, use `bx bx-empty`.
|
||||
*/
|
||||
uiIcon?: string;
|
||||
badges?: MenuItemBadge[];
|
||||
templateNoteId?: string;
|
||||
|
||||
@ -12,11 +12,12 @@ async function addLabel(noteId: string, name: string, value: string = "", isInhe
|
||||
});
|
||||
}
|
||||
|
||||
export async function setLabel(noteId: string, name: string, value: string = "") {
|
||||
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
await server.put(`notes/${noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name: name,
|
||||
value: value
|
||||
value: value,
|
||||
isInheritable
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation
|
||||
import RenameNoteBulkAction from "../widgets/bulk_actions/note/rename_note.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import toast from "./toast.js";
|
||||
import { BulkAction } from "@triliumnext/commons";
|
||||
|
||||
const ACTION_GROUPS = [
|
||||
{
|
||||
@ -89,6 +91,17 @@ function parseActions(note: FNote) {
|
||||
.filter((action) => !!action);
|
||||
}
|
||||
|
||||
export async function executeBulkActions(targetNoteIds: string[], actions: BulkAction[], includeDescendants = false) {
|
||||
await server.post("bulk-action/execute", {
|
||||
noteIds: targetNoteIds,
|
||||
includeDescendants,
|
||||
actions
|
||||
});
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
toast.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
|
||||
}
|
||||
|
||||
export default {
|
||||
addAction,
|
||||
parseActions,
|
||||
|
||||
@ -41,8 +41,14 @@ async function info(message: string) {
|
||||
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a confirmation dialog with the given message.
|
||||
*
|
||||
* @param message the message to display in the dialog.
|
||||
* @returns A promise that resolves to true if the user confirmed, false otherwise.
|
||||
*/
|
||||
async function confirm(message: string) {
|
||||
return new Promise((res) =>
|
||||
return new Promise<boolean>((res) =>
|
||||
appContext.triggerCommand("showConfirmDialog", <ConfirmWithMessageOptions>{
|
||||
message,
|
||||
callback: (x: false | ConfirmDialogOptions) => res(x && x.confirmed)
|
||||
|
||||
@ -316,7 +316,7 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent
|
||||
const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey;
|
||||
|
||||
if (notePath) {
|
||||
if (openInPopup) {
|
||||
if (isLeftClick && openInPopup) {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath });
|
||||
} else if (openInNewWindow) {
|
||||
appContext.triggerCommand("openInWindow", { notePath, viewScope });
|
||||
@ -405,7 +405,7 @@ function linkContextMenu(e: PointerEvent) {
|
||||
linkContextMenuService.openContextMenu(notePath, e, viewScope, null);
|
||||
}
|
||||
|
||||
export async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) {
|
||||
async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) {
|
||||
const $link = $el[0].tagName === "A" ? $el : $el.find("a");
|
||||
|
||||
href = href || $link.attr("href");
|
||||
|
||||
@ -40,7 +40,10 @@ interface Options {
|
||||
allowCreatingNotes?: boolean;
|
||||
allowJumpToSearchNotes?: boolean;
|
||||
allowExternalLinks?: boolean;
|
||||
/** If set, hides the right-side button corresponding to go to selected note. */
|
||||
hideGoToSelectedNoteButton?: boolean;
|
||||
/** If set, hides all right-side buttons in the autocomplete dropdown */
|
||||
hideAllButtons?: boolean;
|
||||
}
|
||||
|
||||
async function autocompleteSourceForCKEditor(queryText: string) {
|
||||
@ -190,9 +193,11 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
|
||||
const $goToSelectedNoteButton = $("<a>").addClass("input-group-text go-to-selected-note-button bx bx-arrow-to-right");
|
||||
|
||||
$el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton);
|
||||
if (!options.hideAllButtons) {
|
||||
$el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton);
|
||||
}
|
||||
|
||||
if (!options.hideGoToSelectedNoteButton) {
|
||||
if (!options.hideGoToSelectedNoteButton && !options.hideAllButtons) {
|
||||
$el.after($goToSelectedNoteButton);
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import BoardView from "../widgets/view_widgets/board_view/index.js";
|
||||
import CalendarView from "../widgets/view_widgets/calendar_view.js";
|
||||
import GeoView from "../widgets/view_widgets/geo_view/index.js";
|
||||
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
|
||||
@ -6,8 +7,9 @@ import TableView from "../widgets/view_widgets/table_view/index.js";
|
||||
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
|
||||
import type ViewMode from "../widgets/view_widgets/view_mode.js";
|
||||
|
||||
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
|
||||
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
|
||||
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap";
|
||||
export type ViewTypeOptions = typeof allViewTypes[number];
|
||||
|
||||
export default class NoteListRenderer {
|
||||
|
||||
@ -23,7 +25,7 @@ export default class NoteListRenderer {
|
||||
#getViewType(parentNote: FNote): ViewTypeOptions {
|
||||
const viewType = parentNote.getLabelValue("viewType");
|
||||
|
||||
if (!["list", "grid", "calendar", "table", "geoMap"].includes(viewType || "")) {
|
||||
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
|
||||
// when not explicitly set, decide based on the note type
|
||||
return parentNote.type === "search" ? "list" : "grid";
|
||||
} else {
|
||||
@ -57,6 +59,8 @@ export default class NoteListRenderer {
|
||||
return new TableView(args);
|
||||
case "geoMap":
|
||||
return new GeoView(args);
|
||||
case "board":
|
||||
return new BoardView(args);
|
||||
case "list":
|
||||
case "grid":
|
||||
default:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url";
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
@ -17,7 +17,7 @@ function parse(value: string) {
|
||||
for (const token of tokens) {
|
||||
if (token === "promoted") {
|
||||
defObj.isPromoted = true;
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) {
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
defObj.labelType = token as LabelType;
|
||||
} else if (["single", "multi"].includes(token)) {
|
||||
defObj.multiplicity = token as Multiplicity;
|
||||
|
||||
@ -51,6 +51,14 @@ export default class SpacedUpdate {
|
||||
this.lastUpdated = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the update interval for the spaced update.
|
||||
* @param interval The update interval in milliseconds.
|
||||
*/
|
||||
setUpdateInterval(interval: number) {
|
||||
this.updateInterval = interval;
|
||||
}
|
||||
|
||||
triggerUpdate() {
|
||||
if (!this.changed) {
|
||||
return;
|
||||
|
||||
@ -29,6 +29,14 @@ async function formatCodeBlocks() {
|
||||
await formatCodeBlocks($("#content"));
|
||||
}
|
||||
|
||||
async function setupTextNote() {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
|
||||
const setupMermaid = (await import("./share/mermaid.js")).default;
|
||||
setupMermaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch note with given ID from backend
|
||||
*
|
||||
@ -47,8 +55,11 @@ async function fetchNote(noteId: string | null = null) {
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
const noteType = determineNoteType();
|
||||
|
||||
if (noteType === "text") {
|
||||
setupTextNote();
|
||||
}
|
||||
|
||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
||||
const layout = document.getElementById("layout");
|
||||
@ -60,6 +71,12 @@ document.addEventListener(
|
||||
false
|
||||
);
|
||||
|
||||
function determineNoteType() {
|
||||
const bodyClass = document.body.className;
|
||||
const match = bodyClass.match(/type-([^\s]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
|
||||
// add fetchNote as property to the window object
|
||||
Object.defineProperty(window, "fetchNote", {
|
||||
|
||||
17
apps/client/src/share/mermaid.ts
Normal file
17
apps/client/src/share/mermaid.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import mermaid from "mermaid";
|
||||
|
||||
export default function setupMermaid() {
|
||||
for (const codeBlock of document.querySelectorAll("#content pre code.language-mermaid")) {
|
||||
const parentPre = codeBlock.parentElement;
|
||||
if (!parentPre) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mermaidDiv = document.createElement("div");
|
||||
mermaidDiv.classList.add("mermaid");
|
||||
mermaidDiv.innerHTML = codeBlock.innerHTML;
|
||||
parentPre.replaceWith(mermaidDiv);
|
||||
}
|
||||
|
||||
mermaid.init();
|
||||
}
|
||||
@ -139,12 +139,6 @@ textarea,
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* Restore default apperance */
|
||||
input[type="number"],
|
||||
input[type="checkbox"] {
|
||||
appearance: auto !important;
|
||||
}
|
||||
|
||||
/* Add a gap between consecutive radios / check boxes */
|
||||
label.tn-radio + label.tn-radio,
|
||||
label.tn-checkbox + label.tn-checkbox {
|
||||
|
||||
@ -80,6 +80,7 @@
|
||||
|
||||
.tabulator-tableholder {
|
||||
padding-top: 10px;
|
||||
height: unset !important; /* Don't extend on the full height */
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
@ -152,8 +153,10 @@
|
||||
color: var(--row-text-color);
|
||||
}
|
||||
|
||||
.tabulator-data-tree-branch {
|
||||
visibility: hidden;
|
||||
/* Align items without children/expander to the ones with. */
|
||||
.tabulator-cell[tabulator-field="title"] > span:first-child, /* 1st level */
|
||||
.tabulator-cell[tabulator-field="title"] > div:first-child + span { /* sub-level */
|
||||
padding-left: 21px;
|
||||
}
|
||||
|
||||
/* Checkbox cells */
|
||||
@ -186,4 +189,11 @@
|
||||
border: 1px solid transparent;
|
||||
color: var(--menu-text-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
:root .tabulator .tabulator-footer {
|
||||
border-top: unset;
|
||||
padding: 10px 0;
|
||||
}
|
||||
@ -184,7 +184,7 @@ html body .dropdown-item[disabled] {
|
||||
|
||||
/* Menu item icon */
|
||||
.dropdown-item .bx {
|
||||
transform: translateY(var(--menu-item-icon-vert-offset));
|
||||
translate: 0 var(--menu-item-icon-vert-offset);
|
||||
color: var(--menu-item-icon-color) !important;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
@ -1678,4 +1678,42 @@ div.find-replace-widget div.find-widget-found-wrapper > span {
|
||||
#right-pane .highlights-list li:active {
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/** Canvas **/
|
||||
|
||||
.excalidraw {
|
||||
--border-radius-lg: 6px;
|
||||
}
|
||||
|
||||
.excalidraw .Island {
|
||||
backdrop-filter: var(--dropdown-backdrop-filter);
|
||||
}
|
||||
|
||||
.excalidraw .Island.App-toolbar {
|
||||
--island-bg-color: var(--floating-button-background-color);
|
||||
--shadow-island: 1px 1px 1px var(--floating-button-shadow-color);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu {
|
||||
border: unset !important;
|
||||
box-shadow: unset !important;
|
||||
background-color: transparent !important;
|
||||
--island-bg-color: var(--menu-background-color);
|
||||
--shadow-island: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
|
||||
--default-border-color: var(--bs-dropdown-divider-bg);
|
||||
--button-hover-bg: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu .dropdown-menu-container {
|
||||
border-radius: var(--dropdown-border-radius);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu .dropdown-menu-container > div:not([class]):not(:last-child) {
|
||||
margin-left: calc(var(--padding) * var(--space-factor) * -1) !important;
|
||||
margin-right: calc(var(--padding) * var(--space-factor) * -1) !important;
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu:before {
|
||||
content: unset !important;
|
||||
}
|
||||
@ -1449,7 +1449,7 @@
|
||||
"relation-map": "关系图",
|
||||
"note-map": "笔记地图",
|
||||
"render-note": "渲染笔记",
|
||||
"book": "书",
|
||||
"book": "",
|
||||
"mermaid-diagram": "Mermaid 图",
|
||||
"canvas": "画布",
|
||||
"web-view": "网页视图",
|
||||
|
||||
@ -1403,7 +1403,7 @@
|
||||
"relation-map": "Beziehungskarte",
|
||||
"note-map": "Notizkarte",
|
||||
"render-note": "Render Notiz",
|
||||
"book": "Buch",
|
||||
"book": "",
|
||||
"mermaid-diagram": "Mermaid Diagram",
|
||||
"canvas": "Canvas",
|
||||
"web-view": "Webansicht",
|
||||
|
||||
@ -443,7 +443,8 @@
|
||||
"other_notes_with_name": "Other notes with {{attributeType}} name \"{{attributeName}}\"",
|
||||
"and_more": "... and {{count}} more.",
|
||||
"print_landscape": "When exporting to PDF, changes the orientation of the page to landscape instead of portrait.",
|
||||
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>."
|
||||
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
|
||||
"color_type": "Color"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "To add label, just type e.g. <code>#rock</code> or if you want to add also value then e.g. <code>#year = 2020</code>",
|
||||
@ -762,7 +763,8 @@
|
||||
"invalid_view_type": "Invalid view type '{{type}}'",
|
||||
"calendar": "Calendar",
|
||||
"table": "Table",
|
||||
"geo-map": "Geo Map"
|
||||
"geo-map": "Geo Map",
|
||||
"board": "Board"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "No edited notes on this day yet...",
|
||||
@ -839,7 +841,8 @@
|
||||
"unknown_label_type": "Unknown label type '{{type}}'",
|
||||
"unknown_attribute_type": "Unknown attribute type '{{type}}'",
|
||||
"add_new_attribute": "Add new attribute",
|
||||
"remove_this_attribute": "Remove this attribute"
|
||||
"remove_this_attribute": "Remove this attribute",
|
||||
"remove_color": "Remove the color label"
|
||||
},
|
||||
"script_executor": {
|
||||
"query": "Query",
|
||||
@ -1630,7 +1633,7 @@
|
||||
"relation-map": "Relation Map",
|
||||
"note-map": "Note Map",
|
||||
"render-note": "Render Note",
|
||||
"book": "Book",
|
||||
"book": "Collection",
|
||||
"mermaid-diagram": "Mermaid Diagram",
|
||||
"canvas": "Canvas",
|
||||
"web-view": "Web View",
|
||||
@ -1971,13 +1974,33 @@
|
||||
"row-insert-child": "Insert child note",
|
||||
"add-column-to-the-left": "Add column to the left",
|
||||
"add-column-to-the-right": "Add column to the right",
|
||||
"edit-column": "Edit column"
|
||||
"edit-column": "Edit column",
|
||||
"delete_column_confirmation": "Are you sure you want to delete this column? The corresponding attribute will be removed from all notes.",
|
||||
"delete-column": "Delete column",
|
||||
"new-column-label": "Label",
|
||||
"new-column-relation": "Relation"
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Hide weekends",
|
||||
"display-week-numbers": "Display week numbers"
|
||||
"display-week-numbers": "Display week numbers",
|
||||
"map-style": "Map style:",
|
||||
"max-nesting-depth": "Max nesting depth:",
|
||||
"raster": "Raster",
|
||||
"vector_light": "Vector (Light)",
|
||||
"vector_dark": "Vector (Dark)",
|
||||
"show-scale": "Show scale"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Delete row"
|
||||
},
|
||||
"board_view": {
|
||||
"delete-note": "Delete Note",
|
||||
"move-to": "Move to",
|
||||
"insert-above": "Insert above",
|
||||
"insert-below": "Insert below",
|
||||
"delete-column": "Delete column",
|
||||
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.",
|
||||
"new-item": "New item",
|
||||
"add-column": "Add Column"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1612,7 +1612,7 @@
|
||||
"relation-map": "Mapa de Relaciones",
|
||||
"note-map": "Mapa de Notas",
|
||||
"render-note": "Nota de Renderizado",
|
||||
"book": "Libro",
|
||||
"book": "",
|
||||
"mermaid-diagram": "Diagrama Mermaid",
|
||||
"canvas": "Lienzo",
|
||||
"web-view": "Vista Web",
|
||||
|
||||
@ -1408,7 +1408,7 @@
|
||||
"relation-map": "Carte des relations",
|
||||
"note-map": "Carte de notes",
|
||||
"render-note": "Rendu Html",
|
||||
"book": "Livre",
|
||||
"book": "",
|
||||
"mermaid-diagram": "Diagramme Mermaid",
|
||||
"canvas": "Canevas",
|
||||
"web-view": "Affichage Web",
|
||||
|
||||
@ -1377,7 +1377,7 @@
|
||||
"shared_publicly": "Această notiță este partajată public la"
|
||||
},
|
||||
"note_types": {
|
||||
"book": "Carte",
|
||||
"book": "Colecție",
|
||||
"canvas": "Schiță",
|
||||
"code": "Cod sursă",
|
||||
"mermaid-diagram": "Diagramă Mermaid",
|
||||
|
||||
@ -1354,7 +1354,7 @@
|
||||
"relation-map": "關係圖",
|
||||
"note-map": "筆記地圖",
|
||||
"render-note": "渲染筆記",
|
||||
"book": "書",
|
||||
"book": "",
|
||||
"mermaid-diagram": "美人魚圖(Mermaid)",
|
||||
"canvas": "畫布",
|
||||
"web-view": "網頁視圖",
|
||||
|
||||
5
apps/client/src/types-assets.d.ts
vendored
5
apps/client/src/types-assets.d.ts
vendored
@ -3,6 +3,11 @@ declare module "*.png" {
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "*.json" {
|
||||
var content: any;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*?url" {
|
||||
var path: string;
|
||||
export default path;
|
||||
|
||||
@ -78,7 +78,7 @@ const TPL = /*html*/`
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
|
||||
<h5 class="attr-detail-title">${t("attribute_detail.attr_detail_title")}</h5>
|
||||
|
||||
<span class="bx bx-x close-attr-detail-button tn-tool-button" title="${t("attribute_detail.close_button_title")}"></span>
|
||||
@ -142,6 +142,7 @@ const TPL = /*html*/`
|
||||
<option value="datetime">${t("attribute_detail.date_time")}</option>
|
||||
<option value="time">${t("attribute_detail.time")}</option>
|
||||
<option value="url">${t("attribute_detail.url")}</option>
|
||||
<option value="color">${t("attribute_detail.color_type")}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@ -296,6 +297,7 @@ interface AttributeDetailOpts {
|
||||
y: number;
|
||||
focus?: "name";
|
||||
parent?: HTMLElement;
|
||||
hideMultiplicity?: boolean;
|
||||
}
|
||||
|
||||
interface SearchRelatedResponse {
|
||||
@ -478,7 +480,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
});
|
||||
}
|
||||
|
||||
async showAttributeDetail({ allAttributes, attribute, isOwned, x, y, focus }: AttributeDetailOpts) {
|
||||
async showAttributeDetail({ allAttributes, attribute, isOwned, x, y, focus, hideMultiplicity }: AttributeDetailOpts) {
|
||||
if (!attribute) {
|
||||
this.hide();
|
||||
|
||||
@ -529,7 +531,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
this.$rowPromotedAlias.toggle(!!definition.isPromoted);
|
||||
this.$inputPromotedAlias.val(definition.promotedAlias || "").attr("disabled", disabledFn);
|
||||
|
||||
this.$rowMultiplicity.toggle(["label-definition", "relation-definition"].includes(this.attrType || ""));
|
||||
this.$rowMultiplicity.toggle(["label-definition", "relation-definition"].includes(this.attrType || "") && !hideMultiplicity);
|
||||
this.$inputMultiplicity.val(definition.multiplicity || "").attr("disabled", disabledFn);
|
||||
|
||||
this.$rowLabelType.toggle(this.attrType === "label-definition");
|
||||
|
||||
@ -106,7 +106,11 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
focus: false
|
||||
});
|
||||
|
||||
await this.noteContext.setNote(noteIdOrPath);
|
||||
await this.noteContext.setNote(noteIdOrPath, {
|
||||
viewScope: {
|
||||
readOnlyTemporarilyDisabled: true
|
||||
}
|
||||
});
|
||||
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && "blur" in activeEl) {
|
||||
|
||||
@ -35,7 +35,8 @@ export const byBookType: Record<ViewTypeOptions, string | null> = {
|
||||
grid: "8QqnMzx393bx",
|
||||
calendar: "xWbu3jpNWapp",
|
||||
table: "2FvYrpmOXm29",
|
||||
geoMap: "81SGnPGMk7Xc"
|
||||
geoMap: "81SGnPGMk7Xc",
|
||||
board: "CtBQqbwXDx1w"
|
||||
};
|
||||
|
||||
export default class ContextualHelpButton extends NoteContextAwareWidget {
|
||||
|
||||
@ -5,6 +5,16 @@ import type FNote from "../../entities/fnote.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { bookPropertiesConfig, BookProperty } from "./book_properties_config.js";
|
||||
import attributes from "../../services/attributes.js";
|
||||
import type { ViewTypeOptions } from "../../services/note_list_renderer.js";
|
||||
|
||||
const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
grid: t("book_properties.grid"),
|
||||
list: t("book_properties.list"),
|
||||
calendar: t("book_properties.calendar"),
|
||||
table: t("book_properties.table"),
|
||||
geoMap: t("book_properties.geo-map"),
|
||||
board: t("book_properties.board")
|
||||
};
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="book-properties-widget">
|
||||
@ -23,24 +33,37 @@ const TPL = /*html*/`
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.book-properties-container > * {
|
||||
.book-properties-container > div {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.book-properties-container > .type-number > label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.book-properties-container input[type="checkbox"] {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.book-properties-container label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="display: flex; align-items: baseline">
|
||||
<span style="white-space: nowrap">${t("book_properties.view_type")}: </span>
|
||||
|
||||
<select class="view-type-select form-select form-select-sm">
|
||||
<option value="grid">${t("book_properties.grid")}</option>
|
||||
<option value="list">${t("book_properties.list")}</option>
|
||||
<option value="calendar">${t("book_properties.calendar")}</option>
|
||||
<option value="table">${t("book_properties.table")}</option>
|
||||
<option value="geoMap">${t("book_properties.geo-map")}</option>
|
||||
${Object.entries(VIEW_TYPE_MAPPINGS)
|
||||
.filter(([type]) => type !== "raster")
|
||||
.map(([type, label]) => `
|
||||
<option value="${type}">${label}</option>
|
||||
`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -110,7 +133,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["list", "grid", "calendar", "table", "geoMap"].includes(type)) {
|
||||
if (!VIEW_TYPE_MAPPINGS.hasOwnProperty(type)) {
|
||||
throw new Error(t("book_properties.invalid_view_type", { type }));
|
||||
}
|
||||
|
||||
@ -127,6 +150,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
|
||||
|
||||
renderBookProperty(property: BookProperty) {
|
||||
const $container = $("<div>");
|
||||
$container.addClass(`type-${property.type}`);
|
||||
const note = this.note;
|
||||
if (!note) {
|
||||
return $container;
|
||||
@ -168,6 +192,56 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
|
||||
});
|
||||
$container.append($button);
|
||||
break;
|
||||
case "number":
|
||||
const $numberInput = $("<input>", {
|
||||
type: "number",
|
||||
class: "form-control form-control-sm",
|
||||
value: note.getLabelValue(property.bindToLabel) || "",
|
||||
width: property.width ?? 100,
|
||||
min: property.min ?? 0
|
||||
});
|
||||
$numberInput.on("change", () => {
|
||||
const value = $numberInput.val();
|
||||
if (value === "") {
|
||||
attributes.removeOwnedLabelByName(note, property.bindToLabel);
|
||||
} else {
|
||||
attributes.setLabel(note.noteId, property.bindToLabel, String(value));
|
||||
}
|
||||
});
|
||||
$container.append($("<label>")
|
||||
.text(property.label)
|
||||
.append(" ".repeat(2))
|
||||
.append($numberInput));
|
||||
break;
|
||||
case "combobox":
|
||||
const $select = $("<select>", {
|
||||
class: "form-select form-select-sm"
|
||||
});
|
||||
const actualValue = note.getLabelValue(property.bindToLabel) ?? property.defaultValue ?? "";
|
||||
for (const option of property.options) {
|
||||
if ("items" in option) {
|
||||
const $optGroup = $("<optgroup>", { label: option.name });
|
||||
for (const item of option.items) {
|
||||
buildComboBoxItem(item, actualValue).appendTo($optGroup);
|
||||
}
|
||||
$optGroup.appendTo($select);
|
||||
} else {
|
||||
buildComboBoxItem(option, actualValue).appendTo($select);
|
||||
}
|
||||
}
|
||||
$select.on("change", () => {
|
||||
const value = $select.val();
|
||||
if (value === null || value === "") {
|
||||
attributes.removeOwnedLabelByName(note, property.bindToLabel);
|
||||
} else {
|
||||
attributes.setLabel(note.noteId, property.bindToLabel, String(value));
|
||||
}
|
||||
});
|
||||
$container.append($("<label>")
|
||||
.text(property.label)
|
||||
.append(" ".repeat(2))
|
||||
.append($select));
|
||||
break;
|
||||
}
|
||||
|
||||
return $container;
|
||||
@ -175,3 +249,14 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
|
||||
|
||||
|
||||
}
|
||||
|
||||
function buildComboBoxItem({ value, label }: { value: string, label: string }, actualValue: string) {
|
||||
const $option = $("<option>", {
|
||||
value,
|
||||
text: label
|
||||
});
|
||||
if (actualValue === value) {
|
||||
$option.prop("selected", true);
|
||||
}
|
||||
return $option;
|
||||
}
|
||||
|
||||
@ -3,8 +3,7 @@ import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { ViewTypeOptions } from "../../services/note_list_renderer"
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget";
|
||||
|
||||
export type BookProperty = CheckBoxProperty | ButtonProperty;
|
||||
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../view_widgets/geo_view/map_layer";
|
||||
|
||||
interface BookConfig {
|
||||
properties: BookProperty[];
|
||||
@ -24,6 +23,37 @@ interface ButtonProperty {
|
||||
onClick: (context: BookContext) => void;
|
||||
}
|
||||
|
||||
interface NumberProperty {
|
||||
type: "number",
|
||||
label: string;
|
||||
bindToLabel: string;
|
||||
width?: number;
|
||||
min?: number;
|
||||
}
|
||||
|
||||
interface ComboBoxItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ComboBoxGroup {
|
||||
name: string;
|
||||
items: ComboBoxItem[];
|
||||
}
|
||||
|
||||
interface ComboBoxProperty {
|
||||
type: "combobox",
|
||||
label: string;
|
||||
bindToLabel: string;
|
||||
/**
|
||||
* The default value is used when the label is not set.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
options: (ComboBoxItem | ComboBoxGroup)[];
|
||||
}
|
||||
|
||||
export type BookProperty = CheckBoxProperty | ButtonProperty | NumberProperty | ComboBoxProperty;
|
||||
|
||||
interface BookContext {
|
||||
note: FNote;
|
||||
triggerCommand: NoteContextAwareWidget["triggerCommand"];
|
||||
@ -82,9 +112,58 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
|
||||
]
|
||||
},
|
||||
geoMap: {
|
||||
properties: []
|
||||
properties: [
|
||||
{
|
||||
label: t("book_properties_config.map-style"),
|
||||
type: "combobox",
|
||||
bindToLabel: "map:style",
|
||||
defaultValue: DEFAULT_MAP_LAYER_NAME,
|
||||
options: [
|
||||
{
|
||||
name: t("book_properties_config.raster"),
|
||||
items: Object.entries(MAP_LAYERS)
|
||||
.filter(([_, layer]) => layer.type === "raster")
|
||||
.map(buildMapLayer)
|
||||
},
|
||||
{
|
||||
name: t("book_properties_config.vector_light"),
|
||||
items: Object.entries(MAP_LAYERS)
|
||||
.filter(([_, layer]) => layer.type === "vector" && !layer.isDarkTheme)
|
||||
.map(buildMapLayer)
|
||||
},
|
||||
{
|
||||
name: t("book_properties_config.vector_dark"),
|
||||
items: Object.entries(MAP_LAYERS)
|
||||
.filter(([_, layer]) => layer.type === "vector" && layer.isDarkTheme)
|
||||
.map(buildMapLayer)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t("book_properties_config.show-scale"),
|
||||
type: "checkbox",
|
||||
bindToLabel: "map:scale"
|
||||
}
|
||||
]
|
||||
},
|
||||
table: {
|
||||
properties: [
|
||||
{
|
||||
label: t("book_properties_config.max-nesting-depth"),
|
||||
type: "number",
|
||||
bindToLabel: "maxNestingDepth",
|
||||
width: 65
|
||||
}
|
||||
]
|
||||
},
|
||||
board: {
|
||||
properties: []
|
||||
}
|
||||
};
|
||||
|
||||
function buildMapLayer([ id, layer ]: [ string, MapLayer ]): ComboBoxItem {
|
||||
return {
|
||||
value: id,
|
||||
label: layer.name
|
||||
};
|
||||
}
|
||||
|
||||
@ -53,12 +53,56 @@ const TPL = /*html*/`
|
||||
word-break:keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
width: 22px !important;
|
||||
flex-grow: 0;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
/* Restore default apperance */
|
||||
.promoted-attribute-cell input[type="number"],
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 2px;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
border-radius: 25% !important;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 0px;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transform: rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="promoted-attributes-container"></div>
|
||||
@ -258,6 +302,35 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
||||
.on("click", () => window.open($input.val() as string, "_blank"));
|
||||
|
||||
$input.after($openButton);
|
||||
} else if (definition.labelType === "color") {
|
||||
const defaultColor = "#ffffff";
|
||||
$input.prop("type", "hidden");
|
||||
$input.val(valueAttr.value ?? "");
|
||||
|
||||
// We insert a separate input since the color input does not support empty value.
|
||||
// This is a workaround to allow clearing the color input.
|
||||
const $colorInput = $("<input>")
|
||||
.prop("type", "color")
|
||||
.prop("value", valueAttr.value || defaultColor)
|
||||
.addClass("form-control promoted-attribute-input")
|
||||
.on("change", e => setValue((e.target as HTMLInputElement).value, e));
|
||||
$input.after($colorInput);
|
||||
|
||||
const $clearButton = $("<span>")
|
||||
.addClass("input-group-text bx bxs-tag-x")
|
||||
.prop("title", t("promoted_attributes.remove_color"))
|
||||
.on("click", e => setValue("", e));
|
||||
|
||||
const setValue = (color: string, event: JQuery.TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => {
|
||||
$input.val(color);
|
||||
if (!color) {
|
||||
$colorInput.val(defaultColor);
|
||||
}
|
||||
event.target = $input[0]; // Set the event target to the main input
|
||||
this.promotedAttributeChanged(event);
|
||||
};
|
||||
|
||||
$colorInput.after($clearButton);
|
||||
} else {
|
||||
ws.logError(t("promoted_attributes.unknown_label_type", { type: definition.labelType }));
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import appContext, { type CommandNames, type CommandListenerData, type EventData
|
||||
import froca from "../services/froca.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import type NoteContext from "../components/note_context.js";
|
||||
import { setupHorizontalScrollViaWheel } from "./widget_utils.js";
|
||||
|
||||
const isDesktop = utils.isDesktop();
|
||||
|
||||
@ -386,15 +387,7 @@ export default class TabRowWidget extends BasicWidget {
|
||||
};
|
||||
|
||||
setupScrollEvents() {
|
||||
this.$tabScrollingContainer.on('wheel', (event) => {
|
||||
const wheelEvent = event.originalEvent as WheelEvent;
|
||||
if (utils.isCtrlKey(event) || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
event.currentTarget.scrollLeft += wheelEvent.deltaY + wheelEvent.deltaX;
|
||||
});
|
||||
setupHorizontalScrollViaWheel(this.$tabScrollingContainer);
|
||||
|
||||
this.$scrollButtonLeft[0].addEventListener('click', () => this.scrollTabContainer(-210));
|
||||
this.$scrollButtonRight[0].addEventListener('click', () => this.scrollTabContainer(210));
|
||||
|
||||
@ -130,7 +130,8 @@ export default abstract class AbstractSplitTypeWidget extends TypeWidget {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.editorTypeWidget = new EditableCodeTypeWidget();
|
||||
|
||||
this.editorTypeWidget = new EditableCodeTypeWidget(true);
|
||||
this.editorTypeWidget.updateBackgroundColor = () => {};
|
||||
this.editorTypeWidget.isEnabled = () => true;
|
||||
|
||||
@ -146,6 +147,8 @@ export default abstract class AbstractSplitTypeWidget extends TypeWidget {
|
||||
doRender(): void {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.spacedUpdate.setUpdateInterval(750);
|
||||
|
||||
// Preview pane
|
||||
this.$previewCol = this.$widget.find(".note-detail-split-preview-col");
|
||||
this.$preview = this.$widget.find(".note-detail-split-preview");
|
||||
|
||||
@ -45,12 +45,11 @@ export default class BookTypeWidget extends TypeWidget {
|
||||
}
|
||||
|
||||
switch (this.note?.getAttributeValue("label", "viewType")) {
|
||||
case "calendar":
|
||||
case "table":
|
||||
case "geoMap":
|
||||
return false;
|
||||
default:
|
||||
case "list":
|
||||
case "grid":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -166,7 +166,6 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
onChange: () => this.onChangeHandler(),
|
||||
viewModeEnabled: options.is("databaseReadonly"),
|
||||
zenModeEnabled: false,
|
||||
gridModeEnabled: false,
|
||||
isCollaborating: false,
|
||||
detectScroll: false,
|
||||
handleKeyboardGlobally: false,
|
||||
|
||||
@ -153,7 +153,8 @@ export default class Canvas {
|
||||
appState: {
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
zoom: appState.zoom
|
||||
zoom: appState.zoom,
|
||||
gridModeEnabled: appState.gridModeEnabled
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -28,6 +28,16 @@ const TPL = /*html*/`
|
||||
|
||||
export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
|
||||
|
||||
private debounceUpdate: boolean;
|
||||
|
||||
/**
|
||||
* @param debounceUpdate if true, the update will be debounced to prevent excessive updates. Especially useful if the editor is linked to a live preview.
|
||||
*/
|
||||
constructor(debounceUpdate: boolean = false) {
|
||||
super();
|
||||
this.debounceUpdate = debounceUpdate;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "editableCode";
|
||||
}
|
||||
@ -46,7 +56,13 @@ export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
|
||||
return {
|
||||
placeholder: t("editable_code.placeholder"),
|
||||
vimKeybindings: options.is("vimKeymapEnabled"),
|
||||
onContentChanged: () => this.spacedUpdate.scheduleUpdate(),
|
||||
onContentChanged: () => {
|
||||
if (this.debounceUpdate) {
|
||||
this.spacedUpdate.resetUpdateTimer();
|
||||
}
|
||||
|
||||
this.spacedUpdate.scheduleUpdate();
|
||||
},
|
||||
tabIndex: 300
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,8 +76,10 @@ export default abstract class TypeWidget extends NoteContextAwareWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore focus to the editor when switching tabs.
|
||||
this.focus();
|
||||
// Restore focus to the editor when switching tabs, but only if the note tree is not already focused.
|
||||
if (!document.activeElement?.classList.contains("fancytree-title")) {
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
180
apps/client/src/widgets/view_widgets/board_view/api.ts
Normal file
180
apps/client/src/widgets/view_widgets/board_view/api.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import attributes from "../../../services/attributes";
|
||||
import { executeBulkActions } from "../../../services/bulk_action";
|
||||
import note_create from "../../../services/note_create";
|
||||
import ViewModeStorage from "../view_mode_storage";
|
||||
import { BoardData } from "./config";
|
||||
import { ColumnMap, getBoardData } from "./data";
|
||||
|
||||
export default class BoardApi {
|
||||
|
||||
private constructor(
|
||||
private _columns: string[],
|
||||
private _parentNoteId: string,
|
||||
private viewStorage: ViewModeStorage<BoardData>,
|
||||
private byColumn: ColumnMap,
|
||||
private persistedData: BoardData,
|
||||
private _statusAttribute: string) {}
|
||||
|
||||
get columns() {
|
||||
return this._columns;
|
||||
}
|
||||
|
||||
get statusAttribute() {
|
||||
return this._statusAttribute;
|
||||
}
|
||||
|
||||
getColumn(column: string) {
|
||||
return this.byColumn.get(column);
|
||||
}
|
||||
|
||||
async changeColumn(noteId: string, newColumn: string) {
|
||||
await attributes.setLabel(noteId, this._statusAttribute, newColumn);
|
||||
}
|
||||
|
||||
openNote(noteId: string) {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId });
|
||||
}
|
||||
|
||||
async insertRowAtPosition(
|
||||
column: string,
|
||||
relativeToBranchId: string,
|
||||
direction: "before" | "after",
|
||||
open: boolean = true) {
|
||||
const { note } = await note_create.createNote(this._parentNoteId, {
|
||||
activate: false,
|
||||
targetBranchId: relativeToBranchId,
|
||||
target: direction,
|
||||
title: "New item"
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
throw new Error("Failed to create note");
|
||||
}
|
||||
|
||||
const { noteId } = note;
|
||||
await this.changeColumn(noteId, column);
|
||||
if (open) {
|
||||
this.openNote(noteId);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async renameColumn(oldValue: string, newValue: string, noteIds: string[]) {
|
||||
// Change the value in the notes.
|
||||
await executeBulkActions(noteIds, [
|
||||
{
|
||||
name: "updateLabelValue",
|
||||
labelName: this._statusAttribute,
|
||||
labelValue: newValue
|
||||
}
|
||||
]);
|
||||
|
||||
// Rename the column in the persisted data.
|
||||
for (const column of this.persistedData.columns || []) {
|
||||
if (column.value === oldValue) {
|
||||
column.value = newValue;
|
||||
}
|
||||
}
|
||||
await this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
async removeColumn(column: string) {
|
||||
// Remove the value from the notes.
|
||||
const noteIds = this.byColumn.get(column)?.map(item => item.note.noteId) || [];
|
||||
await executeBulkActions(noteIds, [
|
||||
{
|
||||
name: "deleteLabel",
|
||||
labelName: this._statusAttribute
|
||||
}
|
||||
]);
|
||||
|
||||
this.persistedData.columns = (this.persistedData.columns ?? []).filter(col => col.value !== column);
|
||||
this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
async createColumn(columnValue: string) {
|
||||
// Add the new column to persisted data if it doesn't exist
|
||||
if (!this.persistedData.columns) {
|
||||
this.persistedData.columns = [];
|
||||
}
|
||||
|
||||
const existingColumn = this.persistedData.columns.find(col => col.value === columnValue);
|
||||
if (!existingColumn) {
|
||||
this.persistedData.columns.push({ value: columnValue });
|
||||
await this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
return columnValue;
|
||||
}
|
||||
|
||||
async reorderColumns(newColumnOrder: string[]) {
|
||||
// Update the column order in persisted data
|
||||
if (!this.persistedData.columns) {
|
||||
this.persistedData.columns = [];
|
||||
}
|
||||
|
||||
// Create a map of existing column data
|
||||
const columnDataMap = new Map();
|
||||
this.persistedData.columns.forEach(col => {
|
||||
columnDataMap.set(col.value, col);
|
||||
});
|
||||
|
||||
// Reorder columns based on new order
|
||||
this.persistedData.columns = newColumnOrder.map(columnValue => {
|
||||
return columnDataMap.get(columnValue) || { value: columnValue };
|
||||
});
|
||||
|
||||
// Update internal columns array
|
||||
this._columns = newColumnOrder;
|
||||
|
||||
await this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
async refresh(parentNote: FNote) {
|
||||
// Refresh the API data by re-fetching from the parent note
|
||||
const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status";
|
||||
this._statusAttribute = statusAttribute;
|
||||
|
||||
// Use the current in-memory persisted data instead of restoring from storage
|
||||
// This ensures we don't lose recent updates like column renames
|
||||
const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, this.persistedData);
|
||||
|
||||
// Update internal state
|
||||
this.byColumn = byColumn;
|
||||
|
||||
if (newPersistedData) {
|
||||
this.persistedData = newPersistedData;
|
||||
this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
// Use the order from persistedData.columns, then add any new columns found
|
||||
const orderedColumns = this.persistedData.columns?.map(col => col.value) || [];
|
||||
const allColumns = Array.from(byColumn.keys());
|
||||
const newColumns = allColumns.filter(col => !orderedColumns.includes(col));
|
||||
this._columns = [...orderedColumns, ...newColumns];
|
||||
}
|
||||
|
||||
static async build(parentNote: FNote, viewStorage: ViewModeStorage<BoardData>) {
|
||||
const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status";
|
||||
|
||||
let persistedData = await viewStorage.restore() ?? {};
|
||||
const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, persistedData);
|
||||
|
||||
// Use the order from persistedData.columns, then add any new columns found
|
||||
const orderedColumns = persistedData.columns?.map(col => col.value) || [];
|
||||
const allColumns = Array.from(byColumn.keys());
|
||||
const newColumns = allColumns.filter(col => !orderedColumns.includes(col));
|
||||
const columns = [...orderedColumns, ...newColumns];
|
||||
|
||||
if (newPersistedData) {
|
||||
persistedData = newPersistedData;
|
||||
viewStorage.store(persistedData);
|
||||
}
|
||||
|
||||
return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData, statusAttribute);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,278 @@
|
||||
import BoardApi from "./api";
|
||||
import { DragContext, BaseDragHandler } from "./drag_types";
|
||||
|
||||
export class ColumnDragHandler implements BaseDragHandler {
|
||||
private $container: JQuery<HTMLElement>;
|
||||
private api: BoardApi;
|
||||
private context: DragContext;
|
||||
|
||||
constructor(
|
||||
$container: JQuery<HTMLElement>,
|
||||
api: BoardApi,
|
||||
context: DragContext,
|
||||
) {
|
||||
this.$container = $container;
|
||||
this.api = api;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
setupColumnDrag($columnEl: JQuery<HTMLElement>, columnValue: string) {
|
||||
const $titleEl = $columnEl.find('h3[data-column-value]');
|
||||
|
||||
$titleEl.attr("draggable", "true");
|
||||
|
||||
// Delay drag start to allow click detection
|
||||
let dragStartTimer: number | null = null;
|
||||
|
||||
$titleEl.on("mousedown", (e) => {
|
||||
// Don't interfere with editing mode or input field interactions
|
||||
if ($titleEl.hasClass('editing') || $(e.target).is('input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timer
|
||||
if (dragStartTimer) {
|
||||
clearTimeout(dragStartTimer);
|
||||
dragStartTimer = null;
|
||||
}
|
||||
|
||||
// Set a short delay before enabling dragging
|
||||
dragStartTimer = window.setTimeout(() => {
|
||||
$titleEl.attr("draggable", "true");
|
||||
dragStartTimer = null;
|
||||
}, 150);
|
||||
});
|
||||
|
||||
$titleEl.on("mouseup mouseleave", (e) => {
|
||||
// Don't interfere with editing mode
|
||||
if ($titleEl.hasClass('editing') || $(e.target).is('input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel drag start timer on mouse up or leave
|
||||
if (dragStartTimer) {
|
||||
clearTimeout(dragStartTimer);
|
||||
dragStartTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
$titleEl.on("dragstart", (e) => {
|
||||
// Only start dragging if the target is not an input (for inline editing)
|
||||
if ($(e.target).is('input') || $titleEl.hasClass('editing')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.context.draggedColumn = columnValue;
|
||||
this.context.draggedColumnElement = $columnEl;
|
||||
$columnEl.addClass("column-dragging");
|
||||
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
if (originalEvent.dataTransfer) {
|
||||
originalEvent.dataTransfer.effectAllowed = "move";
|
||||
originalEvent.dataTransfer.setData("text/plain", columnValue);
|
||||
}
|
||||
|
||||
// Prevent note dragging when column is being dragged
|
||||
e.stopPropagation();
|
||||
|
||||
// Setup global drag tracking for better drop indicator positioning
|
||||
this.setupGlobalColumnDragTracking();
|
||||
});
|
||||
|
||||
$titleEl.on("dragend", () => {
|
||||
$columnEl.removeClass("column-dragging");
|
||||
this.context.draggedColumn = null;
|
||||
this.context.draggedColumnElement = null;
|
||||
this.cleanupColumnDropIndicators();
|
||||
this.cleanupGlobalColumnDragTracking();
|
||||
|
||||
// Re-enable draggable
|
||||
$titleEl.attr("draggable", "true");
|
||||
});
|
||||
}
|
||||
|
||||
setupColumnDropZone($columnEl: JQuery<HTMLElement>) {
|
||||
$columnEl.on("dragover", (e) => {
|
||||
// Only handle column drops when a column is being dragged
|
||||
if (this.context.draggedColumn && !this.context.draggedNote) {
|
||||
e.preventDefault();
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
if (originalEvent.dataTransfer) {
|
||||
originalEvent.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
// Don't highlight columns - we only care about the drop indicator position
|
||||
}
|
||||
});
|
||||
|
||||
$columnEl.on("drop", async (e) => {
|
||||
if (this.context.draggedColumn && !this.context.draggedNote) {
|
||||
e.preventDefault();
|
||||
console.log("Column drop event triggered for column:", this.context.draggedColumn);
|
||||
|
||||
// Use the drop indicator position to determine where to place the column
|
||||
await this.handleColumnDrop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.cleanupColumnDropIndicators();
|
||||
this.context.draggedColumn = null;
|
||||
this.context.draggedColumnElement = null;
|
||||
this.cleanupGlobalColumnDragTracking();
|
||||
}
|
||||
|
||||
private setupGlobalColumnDragTracking() {
|
||||
// Add container-level drag tracking for better indicator positioning
|
||||
this.$container.on("dragover.columnDrag", (e) => {
|
||||
if (this.context.draggedColumn) {
|
||||
e.preventDefault();
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
this.showColumnDropIndicator(originalEvent.clientX);
|
||||
}
|
||||
});
|
||||
|
||||
// Add container-level drop handler for column reordering
|
||||
this.$container.on("drop.columnDrag", async (e) => {
|
||||
if (this.context.draggedColumn) {
|
||||
e.preventDefault();
|
||||
console.log("Container drop event triggered for column:", this.context.draggedColumn);
|
||||
await this.handleColumnDrop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private cleanupGlobalColumnDragTracking() {
|
||||
this.$container.off("dragover.columnDrag");
|
||||
this.$container.off("drop.columnDrag");
|
||||
}
|
||||
|
||||
private cleanupColumnDropIndicators() {
|
||||
// Remove column drop indicators
|
||||
this.$container.find(".column-drop-indicator").remove();
|
||||
}
|
||||
|
||||
private showColumnDropIndicator(mouseX: number) {
|
||||
// Clean up existing indicators
|
||||
this.cleanupColumnDropIndicators();
|
||||
|
||||
// Get all columns (excluding the dragged one if it exists)
|
||||
let $allColumns = this.$container.find('.board-column');
|
||||
if (this.context.draggedColumnElement) {
|
||||
$allColumns = $allColumns.not(this.context.draggedColumnElement);
|
||||
}
|
||||
|
||||
let $targetColumn: JQuery<HTMLElement> = $();
|
||||
let insertBefore = false;
|
||||
|
||||
// Find which column the mouse is closest to
|
||||
$allColumns.each((_, columnEl) => {
|
||||
const $column = $(columnEl);
|
||||
const rect = columnEl.getBoundingClientRect();
|
||||
const columnMiddle = rect.left + rect.width / 2;
|
||||
|
||||
if (mouseX >= rect.left && mouseX <= rect.right) {
|
||||
// Mouse is over this column
|
||||
$targetColumn = $column;
|
||||
insertBefore = mouseX < columnMiddle;
|
||||
return false; // Break the loop
|
||||
}
|
||||
});
|
||||
|
||||
// If no column found under mouse, find the closest one
|
||||
if ($targetColumn.length === 0) {
|
||||
let closestDistance = Infinity;
|
||||
$allColumns.each((_, columnEl) => {
|
||||
const $column = $(columnEl);
|
||||
const rect = columnEl.getBoundingClientRect();
|
||||
const columnCenter = rect.left + rect.width / 2;
|
||||
const distance = Math.abs(mouseX - columnCenter);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
$targetColumn = $column;
|
||||
insertBefore = mouseX < columnCenter;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($targetColumn.length > 0) {
|
||||
const $dropIndicator = $("<div>").addClass("column-drop-indicator");
|
||||
|
||||
if (insertBefore) {
|
||||
$targetColumn.before($dropIndicator);
|
||||
} else {
|
||||
$targetColumn.after($dropIndicator);
|
||||
}
|
||||
|
||||
$dropIndicator.addClass("show");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleColumnDrop() {
|
||||
console.log("handleColumnDrop called for:", this.context.draggedColumn);
|
||||
|
||||
if (!this.context.draggedColumn || !this.context.draggedColumnElement) {
|
||||
console.log("No dragged column or element found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the drop indicator to determine insert position
|
||||
const $dropIndicator = this.$container.find(".column-drop-indicator.show");
|
||||
console.log("Drop indicator found:", $dropIndicator.length > 0);
|
||||
|
||||
if ($dropIndicator.length > 0) {
|
||||
// Get current column order from the API (source of truth)
|
||||
const currentOrder = [...this.api.columns];
|
||||
|
||||
let newOrder = [...currentOrder];
|
||||
|
||||
// Remove dragged column from current position
|
||||
newOrder = newOrder.filter(col => col !== this.context.draggedColumn);
|
||||
|
||||
// Determine insertion position based on drop indicator position
|
||||
const $nextColumn = $dropIndicator.next('.board-column');
|
||||
const $prevColumn = $dropIndicator.prev('.board-column');
|
||||
|
||||
let insertIndex = -1;
|
||||
|
||||
if ($nextColumn.length > 0) {
|
||||
// Insert before the next column
|
||||
const nextColumnValue = $nextColumn.attr('data-column');
|
||||
if (nextColumnValue) {
|
||||
insertIndex = newOrder.indexOf(nextColumnValue);
|
||||
}
|
||||
} else if ($prevColumn.length > 0) {
|
||||
// Insert after the previous column
|
||||
const prevColumnValue = $prevColumn.attr('data-column');
|
||||
if (prevColumnValue) {
|
||||
insertIndex = newOrder.indexOf(prevColumnValue) + 1;
|
||||
}
|
||||
} else {
|
||||
// Insert at the beginning
|
||||
insertIndex = 0;
|
||||
}
|
||||
|
||||
// Insert the dragged column at the determined position
|
||||
if (insertIndex >= 0 && insertIndex <= newOrder.length) {
|
||||
newOrder.splice(insertIndex, 0, this.context.draggedColumn);
|
||||
} else {
|
||||
// Fallback: insert at the end
|
||||
newOrder.push(this.context.draggedColumn);
|
||||
}
|
||||
|
||||
// Update column order in API
|
||||
await this.api.reorderColumns(newOrder);
|
||||
} else {
|
||||
console.warn("No drop indicator found for column drop");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reorder columns:", error);
|
||||
} finally {
|
||||
this.cleanupColumnDropIndicators();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
export interface BoardColumnData {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface BoardData {
|
||||
columns?: BoardColumnData[];
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu.js";
|
||||
import link_context_menu from "../../../menus/link_context_menu.js";
|
||||
import branches from "../../../services/branches.js";
|
||||
import dialog from "../../../services/dialog.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BoardApi from "./api.js";
|
||||
import type BoardView from "./index.js";
|
||||
|
||||
interface ShowNoteContextMenuArgs {
|
||||
$container: JQuery<HTMLElement>;
|
||||
api: BoardApi;
|
||||
boardView: BoardView;
|
||||
}
|
||||
|
||||
export function setupContextMenu({ $container, api, boardView }: ShowNoteContextMenuArgs) {
|
||||
$container.on("contextmenu", ".board-note", showNoteContextMenu);
|
||||
$container.on("contextmenu", ".board-column h3", showColumnContextMenu);
|
||||
|
||||
function showColumnContextMenu(event: ContextMenuEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const $el = $(event.currentTarget);
|
||||
const column = $el.closest(".board-column").data("column");
|
||||
|
||||
contextMenu.show({
|
||||
x: event.pageX,
|
||||
y: event.pageY,
|
||||
items: [
|
||||
{
|
||||
title: t("board_view.delete-column"),
|
||||
uiIcon: "bx bx-trash",
|
||||
async handler() {
|
||||
const confirmed = await dialog.confirm(t("board_view.delete-column-confirmation"));
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.removeColumn(column);
|
||||
}
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler() {}
|
||||
});
|
||||
}
|
||||
|
||||
function showNoteContextMenu(event: ContextMenuEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const $el = $(event.currentTarget);
|
||||
const noteId = $el.data("note-id");
|
||||
const branchId = $el.data("branch-id");
|
||||
const column = $el.closest(".board-column").data("column");
|
||||
if (!noteId) return;
|
||||
|
||||
contextMenu.show({
|
||||
x: event.pageX,
|
||||
y: event.pageY,
|
||||
items: [
|
||||
...link_context_menu.getItems(),
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.move-to"),
|
||||
uiIcon: "bx bx-transfer",
|
||||
items: api.columns.map(columnToMoveTo => ({
|
||||
title: columnToMoveTo,
|
||||
enabled: columnToMoveTo !== column,
|
||||
handler: () => api.changeColumn(noteId, columnToMoveTo)
|
||||
}))
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.insert-above"),
|
||||
uiIcon: "bx bx-list-plus",
|
||||
handler: () => boardView.insertItemAtPosition(column, branchId, "before")
|
||||
},
|
||||
{
|
||||
title: t("board_view.insert-below"),
|
||||
uiIcon: "bx bx-empty",
|
||||
handler: () => boardView.insertItemAtPosition(column, branchId, "after")
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.delete-note"),
|
||||
uiIcon: "bx bx-trash",
|
||||
handler: () => branches.deleteNotes([ branchId ], false, false)
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId),
|
||||
});
|
||||
}
|
||||
}
|
||||
84
apps/client/src/widgets/view_widgets/board_view/data.ts
Normal file
84
apps/client/src/widgets/view_widgets/board_view/data.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import FBranch from "../../../entities/fbranch";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { BoardData } from "./config";
|
||||
|
||||
export type ColumnMap = Map<string, {
|
||||
branch: FBranch;
|
||||
note: FNote;
|
||||
}[]>;
|
||||
|
||||
export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardData) {
|
||||
const byColumn: ColumnMap = new Map();
|
||||
|
||||
// First, scan all notes to find what columns actually exist
|
||||
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn);
|
||||
|
||||
// Get all columns that exist in the notes
|
||||
const columnsFromNotes = [...byColumn.keys()];
|
||||
|
||||
// Get existing persisted columns and preserve their order
|
||||
const existingPersistedColumns = persistedData.columns || [];
|
||||
const existingColumnValues = existingPersistedColumns.map(c => c.value);
|
||||
|
||||
// Find truly new columns (exist in notes but not in persisted data)
|
||||
const newColumnValues = columnsFromNotes.filter(col => !existingColumnValues.includes(col));
|
||||
|
||||
// Build the complete correct column list: existing + new
|
||||
const allColumns = [
|
||||
...existingPersistedColumns, // Preserve existing order
|
||||
...newColumnValues.map(value => ({ value })) // Add new columns
|
||||
];
|
||||
|
||||
// Remove duplicates (just in case) and ensure we only keep columns that exist in notes or are explicitly preserved
|
||||
const deduplicatedColumns = allColumns.filter((column, index) => {
|
||||
const firstIndex = allColumns.findIndex(c => c.value === column.value);
|
||||
return firstIndex === index; // Keep only the first occurrence
|
||||
});
|
||||
|
||||
// Ensure all persisted columns have empty arrays in byColumn (even if no notes use them)
|
||||
for (const column of deduplicatedColumns) {
|
||||
if (!byColumn.has(column.value)) {
|
||||
byColumn.set(column.value, []);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated persisted data only if there were changes
|
||||
let newPersistedData: BoardData | undefined;
|
||||
const hasChanges = newColumnValues.length > 0 ||
|
||||
existingPersistedColumns.length !== deduplicatedColumns.length ||
|
||||
!existingPersistedColumns.every((col, idx) => deduplicatedColumns[idx]?.value === col.value);
|
||||
|
||||
if (hasChanges) {
|
||||
newPersistedData = {
|
||||
...persistedData,
|
||||
columns: deduplicatedColumns
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
byColumn,
|
||||
newPersistedData
|
||||
};
|
||||
}
|
||||
|
||||
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string) {
|
||||
for (const branch of branches) {
|
||||
const note = await branch.getNote();
|
||||
if (!note) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = note.getLabelValue(groupByColumn);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byColumn.has(group)) {
|
||||
byColumn.set(group, []);
|
||||
}
|
||||
byColumn.get(group)!.push({
|
||||
branch,
|
||||
note
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,539 @@
|
||||
import { BoardDragHandler } from "./drag_handler";
|
||||
import BoardApi from "./api";
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import ViewModeStorage from "../view_mode_storage";
|
||||
import { BoardData } from "./config";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
|
||||
export interface BoardState {
|
||||
columns: { [key: string]: { note: any; branch: any }[] };
|
||||
columnOrder: string[];
|
||||
}
|
||||
|
||||
export class DifferentialBoardRenderer {
|
||||
private $container: JQuery<HTMLElement>;
|
||||
private api: BoardApi;
|
||||
private dragHandler: BoardDragHandler;
|
||||
private lastState: BoardState | null = null;
|
||||
private onCreateNewItem: (column: string) => void;
|
||||
private updateTimeout: number | null = null;
|
||||
private pendingUpdate = false;
|
||||
private parentNote: FNote;
|
||||
private viewStorage: ViewModeStorage<BoardData>;
|
||||
private onRefreshApi: () => Promise<void>;
|
||||
|
||||
constructor(
|
||||
$container: JQuery<HTMLElement>,
|
||||
api: BoardApi,
|
||||
dragHandler: BoardDragHandler,
|
||||
onCreateNewItem: (column: string) => void,
|
||||
parentNote: FNote,
|
||||
viewStorage: ViewModeStorage<BoardData>,
|
||||
onRefreshApi: () => Promise<void>
|
||||
) {
|
||||
this.$container = $container;
|
||||
this.api = api;
|
||||
this.dragHandler = dragHandler;
|
||||
this.onCreateNewItem = onCreateNewItem;
|
||||
this.parentNote = parentNote;
|
||||
this.viewStorage = viewStorage;
|
||||
this.onRefreshApi = onRefreshApi;
|
||||
}
|
||||
|
||||
async renderBoard(refreshApi = false): Promise<void> {
|
||||
// Refresh API data if requested
|
||||
if (refreshApi) {
|
||||
await this.onRefreshApi();
|
||||
}
|
||||
|
||||
// Debounce rapid updates
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout);
|
||||
}
|
||||
|
||||
this.updateTimeout = window.setTimeout(async () => {
|
||||
await this.performUpdate();
|
||||
this.updateTimeout = null;
|
||||
}, 16); // ~60fps
|
||||
}
|
||||
|
||||
private async performUpdate(): Promise<void> {
|
||||
// Clean up any stray drag indicators before updating
|
||||
this.dragHandler.cleanup();
|
||||
|
||||
const currentState = this.getCurrentState();
|
||||
|
||||
if (!this.lastState) {
|
||||
// First render - do full render
|
||||
await this.fullRender(currentState);
|
||||
} else {
|
||||
// Differential render - only update what changed
|
||||
await this.differentialRender(this.lastState, currentState);
|
||||
}
|
||||
|
||||
this.lastState = currentState;
|
||||
}
|
||||
|
||||
private getCurrentState(): BoardState {
|
||||
const columns: { [key: string]: { note: any; branch: any }[] } = {};
|
||||
const columnOrder: string[] = [];
|
||||
|
||||
for (const column of this.api.columns) {
|
||||
columnOrder.push(column);
|
||||
columns[column] = this.api.getColumn(column) || [];
|
||||
}
|
||||
|
||||
return { columns, columnOrder };
|
||||
}
|
||||
|
||||
private async fullRender(state: BoardState): Promise<void> {
|
||||
this.$container.empty();
|
||||
|
||||
for (const column of state.columnOrder) {
|
||||
const columnItems = state.columns[column];
|
||||
const $columnEl = this.createColumn(column, columnItems);
|
||||
this.$container.append($columnEl);
|
||||
}
|
||||
|
||||
this.addAddColumnButton();
|
||||
}
|
||||
|
||||
private async differentialRender(oldState: BoardState, newState: BoardState): Promise<void> {
|
||||
// Store scroll positions before making changes
|
||||
const scrollPositions = this.saveScrollPositions();
|
||||
|
||||
// Handle column additions/removals
|
||||
this.updateColumns(oldState, newState);
|
||||
|
||||
// Handle card updates within existing columns
|
||||
for (const column of newState.columnOrder) {
|
||||
this.updateColumnCards(column, oldState.columns[column] || [], newState.columns[column]);
|
||||
}
|
||||
|
||||
// Restore scroll positions
|
||||
this.restoreScrollPositions(scrollPositions);
|
||||
}
|
||||
|
||||
private saveScrollPositions(): { [column: string]: number } {
|
||||
const positions: { [column: string]: number } = {};
|
||||
this.$container.find('.board-column').each((_, el) => {
|
||||
const column = $(el).attr('data-column');
|
||||
if (column) {
|
||||
positions[column] = el.scrollTop;
|
||||
}
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
private restoreScrollPositions(positions: { [column: string]: number }): void {
|
||||
this.$container.find('.board-column').each((_, el) => {
|
||||
const column = $(el).attr('data-column');
|
||||
if (column && positions[column] !== undefined) {
|
||||
el.scrollTop = positions[column];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateColumns(oldState: BoardState, newState: BoardState): void {
|
||||
// Check if column order has changed
|
||||
const orderChanged = !this.arraysEqual(oldState.columnOrder, newState.columnOrder);
|
||||
|
||||
if (orderChanged) {
|
||||
// If order changed, we need to reorder the columns in the DOM
|
||||
this.reorderColumns(newState.columnOrder);
|
||||
}
|
||||
|
||||
// Remove columns that no longer exist
|
||||
for (const oldColumn of oldState.columnOrder) {
|
||||
if (!newState.columnOrder.includes(oldColumn)) {
|
||||
this.$container.find(`[data-column="${oldColumn}"]`).remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Add new columns
|
||||
for (const newColumn of newState.columnOrder) {
|
||||
if (!oldState.columnOrder.includes(newColumn)) {
|
||||
const columnItems = newState.columns[newColumn];
|
||||
const $columnEl = this.createColumn(newColumn, columnItems);
|
||||
|
||||
// Insert at correct position
|
||||
const insertIndex = newState.columnOrder.indexOf(newColumn);
|
||||
const $existingColumns = this.$container.find('.board-column');
|
||||
|
||||
if (insertIndex === 0) {
|
||||
this.$container.prepend($columnEl);
|
||||
} else if (insertIndex >= $existingColumns.length) {
|
||||
this.$container.find('.board-add-column').before($columnEl);
|
||||
} else {
|
||||
$($existingColumns[insertIndex - 1]).after($columnEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private arraysEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
}
|
||||
|
||||
private reorderColumns(newOrder: string[]): void {
|
||||
// Get all existing column elements
|
||||
const $columns = this.$container.find('.board-column');
|
||||
const $addColumnButton = this.$container.find('.board-add-column');
|
||||
|
||||
// Create a map of column elements by their data-column attribute
|
||||
const columnElements = new Map<string, JQuery<HTMLElement>>();
|
||||
$columns.each((_, el) => {
|
||||
const $el = $(el);
|
||||
const columnValue = $el.attr('data-column');
|
||||
if (columnValue) {
|
||||
columnElements.set(columnValue, $el);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove all columns from DOM (but keep references)
|
||||
$columns.detach();
|
||||
|
||||
// Re-insert columns in the new order
|
||||
let $insertAfter: JQuery<HTMLElement> | null = null;
|
||||
for (const columnValue of newOrder) {
|
||||
const $columnEl = columnElements.get(columnValue);
|
||||
if ($columnEl) {
|
||||
if ($insertAfter) {
|
||||
$insertAfter.after($columnEl);
|
||||
} else {
|
||||
// Insert at the beginning
|
||||
this.$container.prepend($columnEl);
|
||||
}
|
||||
$insertAfter = $columnEl;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure add column button is at the end
|
||||
if ($addColumnButton.length) {
|
||||
this.$container.append($addColumnButton);
|
||||
}
|
||||
}
|
||||
|
||||
private updateColumnCards(column: string, oldCards: { note: any; branch: any }[], newCards: { note: any; branch: any }[]): void {
|
||||
const $column = this.$container.find(`[data-column="${column}"]`);
|
||||
if (!$column.length) return;
|
||||
|
||||
const $cardContainer = $column;
|
||||
const oldCardIds = oldCards.map(item => item.note.noteId);
|
||||
const newCardIds = newCards.map(item => item.note.noteId);
|
||||
|
||||
// Remove cards that no longer exist
|
||||
$cardContainer.find('.board-note').each((_, el) => {
|
||||
const noteId = $(el).attr('data-note-id');
|
||||
if (noteId && !newCardIds.includes(noteId)) {
|
||||
$(el).addClass('fade-out');
|
||||
setTimeout(() => $(el).remove(), 150);
|
||||
}
|
||||
});
|
||||
|
||||
// Add or update cards
|
||||
for (let i = 0; i < newCards.length; i++) {
|
||||
const item = newCards[i];
|
||||
const noteId = item.note.noteId;
|
||||
const $existingCard = $cardContainer.find(`[data-note-id="${noteId}"]`);
|
||||
const isNewCard = !oldCardIds.includes(noteId);
|
||||
|
||||
if ($existingCard.length) {
|
||||
// Check for changes in title, icon, or color
|
||||
const currentTitle = $existingCard.text().trim();
|
||||
const currentIconClass = $existingCard.attr('data-icon-class');
|
||||
const currentColorClass = $existingCard.attr('data-color-class') || '';
|
||||
|
||||
const newIconClass = item.note.getIcon();
|
||||
const newColorClass = item.note.getColorClass() || '';
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
// Update title if changed
|
||||
if (currentTitle !== item.note.title) {
|
||||
$existingCard.contents().filter(function() {
|
||||
return this.nodeType === 3; // Text nodes
|
||||
}).remove();
|
||||
$existingCard.append(document.createTextNode(item.note.title));
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update icon if changed
|
||||
if (currentIconClass !== newIconClass) {
|
||||
const $icon = $existingCard.find('.icon');
|
||||
$icon.removeClass().addClass('icon').addClass(newIconClass);
|
||||
$existingCard.attr('data-icon-class', newIconClass);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update color if changed
|
||||
if (currentColorClass !== newColorClass) {
|
||||
// Remove old color class if it exists
|
||||
if (currentColorClass) {
|
||||
$existingCard.removeClass(currentColorClass);
|
||||
}
|
||||
// Add new color class if it exists
|
||||
if (newColorClass) {
|
||||
$existingCard.addClass(newColorClass);
|
||||
}
|
||||
$existingCard.attr('data-color-class', newColorClass);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Add subtle animation if there were changes
|
||||
if (hasChanges) {
|
||||
$existingCard.addClass('card-updated');
|
||||
setTimeout(() => $existingCard.removeClass('card-updated'), 300);
|
||||
}
|
||||
|
||||
// Ensure card is in correct position
|
||||
this.ensureCardPosition($existingCard, i, $cardContainer);
|
||||
} else {
|
||||
// Create new card
|
||||
const $newCard = this.createCard(item.note, item.branch, column);
|
||||
$newCard.addClass('fade-in').css('opacity', '0');
|
||||
|
||||
// Insert at correct position
|
||||
if (i === 0) {
|
||||
$cardContainer.find('h3').after($newCard);
|
||||
} else {
|
||||
const $prevCard = $cardContainer.find('.board-note').eq(i - 1);
|
||||
if ($prevCard.length) {
|
||||
$prevCard.after($newCard);
|
||||
} else {
|
||||
$cardContainer.find('.board-new-item').before($newCard);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger fade in animation
|
||||
setTimeout(() => $newCard.css('opacity', '1'), 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureCardPosition($card: JQuery<HTMLElement>, targetIndex: number, $container: JQuery<HTMLElement>): void {
|
||||
const $allCards = $container.find('.board-note');
|
||||
const currentIndex = $allCards.index($card);
|
||||
|
||||
if (currentIndex !== targetIndex) {
|
||||
if (targetIndex === 0) {
|
||||
$container.find('h3').after($card);
|
||||
} else {
|
||||
const $targetPrev = $allCards.eq(targetIndex - 1);
|
||||
if ($targetPrev.length) {
|
||||
$targetPrev.after($card);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createColumn(column: string, columnItems: { note: any; branch: any }[]): JQuery<HTMLElement> {
|
||||
const $columnEl = $("<div>")
|
||||
.addClass("board-column")
|
||||
.attr("data-column", column);
|
||||
|
||||
// Create header
|
||||
const $titleEl = $("<h3>").attr("data-column-value", column);
|
||||
|
||||
// Create title text
|
||||
const $titleText = $("<span>").text(column);
|
||||
|
||||
// Create edit icon
|
||||
const $editIcon = $("<span>")
|
||||
.addClass("edit-icon icon bx bx-edit-alt")
|
||||
.attr("title", "Click to edit column title");
|
||||
|
||||
$titleEl.append($titleText, $editIcon);
|
||||
$columnEl.append($titleEl);
|
||||
|
||||
// Setup column dragging
|
||||
this.dragHandler.setupColumnDrag($columnEl, column);
|
||||
|
||||
// Handle wheel events for scrolling
|
||||
$columnEl.on("wheel", (event) => {
|
||||
const el = $columnEl[0];
|
||||
const needsScroll = el.scrollHeight > el.clientHeight;
|
||||
if (needsScroll) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
// Setup drop zones for both notes and columns
|
||||
this.dragHandler.setupNoteDropZone($columnEl, column);
|
||||
this.dragHandler.setupColumnDropZone($columnEl);
|
||||
|
||||
// Add cards
|
||||
for (const item of columnItems) {
|
||||
if (item.note) {
|
||||
const $noteEl = this.createCard(item.note, item.branch, column);
|
||||
$columnEl.append($noteEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Add "New item" button
|
||||
const $newItemEl = $("<div>")
|
||||
.addClass("board-new-item")
|
||||
.attr("data-column", column)
|
||||
.html(`<span class="icon bx bx-plus"></span> ${t("board_view.new-item")}`);
|
||||
|
||||
$newItemEl.on("click", () => this.onCreateNewItem(column));
|
||||
$columnEl.append($newItemEl);
|
||||
|
||||
return $columnEl;
|
||||
}
|
||||
|
||||
private createCard(note: any, branch: any, column: string): JQuery<HTMLElement> {
|
||||
const $iconEl = $("<span>")
|
||||
.addClass("icon")
|
||||
.addClass(note.getIcon());
|
||||
|
||||
const colorClass = note.getColorClass() || '';
|
||||
|
||||
const $noteEl = $("<div>")
|
||||
.addClass("board-note")
|
||||
.attr("data-note-id", note.noteId)
|
||||
.attr("data-branch-id", branch.branchId)
|
||||
.attr("data-current-column", column)
|
||||
.attr("data-icon-class", note.getIcon())
|
||||
.attr("data-color-class", colorClass)
|
||||
.text(note.title);
|
||||
|
||||
// Add color class to the card if it exists
|
||||
if (colorClass) {
|
||||
$noteEl.addClass(colorClass);
|
||||
}
|
||||
|
||||
$noteEl.prepend($iconEl);
|
||||
$noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId }));
|
||||
|
||||
// Setup drag functionality
|
||||
this.dragHandler.setupNoteDrag($noteEl, note, branch);
|
||||
|
||||
return $noteEl;
|
||||
}
|
||||
|
||||
private addAddColumnButton(): void {
|
||||
if (this.$container.find('.board-add-column').length === 0) {
|
||||
const $addColumnEl = $("<div>")
|
||||
.addClass("board-add-column")
|
||||
.html(`<span class="icon bx bx-plus"></span> ${t("board_view.add-column")}`);
|
||||
|
||||
this.$container.append($addColumnEl);
|
||||
}
|
||||
}
|
||||
|
||||
forceFullRender(): void {
|
||||
this.lastState = null;
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout);
|
||||
this.updateTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
async flushPendingUpdates(): Promise<void> {
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout);
|
||||
this.updateTimeout = null;
|
||||
await this.performUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
startInlineEditing(noteId: string): void {
|
||||
// Use setTimeout to ensure the card is rendered before trying to edit it
|
||||
setTimeout(() => {
|
||||
const $card = this.$container.find(`[data-note-id="${noteId}"]`);
|
||||
if ($card.length) {
|
||||
this.makeCardEditable($card, noteId);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private makeCardEditable($card: JQuery<HTMLElement>, noteId: string): void {
|
||||
if ($card.hasClass('editing')) {
|
||||
return; // Already editing
|
||||
}
|
||||
|
||||
// Get the current title (get text without icon)
|
||||
const $icon = $card.find('.icon');
|
||||
const currentTitle = $card.text().trim();
|
||||
|
||||
// Add editing class and store original click handler
|
||||
$card.addClass('editing');
|
||||
$card.off('click'); // Remove any existing click handlers temporarily
|
||||
|
||||
// Create input element
|
||||
const $input = $('<input>')
|
||||
.attr('type', 'text')
|
||||
.val(currentTitle)
|
||||
.css({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
color: 'inherit',
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
padding: '0',
|
||||
marginLeft: '0.25em'
|
||||
});
|
||||
|
||||
// Create a flex container to keep icon and input inline
|
||||
const $editContainer = $('<div>')
|
||||
.css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
// Replace content with icon + input in flex container
|
||||
$editContainer.append($icon.clone(), $input);
|
||||
$card.empty().append($editContainer);
|
||||
$input.focus().select();
|
||||
|
||||
const finishEdit = async (save = true) => {
|
||||
if (!$card.hasClass('editing')) {
|
||||
return; // Already finished
|
||||
}
|
||||
|
||||
$card.removeClass('editing');
|
||||
|
||||
let finalTitle = currentTitle;
|
||||
if (save) {
|
||||
const newTitle = $input.val() as string;
|
||||
if (newTitle.trim() && newTitle !== currentTitle) {
|
||||
try {
|
||||
// Update the note title using the board view's server call
|
||||
import('../../../services/server').then(async ({ default: server }) => {
|
||||
await server.put(`notes/${noteId}/title`, { title: newTitle.trim() });
|
||||
finalTitle = newTitle.trim();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update note title:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the card content
|
||||
const iconClass = $card.attr('data-icon-class') || 'bx bx-file';
|
||||
const $newIcon = $('<span>').addClass('icon').addClass(iconClass);
|
||||
$card.text(finalTitle);
|
||||
$card.prepend($newIcon);
|
||||
|
||||
// Re-attach click handler for quick edit (for existing cards)
|
||||
$card.on('click', () => appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId }));
|
||||
};
|
||||
|
||||
$input.on('blur', () => finishEdit(true));
|
||||
$input.on('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
finishEdit(true);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
finishEdit(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import BoardApi from "./api";
|
||||
import { DragContext } from "./drag_types";
|
||||
import { NoteDragHandler } from "./note_drag_handler";
|
||||
import { ColumnDragHandler } from "./column_drag_handler";
|
||||
|
||||
export class BoardDragHandler {
|
||||
private noteDragHandler: NoteDragHandler;
|
||||
private columnDragHandler: ColumnDragHandler;
|
||||
|
||||
constructor(
|
||||
$container: JQuery<HTMLElement>,
|
||||
api: BoardApi,
|
||||
context: DragContext,
|
||||
) {
|
||||
// Initialize specialized drag handlers
|
||||
this.noteDragHandler = new NoteDragHandler($container, api, context);
|
||||
this.columnDragHandler = new ColumnDragHandler($container, api, context);
|
||||
}
|
||||
|
||||
// Note drag methods - delegate to NoteDragHandler
|
||||
setupNoteDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
|
||||
this.noteDragHandler.setupNoteDrag($noteEl, note, branch);
|
||||
}
|
||||
|
||||
setupNoteDropZone($columnEl: JQuery<HTMLElement>, column: string) {
|
||||
this.noteDragHandler.setupNoteDropZone($columnEl, column);
|
||||
}
|
||||
|
||||
// Column drag methods - delegate to ColumnDragHandler
|
||||
setupColumnDrag($columnEl: JQuery<HTMLElement>, columnValue: string) {
|
||||
this.columnDragHandler.setupColumnDrag($columnEl, columnValue);
|
||||
}
|
||||
|
||||
setupColumnDropZone($columnEl: JQuery<HTMLElement>) {
|
||||
this.columnDragHandler.setupColumnDropZone($columnEl);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.noteDragHandler.cleanup();
|
||||
this.columnDragHandler.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Export the drag context type for external use
|
||||
export type { DragContext } from "./drag_types";
|
||||
@ -0,0 +1,11 @@
|
||||
export interface DragContext {
|
||||
draggedNote: any;
|
||||
draggedBranch: any;
|
||||
draggedNoteElement: JQuery<HTMLElement> | null;
|
||||
draggedColumn: string | null;
|
||||
draggedColumnElement: JQuery<HTMLElement> | null;
|
||||
}
|
||||
|
||||
export interface BaseDragHandler {
|
||||
cleanup(): void;
|
||||
}
|
||||
650
apps/client/src/widgets/view_widgets/board_view/index.ts
Normal file
650
apps/client/src/widgets/view_widgets/board_view/index.ts
Normal file
@ -0,0 +1,650 @@
|
||||
import { setupHorizontalScrollViaWheel } from "../../widget_utils";
|
||||
import ViewMode, { ViewModeArgs } from "../view_mode";
|
||||
import noteCreateService from "../../../services/note_create";
|
||||
import { EventData } from "../../../components/app_context";
|
||||
import { BoardData } from "./config";
|
||||
import SpacedUpdate from "../../../services/spaced_update";
|
||||
import { setupContextMenu } from "./context_menu";
|
||||
import BoardApi from "./api";
|
||||
import { BoardDragHandler, DragContext } from "./drag_handler";
|
||||
import { DifferentialBoardRenderer } from "./differential_renderer";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="board-view">
|
||||
<style>
|
||||
.board-view {
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.board-view-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
padding: 1em;
|
||||
padding-bottom: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.board-view-container .board-column {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.5em;
|
||||
background-color: var(--accented-background-color);
|
||||
transition: border-color 0.2s ease;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.board-view-container .board-column.drag-over {
|
||||
border-color: var(--main-text-color);
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3 {
|
||||
font-size: 1em;
|
||||
margin-bottom: 0.75em;
|
||||
padding: 0.5em 0.5em 0.5em 0.5em;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
transition: background-color 0.2s ease, border-radius 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3.editing {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3:hover {
|
||||
background-color: var(--hover-item-background-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3.editing {
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-text-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.board-view-container .board-column.column-dragging {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.98);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3 input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3 .edit-icon {
|
||||
opacity: 0;
|
||||
margin-left: 0.5em;
|
||||
transition: opacity 0.2s ease;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3.editing .edit-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board-view-container .board-note {
|
||||
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25);
|
||||
margin: 0.65em 0;
|
||||
padding: 0.5em;
|
||||
border-radius: 5px;
|
||||
cursor: move;
|
||||
position: relative;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-view-container .board-note.fade-in {
|
||||
animation: fadeIn 0.15s ease-in;
|
||||
}
|
||||
|
||||
.board-view-container .board-note.fade-out {
|
||||
animation: fadeOut 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.board-view-container .board-note.card-updated {
|
||||
animation: cardUpdate 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes cardUpdate {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.board-view-container .board-note:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.board-view-container .board-note.dragging {
|
||||
opacity: 0.8;
|
||||
transform: rotate(5deg);
|
||||
z-index: 1000;
|
||||
box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.board-view-container .board-note.editing {
|
||||
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35);
|
||||
border-color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.board-view-container .board-note.editing input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.board-view-container .board-note .icon {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.board-drop-indicator {
|
||||
height: 3px;
|
||||
background-color: var(--main-text-color);
|
||||
border-radius: 2px;
|
||||
margin: 0.25em 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.board-drop-indicator.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.column-drop-indicator {
|
||||
width: 4px;
|
||||
background-color: var(--main-text-color);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-drop-indicator.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-new-item {
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em;
|
||||
border-radius: 5px;
|
||||
color: var(--muted-text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.board-new-item:hover {
|
||||
border-color: var(--main-text-color);
|
||||
color: var(--main-text-color);
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-new-item .icon {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.board-add-column {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
padding: 0.5em;
|
||||
background-color: var(--accented-background-color);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.9em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.board-add-column:hover {
|
||||
border-color: var(--main-text-color);
|
||||
color: var(--main-text-color);
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-add-column .icon {
|
||||
margin-right: 0.5em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.board-drag-preview {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
transform: rotate(5deg);
|
||||
box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 0.5em;
|
||||
font-size: 0.9em;
|
||||
max-width: 200px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="board-view-container"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class BoardView extends ViewMode<BoardData> {
|
||||
|
||||
private $root: JQuery<HTMLElement>;
|
||||
private $container: JQuery<HTMLElement>;
|
||||
private spacedUpdate: SpacedUpdate;
|
||||
private dragContext: DragContext;
|
||||
private persistentData: BoardData;
|
||||
private api?: BoardApi;
|
||||
private dragHandler?: BoardDragHandler;
|
||||
private renderer?: DifferentialBoardRenderer;
|
||||
|
||||
constructor(args: ViewModeArgs) {
|
||||
super(args, "board");
|
||||
|
||||
this.$root = $(TPL);
|
||||
setupHorizontalScrollViaWheel(this.$root);
|
||||
this.$container = this.$root.find(".board-view-container");
|
||||
this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000);
|
||||
this.persistentData = {
|
||||
columns: []
|
||||
};
|
||||
this.dragContext = {
|
||||
draggedNote: null,
|
||||
draggedBranch: null,
|
||||
draggedNoteElement: null,
|
||||
draggedColumn: null,
|
||||
draggedColumnElement: null
|
||||
};
|
||||
|
||||
args.$parent.append(this.$root);
|
||||
}
|
||||
|
||||
async renderList(): Promise<JQuery<HTMLElement> | undefined> {
|
||||
if (!this.renderer) {
|
||||
// First time setup
|
||||
this.$container.empty();
|
||||
await this.initializeRenderer();
|
||||
}
|
||||
|
||||
await this.renderer!.renderBoard();
|
||||
return this.$root;
|
||||
}
|
||||
|
||||
private async initializeRenderer() {
|
||||
this.api = await BoardApi.build(this.parentNote, this.viewStorage);
|
||||
this.dragHandler = new BoardDragHandler(
|
||||
this.$container,
|
||||
this.api,
|
||||
this.dragContext
|
||||
);
|
||||
|
||||
this.renderer = new DifferentialBoardRenderer(
|
||||
this.$container,
|
||||
this.api,
|
||||
this.dragHandler,
|
||||
(column: string) => this.createNewItem(column),
|
||||
this.parentNote,
|
||||
this.viewStorage,
|
||||
() => this.refreshApi()
|
||||
);
|
||||
|
||||
setupContextMenu({
|
||||
$container: this.$container,
|
||||
api: this.api,
|
||||
boardView: this
|
||||
});
|
||||
|
||||
// Setup column title editing and add column functionality
|
||||
this.setupBoardInteractions();
|
||||
}
|
||||
|
||||
private async refreshApi(): Promise<void> {
|
||||
if (!this.api) {
|
||||
throw new Error("API not initialized");
|
||||
}
|
||||
|
||||
await this.api.refresh(this.parentNote);
|
||||
}
|
||||
|
||||
private setupBoardInteractions() {
|
||||
// Handle column title editing with click detection that works with dragging
|
||||
this.$container.on('mousedown', 'h3[data-column-value]', (e) => {
|
||||
const $titleEl = $(e.currentTarget);
|
||||
|
||||
// Don't interfere with editing mode
|
||||
if ($titleEl.hasClass('editing') || $(e.target).is('input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let hasMoved = false;
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
|
||||
const handleMouseMove = (moveEvent: JQuery.MouseMoveEvent) => {
|
||||
const deltaX = Math.abs(moveEvent.clientX - startX);
|
||||
const deltaY = Math.abs(moveEvent.clientY - startY);
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
hasMoved = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (upEvent: JQuery.MouseUpEvent) => {
|
||||
const duration = Date.now() - startTime;
|
||||
$(document).off('mousemove', handleMouseMove);
|
||||
$(document).off('mouseup', handleMouseUp);
|
||||
|
||||
// If it was a quick click without much movement, treat as edit request
|
||||
if (duration < 500 && !hasMoved && upEvent.button === 0) {
|
||||
const columnValue = $titleEl.attr('data-column-value');
|
||||
if (columnValue) {
|
||||
const columnItems = this.api?.getColumn(columnValue) || [];
|
||||
this.startEditingColumnTitle($titleEl, columnValue, columnItems);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$(document).on('mousemove', handleMouseMove);
|
||||
$(document).on('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
// Handle add column button
|
||||
this.$container.on('click', '.board-add-column', (e) => {
|
||||
e.stopPropagation();
|
||||
this.startCreatingNewColumn($(e.currentTarget));
|
||||
});
|
||||
}
|
||||
|
||||
private createTitleStructure(title: string): { $titleText: JQuery<HTMLElement>; $editIcon: JQuery<HTMLElement> } {
|
||||
const $titleText = $("<span>").text(title);
|
||||
const $editIcon = $("<span>")
|
||||
.addClass("edit-icon icon bx bx-edit-alt")
|
||||
.attr("title", "Click to edit column title");
|
||||
|
||||
return { $titleText, $editIcon };
|
||||
}
|
||||
|
||||
private startEditingColumnTitle($titleEl: JQuery<HTMLElement>, columnValue: string, columnItems: { branch: any; note: any; }[]) {
|
||||
if ($titleEl.hasClass("editing")) {
|
||||
return; // Already editing
|
||||
}
|
||||
|
||||
const $titleSpan = $titleEl.find("span").first(); // Get the text span
|
||||
const currentTitle = $titleSpan.text();
|
||||
$titleEl.addClass("editing");
|
||||
|
||||
// Disable dragging while editing
|
||||
$titleEl.attr("draggable", "false");
|
||||
|
||||
const $input = $("<input>")
|
||||
.attr("type", "text")
|
||||
.val(currentTitle)
|
||||
.attr("placeholder", "Column title");
|
||||
|
||||
// Prevent events from bubbling to parent drag handlers
|
||||
$input.on('mousedown mouseup click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
$titleEl.empty().append($input);
|
||||
$input.focus().select();
|
||||
|
||||
const finishEdit = async (save: boolean = true) => {
|
||||
if (!$titleEl.hasClass("editing")) {
|
||||
return; // Already finished
|
||||
}
|
||||
|
||||
$titleEl.removeClass("editing");
|
||||
|
||||
// Re-enable dragging after editing
|
||||
$titleEl.attr("draggable", "true");
|
||||
|
||||
let finalTitle = currentTitle;
|
||||
if (save) {
|
||||
const newTitle = $input.val() as string;
|
||||
if (newTitle.trim() && newTitle !== currentTitle) {
|
||||
await this.renameColumn(columnValue, newTitle.trim(), columnItems);
|
||||
finalTitle = newTitle.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate the title structure
|
||||
const { $titleText, $editIcon } = this.createTitleStructure(finalTitle);
|
||||
$titleEl.empty().append($titleText, $editIcon);
|
||||
};
|
||||
|
||||
$input.on("blur", () => finishEdit(true));
|
||||
$input.on("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishEdit(true);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
finishEdit(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async renameColumn(oldValue: string, newValue: string, columnItems: { branch: any; note: any; }[]) {
|
||||
try {
|
||||
// Get all note IDs in this column
|
||||
const noteIds = columnItems.map(item => item.note.noteId);
|
||||
|
||||
// Use the API to rename the column (update all notes)
|
||||
// This will trigger onEntitiesReloaded which will automatically refresh the board
|
||||
await this.api?.renameColumn(oldValue, newValue, noteIds);
|
||||
} catch (error) {
|
||||
console.error("Failed to rename column:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createNewItem(column: string) {
|
||||
try {
|
||||
// Get the parent note path
|
||||
const parentNotePath = this.parentNote.noteId;
|
||||
|
||||
// Create a new note as a child of the parent note
|
||||
const { note: newNote } = await noteCreateService.createNote(parentNotePath, {
|
||||
activate: false,
|
||||
title: "New item"
|
||||
});
|
||||
|
||||
if (newNote) {
|
||||
// Set the status label to place it in the correct column
|
||||
await this.api?.changeColumn(newNote.noteId, column);
|
||||
|
||||
// Refresh the board to show the new item
|
||||
await this.renderList();
|
||||
|
||||
// Start inline editing of the newly created card
|
||||
this.startInlineEditingCard(newNote.noteId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create new item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async insertItemAtPosition(column: string, relativeToBranchId: string, direction: "before" | "after"): Promise<void> {
|
||||
try {
|
||||
// Create the note without opening it
|
||||
const newNote = await this.api?.insertRowAtPosition(column, relativeToBranchId, direction, false);
|
||||
|
||||
if (newNote) {
|
||||
// Refresh the board to show the new item
|
||||
await this.renderList();
|
||||
|
||||
// Start inline editing of the newly created card
|
||||
this.startInlineEditingCard(newNote.noteId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to insert new item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private startInlineEditingCard(noteId: string) {
|
||||
this.renderer?.startInlineEditing(noteId);
|
||||
}
|
||||
|
||||
forceFullRefresh() {
|
||||
this.renderer?.forceFullRender();
|
||||
return this.renderList();
|
||||
}
|
||||
|
||||
private startCreatingNewColumn($addColumnEl: JQuery<HTMLElement>) {
|
||||
if ($addColumnEl.hasClass("editing")) {
|
||||
return; // Already editing
|
||||
}
|
||||
|
||||
$addColumnEl.addClass("editing");
|
||||
|
||||
const $input = $("<input>")
|
||||
.attr("type", "text")
|
||||
.attr("placeholder", "Enter column name...")
|
||||
.css({
|
||||
background: "var(--main-background-color)",
|
||||
border: "1px solid var(--main-text-color)",
|
||||
borderRadius: "4px",
|
||||
padding: "0.5em",
|
||||
color: "var(--main-text-color)",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
width: "100%",
|
||||
textAlign: "center"
|
||||
});
|
||||
|
||||
$addColumnEl.empty().append($input);
|
||||
$input.focus();
|
||||
|
||||
const finishEdit = async (save: boolean = true) => {
|
||||
if (!$addColumnEl.hasClass("editing")) {
|
||||
return; // Already finished
|
||||
}
|
||||
|
||||
$addColumnEl.removeClass("editing");
|
||||
|
||||
if (save) {
|
||||
const columnName = $input.val() as string;
|
||||
if (columnName.trim()) {
|
||||
await this.createNewColumn(columnName.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the add button
|
||||
$addColumnEl.html('<span class="icon bx bx-plus"></span>Add Column');
|
||||
};
|
||||
|
||||
$input.on("blur", () => finishEdit(true));
|
||||
$input.on("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishEdit(true);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
finishEdit(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async createNewColumn(columnName: string) {
|
||||
try {
|
||||
// Check if column already exists
|
||||
if (this.api?.columns.includes(columnName)) {
|
||||
console.warn("A column with this name already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the new column
|
||||
await this.api?.createColumn(columnName);
|
||||
|
||||
// Refresh the board to show the new column
|
||||
await this.renderList();
|
||||
} catch (error) {
|
||||
console.error("Failed to create new column:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// Check if any changes affect our board
|
||||
const hasRelevantChanges =
|
||||
// React to changes in status attribute for notes in this board
|
||||
loadResults.getAttributeRows().some(attr => attr.name === this.api?.statusAttribute && this.noteIds.includes(attr.noteId!)) ||
|
||||
// React to changes in note title
|
||||
loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)) ||
|
||||
// React to changes in branches for subchildren (e.g., moved, added, or removed notes)
|
||||
loadResults.getBranchRows().some(branch => this.noteIds.includes(branch.noteId!)) ||
|
||||
// React to changes in note icon or color.
|
||||
loadResults.getAttributeRows().some(attr => [ "iconClass", "color" ].includes(attr.name ?? "") && this.noteIds.includes(attr.noteId ?? "")) ||
|
||||
// React to attachment change
|
||||
loadResults.getAttachmentRows().some(att => att.ownerId === this.parentNote.noteId && att.title === "board.json") ||
|
||||
// React to changes in "groupBy"
|
||||
loadResults.getAttributeRows().some(attr => attr.name === "board:groupBy" && attr.noteId === this.parentNote.noteId);
|
||||
|
||||
if (hasRelevantChanges && this.renderer) {
|
||||
// Use differential rendering with API refresh
|
||||
await this.renderer.renderBoard(true);
|
||||
}
|
||||
|
||||
// Don't trigger full view refresh - let differential renderer handle it
|
||||
return false;
|
||||
}
|
||||
|
||||
private onSave() {
|
||||
this.viewStorage.store(this.persistentData);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,322 @@
|
||||
import branchService from "../../../services/branches";
|
||||
import BoardApi from "./api";
|
||||
import { DragContext, BaseDragHandler } from "./drag_types";
|
||||
|
||||
export class NoteDragHandler implements BaseDragHandler {
|
||||
private $container: JQuery<HTMLElement>;
|
||||
private api: BoardApi;
|
||||
private context: DragContext;
|
||||
|
||||
constructor(
|
||||
$container: JQuery<HTMLElement>,
|
||||
api: BoardApi,
|
||||
context: DragContext,
|
||||
) {
|
||||
this.$container = $container;
|
||||
this.api = api;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
setupNoteDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
|
||||
$noteEl.attr("draggable", "true");
|
||||
|
||||
// Mouse drag events
|
||||
this.setupMouseDrag($noteEl, note, branch);
|
||||
|
||||
// Touch drag events
|
||||
this.setupTouchDrag($noteEl, note, branch);
|
||||
}
|
||||
|
||||
setupNoteDropZone($columnEl: JQuery<HTMLElement>, column: string) {
|
||||
$columnEl.on("dragover", (e) => {
|
||||
// Only handle note drops when a note is being dragged
|
||||
if (this.context.draggedNote && !this.context.draggedColumn) {
|
||||
e.preventDefault();
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
if (originalEvent.dataTransfer) {
|
||||
originalEvent.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
$columnEl.addClass("drag-over");
|
||||
this.showDropIndicator($columnEl, e);
|
||||
}
|
||||
});
|
||||
|
||||
$columnEl.on("dragleave", (e) => {
|
||||
// Only remove drag-over if we're leaving the column entirely
|
||||
const rect = $columnEl[0].getBoundingClientRect();
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
const x = originalEvent.clientX;
|
||||
const y = originalEvent.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
$columnEl.removeClass("drag-over");
|
||||
this.cleanupNoteDropIndicators($columnEl);
|
||||
}
|
||||
});
|
||||
|
||||
$columnEl.on("drop", async (e) => {
|
||||
if (this.context.draggedNote && !this.context.draggedColumn) {
|
||||
e.preventDefault();
|
||||
$columnEl.removeClass("drag-over");
|
||||
|
||||
if (this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) {
|
||||
await this.handleNoteDrop($columnEl, column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.cleanupAllDropIndicators();
|
||||
this.$container.find('.board-column').removeClass('drag-over');
|
||||
}
|
||||
|
||||
private setupMouseDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
|
||||
$noteEl.on("dragstart", (e) => {
|
||||
this.context.draggedNote = note;
|
||||
this.context.draggedBranch = branch;
|
||||
this.context.draggedNoteElement = $noteEl;
|
||||
$noteEl.addClass("dragging");
|
||||
|
||||
// Set drag data
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
if (originalEvent.dataTransfer) {
|
||||
originalEvent.dataTransfer.effectAllowed = "move";
|
||||
originalEvent.dataTransfer.setData("text/plain", note.noteId);
|
||||
}
|
||||
});
|
||||
|
||||
$noteEl.on("dragend", () => {
|
||||
$noteEl.removeClass("dragging");
|
||||
this.context.draggedNote = null;
|
||||
this.context.draggedBranch = null;
|
||||
this.context.draggedNoteElement = null;
|
||||
|
||||
// Clean up all drop indicators properly
|
||||
this.cleanupAllDropIndicators();
|
||||
});
|
||||
}
|
||||
|
||||
private setupTouchDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
|
||||
let isDragging = false;
|
||||
let startY = 0;
|
||||
let startX = 0;
|
||||
let dragThreshold = 10; // Minimum distance to start dragging
|
||||
let $dragPreview: JQuery<HTMLElement> | null = null;
|
||||
|
||||
$noteEl.on("touchstart", (e) => {
|
||||
const touch = (e.originalEvent as TouchEvent).touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
isDragging = false;
|
||||
$dragPreview = null;
|
||||
});
|
||||
|
||||
$noteEl.on("touchmove", (e) => {
|
||||
e.preventDefault(); // Prevent scrolling
|
||||
const touch = (e.originalEvent as TouchEvent).touches[0];
|
||||
const deltaX = Math.abs(touch.clientX - startX);
|
||||
const deltaY = Math.abs(touch.clientY - startY);
|
||||
|
||||
// Start dragging if we've moved beyond threshold
|
||||
if (!isDragging && (deltaX > dragThreshold || deltaY > dragThreshold)) {
|
||||
isDragging = true;
|
||||
this.context.draggedNote = note;
|
||||
this.context.draggedBranch = branch;
|
||||
this.context.draggedNoteElement = $noteEl;
|
||||
$noteEl.addClass("dragging");
|
||||
|
||||
// Create drag preview
|
||||
$dragPreview = this.createDragPreview($noteEl, touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
if (isDragging && $dragPreview) {
|
||||
// Update drag preview position
|
||||
$dragPreview.css({
|
||||
left: touch.clientX - ($dragPreview.outerWidth() || 0) / 2,
|
||||
top: touch.clientY - ($dragPreview.outerHeight() || 0) / 2
|
||||
});
|
||||
|
||||
// Find element under touch point
|
||||
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
if (elementBelow) {
|
||||
const $columnEl = $(elementBelow).closest('.board-column');
|
||||
|
||||
if ($columnEl.length > 0) {
|
||||
// Remove drag-over from all columns
|
||||
this.$container.find('.board-column').removeClass('drag-over');
|
||||
$columnEl.addClass('drag-over');
|
||||
|
||||
// Show drop indicator
|
||||
this.showDropIndicatorAtPoint($columnEl, touch.clientY);
|
||||
} else {
|
||||
// Remove all drag indicators if not over a column
|
||||
this.$container.find('.board-column').removeClass('drag-over');
|
||||
this.cleanupAllDropIndicators();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$noteEl.on("touchend", async (e) => {
|
||||
if (isDragging) {
|
||||
const touch = (e.originalEvent as TouchEvent).changedTouches[0];
|
||||
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
if (elementBelow) {
|
||||
const $columnEl = $(elementBelow).closest('.board-column');
|
||||
|
||||
if ($columnEl.length > 0) {
|
||||
const column = $columnEl.attr('data-column');
|
||||
if (column && this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) {
|
||||
await this.handleNoteDrop($columnEl, column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
$noteEl.removeClass("dragging");
|
||||
this.context.draggedNote = null;
|
||||
this.context.draggedBranch = null;
|
||||
this.context.draggedNoteElement = null;
|
||||
this.$container.find('.board-column').removeClass('drag-over');
|
||||
this.cleanupAllDropIndicators();
|
||||
|
||||
// Remove drag preview
|
||||
if ($dragPreview) {
|
||||
$dragPreview.remove();
|
||||
$dragPreview = null;
|
||||
}
|
||||
}
|
||||
isDragging = false;
|
||||
});
|
||||
}
|
||||
|
||||
private createDragPreview($noteEl: JQuery<HTMLElement>, x: number, y: number): JQuery<HTMLElement> {
|
||||
// Clone the note element for the preview
|
||||
const $preview = $noteEl.clone();
|
||||
|
||||
$preview
|
||||
.addClass('board-drag-preview')
|
||||
.css({
|
||||
position: 'fixed',
|
||||
left: x - ($noteEl.outerWidth() || 0) / 2,
|
||||
top: y - ($noteEl.outerHeight() || 0) / 2,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10000
|
||||
})
|
||||
.appendTo('body');
|
||||
|
||||
return $preview;
|
||||
}
|
||||
|
||||
private showDropIndicator($columnEl: JQuery<HTMLElement>, e: JQuery.DragOverEvent) {
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
const mouseY = originalEvent.clientY;
|
||||
this.showDropIndicatorAtY($columnEl, mouseY);
|
||||
}
|
||||
|
||||
private showDropIndicatorAtPoint($columnEl: JQuery<HTMLElement>, touchY: number) {
|
||||
this.showDropIndicatorAtY($columnEl, touchY);
|
||||
}
|
||||
|
||||
private showDropIndicatorAtY($columnEl: JQuery<HTMLElement>, y: number) {
|
||||
const columnRect = $columnEl[0].getBoundingClientRect();
|
||||
const relativeY = y - columnRect.top;
|
||||
|
||||
// Clean up any existing drop indicators in this column first
|
||||
this.cleanupNoteDropIndicators($columnEl);
|
||||
|
||||
// Create a new drop indicator
|
||||
const $dropIndicator = $("<div>").addClass("board-drop-indicator");
|
||||
|
||||
// Find the best position to insert the note
|
||||
const $notes = this.context.draggedNoteElement ?
|
||||
$columnEl.find(".board-note").not(this.context.draggedNoteElement) :
|
||||
$columnEl.find(".board-note");
|
||||
let insertAfterElement: HTMLElement | null = null;
|
||||
|
||||
$notes.each((_, noteEl) => {
|
||||
const noteRect = noteEl.getBoundingClientRect();
|
||||
const noteMiddle = noteRect.top + noteRect.height / 2 - columnRect.top;
|
||||
|
||||
if (relativeY > noteMiddle) {
|
||||
insertAfterElement = noteEl;
|
||||
}
|
||||
});
|
||||
|
||||
// Position the drop indicator
|
||||
if (insertAfterElement) {
|
||||
$(insertAfterElement).after($dropIndicator);
|
||||
} else {
|
||||
// Insert at the beginning (after the header)
|
||||
const $header = $columnEl.find("h3");
|
||||
$header.after($dropIndicator);
|
||||
}
|
||||
|
||||
$dropIndicator.addClass("show");
|
||||
}
|
||||
|
||||
private async handleNoteDrop($columnEl: JQuery<HTMLElement>, column: string) {
|
||||
const draggedNoteElement = this.context.draggedNoteElement;
|
||||
const draggedNote = this.context.draggedNote;
|
||||
const draggedBranch = this.context.draggedBranch;
|
||||
|
||||
if (draggedNote && draggedNoteElement && draggedBranch) {
|
||||
const currentColumn = draggedNoteElement.attr("data-current-column");
|
||||
|
||||
// Capture drop indicator position BEFORE removing it
|
||||
const dropIndicator = $columnEl.find(".board-drop-indicator.show");
|
||||
let targetBranchId: string | null = null;
|
||||
let moveType: "before" | "after" | null = null;
|
||||
|
||||
if (dropIndicator.length > 0) {
|
||||
// Find the note element that the drop indicator is positioned relative to
|
||||
const nextNote = dropIndicator.next(".board-note");
|
||||
const prevNote = dropIndicator.prev(".board-note");
|
||||
|
||||
if (nextNote.length > 0) {
|
||||
targetBranchId = nextNote.attr("data-branch-id") || null;
|
||||
moveType = "before";
|
||||
} else if (prevNote.length > 0) {
|
||||
targetBranchId = prevNote.attr("data-branch-id") || null;
|
||||
moveType = "after";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle column change
|
||||
if (currentColumn !== column) {
|
||||
await this.api.changeColumn(draggedNote.noteId, column);
|
||||
}
|
||||
|
||||
// Handle position change (works for both same column and different column moves)
|
||||
if (targetBranchId && moveType) {
|
||||
if (moveType === "before") {
|
||||
await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId);
|
||||
} else if (moveType === "after") {
|
||||
await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the data attributes
|
||||
draggedNoteElement.attr("data-current-column", column);
|
||||
} catch (error) {
|
||||
console.error("Failed to update note position:", error);
|
||||
} finally {
|
||||
// Always clean up drop indicators after drop operation
|
||||
this.cleanupAllDropIndicators();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupAllDropIndicators() {
|
||||
// Remove all drop indicators from the DOM to prevent layout issues
|
||||
this.$container.find(".board-drop-indicator").remove();
|
||||
}
|
||||
|
||||
private cleanupNoteDropIndicators($columnEl: JQuery<HTMLElement>) {
|
||||
// Remove note drop indicators from a specific column
|
||||
$columnEl.find(".board-drop-indicator").remove();
|
||||
}
|
||||
}
|
||||
@ -401,9 +401,14 @@ export default class CalendarView extends ViewMode<{}> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Refresh on note title change.
|
||||
if (loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId))) {
|
||||
this.calendar?.refetchEvents();
|
||||
}
|
||||
|
||||
// Refresh dataset on subnote change.
|
||||
if (this.calendar && loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) {
|
||||
this.calendar.refetchEvents();
|
||||
if (loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) {
|
||||
this.calendar?.refetchEvents();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import ViewMode, { ViewModeArgs } from "../view_mode.js";
|
||||
import L from "leaflet";
|
||||
import type { GPX, LatLng, LeafletMouseEvent, Map, Marker } from "leaflet";
|
||||
import type { GPX, LatLng, Layer, LeafletMouseEvent, Map, Marker } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
@ -10,6 +10,8 @@ import toast from "../../../services/toast.js";
|
||||
import { CommandListenerData, EventData } from "../../../components/app_context.js";
|
||||
import { createNewNote, moveMarker, setupDragging } from "./editing.js";
|
||||
import { openMapContextMenu } from "./context_menu.js";
|
||||
import attributes from "../../../services/attributes.js";
|
||||
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="geo-view">
|
||||
@ -83,6 +85,11 @@ const TPL = /*html*/`
|
||||
white-space: no-wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.geo-map-container.dark .leaflet-div-icon .title-label {
|
||||
color: white;
|
||||
text-shadow: -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 1px 0 black;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="geo-map-container"></div>
|
||||
@ -138,10 +145,32 @@ export default class GeoView extends ViewMode<MapData> {
|
||||
const map = L.map(this.$container[0], {
|
||||
worldCopyJump: true
|
||||
});
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
detectRetina: true
|
||||
}).addTo(map);
|
||||
|
||||
const layerName = this.parentNote.getLabelValue("map:style") ?? DEFAULT_MAP_LAYER_NAME;
|
||||
let layer: Layer;
|
||||
const layerData = MAP_LAYERS[layerName];
|
||||
|
||||
if (layerData.type === "vector") {
|
||||
const style = (typeof layerData.style === "string" ? layerData.style : await layerData.style());
|
||||
await import("@maplibre/maplibre-gl-leaflet");
|
||||
|
||||
layer = L.maplibreGL({
|
||||
style: style as any
|
||||
});
|
||||
} else {
|
||||
layer = L.tileLayer(layerData.url, {
|
||||
attribution: layerData.attribution,
|
||||
detectRetina: true
|
||||
});
|
||||
}
|
||||
|
||||
if (this.parentNote.hasLabel("map:scale")) {
|
||||
L.control.scale().addTo(map);
|
||||
}
|
||||
|
||||
this.$container.toggleClass("dark", !!layerData.isDarkTheme);
|
||||
|
||||
layer.addTo(map);
|
||||
|
||||
this.map = map;
|
||||
|
||||
@ -226,7 +255,7 @@ export default class GeoView extends ViewMode<MapData> {
|
||||
|
||||
// Add the new markers.
|
||||
this.currentMarkerData = {};
|
||||
const notes = await this.parentNote.getChildNotes();
|
||||
const notes = await this.parentNote.getSubtreeNotes();
|
||||
const draggable = !this.isReadOnly;
|
||||
for (const childNote of notes) {
|
||||
if (childNote.mime === "application/gpx+xml") {
|
||||
@ -261,9 +290,14 @@ export default class GeoView extends ViewMode<MapData> {
|
||||
// If any of note has its location attribute changed.
|
||||
// TODO: Should probably filter by parent here as well.
|
||||
const attributeRows = loadResults.getAttributeRows();
|
||||
if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color"].includes(at.name ?? ""))) {
|
||||
if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color", "iconClass"].includes(at.name ?? ""))) {
|
||||
this.#reloadMarkers();
|
||||
}
|
||||
|
||||
// Full reload if map layer is changed.
|
||||
if (loadResults.getAttributeRows().some(attr => (attr.name?.startsWith("map:") && attributes.isAffecting(attr, this.parentNote)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async geoMapCreateChildNoteEvent({ ntxId }: EventData<"geoMapCreateChildNote">) {
|
||||
@ -330,3 +364,4 @@ export default class GeoView extends ViewMode<MapData> {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
53
apps/client/src/widgets/view_widgets/geo_view/map_layer.ts
Normal file
53
apps/client/src/widgets/view_widgets/geo_view/map_layer.ts
Normal file
@ -0,0 +1,53 @@
|
||||
export interface MapLayer {
|
||||
name: string;
|
||||
isDarkTheme?: boolean;
|
||||
}
|
||||
|
||||
interface VectorLayer extends MapLayer {
|
||||
type: "vector";
|
||||
style: string | (() => Promise<{}>)
|
||||
}
|
||||
|
||||
interface RasterLayer extends MapLayer {
|
||||
type: "raster";
|
||||
url: string;
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
export const MAP_LAYERS: Record<string, VectorLayer | RasterLayer> = {
|
||||
"openstreetmap": {
|
||||
name: "OpenStreetMap",
|
||||
type: "raster",
|
||||
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
},
|
||||
"versatiles-colorful": {
|
||||
name: "VersaTiles Colorful",
|
||||
type: "vector",
|
||||
style: async () => (await import("./styles/colorful/en.json")).default
|
||||
},
|
||||
"versatiles-eclipse": {
|
||||
name: "VersaTiles Eclipse",
|
||||
type: "vector",
|
||||
style: async () => (await import("./styles/eclipse/en.json")).default,
|
||||
isDarkTheme: true
|
||||
},
|
||||
"versatiles-graybeard": {
|
||||
name: "VersaTiles Graybeard",
|
||||
type: "vector",
|
||||
style: async () => (await import("./styles/graybeard/en.json")).default
|
||||
},
|
||||
"versatiles-neutrino": {
|
||||
name: "VersaTiles Neutrino",
|
||||
type: "vector",
|
||||
style: async () => (await import("./styles/neutrino/en.json")).default
|
||||
},
|
||||
"versatiles-shadow": {
|
||||
name: "VersaTiles Shadow",
|
||||
type: "vector",
|
||||
style: async () => (await import("./styles/shadow/en.json")).default,
|
||||
isDarkTheme: true
|
||||
}
|
||||
};
|
||||
|
||||
export const DEFAULT_MAP_LAYER_NAME: keyof typeof MAP_LAYERS = "versatiles-colorful";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
4811
apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/de.json
Normal file
4811
apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/de.json
Normal file
File diff suppressed because it is too large
Load Diff
4811
apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/en.json
Normal file
4811
apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/en.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
4811
apps/client/src/widgets/view_widgets/geo_view/styles/shadow/de.json
Normal file
4811
apps/client/src/widgets/view_widgets/geo_view/styles/shadow/de.json
Normal file
File diff suppressed because it is too large
Load Diff
4811
apps/client/src/widgets/view_widgets/geo_view/styles/shadow/en.json
Normal file
4811
apps/client/src/widgets/view_widgets/geo_view/styles/shadow/en.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,32 +1,31 @@
|
||||
import { t } from "i18next";
|
||||
import attributes from "../../../services/attributes";
|
||||
import froca from "../../../services/froca";
|
||||
import server from "../../../services/server";
|
||||
import toast from "../../../services/toast";
|
||||
import ws from "../../../services/ws";
|
||||
import { executeBulkActions } from "../../../services/bulk_action.js";
|
||||
|
||||
export async function renameColumn(parentNoteId: string, type: "label" | "relation", originalName: string, newName: string) {
|
||||
const bulkActionNote = await froca.getNote("_bulkAction");
|
||||
if (!bulkActionNote) {
|
||||
console.warn("Bulk action note not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "label") {
|
||||
attributes.setLabel("_bulkAction", "action", JSON.stringify({
|
||||
return executeBulkActions([parentNoteId], [{
|
||||
name: "renameLabel",
|
||||
oldLabelName: originalName,
|
||||
newLabelName: newName
|
||||
}));
|
||||
await server.post("bulk-action/execute", {
|
||||
noteIds: [ parentNoteId ],
|
||||
includeDescendants: true
|
||||
});
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
toast.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
|
||||
}], true);
|
||||
} else {
|
||||
console.warn("Renaming relation columns is not supported yet");
|
||||
return;
|
||||
return executeBulkActions([parentNoteId], [{
|
||||
name: "renameRelation",
|
||||
oldRelationName: originalName,
|
||||
newRelationName: newName
|
||||
}], true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteColumn(parentNoteId: string, type: "label" | "relation", columnName: string) {
|
||||
if (type === "label") {
|
||||
return executeBulkActions([parentNoteId], [{
|
||||
name: "deleteLabel",
|
||||
labelName: columnName
|
||||
}], true);
|
||||
} else {
|
||||
return executeBulkActions([parentNoteId], [{
|
||||
name: "deleteRelation",
|
||||
relationName: columnName
|
||||
}], true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,9 @@ import Component from "../../../components/component";
|
||||
import { CommandListenerData, EventData } from "../../../components/app_context";
|
||||
import attributes from "../../../services/attributes";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { renameColumn } from "./bulk_actions";
|
||||
import { deleteColumn, renameColumn } from "./bulk_actions";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { t } from "../../../services/i18n";
|
||||
|
||||
export default class TableColumnEditing extends Component {
|
||||
|
||||
@ -28,7 +30,7 @@ export default class TableColumnEditing extends Component {
|
||||
this.parentNote = parentNote;
|
||||
}
|
||||
|
||||
addNewTableColumnCommand({ referenceColumn, columnToEdit, direction }: EventData<"addNewTableColumn">) {
|
||||
addNewTableColumnCommand({ referenceColumn, columnToEdit, direction, type }: EventData<"addNewTableColumn">) {
|
||||
let attr: Attribute | undefined;
|
||||
|
||||
this.existingAttributeToEdit = undefined;
|
||||
@ -42,8 +44,9 @@ export default class TableColumnEditing extends Component {
|
||||
if (!attr) {
|
||||
attr = {
|
||||
type: "label",
|
||||
name: "label:myLabel",
|
||||
value: "promoted,single,text"
|
||||
name: `${type ?? "label"}:myLabel`,
|
||||
value: "promoted,single,text",
|
||||
isInheritable: true
|
||||
};
|
||||
}
|
||||
|
||||
@ -63,7 +66,8 @@ export default class TableColumnEditing extends Component {
|
||||
isOwned: true,
|
||||
x: 0,
|
||||
y: 150,
|
||||
focus: "name"
|
||||
focus: "name",
|
||||
hideMultiplicity: true
|
||||
});
|
||||
}
|
||||
|
||||
@ -76,21 +80,44 @@ export default class TableColumnEditing extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, type, value } = this.newAttribute;
|
||||
const { name, value, isInheritable } = this.newAttribute;
|
||||
|
||||
this.api.blockRedraw();
|
||||
const isRename = (this.existingAttributeToEdit && this.existingAttributeToEdit.name !== name);
|
||||
try {
|
||||
if (isRename) {
|
||||
const oldName = this.existingAttributeToEdit!.name.split(":")[1];
|
||||
const [ type, newName ] = name.split(":");
|
||||
await renameColumn(this.parentNote.noteId, type as "label" | "relation", oldName, newName);
|
||||
}
|
||||
|
||||
if (this.existingAttributeToEdit && this.existingAttributeToEdit.name !== name) {
|
||||
const oldName = this.existingAttributeToEdit.name.split(":")[1];
|
||||
const newName = name.split(":")[1];
|
||||
await renameColumn(this.parentNote.noteId, type, oldName, newName);
|
||||
if (this.existingAttributeToEdit && (isRename || this.existingAttributeToEdit.isInheritable !== isInheritable)) {
|
||||
attributes.removeOwnedLabelByName(this.parentNote, this.existingAttributeToEdit.name);
|
||||
}
|
||||
attributes.setLabel(this.parentNote.noteId, name, value, isInheritable);
|
||||
} finally {
|
||||
this.api.restoreRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTableColumnCommand({ columnToDelete }: CommandListenerData<"deleteTableColumn">) {
|
||||
if (!columnToDelete || !await dialog.confirm(t("table_view.delete_column_confirmation"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
attributes.setLabel(this.parentNote.noteId, name, value);
|
||||
if (this.existingAttributeToEdit) {
|
||||
attributes.removeOwnedLabelByName(this.parentNote, this.existingAttributeToEdit.name);
|
||||
let [ type, name ] = columnToDelete.getField()?.split(".", 2);
|
||||
if (!type || !name) {
|
||||
return;
|
||||
}
|
||||
type = type.replace("s", "");
|
||||
|
||||
this.api.blockRedraw();
|
||||
try {
|
||||
await deleteColumn(this.parentNote.noteId, type as "label" | "relation", name);
|
||||
attributes.removeOwnedLabelByName(this.parentNote, `${type}:${name}`);
|
||||
} finally {
|
||||
this.api.restoreRedraw();
|
||||
}
|
||||
this.api.restoreRedraw();
|
||||
}
|
||||
|
||||
getNewAttributePosition() {
|
||||
@ -109,17 +136,17 @@ export default class TableColumnEditing extends Component {
|
||||
return this.parentNote.getLabel(attrName);
|
||||
}
|
||||
|
||||
getAttributeFromField(field: string) {
|
||||
getAttributeFromField(field: string): Attribute | undefined {
|
||||
const fAttribute = this.getFAttributeFromField(field);
|
||||
if (fAttribute) {
|
||||
return {
|
||||
name: fAttribute.name,
|
||||
value: fAttribute.value,
|
||||
type: fAttribute.type
|
||||
type: fAttribute.type,
|
||||
isInheritable: fAttribute.isInheritable
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -108,4 +108,26 @@ describe("restoreExistingData", () => {
|
||||
const restored = restoreExistingData(newDefs, oldDefs);
|
||||
expect(restored).toStrictEqual(newDefs);
|
||||
});
|
||||
|
||||
it("allows hiding the row number column", () => {
|
||||
const newDefs: ColumnDefinition[] = [
|
||||
{ title: "#", headerSort: false, hozAlign: "center", resizable: false, frozen: true, rowHandle: false },
|
||||
]
|
||||
const oldDefs: ColumnDefinition[] = [
|
||||
{ title: "#", headerSort: false, hozAlign: "center", resizable: false, rowHandle: false, visible: false },
|
||||
];
|
||||
const restored = restoreExistingData(newDefs, oldDefs);
|
||||
expect(restored[0].visible).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it("enforces size for non-resizable columns", () => {
|
||||
const newDefs: ColumnDefinition[] = [
|
||||
{ title: "#", resizable: false, width: "100px" },
|
||||
]
|
||||
const oldDefs: ColumnDefinition[] = [
|
||||
{ title: "#", resizable: false, width: "120px" },
|
||||
];
|
||||
const restored = restoreExistingData(newDefs, oldDefs);
|
||||
expect(restored[0].width).toStrictEqual("100px");
|
||||
});
|
||||
});
|
||||
|
||||
@ -35,13 +35,30 @@ const labelTypeMappings: Record<ColumnType, Partial<ColumnDefinition>> = {
|
||||
formatter: "link",
|
||||
editor: "input"
|
||||
},
|
||||
color: {
|
||||
editor: "input",
|
||||
formatter: "color",
|
||||
editorParams: {
|
||||
elementAttributes: {
|
||||
type: "color"
|
||||
}
|
||||
}
|
||||
},
|
||||
relation: {
|
||||
editor: RelationEditor,
|
||||
formatter: NoteFormatter
|
||||
}
|
||||
};
|
||||
|
||||
export function buildColumnDefinitions(info: AttributeDefinitionInformation[], movableRows: boolean, existingColumnData?: ColumnDefinition[], position?: number) {
|
||||
interface BuildColumnArgs {
|
||||
info: AttributeDefinitionInformation[];
|
||||
movableRows: boolean;
|
||||
existingColumnData: ColumnDefinition[] | undefined;
|
||||
rowNumberHint: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export function buildColumnDefinitions({ info, movableRows, existingColumnData, rowNumberHint, position }: BuildColumnArgs) {
|
||||
let columnDefs: ColumnDefinition[] = [
|
||||
{
|
||||
title: "#",
|
||||
@ -50,6 +67,7 @@ export function buildColumnDefinitions(info: AttributeDefinitionInformation[], m
|
||||
resizable: false,
|
||||
frozen: true,
|
||||
rowHandle: movableRows,
|
||||
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
|
||||
formatter: RowNumberFormatter(movableRows)
|
||||
},
|
||||
{
|
||||
@ -102,10 +120,10 @@ export function restoreExistingData(newDefs: ColumnDefinition[], oldDefs: Column
|
||||
.filter(item => (item.field && newItemsByField.has(item.field!)) || item.title === "#")
|
||||
.map(oldItem => {
|
||||
const data = newItemsByField.get(oldItem.field!)!;
|
||||
if (oldItem.width) {
|
||||
if (oldItem.resizable !== false && oldItem.width !== undefined) {
|
||||
data.width = oldItem.width;
|
||||
}
|
||||
if (oldItem.visible) {
|
||||
if (oldItem.visible !== undefined) {
|
||||
data.visible = oldItem.visible;
|
||||
}
|
||||
return data;
|
||||
@ -128,3 +146,11 @@ export function restoreExistingData(newDefs: ColumnDefinition[], oldDefs: Column
|
||||
...existingColumns.slice(insertPos)
|
||||
];
|
||||
}
|
||||
|
||||
function calculateIndexColumnWidth(rowNumberHint: number, movableRows: boolean): number {
|
||||
let columnWidth = 16 * (rowNumberHint.toString().length || 1);
|
||||
if (movableRows) {
|
||||
columnWidth += 32;
|
||||
}
|
||||
return columnWidth;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ColumnComponent, MenuSeparator, RowComponent, Tabulator } from "tabulator-tables";
|
||||
import { ColumnComponent, RowComponent, Tabulator } from "tabulator-tables";
|
||||
import contextMenu, { MenuItem } from "../../../menus/context_menu.js";
|
||||
import { TableData } from "./rows.js";
|
||||
import branches from "../../../services/branches.js";
|
||||
@ -10,7 +10,11 @@ import type Component from "../../../components/component.js";
|
||||
|
||||
export function setupContextMenu(tabulator: Tabulator, parentNote: FNote) {
|
||||
tabulator.on("rowContext", (e, row) => showRowContextMenu(e, row, parentNote, tabulator));
|
||||
tabulator.on("headerContext", (e, col) => showColumnContextMenu(e, col, tabulator));
|
||||
tabulator.on("headerContext", (e, col) => showColumnContextMenu(e, col, parentNote, tabulator));
|
||||
tabulator.on("renderComplete", () => {
|
||||
const headerRow = tabulator.element.querySelector(".tabulator-header-contents");
|
||||
headerRow?.addEventListener("contextmenu", (e) => showHeaderContextMenu(e, tabulator));
|
||||
});
|
||||
|
||||
// Pressing the expand button prevents bubbling and the context menu remains menu when it shouldn't.
|
||||
if (tabulator.options.dataTree) {
|
||||
@ -20,12 +24,13 @@ export function setupContextMenu(tabulator: Tabulator, parentNote: FNote) {
|
||||
}
|
||||
}
|
||||
|
||||
function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator: Tabulator) {
|
||||
function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote: FNote, tabulator: Tabulator) {
|
||||
const e = _e as MouseEvent;
|
||||
const { title, field } = column.getDefinition();
|
||||
|
||||
const sorters = tabulator.getSorters();
|
||||
const sorter = sorters.find(sorter => sorter.field === field);
|
||||
const isUserDefinedColumn = (!!field && (field?.startsWith("labels.") || field?.startsWith("relations.")));
|
||||
|
||||
contextMenu.show({
|
||||
items: [
|
||||
@ -61,7 +66,7 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator:
|
||||
{
|
||||
title: t("table_view.sort-column-clear"),
|
||||
enabled: sorters.length > 0,
|
||||
uiIcon: "bx bx-empty",
|
||||
uiIcon: "bx bx-x-circle",
|
||||
handler: () => tabulator.clearSort()
|
||||
},
|
||||
{
|
||||
@ -69,39 +74,49 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator:
|
||||
},
|
||||
{
|
||||
title: t("table_view.hide-column", { title }),
|
||||
enabled: !!field,
|
||||
uiIcon: "bx bx-hide",
|
||||
handler: () => column.hide()
|
||||
},
|
||||
{
|
||||
title: t("table_view.show-hide-columns"),
|
||||
uiIcon: "bx bx-empty",
|
||||
items: buildColumnItems()
|
||||
uiIcon: "bx bx-columns",
|
||||
items: buildColumnItems(tabulator)
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("table_view.add-column-to-the-left"),
|
||||
uiIcon: "bx bx-horizontal-left",
|
||||
enabled: !column.getDefinition().frozen,
|
||||
items: buildInsertSubmenu(e, column, "before"),
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column
|
||||
})
|
||||
},
|
||||
{
|
||||
title: t("table_view.add-column-to-the-right"),
|
||||
uiIcon: "bx bx-horizontal-right",
|
||||
items: buildInsertSubmenu(e, column, "after"),
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column,
|
||||
direction: "after"
|
||||
})
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("table_view.edit-column"),
|
||||
uiIcon: "bx bx-edit",
|
||||
enabled: !!column.getField() && column.getField() !== "title",
|
||||
uiIcon: "bx bxs-edit-alt",
|
||||
enabled: isUserDefinedColumn,
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column,
|
||||
columnToEdit: column
|
||||
})
|
||||
},
|
||||
{
|
||||
title: t("table_view.add-column-to-the-right"),
|
||||
uiIcon: "bx bx-horizontal-right",
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column,
|
||||
direction: "after"
|
||||
title: t("table_view.delete-column"),
|
||||
uiIcon: "bx bx-trash",
|
||||
enabled: isUserDefinedColumn,
|
||||
handler: () => getParentComponent(e)?.triggerCommand("deleteTableColumn", {
|
||||
columnToDelete: column
|
||||
})
|
||||
}
|
||||
],
|
||||
@ -110,23 +125,34 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator:
|
||||
y: e.pageY
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function buildColumnItems() {
|
||||
const items: MenuItem<unknown>[] = [];
|
||||
for (const column of tabulator.getColumns()) {
|
||||
const { title, field } = column.getDefinition();
|
||||
|
||||
items.push({
|
||||
title,
|
||||
checked: column.isVisible(),
|
||||
/**
|
||||
* Shows a context menu which has options dedicated to the header area (the part where the columns are, but in the empty space).
|
||||
* Provides generic options such as toggling columns.
|
||||
*/
|
||||
function showHeaderContextMenu(_e: Event, tabulator: Tabulator) {
|
||||
const e = _e as MouseEvent;
|
||||
contextMenu.show({
|
||||
items: [
|
||||
{
|
||||
title: t("table_view.show-hide-columns"),
|
||||
uiIcon: "bx bx-columns",
|
||||
items: buildColumnItems(tabulator)
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("table_view.new-column"),
|
||||
uiIcon: "bx bx-empty",
|
||||
enabled: !!field,
|
||||
handler: () => column.toggle()
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
enabled: false
|
||||
},
|
||||
...buildInsertSubmenu(e)
|
||||
],
|
||||
selectMenuItemHandler() {},
|
||||
x: e.pageX,
|
||||
y: e.pageY
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: FNote, tabulator: Tabulator) {
|
||||
@ -148,7 +174,7 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("table_view.row-insert-above"),
|
||||
uiIcon: "bx bx-list-plus",
|
||||
uiIcon: "bx bx-horizontal-left bx-rotate-90",
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
|
||||
parentNotePath: parentNoteId,
|
||||
customOpts: {
|
||||
@ -159,7 +185,7 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
},
|
||||
{
|
||||
title: t("table_view.row-insert-child"),
|
||||
uiIcon: "bx bx-empty",
|
||||
uiIcon: "bx bx-subdirectory-right",
|
||||
handler: async () => {
|
||||
const branchId = row.getData().branchId;
|
||||
const note = await froca.getBranch(branchId)?.getNote();
|
||||
@ -174,7 +200,7 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
},
|
||||
{
|
||||
title: t("table_view.row-insert-below"),
|
||||
uiIcon: "bx bx-empty",
|
||||
uiIcon: "bx bx-horizontal-left bx-rotate-270",
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
|
||||
parentNotePath: parentNoteId,
|
||||
customOpts: {
|
||||
@ -206,3 +232,46 @@ function getParentComponent(e: MouseEvent) {
|
||||
.closest(".component")
|
||||
.prop("component") as Component;
|
||||
}
|
||||
|
||||
function buildColumnItems(tabulator: Tabulator) {
|
||||
const items: MenuItem<unknown>[] = [];
|
||||
for (const column of tabulator.getColumns()) {
|
||||
const { title } = column.getDefinition();
|
||||
|
||||
items.push({
|
||||
title,
|
||||
checked: column.isVisible(),
|
||||
uiIcon: "bx bx-empty",
|
||||
handler: () => column.toggle()
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildInsertSubmenu(e: MouseEvent, referenceColumn?: ColumnComponent, direction?: "before" | "after"): MenuItem<unknown>[] {
|
||||
return [
|
||||
{
|
||||
title: t("table_view.new-column-label"),
|
||||
uiIcon: "bx bx-hash",
|
||||
handler: () => {
|
||||
getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn,
|
||||
type: "label",
|
||||
direction
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("table_view.new-column-relation"),
|
||||
uiIcon: "bx bx-transfer",
|
||||
handler: () => {
|
||||
getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn,
|
||||
type: "relation",
|
||||
direction
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -11,12 +11,12 @@ export default function buildFooter(parentNote: FNote) {
|
||||
}
|
||||
|
||||
return /*html*/`\
|
||||
<button class="btn btn-sm" style="padding: 0px 10px 0px 10px;" data-trigger-command="addNewRow">
|
||||
<button class="btn btn-sm" data-trigger-command="addNewRow">
|
||||
<span class="bx bx-plus"></span> ${t("table_view.new-row")}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm" style="padding: 0px 10px 0px 10px;" data-trigger-command="addNewTableColumn">
|
||||
<span class="bx bx-columns"></span> ${t("table_view.new-column")}
|
||||
<button class="btn btn-sm" data-trigger-command="addNewTableColumn">
|
||||
<span class="bx bx-carousel"></span> ${t("table_view.new-column")}
|
||||
</button>
|
||||
`.trimStart();
|
||||
}
|
||||
|
||||
@ -1,38 +1,62 @@
|
||||
import { CellComponent } from "tabulator-tables";
|
||||
import { loadReferenceLinkTitle } from "../../../services/link.js";
|
||||
import froca from "../../../services/froca.js";
|
||||
import FNote from "../../../entities/fnote.js";
|
||||
|
||||
/**
|
||||
* Custom formatter to represent a note, with the icon and note title being rendered.
|
||||
*
|
||||
* The value of the cell must be the note ID.
|
||||
*/
|
||||
export function NoteFormatter(cell: CellComponent, _formatterParams, onRendered) {
|
||||
export function NoteFormatter(cell: CellComponent, _formatterParams, onRendered): string {
|
||||
let noteId = cell.getValue();
|
||||
if (!noteId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
onRendered(async () => {
|
||||
const { $noteRef, href } = buildNoteLink(noteId);
|
||||
await loadReferenceLinkTitle($noteRef, href);
|
||||
cell.getElement().appendChild($noteRef[0]);
|
||||
});
|
||||
return "";
|
||||
function buildLink(note: FNote | undefined) {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconClass = note.getIcon();
|
||||
const title = note.title;
|
||||
const { $noteRef } = buildNoteLink(noteId, title, iconClass, note.getColorClass());
|
||||
return $noteRef[0];
|
||||
}
|
||||
|
||||
const cachedNote = froca.getNoteFromCache(noteId);
|
||||
if (cachedNote) {
|
||||
// Cache hit, build the link immediately
|
||||
const el = buildLink(cachedNote);
|
||||
return el?.outerHTML ?? "";
|
||||
} else {
|
||||
// Cache miss, load the note asynchronously
|
||||
onRendered(async () => {
|
||||
const note = await froca.getNote(noteId);
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = buildLink(note);
|
||||
if (el) {
|
||||
cell.getElement().appendChild(el);
|
||||
}
|
||||
});
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom formatter for the note title that is quite similar to {@link NoteFormatter}, but where the title and icons are read from separate fields.
|
||||
*/
|
||||
export function NoteTitleFormatter(cell: CellComponent) {
|
||||
const { noteId, iconClass } = cell.getRow().getData();
|
||||
const { noteId, iconClass, colorClass } = cell.getRow().getData();
|
||||
if (!noteId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const { $noteRef } = buildNoteLink(noteId);
|
||||
$noteRef.text(cell.getValue());
|
||||
$noteRef.prepend($("<span>").addClass(iconClass));
|
||||
|
||||
const { $noteRef } = buildNoteLink(noteId, cell.getValue(), iconClass, colorClass);
|
||||
return $noteRef[0].outerHTML;
|
||||
}
|
||||
|
||||
@ -51,10 +75,15 @@ export function MonospaceFormatter(cell: CellComponent) {
|
||||
return `<code>${cell.getValue()}</code>`;
|
||||
}
|
||||
|
||||
function buildNoteLink(noteId: string) {
|
||||
function buildNoteLink(noteId: string, title: string, iconClass: string, colorClass?: string) {
|
||||
const $noteRef = $("<span>");
|
||||
const href = `#root/${noteId}`;
|
||||
$noteRef.addClass("reference-link");
|
||||
$noteRef.attr("data-href", href);
|
||||
$noteRef.text(title);
|
||||
$noteRef.prepend($("<span>").addClass(iconClass));
|
||||
if (colorClass) {
|
||||
$noteRef.addClass(colorClass);
|
||||
}
|
||||
return { $noteRef, href };
|
||||
}
|
||||
|
||||
@ -104,6 +104,8 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
private persistentData: StateInfo["tableData"];
|
||||
private colEditing?: TableColumnEditing;
|
||||
private rowEditing?: TableRowEditing;
|
||||
private maxDepth: number = -1;
|
||||
private rowNumberHint: number = 1;
|
||||
|
||||
constructor(args: ViewModeArgs) {
|
||||
super(args, "table");
|
||||
@ -135,9 +137,16 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
const viewStorage = await this.viewStorage.restore();
|
||||
this.persistentData = viewStorage?.tableData || {};
|
||||
|
||||
const { definitions: rowData, hasSubtree: hasChildren } = await buildRowDefinitions(this.parentNote, info);
|
||||
this.maxDepth = parseInt(this.parentNote.getLabelValue("maxNestingDepth") ?? "-1", 10);
|
||||
const { definitions: rowData, hasSubtree: hasChildren, rowNumber } = await buildRowDefinitions(this.parentNote, info, this.maxDepth);
|
||||
this.rowNumberHint = rowNumber;
|
||||
const movableRows = canReorderRows(this.parentNote) && !hasChildren;
|
||||
const columnDefs = buildColumnDefinitions(info, movableRows);
|
||||
const columnDefs = buildColumnDefinitions({
|
||||
info,
|
||||
movableRows,
|
||||
existingColumnData: this.persistentData.columns,
|
||||
rowNumberHint: this.rowNumberHint
|
||||
});
|
||||
let opts: Options = {
|
||||
layout: "fitDataFill",
|
||||
index: "branchId",
|
||||
@ -159,7 +168,9 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
...opts,
|
||||
dataTree: hasChildren,
|
||||
dataTreeStartExpanded: true,
|
||||
dataTreeBranchElement: false,
|
||||
dataTreeElementColumn: "title",
|
||||
dataTreeChildIndent: 20,
|
||||
dataTreeExpandElement: `<button class="tree-expand"><span class="bx bx-chevron-right"></span></button>`,
|
||||
dataTreeCollapseElement: `<button class="tree-collapse"><span class="bx bx-chevron-down"></span></button>`
|
||||
}
|
||||
@ -201,9 +212,15 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
return await this.#manageRowsUpdate();
|
||||
}
|
||||
|
||||
// Refresh max depth
|
||||
if (loadResults.getAttributeRows().find(attr => attr.type === "label" && attr.name === "maxNestingDepth" && attributes.isAffecting(attr, this.parentNote))) {
|
||||
this.maxDepth = parseInt(this.parentNote.getLabelValue("maxNestingDepth") ?? "-1", 10);
|
||||
return await this.#manageRowsUpdate();
|
||||
}
|
||||
|
||||
if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? ""))
|
||||
|| loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)
|
||||
|| loadResults.getAttributeRows().some(attr => this.noteIds.includes(attr.noteId!)))) {
|
||||
|| loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId))
|
||||
|| loadResults.getAttributeRows().some(attr => this.noteIds.includes(attr.noteId!))) {
|
||||
return await this.#manageRowsUpdate();
|
||||
}
|
||||
|
||||
@ -216,27 +233,22 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
}
|
||||
|
||||
const info = getAttributeDefinitionInformation(this.parentNote);
|
||||
const columnDefs = buildColumnDefinitions(info, !!this.api.options.movableRows, this.persistentData?.columns, this.colEditing?.getNewAttributePosition());
|
||||
const columnDefs = buildColumnDefinitions({
|
||||
info,
|
||||
movableRows: !!this.api.options.movableRows,
|
||||
existingColumnData: this.persistentData?.columns,
|
||||
rowNumberHint: this.rowNumberHint,
|
||||
position: this.colEditing?.getNewAttributePosition()
|
||||
});
|
||||
this.api.setColumns(columnDefs);
|
||||
this.colEditing?.resetNewAttributePosition();
|
||||
}
|
||||
|
||||
addNewRowCommand(e) {
|
||||
this.rowEditing?.addNewRowCommand(e);
|
||||
}
|
||||
|
||||
addNewTableColumnCommand(e) {
|
||||
this.colEditing?.addNewTableColumnCommand(e);
|
||||
}
|
||||
|
||||
updateAttributeListCommand(e) {
|
||||
this.colEditing?.updateAttributeListCommand(e);
|
||||
}
|
||||
|
||||
saveAttributesCommand() {
|
||||
this.colEditing?.saveAttributesCommand();
|
||||
}
|
||||
|
||||
addNewRowCommand(e) { this.rowEditing?.addNewRowCommand(e); }
|
||||
addNewTableColumnCommand(e) { this.colEditing?.addNewTableColumnCommand(e); }
|
||||
deleteTableColumnCommand(e) { this.colEditing?.deleteTableColumnCommand(e); }
|
||||
updateAttributeListCommand(e) { this.colEditing?.updateAttributeListCommand(e); }
|
||||
saveAttributesCommand() { this.colEditing?.saveAttributesCommand(); }
|
||||
|
||||
async #manageRowsUpdate() {
|
||||
if (!this.api) {
|
||||
@ -244,7 +256,8 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
}
|
||||
|
||||
const info = getAttributeDefinitionInformation(this.parentNote);
|
||||
const { definitions, hasSubtree } = await buildRowDefinitions(this.parentNote, info);
|
||||
const { definitions, hasSubtree, rowNumber } = await buildRowDefinitions(this.parentNote, info, this.maxDepth);
|
||||
this.rowNumberHint = rowNumber;
|
||||
|
||||
// Force a refresh if the data tree needs enabling/disabling.
|
||||
if (this.api.options.dataTree !== hasSubtree) {
|
||||
|
||||
@ -21,24 +21,29 @@ export function RelationEditor(cell: CellComponent, onRendered, success, cancel,
|
||||
editor.style.boxSizing = "border-box";
|
||||
|
||||
//Set value of editor to the current value of the cell
|
||||
const noteId = cell.getValue();
|
||||
if (noteId) {
|
||||
const note = froca.getNoteFromCache(noteId);
|
||||
const originalNoteId = cell.getValue();
|
||||
if (originalNoteId) {
|
||||
const note = froca.getNoteFromCache(originalNoteId);
|
||||
editor.value = note.title;
|
||||
} else {
|
||||
editor.value = "";
|
||||
}
|
||||
|
||||
//set focus on the select box when the editor is selected
|
||||
onRendered(function(){
|
||||
let newNoteId = originalNoteId;
|
||||
|
||||
note_autocomplete.initNoteAutocomplete($editor, {
|
||||
allowCreatingNotes: true
|
||||
allowCreatingNotes: true,
|
||||
hideAllButtons: true
|
||||
}).on("autocomplete:noteselected", (event, suggestion, dataset) => {
|
||||
const notePath = suggestion.notePath;
|
||||
if (!notePath) {
|
||||
return;
|
||||
newNoteId = (notePath ?? "").split("/").at(-1);
|
||||
}).on("blur", () => {
|
||||
if (!editor.value) {
|
||||
newNoteId = "";
|
||||
}
|
||||
|
||||
const noteId = notePath.split("/").at(-1);
|
||||
success(noteId);
|
||||
success(newNoteId);
|
||||
});
|
||||
editor.focus();
|
||||
});
|
||||
|
||||
@ -9,13 +9,17 @@ export type TableData = {
|
||||
labels: Record<string, boolean | string | null>;
|
||||
relations: Record<string, boolean | string | null>;
|
||||
branchId: string;
|
||||
colorClass: string | undefined;
|
||||
_children?: TableData[];
|
||||
};
|
||||
|
||||
export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[]) {
|
||||
export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[], maxDepth = -1, currentDepth = 0) {
|
||||
const definitions: TableData[] = [];
|
||||
const childBranches = parentNote.getChildBranches();
|
||||
let hasSubtree = false;
|
||||
for (const branch of parentNote.getChildBranches()) {
|
||||
let rowNumber = childBranches.length;
|
||||
|
||||
for (const branch of childBranches) {
|
||||
const note = await branch.getNote();
|
||||
if (!note) {
|
||||
continue; // Skip if the note is not found
|
||||
@ -38,11 +42,14 @@ export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDef
|
||||
labels,
|
||||
relations,
|
||||
branchId: branch.branchId,
|
||||
colorClass: note.getColorClass()
|
||||
}
|
||||
|
||||
if (note.hasChildren()) {
|
||||
def._children = (await buildRowDefinitions(note, infos)).definitions;
|
||||
if (note.hasChildren() && (maxDepth < 0 || currentDepth < maxDepth)) {
|
||||
const { definitions, rowNumber: subRowNumber } = (await buildRowDefinitions(note, infos, maxDepth, currentDepth + 1));
|
||||
def._children = definitions;
|
||||
hasSubtree = true;
|
||||
rowNumber += subRowNumber;
|
||||
}
|
||||
|
||||
definitions.push(def);
|
||||
@ -50,7 +57,8 @@ export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDef
|
||||
|
||||
return {
|
||||
definitions,
|
||||
hasSubtree
|
||||
hasSubtree,
|
||||
rowNumber
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
18
apps/client/src/widgets/widget_utils.ts
Normal file
18
apps/client/src/widgets/widget_utils.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
/**
|
||||
* Enables scrolling of a container horizontally using the mouse wheel, instead of having to use the scrollbar or keep Shift pressed.
|
||||
*
|
||||
* @param $container the jQuery-wrapped container element to enable horizontal scrolling for.
|
||||
*/
|
||||
export function setupHorizontalScrollViaWheel($container: JQuery<HTMLElement>) {
|
||||
$container.on("wheel", (event) => {
|
||||
const wheelEvent = event.originalEvent as WheelEvent;
|
||||
if (utils.isCtrlKey(event) || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
event.currentTarget.scrollLeft += wheelEvent.deltaY + wheelEvent.deltaX;
|
||||
});
|
||||
}
|
||||
@ -32,7 +32,8 @@
|
||||
"eslint.config.mjs"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
"src/**/*.ts",
|
||||
"src/**/*.json"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@ -19,6 +19,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "17.2.0",
|
||||
"electron": "37.2.2"
|
||||
"electron": "37.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/desktop",
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.1",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"private": true,
|
||||
"main": "main.cjs",
|
||||
@ -17,7 +17,7 @@
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"electron": "37.2.2",
|
||||
"electron": "37.2.4",
|
||||
"@electron-forge/cli": "7.8.1",
|
||||
"@electron-forge/maker-deb": "7.8.1",
|
||||
"@electron-forge/maker-dmg": "7.8.1",
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"electron": "37.2.2",
|
||||
"electron": "37.2.4",
|
||||
"fs-extra": "11.3.0"
|
||||
},
|
||||
"nx": {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM node:22.17.0-bullseye-slim AS builder
|
||||
FROM node:22.17.1-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.17.0-bullseye-slim
|
||||
FROM node:22.17.1-bullseye-slim
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM node:22.17.0-alpine AS builder
|
||||
FROM node:22.17.1-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.17.0-alpine
|
||||
FROM node:22.17.1-alpine
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache su-exec shadow
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM node:22.17.0-alpine AS builder
|
||||
FROM node:22.17.1-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.17.0-alpine
|
||||
FROM node:22.17.1-alpine
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM node:22.17.0-bullseye-slim AS builder
|
||||
FROM node:22.17.1-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.17.0-bullseye-slim
|
||||
FROM node:22.17.1-bullseye-slim
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.1",
|
||||
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
@ -40,34 +40,34 @@
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"express-http-proxy": "2.1.1",
|
||||
"@anthropic-ai/sdk": "0.56.0",
|
||||
"@anthropic-ai/sdk": "0.57.0",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.10.0",
|
||||
"axios": "1.11.0",
|
||||
"bindings": "1.5.0",
|
||||
"chardet": "2.1.0",
|
||||
"cheerio": "1.1.0",
|
||||
"cheerio": "1.1.2",
|
||||
"chokidar": "4.0.3",
|
||||
"cls-hooked": "4.2.2",
|
||||
"compression": "1.8.0",
|
||||
"compression": "1.8.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"csrf-csrf": "3.2.2",
|
||||
"dayjs": "1.11.13",
|
||||
"debounce": "2.2.0",
|
||||
"debug": "4.4.1",
|
||||
"ejs": "3.1.10",
|
||||
"electron": "37.2.2",
|
||||
"electron": "37.2.4",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.1.0",
|
||||
"express-openid-connect": "^2.17.1",
|
||||
"express-rate-limit": "8.0.0",
|
||||
"express-session": "1.18.1",
|
||||
"express-rate-limit": "8.0.1",
|
||||
"express-session": "1.18.2",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
"fs-extra": "11.3.0",
|
||||
"helmet": "8.1.0",
|
||||
@ -84,12 +84,12 @@
|
||||
"jimp": "1.6.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsdom": "26.1.0",
|
||||
"marked": "16.0.0",
|
||||
"marked": "16.1.1",
|
||||
"mime-types": "3.0.1",
|
||||
"multer": "2.0.1",
|
||||
"multer": "2.0.2",
|
||||
"normalize-strings": "1.1.1",
|
||||
"ollama": "0.5.16",
|
||||
"openai": "5.9.2",
|
||||
"openai": "5.10.2",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
@ -99,7 +99,7 @@
|
||||
"stream-throttle": "0.1.3",
|
||||
"strip-bom": "5.0.0",
|
||||
"striptags": "3.2.0",
|
||||
"supertest": "7.1.3",
|
||||
"supertest": "7.1.4",
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"time2fa": "^1.3.0",
|
||||
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
File diff suppressed because one or more lines are too long
@ -52,334 +52,332 @@
|
||||
are multiple labels with the same prefix, consult the specific page linked
|
||||
in the description of that label for more information.</p>
|
||||
</aside>
|
||||
<figure class="table" style="width:100%;">
|
||||
<table class="ck-table-resized">
|
||||
<colgroup>
|
||||
<col style="width:33.82%;">
|
||||
<col style="width:66.18%;">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>disableVersioning</code>
|
||||
</td>
|
||||
<td>Disables automatic creation of <a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a> for
|
||||
a particular note. Useful for e.g. large, but unimportant notes - e.g.
|
||||
large JS libraries used for scripting.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>versioningLimit</code>
|
||||
</td>
|
||||
<td>Limits the maximum number of <a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a> for
|
||||
a particular note, overriding the global settings.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>calendarRoot</code>
|
||||
</td>
|
||||
<td>Marks the note which should be used as root for <a class="reference-link"
|
||||
href="#root/_help_l0tKav7yLHGF">Day Notes</a>. Only one should be marked
|
||||
as such.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>archived</code>
|
||||
</td>
|
||||
<td>Hides notes from default search results and dialogs. Archived notes can
|
||||
optionally be hidden in the <a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>excludeFromExport</code>
|
||||
</td>
|
||||
<td>Excludes this note and its children when exporting.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>run</code>, <code>runOnInstance</code>, <code>runAtHour</code>
|
||||
</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_GPERMystNGTB">Events</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>disableInclusion</code>
|
||||
</td>
|
||||
<td>Scripts with this label won't be included into parent script execution.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sorted</code>
|
||||
</td>
|
||||
<td>
|
||||
<p>Keeps child notes sorted by title alphabetically.</p>
|
||||
<p>When given a value, it will sort by the value of another label instead.
|
||||
If one of the child notes doesn't have the specified label, the title will
|
||||
be used for them instead.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sortDirection</code>
|
||||
</td>
|
||||
<td>
|
||||
<p>If <code>sorted</code> is applied, specifies the direction of the sort:</p>
|
||||
<ul>
|
||||
<li><code>ASC</code>, ascending (default)</li>
|
||||
<li><code>DESC</code>, descending</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sortFoldersFirst</code>
|
||||
</td>
|
||||
<td>If <code>sorted</code> is applied, folders (notes with children) will be
|
||||
sorted as a group at the top, and the rest will be sorted.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>top</code>
|
||||
</td>
|
||||
<td>If <code>sorted</code> is applied to the parent note, keeps given note on
|
||||
top in its parent.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hidePromotedAttributes</code>
|
||||
</td>
|
||||
<td>Hide <a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> on
|
||||
this note. Generally useful when defining inherited attributes, but the
|
||||
parent note doesn't need them.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>readOnly</code>
|
||||
</td>
|
||||
<td>Marks a note to be always be <a href="#root/_help_CoFPLs3dRlXc">read-only</a>,
|
||||
if it's a supported note (text, code, mermaid).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>autoReadOnlyDisabled</code>
|
||||
</td>
|
||||
<td>Disables automatic <a href="#root/_help_CoFPLs3dRlXc">read-only mode</a> for
|
||||
the given note.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>appCss</code>
|
||||
</td>
|
||||
<td>Marks CSS notes which are loaded into the Trilium application and can
|
||||
thus be used to modify Trilium's looks. See <a class="reference-link"
|
||||
href="#root/_help_AlhDUqhENtH7">Custom app-wide CSS</a> for more info.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>appTheme</code>
|
||||
</td>
|
||||
<td>Marks CSS notes which are full Trilium themes and are thus available in
|
||||
Trilium options. See <a class="reference-link" href="#root/_help_pKK96zzmvBGf">Theme development</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>appThemeBase</code>
|
||||
</td>
|
||||
<td>Set to <code>next</code>, <code>next-light</code>, or <code>next-dark</code> to
|
||||
use the corresponding TriliumNext theme (auto, light or dark) as the base
|
||||
for a custom theme, instead of the legacy one. See <a class="reference-link"
|
||||
href="#root/_help_WFGzWeUK6arS">Customize the Next theme</a> for more
|
||||
information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>cssClass</code>
|
||||
</td>
|
||||
<td>Value of this label is then added as CSS class to the node representing
|
||||
given note in the <a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.
|
||||
This can be useful for advanced theming. Can be used in template notes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>iconClass</code>
|
||||
</td>
|
||||
<td>value of this label is added as a CSS class to the icon on the tree which
|
||||
can help visually distinguish the notes in the tree. Example might be bx
|
||||
bx-home - icons are taken from boxicons. Can be used in template notes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>pageSize</code>
|
||||
</td>
|
||||
<td>Specifies the number of items per page in <a class="reference-link"
|
||||
href="#root/_help_0ESUbbAxVnoK">Note List</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>customRequestHandler</code>
|
||||
</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_J5Ex1ZrMbyJ6">Custom Request Handler</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>customResourceProvider</code>
|
||||
</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_d3fAXQ2diepH">Custom Resource Providers</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>widget</code>
|
||||
</td>
|
||||
<td>Marks this note as a custom widget which will be added to the Trilium
|
||||
component tree. See <a class="reference-link" href="#root/_help_MgibgPcfeuGz">Custom Widgets</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>searchHome</code>
|
||||
</td>
|
||||
<td>New search notes will be created as children of this note (see
|
||||
<a
|
||||
class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>workspace</code> and related attributes</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_9sRHySam5fXb">Workspaces</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>inbox</code>
|
||||
</td>
|
||||
<td>default inbox location for new notes - when you create a note using <em>new note</em> button
|
||||
in the sidebar, notes will be created as child notes in the note marked
|
||||
as with <code>#inbox</code> label.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sqlConsoleHome</code>
|
||||
</td>
|
||||
<td>Default location of <a class="reference-link" href="#root/_hidden/_help/_help_tC7s2alapj8V/_help_wX4HbRucYSDD/_help_oyIAJ9PvvwHX/_help__help_YKWqdJhzi2VY">SQL Console</a> notes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bookmarked</code>
|
||||
</td>
|
||||
<td>Indicates this note is a <a href="#root/_help_u3YFHC9tQlpm">bookmark</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bookmarkFolder</code>
|
||||
</td>
|
||||
<td>Note with this label will appear in bookmarks as folder (allowing access
|
||||
to its children). See <a class="reference-link" href="#root/_help_u3YFHC9tQlpm">Bookmarks</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>share*</code>
|
||||
</td>
|
||||
<td>See the attribute reference in <a class="reference-link" href="#root/_help_R9pX4DGra2Vt">Sharing</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>displayRelations</code>, <code>hideRelations</code>
|
||||
</td>
|
||||
<td>Comma delimited names of relations which should be displayed/hidden in
|
||||
a <a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a> (both
|
||||
the note type and the <a class="reference-link" href="#root/_help_BCkXAVs63Ttv">Note Map (Link map, Tree map)</a> general
|
||||
functionality).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>titleTemplate</code>
|
||||
</td>
|
||||
<td>
|
||||
<p>Default title of notes created as children of this note. This value is
|
||||
evaluated as a JavaScript string and thus can be enriched with dynamic
|
||||
content via the injected <code>now</code> and <code>parentNote</code> variables.</p>
|
||||
<p>Examples:</p>
|
||||
<ul>
|
||||
<li><code><span class="math-tex">\({parentNote.getLabel('authorName')}'s literary works</span></code>
|
||||
</li>
|
||||
<li><code>Log for \){now.format('YYYY-MM-DD HH:mm:ss')}</code>
|
||||
</li>
|
||||
<li>to mirror the parent's template.</li>
|
||||
</ul>
|
||||
<p>See <a class="reference-link" href="#root/_help_47ZrP6FNuoG8">Default Note Title</a> for
|
||||
more info.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>template</code>
|
||||
</td>
|
||||
<td>This note will appear in the selection of available template when creating
|
||||
new note. See <a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>toc</code>
|
||||
</td>
|
||||
<td>Controls the display of the <a class="reference-link" href="#root/_help_BFvAtE74rbP6">Table of contents</a> for
|
||||
a given note. <code>#toc</code> or <code>#toc=show</code> to always display
|
||||
the table of contents, <code>#toc=false</code> to always hide it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>color</code>
|
||||
</td>
|
||||
<td>defines color of the note in note tree, links etc. Use any valid CSS color
|
||||
value like 'red' or #a13d5f</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>keyboardShortcut</code>
|
||||
</td>
|
||||
<td>Defines a keyboard shortcut which will immediately jump to this note.
|
||||
Example: 'ctrl+alt+e'. Requires frontend reload for the change to take
|
||||
effect.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>keepCurrentHoisting</code>
|
||||
</td>
|
||||
<td>Opening this link won't change hoisting even if the note is not displayable
|
||||
in the current hoisted subtree.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>executeButton</code>
|
||||
</td>
|
||||
<td>Title of the button which will execute the current code note</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>executeDescription</code>
|
||||
</td>
|
||||
<td>Longer description of the current code note displayed together with the
|
||||
execute button</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>excludeFromNoteMap</code>
|
||||
</td>
|
||||
<td>Notes with this label will be hidden from the <a class="reference-link"
|
||||
href="#root/_help_bdUJEHsAPYQR">Note Map</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>newNotesOnTop</code>
|
||||
</td>
|
||||
<td>New notes will be created at the top of the parent note, not on the bottom.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hideHighlightWidget</code>
|
||||
</td>
|
||||
<td>Hides the <a class="reference-link" href="#root/_help_AxshuNRegLAv">Highlights list</a> widget</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hideChildrenOverview</code>
|
||||
</td>
|
||||
<td>Hides the <a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a> for
|
||||
that particular note.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>printLandscape</code>
|
||||
</td>
|
||||
<td>When exporting to PDF, changes the orientation of the page to landscape
|
||||
instead of portrait.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>printPageSize</code>
|
||||
</td>
|
||||
<td>When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>geolocation</code>
|
||||
</td>
|
||||
<td>Indicates the latitude and longitude of a note, to be displayed in a
|
||||
<a
|
||||
class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>calendar:*</code>
|
||||
</td>
|
||||
<td>Defines specific options for the <a class="reference-link" href="#root/_help_xWbu3jpNWapp">Calendar View</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>viewType</code>
|
||||
</td>
|
||||
<td>Sets the view of child notes (e.g. grid or list). See <a class="reference-link"
|
||||
href="#root/_help_0ESUbbAxVnoK">Note List</a> for more information.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<table class="ck-table-resized">
|
||||
<colgroup>
|
||||
<col style="width:33.82%;">
|
||||
<col style="width:66.18%;">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>disableVersioning</code>
|
||||
</td>
|
||||
<td>Disables automatic creation of <a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a> for
|
||||
a particular note. Useful for e.g. large, but unimportant notes - e.g.
|
||||
large JS libraries used for scripting.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>versioningLimit</code>
|
||||
</td>
|
||||
<td>Limits the maximum number of <a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a> for
|
||||
a particular note, overriding the global settings.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>calendarRoot</code>
|
||||
</td>
|
||||
<td>Marks the note which should be used as root for <a class="reference-link"
|
||||
href="#root/_help_l0tKav7yLHGF">Day Notes</a>. Only one should be marked
|
||||
as such.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>archived</code>
|
||||
</td>
|
||||
<td>Hides notes from default search results and dialogs. Archived notes can
|
||||
optionally be hidden in the <a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>excludeFromExport</code>
|
||||
</td>
|
||||
<td>Excludes this note and its children when exporting.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>run</code>, <code>runOnInstance</code>, <code>runAtHour</code>
|
||||
</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_GPERMystNGTB">Events</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>disableInclusion</code>
|
||||
</td>
|
||||
<td>Scripts with this label won't be included into parent script execution.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sorted</code>
|
||||
</td>
|
||||
<td>
|
||||
<p>Keeps child notes sorted by title alphabetically.</p>
|
||||
<p>When given a value, it will sort by the value of another label instead.
|
||||
If one of the child notes doesn't have the specified label, the title will
|
||||
be used for them instead.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sortDirection</code>
|
||||
</td>
|
||||
<td>
|
||||
<p>If <code>sorted</code> is applied, specifies the direction of the sort:</p>
|
||||
<ul>
|
||||
<li><code>ASC</code>, ascending (default)</li>
|
||||
<li><code>DESC</code>, descending</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sortFoldersFirst</code>
|
||||
</td>
|
||||
<td>If <code>sorted</code> is applied, folders (notes with children) will be
|
||||
sorted as a group at the top, and the rest will be sorted.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>top</code>
|
||||
</td>
|
||||
<td>If <code>sorted</code> is applied to the parent note, keeps given note on
|
||||
top in its parent.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hidePromotedAttributes</code>
|
||||
</td>
|
||||
<td>Hide <a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> on
|
||||
this note. Generally useful when defining inherited attributes, but the
|
||||
parent note doesn't need them.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>readOnly</code>
|
||||
</td>
|
||||
<td>Marks a note to be always be <a href="#root/_help_CoFPLs3dRlXc">read-only</a>,
|
||||
if it's a supported note (text, code, mermaid).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>autoReadOnlyDisabled</code>
|
||||
</td>
|
||||
<td>Disables automatic <a href="#root/_help_CoFPLs3dRlXc">read-only mode</a> for
|
||||
the given note.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>appCss</code>
|
||||
</td>
|
||||
<td>Marks CSS notes which are loaded into the Trilium application and can
|
||||
thus be used to modify Trilium's looks. See <a class="reference-link"
|
||||
href="#root/_help_AlhDUqhENtH7">Custom app-wide CSS</a> for more info.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>appTheme</code>
|
||||
</td>
|
||||
<td>Marks CSS notes which are full Trilium themes and are thus available in
|
||||
Trilium options. See <a class="reference-link" href="#root/_help_pKK96zzmvBGf">Theme development</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>appThemeBase</code>
|
||||
</td>
|
||||
<td>Set to <code>next</code>, <code>next-light</code>, or <code>next-dark</code> to
|
||||
use the corresponding TriliumNext theme (auto, light or dark) as the base
|
||||
for a custom theme, instead of the legacy one. See <a class="reference-link"
|
||||
href="#root/_help_WFGzWeUK6arS">Customize the Next theme</a> for more
|
||||
information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>cssClass</code>
|
||||
</td>
|
||||
<td>Value of this label is then added as CSS class to the node representing
|
||||
given note in the <a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.
|
||||
This can be useful for advanced theming. Can be used in template notes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>iconClass</code>
|
||||
</td>
|
||||
<td>value of this label is added as a CSS class to the icon on the tree which
|
||||
can help visually distinguish the notes in the tree. Example might be bx
|
||||
bx-home - icons are taken from boxicons. Can be used in template notes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>pageSize</code>
|
||||
</td>
|
||||
<td>Specifies the number of items per page in <a class="reference-link"
|
||||
href="#root/_help_0ESUbbAxVnoK">Note List</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>customRequestHandler</code>
|
||||
</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_J5Ex1ZrMbyJ6">Custom Request Handler</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>customResourceProvider</code>
|
||||
</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_d3fAXQ2diepH">Custom Resource Providers</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>widget</code>
|
||||
</td>
|
||||
<td>Marks this note as a custom widget which will be added to the Trilium
|
||||
component tree. See <a class="reference-link" href="#root/_help_MgibgPcfeuGz">Custom Widgets</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>searchHome</code>
|
||||
</td>
|
||||
<td>New search notes will be created as children of this note (see
|
||||
<a
|
||||
class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>workspace</code> and related attributes</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_9sRHySam5fXb">Workspaces</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>inbox</code>
|
||||
</td>
|
||||
<td>default inbox location for new notes - when you create a note using <em>new note</em> button
|
||||
in the sidebar, notes will be created as child notes in the note marked
|
||||
as with <code>#inbox</code> label.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sqlConsoleHome</code>
|
||||
</td>
|
||||
<td>Default location of <a class="reference-link" href="#root/_hidden/_help/_help_tC7s2alapj8V/_help_wX4HbRucYSDD/_help_oyIAJ9PvvwHX/_help__help_YKWqdJhzi2VY">SQL Console</a> notes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bookmarked</code>
|
||||
</td>
|
||||
<td>Indicates this note is a <a href="#root/_help_u3YFHC9tQlpm">bookmark</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bookmarkFolder</code>
|
||||
</td>
|
||||
<td>Note with this label will appear in bookmarks as folder (allowing access
|
||||
to its children). See <a class="reference-link" href="#root/_help_u3YFHC9tQlpm">Bookmarks</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>share*</code>
|
||||
</td>
|
||||
<td>See the attribute reference in <a class="reference-link" href="#root/_help_R9pX4DGra2Vt">Sharing</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>displayRelations</code>, <code>hideRelations</code>
|
||||
</td>
|
||||
<td>Comma delimited names of relations which should be displayed/hidden in
|
||||
a <a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a> (both
|
||||
the note type and the <a class="reference-link" href="#root/_help_BCkXAVs63Ttv">Note Map (Link map, Tree map)</a> general
|
||||
functionality).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>titleTemplate</code>
|
||||
</td>
|
||||
<td>
|
||||
<p>Default title of notes created as children of this note. This value is
|
||||
evaluated as a JavaScript string and thus can be enriched with dynamic
|
||||
content via the injected <code>now</code> and <code>parentNote</code> variables.</p>
|
||||
<p>Examples:</p>
|
||||
<ul>
|
||||
<li><code><span class="math-tex">\({parentNote.getLabel('authorName')}'s literary works</span></code>
|
||||
</li>
|
||||
<li><code>Log for \){now.format('YYYY-MM-DD HH:mm:ss')}</code>
|
||||
</li>
|
||||
<li>to mirror the parent's template.</li>
|
||||
</ul>
|
||||
<p>See <a class="reference-link" href="#root/_help_47ZrP6FNuoG8">Default Note Title</a> for
|
||||
more info.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>template</code>
|
||||
</td>
|
||||
<td>This note will appear in the selection of available template when creating
|
||||
new note. See <a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>toc</code>
|
||||
</td>
|
||||
<td>Controls the display of the <a class="reference-link" href="#root/_help_BFvAtE74rbP6">Table of contents</a> for
|
||||
a given note. <code>#toc</code> or <code>#toc=show</code> to always display
|
||||
the table of contents, <code>#toc=false</code> to always hide it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>color</code>
|
||||
</td>
|
||||
<td>defines color of the note in note tree, links etc. Use any valid CSS color
|
||||
value like 'red' or #a13d5f</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>keyboardShortcut</code>
|
||||
</td>
|
||||
<td>Defines a keyboard shortcut which will immediately jump to this note.
|
||||
Example: 'ctrl+alt+e'. Requires frontend reload for the change to take
|
||||
effect.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>keepCurrentHoisting</code>
|
||||
</td>
|
||||
<td>Opening this link won't change hoisting even if the note is not displayable
|
||||
in the current hoisted subtree.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>executeButton</code>
|
||||
</td>
|
||||
<td>Title of the button which will execute the current code note</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>executeDescription</code>
|
||||
</td>
|
||||
<td>Longer description of the current code note displayed together with the
|
||||
execute button</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>excludeFromNoteMap</code>
|
||||
</td>
|
||||
<td>Notes with this label will be hidden from the <a class="reference-link"
|
||||
href="#root/_help_bdUJEHsAPYQR">Note Map</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>newNotesOnTop</code>
|
||||
</td>
|
||||
<td>New notes will be created at the top of the parent note, not on the bottom.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hideHighlightWidget</code>
|
||||
</td>
|
||||
<td>Hides the <a class="reference-link" href="#root/_help_AxshuNRegLAv">Highlights list</a> widget</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hideChildrenOverview</code>
|
||||
</td>
|
||||
<td>Hides the <a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a> for
|
||||
that particular note.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>printLandscape</code>
|
||||
</td>
|
||||
<td>When exporting to PDF, changes the orientation of the page to landscape
|
||||
instead of portrait.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>printPageSize</code>
|
||||
</td>
|
||||
<td>When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>geolocation</code>
|
||||
</td>
|
||||
<td>Indicates the latitude and longitude of a note, to be displayed in a
|
||||
<a
|
||||
class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>calendar:*</code>
|
||||
</td>
|
||||
<td>Defines specific options for the <a class="reference-link" href="#root/_help_xWbu3jpNWapp">Calendar View</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>viewType</code>
|
||||
</td>
|
||||
<td>Sets the view of child notes (e.g. grid or list). See <a class="reference-link"
|
||||
href="#root/_help_0ESUbbAxVnoK">Note List</a> for more information.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user