Merge remote-tracking branch 'origin/main' into feat/add-ocr-capabilities

This commit is contained in:
Elian Doran 2025-07-26 10:32:56 +03:00
commit 99fa5d89e7
No known key found for this signature in database
286 changed files with 95802 additions and 6306 deletions

40
.github/instructions/nx.instructions.md vendored Normal file
View 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
View File

@ -0,0 +1,8 @@
{
"servers": {
"nx-mcp": {
"type": "http",
"url": "http://localhost:9461/mcp"
}
}
}

10
.vscode/settings.json vendored
View File

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

View File

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

View File

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

View File

@ -287,6 +287,10 @@ export type CommandMappings = {
columnToEdit?: ColumnComponent;
referenceColumn?: ColumnComponent;
direction?: "before" | "after";
type?: "label" | "relation";
};
deleteTableColumn: CommandData & {
columnToDelete?: ColumnComponent;
};
buildTouchBar: CommandData & {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1449,7 +1449,7 @@
"relation-map": "关系图",
"note-map": "笔记地图",
"render-note": "渲染笔记",
"book": "",
"book": "",
"mermaid-diagram": "Mermaid 图",
"canvas": "画布",
"web-view": "网页视图",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1354,7 +1354,7 @@
"relation-map": "關係圖",
"note-map": "筆記地圖",
"render-note": "渲染筆記",
"book": "",
"book": "",
"mermaid-diagram": "美人魚圖Mermaid",
"canvas": "畫布",
"web-view": "網頁視圖",

View File

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

View File

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

View File

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

View File

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

View File

@ -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")}:&nbsp; &nbsp;</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("&nbsp;".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("&nbsp;".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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -153,7 +153,8 @@ export default class Canvas {
appState: {
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom
zoom: appState.zoom,
gridModeEnabled: appState.gridModeEnabled
}
};

View File

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

View File

@ -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();
}
}
/**

View 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);
}
}

View File

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

View File

@ -0,0 +1,7 @@
export interface BoardColumnData {
value: string;
}
export interface BoardData {
columns?: BoardColumnData[];
}

View File

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

View 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
});
}
}

View File

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

View File

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

View File

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

View 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);
}
}

View File

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

View File

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

View File

@ -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: '&copy; <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> {
}
}

View 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: '&copy; <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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
});
}

View File

@ -32,7 +32,8 @@
"eslint.config.mjs"
],
"include": [
"src/**/*.ts"
"src/**/*.ts",
"src/**/*.json"
],
"references": [
{

View File

@ -19,6 +19,6 @@
},
"devDependencies": {
"dotenv": "17.2.0",
"electron": "37.2.2"
"electron": "37.2.4"
}
}

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -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&nbsp;<a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a>&nbsp;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&nbsp;<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&nbsp;<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&nbsp;<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&nbsp;<a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;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&nbsp;<a class="reference-link"
href="#root/_help_AlhDUqhENtH7">Custom app-wide CSS</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_pKK96zzmvBGf">Theme development</a>&nbsp;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&nbsp;<a class="reference-link"
href="#root/_help_WFGzWeUK6arS">Customize the Next theme</a>&nbsp;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&nbsp;<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&nbsp;<a class="reference-link"
href="#root/_help_0ESUbbAxVnoK">Note List</a>.</td>
</tr>
<tr>
<td><code>customRequestHandler</code>
</td>
<td>See&nbsp;<a class="reference-link" href="#root/_help_J5Ex1ZrMbyJ6">Custom Request Handler</a>.</td>
</tr>
<tr>
<td><code>customResourceProvider</code>
</td>
<td>See&nbsp;<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&nbsp;<a class="reference-link" href="#root/_help_MgibgPcfeuGz">Custom Widgets</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>searchHome</code>
</td>
<td>New search notes will be created as children of this note (see&nbsp;
<a
class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>).</td>
</tr>
<tr>
<td><code>workspace</code> and related attributes</td>
<td>See&nbsp;<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&nbsp;<a class="reference-link" href="#root/_hidden/_help/_help_tC7s2alapj8V/_help_wX4HbRucYSDD/_help_oyIAJ9PvvwHX/_help__help_YKWqdJhzi2VY">SQL Console</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_u3YFHC9tQlpm">Bookmarks</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>share*</code>
</td>
<td>See the attribute reference in&nbsp;<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&nbsp;<a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>&nbsp;(both
the note type and the&nbsp;<a class="reference-link" href="#root/_help_BCkXAVs63Ttv">Note Map (Link map, Tree map)</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_47ZrP6FNuoG8">Default Note Title</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>toc</code>
</td>
<td>Controls the display of the&nbsp;<a class="reference-link" href="#root/_help_BFvAtE74rbP6">Table of contents</a>&nbsp;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&nbsp;<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&nbsp;<a class="reference-link" href="#root/_help_AxshuNRegLAv">Highlights list</a>&nbsp;widget</td>
</tr>
<tr>
<td><code>hideChildrenOverview</code>
</td>
<td>Hides the&nbsp;<a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;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&nbsp;
<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&nbsp;<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&nbsp;<a class="reference-link"
href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a>&nbsp;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&nbsp;<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&nbsp;<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&nbsp;<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&nbsp;<a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;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&nbsp;<a class="reference-link"
href="#root/_help_AlhDUqhENtH7">Custom app-wide CSS</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_pKK96zzmvBGf">Theme development</a>&nbsp;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&nbsp;<a class="reference-link"
href="#root/_help_WFGzWeUK6arS">Customize the Next theme</a>&nbsp;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&nbsp;<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&nbsp;<a class="reference-link"
href="#root/_help_0ESUbbAxVnoK">Note List</a>.</td>
</tr>
<tr>
<td><code>customRequestHandler</code>
</td>
<td>See&nbsp;<a class="reference-link" href="#root/_help_J5Ex1ZrMbyJ6">Custom Request Handler</a>.</td>
</tr>
<tr>
<td><code>customResourceProvider</code>
</td>
<td>See&nbsp;<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&nbsp;<a class="reference-link" href="#root/_help_MgibgPcfeuGz">Custom Widgets</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>searchHome</code>
</td>
<td>New search notes will be created as children of this note (see&nbsp;
<a
class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>).</td>
</tr>
<tr>
<td><code>workspace</code> and related attributes</td>
<td>See&nbsp;<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&nbsp;<a class="reference-link" href="#root/_hidden/_help/_help_tC7s2alapj8V/_help_wX4HbRucYSDD/_help_oyIAJ9PvvwHX/_help__help_YKWqdJhzi2VY">SQL Console</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_u3YFHC9tQlpm">Bookmarks</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>share*</code>
</td>
<td>See the attribute reference in&nbsp;<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&nbsp;<a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>&nbsp;(both
the note type and the&nbsp;<a class="reference-link" href="#root/_help_BCkXAVs63Ttv">Note Map (Link map, Tree map)</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_47ZrP6FNuoG8">Default Note Title</a>&nbsp;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&nbsp;<a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>toc</code>
</td>
<td>Controls the display of the&nbsp;<a class="reference-link" href="#root/_help_BFvAtE74rbP6">Table of contents</a>&nbsp;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&nbsp;<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&nbsp;<a class="reference-link" href="#root/_help_AxshuNRegLAv">Highlights list</a>&nbsp;widget</td>
</tr>
<tr>
<td><code>hideChildrenOverview</code>
</td>
<td>Hides the&nbsp;<a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;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&nbsp;
<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&nbsp;<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&nbsp;<a class="reference-link"
href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;for more information.</td>
</tr>
</tbody>
</table>

Some files were not shown because too many files have changed in this diff Show More