diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 017e5b6205..8f87f6a166 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,7 @@ # Trilium Notes - AI Coding Agent Instructions +> **Note**: When updating this file, also update `CLAUDE.md` in the repository root to keep both AI coding assistants in sync. + ## Project Overview Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. Built as a TypeScript monorepo using pnpm, it implements a three-layer caching architecture (Becca/Froca/Shaca) with a widget-based UI system and supports extensive user scripting capabilities. @@ -115,6 +117,15 @@ class MyNoteWidget extends NoteContextAwareWidget { **Important**: Widgets use jQuery (`this.$widget`) for DOM manipulation. Don't mix React patterns here. +### Reusable Preact Components +Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations: +- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states) +- `ActionButton` - Consistent button styling with icon support +- `FormTextBox` - Text input with validation and controlled input handling +- `Slider` - Range slider with label +- `Checkbox`, `RadioButton` - Form controls +- `CollapsibleSection` - Expandable content sections + ## Development Workflow ### Running & Testing @@ -322,8 +333,16 @@ Trilium provides powerful user scripting capabilities: - When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `" in "` vs `"in , "`) - When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md` +#### Client vs Server Translation Usage +- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json` +- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json` +- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped + ## Testing Conventions +- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests +- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it + ```typescript // ETAPI test pattern describe("etapi/feature", () => { diff --git a/CLAUDE.md b/CLAUDE.md index 56073acda4..ab6067e058 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +> **Note**: When updating this file, also update `.github/copilot-instructions.md` to keep both AI coding assistants in sync. + ## 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 pnpm, with multiple applications and shared packages. @@ -66,6 +68,15 @@ Frontend uses a widget system (`apps/client/src/widgets/`): - `RightPanelWidget` - Widgets displayed in the right panel - Type-specific widgets in `type_widgets/` directory +#### Reusable Preact Components +Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations: +- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states) +- `ActionButton` - Consistent button styling with icon support +- `FormTextBox` - Text input with validation and controlled input handling +- `Slider` - Range slider with label +- `Checkbox`, `RadioButton` - Form controls +- `CollapsibleSection` - Expandable content sections + #### API Architecture - **Internal API**: REST endpoints in `apps/server/src/routes/api/` - **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`) @@ -108,6 +119,8 @@ Trilium supports multiple note types, each with specialized widgets: - Client tests can run in parallel - E2E tests use Playwright for both server and desktop apps - Build validation tests check artifact integrity +- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests +- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it ### Scripting System Trilium provides powerful user scripting capabilities: @@ -124,6 +137,11 @@ Trilium provides powerful user scripting capabilities: - When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md` - **Server-side translations** (e.g. hidden subtree titles) go in `apps/server/src/assets/translations/en/server.json`, not in the client `translation.json` +#### Client vs Server Translation Usage +- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json` +- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json` +- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped + ### Electron Desktop App - Desktop entry point: `apps/desktop/src/main.ts`, window management: `apps/server/src/services/window.ts` - IPC communication: use `electron.ipcMain.on(channel, handler)` on server side, `electron.ipcRenderer.send(channel, data)` on client side diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 5fe7caf33c..a30a83975a 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -236,6 +236,16 @@ export default class FNote { return this.hasAttribute("label", "archived"); } + /** + * Returns true if the note's metadata (title, icon) should not be editable. + * This applies to system notes like options, help, and launch bar configuration. + */ + get isMetadataReadOnly() { + return utils.isLaunchBarConfig(this.noteId) + || this.noteId.startsWith("_help_") + || this.noteId.startsWith("_options"); + } + getChildNoteIds() { return this.children; } diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 9ca4fa86fb..18982307ac 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -68,7 +68,8 @@ async function autocompleteSourceForCKEditor(queryText: string) { name: row.notePathTitle || "", link: `#${row.notePath}`, notePath: row.notePath, - highlightedNotePathTitle: row.highlightedNotePathTitle + highlightedNotePathTitle: row.highlightedNotePathTitle, + icon: row.icon }; }) ); diff --git a/apps/client/src/setup.ts b/apps/client/src/setup.ts index 30232b85a3..67ad3e835d 100644 --- a/apps/client/src/setup.ts +++ b/apps/client/src/setup.ts @@ -99,7 +99,7 @@ class SetupController { } private async finish() { - const syncServerHost = this.syncServerHostInput.value.trim(); + const syncServerHost = this.syncServerHostInput.value.trim().replace(/\/+$/, ""); const syncProxy = this.syncProxyInput.value.trim(); const password = this.passwordInput.value; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 6006d665e4..5eef6b9c69 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -806,7 +806,11 @@ "board": "Board", "presentation": "Presentation", "include_archived_notes": "Show archived notes", - "hide_child_notes": "Hide child notes in tree" + "hide_child_notes": "Hide child notes in tree", + "open_all_in_tabs": "Open all", + "open_all_in_tabs_tooltip": "Open all results in new tabs", + "open_all_confirm": "This will open {{count}} notes in new tabs. Continue?", + "open_all_too_many": "Too many results ({{count}}). Maximum is {{max}}." }, "edited_notes": { "no_edited_notes_found": "No edited notes on this day yet...", @@ -860,7 +864,8 @@ "collapse": "Collapse to normal size", "title": "Note Map", "fix-nodes": "Fix nodes", - "link-distance": "Link distance" + "link-distance": "Link distance", + "too-many-notes": "This subtree contains {{count}} notes, which exceeds the limit of {{max}} that can be displayed in the note map." }, "note_paths": { "title": "Note Paths", @@ -1514,7 +1519,7 @@ "config_title": "Sync Configuration", "server_address": "Server instance address", "timeout": "Sync timeout", - "timeout_unit": "milliseconds", + "timeout_description": "How long to wait before giving up on a slow sync connection. Increase if you have an unstable network.", "proxy_label": "Sync proxy server (optional)", "note": "Note", "note_description": "If you leave the proxy setting blank, the system proxy will be used (applies to desktop/electron build only).", diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index dec64feee8..fd0ce3bbb2 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -75,7 +75,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) if (dateNote.hasChildren()) { - const childNoteIds = await dateNote.getSubtreeNoteIds(); + const childNoteIds = dateNote.getChildNoteIds(); for (const childNoteId of childNoteIds) { childNoteToDateMapping[childNoteId] = startDate; } diff --git a/apps/client/src/widgets/collections/table/row_editing.ts b/apps/client/src/widgets/collections/table/row_editing.ts index c4df69e7ae..03f6b8ff49 100644 --- a/apps/client/src/widgets/collections/table/row_editing.ts +++ b/apps/client/src/widgets/collections/table/row_editing.ts @@ -51,6 +51,8 @@ export default function useRowTableEditing(api: RefObject, attributeD if (type === "labels") { if (typeof newValue === "boolean") { newValue = newValue ? "true" : "false"; + } else if (typeof newValue === "number") { + newValue = String(newValue); } setLabel(noteId, name, newValue); } else if (type === "relations") { diff --git a/apps/client/src/widgets/dialogs/jump_to_note.tsx b/apps/client/src/widgets/dialogs/jump_to_note.tsx index 89c4388039..44c825e082 100644 --- a/apps/client/src/widgets/dialogs/jump_to_note.tsx +++ b/apps/client/src/widgets/dialogs/jump_to_note.tsx @@ -80,9 +80,19 @@ export default function JumpToNoteDialogComponent() { break; } - $autoComplete - .trigger("focus") - .trigger("select"); + $autoComplete.trigger("focus"); + + if (mode === "commands") { + // In command mode, place caret at end instead of selecting all text + // This preserves the ">" prefix when the user starts typing + const input = autocompleteRef.current; + if (input) { + const len = input.value.length; + input.setSelectionRange(len, len); + } + } else { + $autoComplete.trigger("select"); + } // Add keyboard shortcut for full search shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => { diff --git a/apps/client/src/widgets/highlights_list.ts b/apps/client/src/widgets/highlights_list.ts index b6e3f2e6f0..512e8eff30 100644 --- a/apps/client/src/widgets/highlights_list.ts +++ b/apps/client/src/widgets/highlights_list.ts @@ -9,7 +9,6 @@ import appContext, { type EventData } from "../components/app_context.js"; import type FNote from "../entities/fnote.js"; import attributeService from "../services/attributes.js"; import { t } from "../services/i18n.js"; -import katex from "../services/math.js"; import options from "../services/options.js"; import OnClickButtonWidget from "./buttons/onclick_button.js"; import RightPanelWidget from "./right_panel_widget.js"; @@ -125,77 +124,6 @@ export default class HighlightsListWidget extends RightPanelWidget { this.triggerCommand("reEvaluateRightPaneVisibility"); } - extractOuterTag(htmlStr: string | null) { - if (htmlStr === null) { - return null; - } - // Regular expressions that match only the outermost tag - const regex = /^<([a-zA-Z]+)([^>]*)>/; - const match = htmlStr.match(regex); - if (match) { - const tagName = match[1].toLowerCase(); // Extract tag name - const attributes = match[2].trim(); // Extract label attributes - return { tagName, attributes }; - } - return null; - } - - areOuterTagsConsistent(str1: string | null, str2: string | null) { - const tag1 = this.extractOuterTag(str1); - const tag2 = this.extractOuterTag(str2); - // If one of them has no label, returns false - if (!tag1 || !tag2) { - return false; - } - // Compare tag names and attributes to see if they are the same - return tag1.tagName === tag2.tagName && tag1.attributes === tag2.attributes; - } - - /** - * Rendering formulas in strings using katex - * - * @param html Note's html content - * @returns The HTML content with mathematical formulas rendered by KaTeX. - */ - async replaceMathTextWithKatax(html: string) { - const mathTextRegex = /\\\(([\s\S]*?)\\\)<\/span>/g; - const matches = [...html.matchAll(mathTextRegex)]; - let modifiedText = html; - - if (matches.length > 0) { - // Process all matches asynchronously - for (const match of matches) { - const latexCode = match[1]; - let rendered; - - try { - rendered = katex.renderToString(latexCode, { - throwOnError: false - }); - } catch (e) { - if (e instanceof ReferenceError && e.message.includes("katex is not defined")) { - // Load KaTeX if it is not already loaded - try { - rendered = katex.renderToString(latexCode, { - throwOnError: false - }); - } catch (renderError) { - console.error("KaTeX rendering error after loading library:", renderError); - rendered = match[0]; // Fall back to original if error persists - } - } else { - console.error("KaTeX rendering error:", e); - rendered = match[0]; // Fall back to original on error - } - } - - // Replace the matched formula in the modified text - modifiedText = modifiedText.replace(match[0], rendered); - } - } - return modifiedText; - } - async getHighlightList(content: string, optionsHighlightsList: string[]) { // matches a span containing background-color const regex1 = /]*style\s*=\s*[^>]*background-color:[^>]*?>[\s\S]*?<\/span>/gi; @@ -239,9 +167,6 @@ export default class HighlightsListWidget extends RightPanelWidget { const $highlightsList = $("
    "); let prevEndIndex = -1, hlLiCount = 0; - let prevSubHtml: string | null = null; - // Used to determine if a string is only a formula - const onlyMathRegex = /^\\\([^\)]*?\)<\/span>(?:\\\([^\)]*?\)<\/span>)*$/; for (let match: RegExpMatchArray | null = null, hltIndex = 0; (match = combinedRegex.exec(content)) !== null; hltIndex++) { const subHtml = match[0]; @@ -257,25 +182,14 @@ export default class HighlightsListWidget extends RightPanelWidget { // If the previous element is connected to this element in HTML, then concatenate them into one. $highlightsList.children().last().append(subHtml); } else { - // TODO: can't be done with $(subHtml).text()? - //Can’t remember why regular expressions are used here, but modified to $(subHtml).text() works as expected - //const hasText = [...subHtml.matchAll(/(?<=^|>)[^><]+?(?=<|$)/g)].map(matchTmp => matchTmp[0]).join('').trim(); const hasText = $(subHtml).text().trim(); if (hasText) { - const substring = content.substring(prevEndIndex, startIndex); - //If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element. - if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) { - const $lastLi = $highlightsList.children("li").last(); - $lastLi.append(await this.replaceMathTextWithKatax(substring)); - $lastLi.append(subHtml); - } else { - $highlightsList.append( - $("
  1. ") - .html(subHtml) - .on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex)) - ); - } + $highlightsList.append( + $("
  2. ") + .html(subHtml) + .on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex)) + ); hlLiCount++; } else { @@ -284,7 +198,6 @@ export default class HighlightsListWidget extends RightPanelWidget { } } prevEndIndex = endIndex; - prevSubHtml = subHtml; } return { $highlightsList, diff --git a/apps/client/src/widgets/note_bars/CollectionProperties.tsx b/apps/client/src/widgets/note_bars/CollectionProperties.tsx index 66bfb32c45..14f8685eaa 100644 --- a/apps/client/src/widgets/note_bars/CollectionProperties.tsx +++ b/apps/client/src/widgets/note_bars/CollectionProperties.tsx @@ -2,10 +2,13 @@ import "./CollectionProperties.css"; import { t } from "i18next"; import { ComponentChildren } from "preact"; -import { useRef } from "preact/hooks"; +import { useRef, useState } from "preact/hooks"; import FNote from "../../entities/fnote"; +import appContext from "../../components/app_context"; +import dialogService from "../../services/dialog"; import { ViewTypeOptions } from "../collections/interface"; +import ActionButton from "../react/ActionButton"; import Dropdown from "../react/Dropdown"; import { FormDropdownDivider, FormListItem } from "../react/FormList"; import { useNoteProperty, useTriliumEvent } from "../react/hooks"; @@ -24,6 +27,8 @@ export const ICON_MAPPINGS: Record = { presentation: "bx bx-rectangle" }; +const MAX_OPEN_TABS = 50; + export default function CollectionProperties({ note, centerChildren, rightChildren }: { note: FNote; centerChildren?: ComponentChildren; @@ -31,6 +36,7 @@ export default function CollectionProperties({ note, centerChildren, rightChildr }) { const [ viewType, setViewType ] = useViewType(note); const noteType = useNoteProperty(note, "type"); + const [ isOpening, setIsOpening ] = useState(false); return ([ "book", "search" ].includes(noteType ?? "") &&
    @@ -43,11 +49,59 @@ export default function CollectionProperties({ note, centerChildren, rightChildr
    {rightChildren} + {noteType === "search" && ( + + )}
    ); } +function OpenAllButton({ note, isOpening, setIsOpening }: { + note: FNote; + isOpening: boolean; + setIsOpening: (value: boolean) => void; +}) { + const noteIds = note.getChildNoteIds(); + const count = noteIds.length; + + const handleOpenAll = async () => { + if (count === 0) return; + + if (count > MAX_OPEN_TABS) { + await dialogService.info(t("book_properties.open_all_too_many", { count, max: MAX_OPEN_TABS })); + return; + } + + if (count > 10) { + const confirmed = await dialogService.confirm(t("book_properties.open_all_confirm", { count })); + if (!confirmed) return; + } + + setIsOpening(true); + try { + for (let i = 0; i < noteIds.length; i++) { + const noteId = noteIds[i]; + const isLast = i === noteIds.length - 1; + await appContext.tabManager.openTabWithNoteWithHoisting(noteId, { + activate: isLast + }); + } + } finally { + setIsOpening(false); + } + }; + + return ( + + ); +} + function ViewTypeSwitcher({ viewType, setViewType }: { viewType: ViewTypeOptions, setViewType: (newValue: ViewTypeOptions) => void }) { // Keyboard shortcut const dropdownContainerRef = useRef(null); diff --git a/apps/client/src/widgets/note_icon.tsx b/apps/client/src/widgets/note_icon.tsx index 31ca7e65b4..c6ed7e618b 100644 --- a/apps/client/src/widgets/note_icon.tsx +++ b/apps/client/src/widgets/note_icon.tsx @@ -42,8 +42,11 @@ export default function NoteIcon() { setIcon(note?.getIcon()); }, [ note, iconClass, workspaceIconClass ]); + const isDisabled = viewScope?.viewMode !== "default" + || note?.isMetadataReadOnly; + if (isMobile()) { - return ; + return ; } return ( @@ -55,16 +58,17 @@ export default function NoteIcon() { dropdownOptions={{ autoClose: "outside" }} buttonClassName={`note-icon tn-focusable-button ${icon ?? "bx bx-empty"}`} hideToggleArrow - disabled={viewScope?.viewMode !== "default"} + disabled={isDisabled} > { note && dropdownRef?.current?.hide()} columnCount={12} /> } ); } -function MobileNoteIconSwitcher({ note, icon }: { +function MobileNoteIconSwitcher({ note, icon, disabled }: { note: FNote | null | undefined; icon: string | null | undefined; + disabled?: boolean; }) { const [ modalShown, setModalShown ] = useState(false); const { windowWidth } = useWindowSize(); @@ -76,6 +80,7 @@ function MobileNoteIconSwitcher({ note, icon }: { icon={icon ?? "bx bx-empty"} text={t("note_icon.change_note_icon")} onClick={() => setModalShown(true)} + disabled={disabled} /> {createPortal(( diff --git a/apps/client/src/widgets/note_map/NoteMap.css b/apps/client/src/widgets/note_map/NoteMap.css index fa49bb39c6..3188f09439 100644 --- a/apps/client/src/widgets/note_map/NoteMap.css +++ b/apps/client/src/widgets/note_map/NoteMap.css @@ -1,5 +1,5 @@ .note-detail-note-map { - height: 100%; + height: 100%; overflow: hidden; } @@ -54,4 +54,4 @@ width: 10px; } -/* End of styling the slider */ \ No newline at end of file +/* End of styling the slider */ diff --git a/apps/client/src/widgets/note_map/NoteMap.tsx b/apps/client/src/widgets/note_map/NoteMap.tsx index 2677e4aa59..a165d467bb 100644 --- a/apps/client/src/widgets/note_map/NoteMap.tsx +++ b/apps/client/src/widgets/note_map/NoteMap.tsx @@ -12,11 +12,15 @@ import { t } from "../../services/i18n"; import { getEffectiveThemeStyle } from "../../services/theme"; import ActionButton from "../react/ActionButton"; import { useElementSize, useNoteLabel } from "../react/hooks"; +import NoItems from "../react/NoItems"; import Slider from "../react/Slider"; import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data"; import { CssData, setupRendering } from "./rendering"; import { MapType, NoteMapWidgetMode, rgb2hex } from "./utils"; +/** Maximum number of notes to render in the note map before showing a warning. */ +const MAX_NOTES_THRESHOLD = 1_000; + interface NoteMapProps { note: FNote; widgetMode: NoteMapWidgetMode; @@ -34,6 +38,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { const containerSize = useElementSize(parentRef); const [ fixNodes, setFixNodes ] = useState(false); const [ linkDistance, setLinkDistance ] = useState(40); + const [ tooManyNotes, setTooManyNotes ] = useState(null); const notesAndRelationsRef = useRef(); const mapRootId = useMemo(() => { @@ -61,6 +66,14 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { const includeRelations = labelValues("mapIncludeRelation"); loadNotesAndRelations(mapRootId, excludeRelations, includeRelations, mapType).then((notesAndRelations) => { if (!containerRef.current || !styleResolverRef.current) return; + + // Guard against rendering too many notes which would freeze the browser. + if (notesAndRelations.nodes.length > MAX_NOTES_THRESHOLD) { + setTooManyNotes(notesAndRelations.nodes.length); + return; + } + setTooManyNotes(null); + const cssData = getCssData(containerRef.current, styleResolverRef.current); // Configure rendering properties. @@ -119,6 +132,12 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { }); }, [ fixNodes, mapType ]); + if (tooManyNotes) { + return ( + + ); + } + return (
    diff --git a/apps/client/src/widgets/note_title.tsx b/apps/client/src/widgets/note_title.tsx index f886d9f7db..34b27c9298 100644 --- a/apps/client/src/widgets/note_title.tsx +++ b/apps/client/src/widgets/note_title.tsx @@ -1,15 +1,16 @@ -import { useEffect, useRef, useState } from "preact/hooks"; -import { t } from "../services/i18n"; -import FormTextBox from "./react/FormTextBox"; -import { useNoteContext, useNoteProperty, useSpacedUpdate, useTriliumEvent, useTriliumEvents } from "./react/hooks"; -import protected_session_holder from "../services/protected_session_holder"; -import server from "../services/server"; import "./note_title.css"; -import { isLaunchBarConfig } from "../services/utils"; + +import clsx from "clsx"; +import { useEffect, useRef, useState } from "preact/hooks"; + import appContext from "../components/app_context"; import branches from "../services/branches"; +import { t } from "../services/i18n"; +import protected_session_holder from "../services/protected_session_holder"; +import server from "../services/server"; import { isIMEComposing } from "../services/shortcuts"; -import clsx from "clsx"; +import FormTextBox from "./react/FormTextBox"; +import { useNoteContext, useNoteProperty, useSpacedUpdate, useTriliumEvent, useTriliumEvents } from "./react/hooks"; export default function NoteTitleWidget(props: {className?: string}) { const { note, noteId, componentId, viewScope, noteContext, parentComponent } = useNoteContext(); @@ -25,8 +26,7 @@ export default function NoteTitleWidget(props: {className?: string}) { const isReadOnly = note === null || note === undefined || (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) - || isLaunchBarConfig(note.noteId) - || note.noteId.startsWith("_help_") + || note.isMetadataReadOnly || viewScope?.viewMode !== "default"; setReadOnly(isReadOnly); }, [ note, note?.noteId, note?.isProtected, viewScope?.viewMode ]); @@ -58,11 +58,29 @@ export default function NoteTitleWidget(props: {className?: string}) { // Manage focus. const textBoxRef = useRef(null); const isNewNote = useRef(); + const pendingSelect = useRef(false); + + // Re-apply selection when title changes if we have a pending select. + // This handles the case where the server sends back entity changes after we've + // already called select(), which causes the controlled input to re-render and lose selection. + useEffect(() => { + if (pendingSelect.current && textBoxRef.current && document.activeElement === textBoxRef.current) { + textBoxRef.current.select(); + pendingSelect.current = false; + } + }, [title]); + useTriliumEvents([ "focusOnTitle", "focusAndSelectTitle" ], (e, eventName) => { if (noteContext?.isActive() && textBoxRef.current) { + // In the new layout, there are two NoteTitleWidget instances. Only handle if visible. + if (!textBoxRef.current.checkVisibility({ checkOpacity: true })) { + return; + } + textBoxRef.current.focus(); if (eventName === "focusAndSelectTitle") { textBoxRef.current.select(); + pendingSelect.current = true; } isNewNote.current = ("isNewNote" in e ? e.isNewNote : false); } @@ -83,6 +101,9 @@ export default function NoteTitleWidget(props: {className?: string}) { spacedUpdate.scheduleUpdate(); }} onKeyDown={(e) => { + // User started typing, stop re-applying selection + pendingSelect.current = false; + // Skip processing if IME is composing to prevent interference // with text input in CJK languages if (isIMEComposing(e)) { @@ -101,6 +122,7 @@ export default function NoteTitleWidget(props: {className?: string}) { } }} onBlur={() => { + pendingSelect.current = false; spacedUpdate.updateNowIfNecessary(); isNewNote.current = false; }} diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index a6f9c4595f..cf14c783d9 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -825,13 +825,43 @@ export function useWindowSize() { return size; } +// Workaround for https://github.com/twbs/bootstrap/issues/37474 +// Bootstrap's dispose() sets ALL properties to null. But pending animation callbacks +// (scheduled via setTimeout) can still fire and crash when accessing null properties. +// We patch dispose() to set safe placeholder values instead of null. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const TooltipProto = Tooltip.prototype as any; +const originalDispose = TooltipProto.dispose; +const disposedTooltipPlaceholder = { + activeTrigger: {}, + element: document.createElement("noscript") +}; +TooltipProto.dispose = function () { + originalDispose.call(this); + // After disposal, set safe values so pending callbacks don't crash + this._activeTrigger = disposedTooltipPlaceholder.activeTrigger; + this._element = disposedTooltipPlaceholder.element; +}; + export function useTooltip(elRef: RefObject, config: Partial) { useEffect(() => { if (!elRef?.current) return; - const $el = $(elRef.current); - $el.tooltip("dispose"); + const element = elRef.current; + const $el = $(element); + + // Dispose any existing tooltip before creating a new one + Tooltip.getInstance(element)?.dispose(); $el.tooltip(config); + + // Capture the tooltip instance now, since elRef.current may be null during cleanup. + const tooltip = Tooltip.getInstance(element); + + return () => { + if (element.isConnected) { + tooltip?.dispose(); + } + }; }, [ elRef, config ]); const showTooltip = useCallback(() => { @@ -866,8 +896,14 @@ export function useStaticTooltip(elRef: RefObject, config?: Partial { + // Capture element now, since elRef.current may be null during cleanup. + const element = elRef.current; + + // Dispose any existing tooltip before creating a new one + Tooltip.getInstance(element)?.dispose(); + + const tooltip = new Tooltip(element, config); + element.addEventListener("show.bs.tooltip", () => { // Hide all the other tooltips. for (const otherTooltip of tooltips) { if (otherTooltip === tooltip) continue; @@ -878,12 +914,11 @@ export function useStaticTooltip(elRef: RefObject, config?: Partial { tooltips.delete(tooltip); - tooltip.dispose(); - // workaround for https://github.com/twbs/bootstrap/issues/37474 - (tooltip as any)._activeTrigger = {}; - (tooltip as any)._element = document.createElement('noscript'); // placeholder with no behavior + if (element.isConnected) { + tooltip.dispose(); + } - // Remove *all* tooltip elements from the DOM + // Remove any lingering tooltip popup elements from the DOM. document .querySelectorAll('.tooltip') .forEach(t => t.remove()); diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 401554203d..d0c447c283 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -1,11 +1,13 @@ import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5"; import { createPortal } from "preact/compat"; -import { useCallback, useEffect, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { t } from "../../services/i18n"; +import math from "../../services/math"; import { randomString } from "../../services/utils"; import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks"; import Modal from "../react/Modal"; +import RawHtml from "../react/RawHtml"; import { HighlightsListOptions } from "../type_widgets/options/text_notes"; import RightPanelWidget from "./RightPanelWidget"; @@ -84,20 +86,11 @@ function AbstractHighlightsList({ highlights, scrollToHi {filteredHighlights.length > 0 ? (
      {filteredHighlights.map(highlight => ( -
    1. scrollToHighlight(highlight)} - > - {highlight.text} -
    2. + /> ))}
    ) : ( @@ -112,6 +105,43 @@ function AbstractHighlightsList({ highlights, scrollToHi ); } +function HighlightItem({ highlight, onClick }: { + highlight: T; + onClick(): void; +}) { + const contentRef = useRef(null); + + // Render math equations after component mounts/updates + useEffect(() => { + if (!contentRef.current) return; + const mathElements = contentRef.current.querySelectorAll(".math-tex"); + + for (const mathEl of mathElements ?? []) { + try { + math.render(mathEl.textContent || "", mathEl as HTMLElement); + } catch (e) { + console.warn("Failed to render math in highlights:", e); + } + } + }, [highlight.text]); + + return ( +
  3. + +
  4. + ); +} + //#region Editable text (CKEditor) interface CKHighlight extends RawHighlight { textNode: ModelText; @@ -201,9 +231,24 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { }; if (Object.values(attrs).some(Boolean)) { + // Get HTML content from DOM (includes nested elements like math) + let html = item.data; + try { + const modelPos = editor.model.createPositionAt(item.textNode, "before"); + const viewPos = editor.editing.mapper.toViewPosition(modelPos); + const domPos = editor.editing.view.domConverter.viewPositionToDom(viewPos); + if (domPos?.parent instanceof HTMLElement) { + // Get the formatting span's innerHTML (includes math elements) + html = domPos.parent.innerHTML; + } + } catch { + // During change:data events, the view may not be fully synchronized with the model. + // Fall back to using the raw text data. + } + result.push({ id: randomString(), - text: item.data, + text: html, attrs, textNode: item.textNode, offset: item.startOffset diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index fdf616ee26..eef19100eb 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -87,7 +87,7 @@ function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: { // Render math equations after component mounts/updates useEffect(() => { if (!contentRef.current) return; - const mathElements = contentRef.current.querySelectorAll(".ck-math-tex"); + const mathElements = contentRef.current.querySelectorAll(".math-tex"); for (const mathEl of mathElements ?? []) { try { diff --git a/apps/client/src/widgets/type_widgets/options/appearance.tsx b/apps/client/src/widgets/type_widgets/options/appearance.tsx index 94fc1f1fbd..dc4a3bdc26 100644 --- a/apps/client/src/widgets/type_widgets/options/appearance.tsx +++ b/apps/client/src/widgets/type_widgets/options/appearance.tsx @@ -3,6 +3,7 @@ import "./appearance.css"; import { FontFamily, OptionNames } from "@triliumnext/commons"; import { useEffect, useState } from "preact/hooks"; +import zoomService from "../../../components/zoom"; import { t } from "../../../services/i18n"; import server from "../../../services/server"; import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils"; @@ -14,9 +15,10 @@ import FormGroup from "../../react/FormGroup"; import FormRadioGroup from "../../react/FormRadioGroup"; import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect"; import FormText from "../../react/FormText"; -import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox"; +import { FormTextBoxWithUnit } from "../../react/FormTextBox"; import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; import Icon from "../../react/Icon"; +import OptionsRow from "./components/OptionsRow"; import OptionsSection from "./components/OptionsSection"; import PlatformIndicator from "./components/PlatformIndicator"; import RadioWithIllustration from "./components/RadioWithIllustration"; @@ -333,20 +335,23 @@ function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, font } function ElectronIntegration() { - const [ zoomFactor, setZoomFactor ] = useTriliumOption("zoomFactor"); + const [ zoomFactor ] = useTriliumOption("zoomFactor"); const [ nativeTitleBarVisible, setNativeTitleBarVisible ] = useTriliumOptionBool("nativeTitleBarVisible"); const [ backgroundEffects, setBackgroundEffects ] = useTriliumOptionBool("backgroundEffects"); + const zoomPercentage = Math.round(parseFloat(zoomFactor || "1") * 100); + return ( - - + zoomService.setZoomFactorAndSave(parseInt(v, 10) / 100)} + unit={t("units.percentage")} /> - -
    + - ) + ); } export function SyncConfiguration() { - const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncServerTimeout", "syncProxy"); + const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncProxy"); const syncServerHost = useRef(options.syncServerHost); - const syncServerTimeout = useRef(options.syncServerTimeout); const syncProxy = useRef(options.syncProxy); return ( @@ -32,13 +34,12 @@ export function SyncConfiguration() {
    { setOptions({ syncServerHost: syncServerHost.current, - syncServerTimeout: syncServerTimeout.current, syncProxy: syncProxy.current }); e.preventDefault(); }}> - syncServerHost.current = newValue} /> @@ -50,27 +51,30 @@ export function SyncConfiguration() { } > - syncProxy.current = newValue} /> - - syncServerTimeout.current = newValue} - /> - -
    + +
    + + + +
    - ) + ); } export function SyncTest() { @@ -90,5 +94,5 @@ export function SyncTest() { }} /> - ) -} \ No newline at end of file + ); +} diff --git a/apps/client/src/widgets/type_widgets/text/config.ts b/apps/client/src/widgets/type_widgets/text/config.ts index 29b1a02699..e4812156e4 100644 --- a/apps/client/src/widgets/type_widgets/text/config.ts +++ b/apps/client/src/widgets/type_widgets/text/config.ts @@ -182,9 +182,21 @@ export async function buildConfig(opts: BuildEditorOptions): Promise noteAutocompleteService.autocompleteSourceForCKEditor(queryText), itemRenderer: (item) => { + const suggestion = item as Suggestion; const itemElement = document.createElement("button"); - itemElement.innerHTML = `${(item as Suggestion).highlightedNotePathTitle} `; + const iconElement = document.createElement("span"); + // Choose appropriate icon based on action + let iconClass = suggestion.icon ?? "bx bx-note"; + if (suggestion.action === "create-note") { + iconClass = "bx bx-plus"; + } + iconElement.className = iconClass; + + itemElement.append(iconElement, document.createTextNode(" ")); + const titleContainer = document.createElement("span"); + titleContainer.innerHTML = suggestion.highlightedNotePathTitle ?? ""; + itemElement.append(...titleContainer.childNodes, document.createTextNode(" ")); return itemElement; }, diff --git a/apps/edit-docs/demo/!!!meta.json b/apps/edit-docs/demo/!!!meta.json index d8c4710f93..50d1460e2b 100644 --- a/apps/edit-docs/demo/!!!meta.json +++ b/apps/edit-docs/demo/!!!meta.json @@ -1,6 +1,6 @@ { "formatVersion": 2, - "appVersion": "0.100.0", + "appVersion": "0.102.2", "files": [ { "isClone": false, diff --git a/apps/edit-docs/demo/root/Trilium Demo.html b/apps/edit-docs/demo/root/Trilium Demo.html index 59f6c6d557..3920159069 100644 --- a/apps/edit-docs/demo/root/Trilium Demo.html +++ b/apps/edit-docs/demo/root/Trilium Demo.html @@ -18,30 +18,23 @@ width="150" height="150">

    Welcome to Trilium Notes! -

    This is a "demo" document packaged with Trilium to showcase some of its features and also give you some ideas on how you might structure your notes. You can play with it, and modify the note content and tree structure as you wish.

    If you need any help, visit triliumnotes.org or - our GitHub repository - -

    -

    Cleanup

    - + our GitHub repository.

    +

    Cleanup

    Once you're finished with experimenting and want to cleanup these pages, you can simply delete them all.

    -

    Formatting

    - +

    Formatting

    Trilium supports classic formatting like italic, bold, bold and italic. - You can add links pointing to external pages or  + You can add links pointing to external pages or   Formatting examples.

    -

    Lists

    - +

    Lists

    Ordered: -

    1. First Item
    2. @@ -56,7 +49,6 @@

    Unordered: -

    • Item
    • @@ -66,8 +58,7 @@
    -

    Block quotes

    - +

    Block quotes

    Whereof one cannot speak, thereof one must be silent”

    – Ludwig Wittgenstein

    @@ -75,9 +66,8 @@

    See also other examples like tables, checkbox lists, highlighting, code blocksand - math examples.

    + href="Trilium%20Demo/Formatting%20examples/Checkbox%20lists.html">checkbox lists, highlighting, code blocks, + and math examples.

    diff --git a/apps/edit-docs/demo/root/Trilium Demo/Books/Book template.html b/apps/edit-docs/demo/root/Trilium Demo/Books/Book template.html index b6ece231ed..e54ae932e5 100644 --- a/apps/edit-docs/demo/root/Trilium Demo/Books/Book template.html +++ b/apps/edit-docs/demo/root/Trilium Demo/Books/Book template.html @@ -14,24 +14,19 @@

    Main characters

    -

    … here put main characters …

     

    -

    Plot

    - +

    Plot

    … describe main plot lines …

     

    -

    Tone

    - +

    Tone

     

    -

    Genre

    - +

    Genre

    scifi / drama / romance

     

    -

    Similar books

    - +

    Similar books

      -
    • +
    diff --git a/apps/edit-docs/demo/style.css b/apps/edit-docs/demo/style.css index d1209630ab..2e961cdb7a 100644 --- a/apps/edit-docs/demo/style.css +++ b/apps/edit-docs/demo/style.css @@ -1,633 +1,658 @@ /** - * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ -:root{ - --ck-content-color-mention-background:hsla(341, 100%, 30%, 0.1); - --ck-content-color-mention-text:hsl(341, 100%, 30%); -} -.ck-content .mention{ - background:var(--ck-content-color-mention-background); - color:var(--ck-content-color-mention-text); -} - .ck-content code{ - background-color:hsla(0, 0%, 78%, 0.3); - padding:.15em; - border-radius:2px; + background-color:#c7c7c74d; + border-radius:2px; + padding:.15em; } .ck-content blockquote{ - overflow:hidden; - padding-right:1.5em; - padding-left:1.5em; - - margin-left:0; - margin-right:0; - font-style:italic; - border-left:solid 5px hsl(0, 0%, 80%); + border-left:5px solid #ccc; + margin-left:0; + margin-right:0; + padding-left:1.5em; + padding-right:1.5em; + font-style:italic; + overflow:hidden; } .ck-content[dir="rtl"] blockquote{ - border-left:0; - border-right:solid 5px hsl(0, 0%, 80%); + border-left:0; + border-right:5px solid #ccc; } .ck-content pre{ - padding:1em; - color:hsl(0, 0%, 20.8%); - background:hsla(0, 0%, 78%, 0.3); - border:1px solid hsl(0, 0%, 77%); - border-radius:2px; - text-align:left; - direction:ltr; - - tab-size:4; - white-space:pre-wrap; - font-style:normal; - min-width:200px; + color:#353535; + text-align:left; + tab-size:4; + white-space:pre-wrap; + direction:ltr; + background:#c7c7c74d; + border:1px solid #c4c4c4; + border-radius:2px; + min-width:200px; + margin:.9em 0; + padding:1em; + font-style:normal; } .ck-content pre code{ - background:unset; - padding:0; - border-radius:0; - } + background:unset; + border-radius:0; + padding:0; +} :root{ - --ck-content-font-family:Helvetica, Arial, Tahoma, Verdana, Sans-Serif; - --ck-content-font-size:medium; - --ck-content-font-color:#000; - --ck-content-line-height:1.5; - --ck-content-word-break:break-word; + --ck-content-font-family:Helvetica, Arial, Tahoma, Verdana, Sans-Serif; + --ck-content-font-size:medium; + --ck-content-font-color:#000; + --ck-content-line-height:1.5; + --ck-content-word-break:normal; + --ck-content-overflow-wrap:break-word; } .ck-content{ - font-family:var(--ck-content-font-family); - font-size:var(--ck-content-font-size); - color:var(--ck-content-font-color); - line-height:var(--ck-content-line-height); - word-break:var(--ck-content-word-break); + font-family:var(--ck-content-font-family); + font-size:var(--ck-content-font-size); + color:var(--ck-content-font-color); + line-height:var(--ck-content-line-height); + word-break:var(--ck-content-word-break); + overflow-wrap:var(--ck-content-overflow-wrap); } :root{ - --ck-content-font-size-tiny:0.7em; - --ck-content-font-size-small:0.85em; - --ck-content-font-size-big:1.4em; - --ck-content-font-size-huge:1.8em; + --ck-content-font-size-tiny:.7em; + --ck-content-font-size-small:.85em; + --ck-content-font-size-big:1.4em; + --ck-content-font-size-huge:1.8em; } + .ck-content .text-tiny{ - font-size:var(--ck-content-font-size-tiny); - } + font-size:var(--ck-content-font-size-tiny); +} + .ck-content .text-small{ - font-size:var(--ck-content-font-size-small); - } + font-size:var(--ck-content-font-size-small); +} + .ck-content .text-big{ - font-size:var(--ck-content-font-size-big); - } + font-size:var(--ck-content-font-size-big); +} + .ck-content .text-huge{ - font-size:var(--ck-content-font-size-huge); - } + font-size:var(--ck-content-font-size-huge); +} :root{ - --ck-content-highlight-marker-yellow:hsl(60, 97%, 73%); - --ck-content-highlight-marker-green:hsl(120, 93%, 68%); - --ck-content-highlight-marker-pink:hsl(345, 96%, 73%); - --ck-content-highlight-marker-blue:hsl(201, 97%, 72%); - --ck-content-highlight-pen-red:hsl(0, 85%, 49%); - --ck-content-highlight-pen-green:hsl(112, 100%, 27%); + --ck-content-highlight-marker-yellow:#fdfd77; + --ck-content-highlight-marker-green:#62f962; + --ck-content-highlight-marker-pink:#fc7899; + --ck-content-highlight-marker-blue:#72ccfd; + --ck-content-highlight-pen-red:#e71313; + --ck-content-highlight-pen-green:#128a00; } .ck-content .marker-yellow{ - background-color:var(--ck-content-highlight-marker-yellow); - } + background-color:var(--ck-content-highlight-marker-yellow); +} + .ck-content .marker-green{ - background-color:var(--ck-content-highlight-marker-green); - } + background-color:var(--ck-content-highlight-marker-green); +} + .ck-content .marker-pink{ - background-color:var(--ck-content-highlight-marker-pink); - } + background-color:var(--ck-content-highlight-marker-pink); +} + .ck-content .marker-blue{ - background-color:var(--ck-content-highlight-marker-blue); - } + background-color:var(--ck-content-highlight-marker-blue); +} .ck-content .pen-red{ - color:var(--ck-content-highlight-pen-red); - background-color:transparent; - } + color:var(--ck-content-highlight-pen-red); + background-color:#0000; +} + .ck-content .pen-green{ - color:var(--ck-content-highlight-pen-green); - background-color:transparent; - } + color:var(--ck-content-highlight-pen-green); + background-color:#0000; +} .ck-content hr{ - margin:15px 0; - height:4px; - background:hsl(0, 0%, 87%); - border:0; + background:#dedede; + border:0; + height:4px; + margin:15px 0; } :root{ - --ck-content-color-image-caption-background:hsl(0, 0%, 97%); - --ck-content-color-image-caption-text:hsl(0, 0%, 20%); + --ck-content-color-image-caption-background:#f7f7f7; + --ck-content-color-image-caption-text:#333; } + .ck-content .image > figcaption{ - display:table-caption; - caption-side:bottom; - word-break:normal; - overflow-wrap:anywhere; - break-before:avoid; - color:var(--ck-content-color-image-caption-text); - background-color:var(--ck-content-color-image-caption-background); - padding:.6em; - font-size:.75em; - outline-offset:-1px; + caption-side:bottom; + word-break:normal; + overflow-wrap:anywhere; + break-before:avoid; + color:var(--ck-content-color-image-caption-text); + background-color:var(--ck-content-color-image-caption-background); + outline-offset:-1px; + padding:.6em; + font-size:.75em; + display:table-caption; } + @media (forced-colors: active){ -.ck-content .image > figcaption{ - background-color:unset; - color:unset; + .ck-content .image > figcaption{ + background-color:unset; + color:unset; + } } - } + .ck-content img.image_resized{ - height:auto; + height:auto; } .ck-content .image.image_resized{ - max-width:100%; - display:block; - box-sizing:border-box; + box-sizing:border-box; + max-width:100%; + display:block; } .ck-content .image.image_resized img{ - width:100%; - } - -.ck-content .image.image_resized > figcaption{ - display:block; - } - -:root{ - --ck-content-image-style-spacing:1.5em; - --ck-content-inline-image-style-spacing:calc(var(--ck-content-image-style-spacing) / 2); + width:100%; } -.ck-content .image.image-style-block-align-left, - .ck-content .image.image-style-block-align-right{ - max-width:calc(100% - var(--ck-content-image-style-spacing)); - } - -.ck-content .image.image-style-align-left, - .ck-content .image.image-style-align-right{ - clear:none; - } - -.ck-content .image.image-style-side{ - float:right; - margin-left:var(--ck-content-image-style-spacing); - max-width:50%; - } - -.ck-content .image.image-style-align-left{ - float:left; - margin-right:var(--ck-content-image-style-spacing); - } - -.ck-content .image.image-style-align-right{ - float:right; - margin-left:var(--ck-content-image-style-spacing); - } - -.ck-content .image.image-style-block-align-right{ - margin-right:0; - margin-left:auto; - } - -.ck-content .image.image-style-block-align-left{ - margin-left:0; - margin-right:auto; - } - -.ck-content .image-style-align-center{ - margin-left:auto; - margin-right:auto; - } - -.ck-content .image-style-align-left{ - float:left; - margin-right:var(--ck-content-image-style-spacing); - } - -.ck-content .image-style-align-right{ - float:right; - margin-left:var(--ck-content-image-style-spacing); - } - -.ck-content p + .image.image-style-align-left, - .ck-content p + .image.image-style-align-right, - .ck-content p + .image.image-style-side{ - margin-top:0; - } - -.ck-content .image-inline.image-style-align-left, - .ck-content .image-inline.image-style-align-right{ - margin-top:var(--ck-content-inline-image-style-spacing); - margin-bottom:var(--ck-content-inline-image-style-spacing); - } - -.ck-content .image-inline.image-style-align-left{ - margin-right:var(--ck-content-inline-image-style-spacing); - } - -.ck-content .image-inline.image-style-align-right{ - margin-left:var(--ck-content-inline-image-style-spacing); - } - -.ck-content .image{ - display:table; - clear:both; - text-align:center; - margin:0.9em auto; - min-width:50px; - } - -.ck-content .image img{ - display:block; - margin:0 auto; - max-width:100%; - min-width:100%; - height:auto; - } - -.ck-content .image-inline{ - display:inline-flex; - max-width:100%; - align-items:flex-start; - } - -.ck-content .image-inline picture{ - display:flex; - } - -.ck-content .image-inline picture, - .ck-content .image-inline img{ - flex-grow:1; - flex-shrink:1; - max-width:100%; - } +.ck-content .image.image_resized > figcaption{ + display:block; +} :root{ - --ck-content-list-marker-color:var(--ck-content-font-color); - --ck-content-list-marker-font-family:var(--ck-content-font-family); - --ck-content-list-marker-font-size:var(--ck-content-font-size); + --ck-content-image-style-spacing:1.5em; + --ck-content-inline-image-style-spacing:calc(var(--ck-content-image-style-spacing) / 2); +} + +.ck-content .image.image-style-block-align-left, .ck-content .image.image-style-block-align-right{ + max-width:calc(100% - var(--ck-content-image-style-spacing)); +} + +.ck-content .image.image-style-align-left, .ck-content .image.image-style-align-right{ + clear:none; +} + +.ck-content .image.image-style-side{ + float:right; + margin-left:var(--ck-content-image-style-spacing); + max-width:50%; +} + +.ck-content .image.image-style-align-left{ + float:left; + margin-right:var(--ck-content-image-style-spacing); +} + +.ck-content .image.image-style-align-right{ + float:right; + margin-left:var(--ck-content-image-style-spacing); +} + +.ck-content .image.image-style-block-align-right{ + margin-left:auto; + margin-right:0; +} + +.ck-content .image.image-style-block-align-left{ + margin-left:0; + margin-right:auto; +} + +.ck-content .image-style-align-center{ + margin-left:auto; + margin-right:auto; +} + +.ck-content .image-style-align-left{ + float:left; + margin-right:var(--ck-content-image-style-spacing); +} + +.ck-content .image-style-align-right{ + float:right; + margin-left:var(--ck-content-image-style-spacing); +} + +.ck-content p + .image.image-style-align-left, .ck-content p + .image.image-style-align-right, .ck-content p + .image.image-style-side{ + margin-top:0; +} + +.ck-content .image-inline.image-style-align-left, .ck-content .image-inline.image-style-align-right{ + margin-top:var(--ck-content-inline-image-style-spacing); + margin-bottom:var(--ck-content-inline-image-style-spacing); +} + +.ck-content .image-inline.image-style-align-left{ + margin-right:var(--ck-content-inline-image-style-spacing); +} + +.ck-content .image-inline.image-style-align-right{ + margin-left:var(--ck-content-inline-image-style-spacing); +} + +.ck-content .image{ + clear:both; + text-align:center; + min-width:50px; + margin:.9em auto; + display:table; +} + +.ck-content .image img{ + min-width:100%; + max-width:100%; + height:auto; + margin:0 auto; + display:block; +} + +.ck-content .image-inline{ + align-items:flex-start; + max-width:100%; + display:inline-flex; +} + +.ck-content .image-inline picture{ + display:flex; +} + +.ck-content .image-inline picture, .ck-content .image-inline img{ + flex-grow:1; + flex-shrink:1; + max-width:100%; +} + +:root{ + --ck-content-list-marker-color:var(--ck-content-font-color); + --ck-content-list-marker-font-family:var(--ck-content-font-family); + --ck-content-list-marker-font-size:var(--ck-content-font-size); } .ck-content li > p:first-of-type{ - margin-top:0; - } + margin-top:0; +} .ck-content li > p:only-of-type{ - margin-top:0; - margin-bottom:0; - } + margin-top:0; + margin-bottom:0; +} .ck-content li.ck-list-marker-bold::marker{ - font-weight:bold; - } + font-weight:bold; +} .ck-content li.ck-list-marker-italic::marker{ - font-style:italic; - } + font-style:italic; +} .ck-content li.ck-list-marker-color::marker{ - color:var(--ck-content-list-marker-color); - } + color:var(--ck-content-list-marker-color); +} .ck-content li.ck-list-marker-font-family::marker{ - font-family:var(--ck-content-list-marker-font-family); - } + font-family:var(--ck-content-list-marker-font-family); +} .ck-content li.ck-list-marker-font-size::marker{ - font-size:var(--ck-content-list-marker-font-size); - } + font-size:var(--ck-content-list-marker-font-size); +} .ck-content li.ck-list-marker-font-size-tiny::marker{ - font-size:var(--ck-content-font-size-tiny); - } + font-size:var(--ck-content-font-size-tiny); +} .ck-content li.ck-list-marker-font-size-small::marker{ - font-size:var(--ck-content-font-size-small); - } + font-size:var(--ck-content-font-size-small); +} .ck-content li.ck-list-marker-font-size-big::marker{ - font-size:var(--ck-content-font-size-big); - } + font-size:var(--ck-content-font-size-big); +} .ck-content li.ck-list-marker-font-size-huge::marker{ - font-size:var(--ck-content-font-size-huge); - } + font-size:var(--ck-content-font-size-huge); +} .ck-content ol{ - list-style-type:decimal; + list-style-type:decimal; } .ck-content ol ol{ - list-style-type:lower-latin; - } + list-style-type:lower-latin; +} .ck-content ol ol ol{ - list-style-type:lower-roman; - } + list-style-type:lower-roman; +} .ck-content ol ol ol ol{ - list-style-type:upper-latin; - } + list-style-type:upper-latin; +} .ck-content ol ol ol ol ol{ - list-style-type:upper-roman; - } + list-style-type:upper-roman; +} .ck-content ul{ - list-style-type:disc; + list-style-type:disc; } .ck-content ul ul{ - list-style-type:circle; - } + list-style-type:circle; +} .ck-content ul ul ul{ - list-style-type:square; - } + list-style-type:square; +} .ck-content ul ul ul ul{ - list-style-type:square; - } + list-style-type:square; +} :root{ - --ck-content-todo-list-checkmark-size:16px; + --ck-content-todo-list-checkmark-size:16px; } -.ck-content .todo-list{ - list-style:none; + +.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input{ + -webkit-appearance:none; + width:var(--ck-content-todo-list-checkmark-size); + height:var(--ck-content-todo-list-checkmark-size); + vertical-align:middle; + border:0; + margin-left:0; + margin-right:-15px; + display:inline-block; + position:relative; + left:-25px; + right:0; } -.ck-content .todo-list li{ - position:relative; - margin-bottom:5px; - } -.ck-content .todo-list li .todo-list{ - margin-top:5px; - } -.ck-content .todo-list .todo-list__label > input{ - -webkit-appearance:none; - display:inline-block; - position:relative; - width:var(--ck-content-todo-list-checkmark-size); - height:var(--ck-content-todo-list-checkmark-size); - vertical-align:middle; - border:0; - left:-25px; - margin-right:-15px; - right:0; - margin-left:0; - } -.ck-content[dir=rtl] .todo-list .todo-list__label > input{ - left:0; - margin-right:0; - right:-25px; - margin-left:-15px; - } -.ck-content .todo-list .todo-list__label > input::before{ - display:block; - position:absolute; - box-sizing:border-box; - content:''; - width:100%; - height:100%; - border:1px solid hsl(0, 0%, 20%); - border-radius:2px; - transition:250ms ease-in-out box-shadow; - } + +[dir="rtl"]:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input){ + margin-left:-15px; + margin-right:0; + left:0; + right:-25px; +} + +:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input):before{ + box-sizing:border-box; + content:""; + border:1px solid #333; + border-radius:2px; + width:100%; + height:100%; + transition:box-shadow .25s ease-in-out; + display:block; + position:absolute; +} + @media (prefers-reduced-motion: reduce){ -.ck-content .todo-list .todo-list__label > input::before{ - transition:none; - } - } -.ck-content .todo-list .todo-list__label > input::after{ - display:block; - position:absolute; - box-sizing:content-box; - pointer-events:none; - content:''; - left:calc( var(--ck-content-todo-list-checkmark-size) / 3); - top:calc( var(--ck-content-todo-list-checkmark-size) / 5.3); - width:calc( var(--ck-content-todo-list-checkmark-size) / 5.3); - height:calc( var(--ck-content-todo-list-checkmark-size) / 2.6); - border-style:solid; - border-color:transparent; - border-width:0 calc( var(--ck-content-todo-list-checkmark-size) / 8) calc( var(--ck-content-todo-list-checkmark-size) / 8) 0; - transform:rotate(45deg); - } -.ck-content .todo-list .todo-list__label > input[checked]::before{ - background:hsl(126, 64%, 41%); - border-color:hsl(126, 64%, 41%); - } -.ck-content .todo-list .todo-list__label > input[checked]::after{ - border-color:hsl(0, 0%, 100%); - } + :is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input):before{ + transition:none; + } +} + +:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input):after{ + box-sizing:content-box; + pointer-events:none; + content:""; + left:calc(var(--ck-content-todo-list-checkmark-size) / 3); + top:calc(var(--ck-content-todo-list-checkmark-size) / 5.3); + width:calc(var(--ck-content-todo-list-checkmark-size) / 5.3); + height:calc(var(--ck-content-todo-list-checkmark-size) / 2.6); + border-style:solid; + border-color:#0000; + border-width:0 calc(var(--ck-content-todo-list-checkmark-size) / 8) calc(var(--ck-content-todo-list-checkmark-size) / 8) 0; + display:block; + position:absolute; + transform:rotate(45deg); +} + +:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input)[checked]:before{ + background:#26ab33; + border-color:#26ab33; +} + +:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input)[checked]:after{ + border-color:#fff; +} + +.ck-content .todo-list{ + list-style:none; +} + +.ck-content .todo-list li{ + margin-bottom:5px; + position:relative; +} + +.ck-content .todo-list li .todo-list{ + margin-top:5px; +} + .ck-content .todo-list .todo-list__label .todo-list__label__description{ - vertical-align:middle; - } -.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type=checkbox]{ - position:absolute; - } + vertical-align:middle; +} + +.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type="checkbox"]{ + position:absolute; +} + +.ck-editor__editable.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input{ + cursor:pointer; +} + +:is(.ck-editor__editable.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input):hover:before{ + box-shadow:0 0 0 5px #0000001a; +} + +.ck-editor__editable.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type="checkbox"]{ + position:absolute; +} .ck-content .media{ - clear:both; - margin:0.9em 0; - display:block; - min-width:15em; + clear:both; + min-width:15em; + margin:.9em 0; + display:block; +} + +:root{ + --ck-content-color-mention-background:#9900301a; + --ck-content-color-mention-text:#990030; +} + +.ck-content .mention{ + background:var(--ck-content-color-mention-background); + color:var(--ck-content-color-mention-text); } .ck-content .page-break{ - position:relative; - clear:both; - padding:5px 0; - display:flex; - align-items:center; - justify-content:center; + clear:both; + justify-content:center; + align-items:center; + padding:5px 0; + display:flex; + position:relative; } -.ck-content .page-break::after{ - content:''; - position:absolute; - border-bottom:2px dashed hsl(0, 0%, 77%); - width:100%; - } +.ck-content .page-break:after{ + content:""; + border-bottom:2px dashed #c4c4c4; + width:100%; + position:absolute; +} .ck-content .page-break__label{ - position:relative; - z-index:1; - padding:.3em .6em; - display:block; - text-transform:uppercase; - border:1px solid hsl(0, 0%, 77%); - border-radius:2px; - font-size:0.75em; - font-weight:bold; - color:hsl(0, 0%, 20%); - background:hsl(0, 0%, 100%); - box-shadow:2px 2px 1px hsla(0, 0%, 0%, 0.15); - -webkit-user-select:none; - -moz-user-select:none; - -ms-user-select:none; - user-select:none; + z-index:1; + text-transform:uppercase; + color:#333; + -webkit-user-select:none; + user-select:none; + background:#fff; + border:1px solid #c4c4c4; + border-radius:2px; + padding:.3em .6em; + font-size:.75em; + font-weight:bold; + display:block; + position:relative; + box-shadow:2px 2px 1px #00000026; } -@media print{ - .ck-content .page-break{ - padding:0; - } - .ck-content .page-break::after{ - display:none; - } - .ck-content *:has(+ .page-break){ - margin-bottom:0; - } +@media print{ + .ck-content .page-break{ + padding:0; + } + + .ck-content .page-break:after{ + display:none; + } + + .ck-content :has( + .page-break){ + margin-bottom:0; + } +} + +.ck-content .table th{ + text-align:start; } .ck-content[dir="rtl"] .table th{ - text-align:right; - } + text-align:right; +} .ck-content[dir="ltr"] .table th{ - text-align:left; - } + text-align:left; +} .ck-content figure.table:not(.layout-table){ - display:table; - } + display:table; +} .ck-content figure.table:not(.layout-table) > table{ - width:100%; - height:100%; - } + width:100%; + height:100%; +} .ck-content .table:not(.layout-table){ - margin:0.9em auto; - } + margin:.9em auto; +} -.ck-content table.table:not(.layout-table), - .ck-content figure.table:not(.layout-table) > table{ - border-collapse:collapse; - border-spacing:0; - border:1px double hsl(0, 0%, 70%); - } +.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table{ + border-collapse:collapse; + border-spacing:0; + border:1px double #b3b3b3; +} -.ck-content table.table:not(.layout-table) > thead > tr > th, .ck-content figure.table:not(.layout-table) > table > thead > tr > th, .ck-content table.table:not(.layout-table) > tbody > tr > th, .ck-content figure.table:not(.layout-table) > table > tbody > tr > th{ - font-weight:bold; - background:hsla(0, 0%, 0%, 5%); - } +:is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > th{ + background:#0000000d; + font-weight:bold; +} -.ck-content table.table:not(.layout-table) > thead > tr > td, - .ck-content figure.table:not(.layout-table) > table > thead > tr > td, - .ck-content table.table:not(.layout-table) > tbody > tr > td, - .ck-content figure.table:not(.layout-table) > table > tbody > tr > td, - .ck-content table.table:not(.layout-table) > thead > tr > th, - .ck-content figure.table:not(.layout-table) > table > thead > tr > th, - .ck-content table.table:not(.layout-table) > tbody > tr > th, - .ck-content figure.table:not(.layout-table) > table > tbody > tr > th{ +:is(:is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > td, :is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > th) > p:first-of-type{ + margin-top:0; +} - min-width:2em; - padding:0.4em; - border:1px solid hsl(0, 0%, 75%); - } +:is(:is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > td, :is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > th) > p:last-of-type{ + margin-bottom:0; +} -.ck-content table.table:not(.layout-table) > thead > tr > td > p:first-of-type, .ck-content figure.table:not(.layout-table) > table > thead > tr > td > p:first-of-type, .ck-content table.table:not(.layout-table) > tbody > tr > td > p:first-of-type, .ck-content figure.table:not(.layout-table) > table > tbody > tr > td > p:first-of-type, .ck-content table.table:not(.layout-table) > thead > tr > th > p:first-of-type, .ck-content figure.table:not(.layout-table) > table > thead > tr > th > p:first-of-type, .ck-content table.table:not(.layout-table) > tbody > tr > th > p:first-of-type, .ck-content figure.table:not(.layout-table) > table > tbody > tr > th > p:first-of-type{ - margin-top:0; - } - -.ck-content table.table:not(.layout-table) > thead > tr > td > p:last-of-type, .ck-content figure.table:not(.layout-table) > table > thead > tr > td > p:last-of-type, .ck-content table.table:not(.layout-table) > tbody > tr > td > p:last-of-type, .ck-content figure.table:not(.layout-table) > table > tbody > tr > td > p:last-of-type, .ck-content table.table:not(.layout-table) > thead > tr > th > p:last-of-type, .ck-content figure.table:not(.layout-table) > table > thead > tr > th > p:last-of-type, .ck-content table.table:not(.layout-table) > tbody > tr > th > p:last-of-type, .ck-content figure.table:not(.layout-table) > table > tbody > tr > th > p:last-of-type{ - margin-bottom:0; - } +:is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > td, :is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > th{ + border:1px solid #bfbfbf; + min-width:2em; + padding:.4em; +} @media print{ - .ck-content figure.table:not(.layout-table){ - width:fit-content; - height:fit-content; - } - .ck-content figure.table:not(.layout-table) > table{ - height:initial; - } + .ck-content figure.table:not(.layout-table){ + width:fit-content; + height:fit-content; + } + + .ck-content figure.table:not(.layout-table) > table{ + height:initial; + } } -.ck-content table.table.layout-table, - .ck-content figure.table.layout-table{ - margin-top:0; - margin-bottom:0; - } - -.ck-content table.table.layout-table, - .ck-content figure.table.layout-table > table{ - border-spacing:0; - } - -:root{ - --ck-content-color-table-caption-background:hsl(0, 0%, 97%); - --ck-content-color-table-caption-text:hsl(0, 0%, 20%); -} -.ck-content .table > figcaption, -.ck-content figure.table > table > caption{ - display:table-caption; - caption-side:top; - word-break:normal; - overflow-wrap:anywhere; - text-align:center; - color:var(--ck-content-color-table-caption-text); - background-color:var(--ck-content-color-table-caption-background); - padding:.6em; - font-size:.75em; - outline-offset:-1px; -} -@media (forced-colors: active){ - .ck-content .table > figcaption, .ck-content figure.table > table > caption{ - background-color:unset; - color:unset; - } - } - -.ck-content .table .ck-table-resized{ - table-layout:fixed; +.ck-content table.table.layout-table, .ck-content figure.table.layout-table{ + margin-top:0; + margin-bottom:0; } -.ck-content .table td, -.ck-content .table th{ - overflow-wrap:break-word; +.ck-content table.table.layout-table, .ck-content figure.table.layout-table > table{ + border-spacing:0; } :root{ - --ck-content-table-style-spacing:1.5em; + --ck-content-table-style-spacing:1.5em; } .ck-content .table.table-style-align-left{ - float:left; - margin-right:var(--ck-content-table-style-spacing); - } + float:left; + margin-right:var(--ck-content-table-style-spacing); +} .ck-content .table.table-style-align-right{ - float:right; - margin-left:var(--ck-content-table-style-spacing); - } + float:right; + margin-left:var(--ck-content-table-style-spacing); +} .ck-content .table.table-style-align-center{ - margin-left:auto; - margin-right:auto; - } + margin-left:auto; + margin-right:auto; +} .ck-content .table.table-style-block-align-left{ - margin-left:0; - margin-right:auto; - } + margin-left:0; + margin-right:auto; +} .ck-content .table.table-style-block-align-right{ - margin-left:auto; - margin-right:0; - } \ No newline at end of file + margin-left:auto; + margin-right:0; +} + +:root{ + --ck-content-color-table-caption-background:#f7f7f7; + --ck-content-color-table-caption-text:#333; +} + +.ck-content .table > figcaption, .ck-content figure.table > table > caption{ + caption-side:top; + word-break:normal; + overflow-wrap:anywhere; + text-align:center; + color:var(--ck-content-color-table-caption-text); + background-color:var(--ck-content-color-table-caption-background); + outline-offset:-1px; + padding:.6em; + font-size:.75em; + display:table-caption; +} + +@media (forced-colors: active){ + .ck-content .table > figcaption, .ck-content figure.table > table > caption{ + background-color:unset; + color:unset; + } +} + +.ck-content .table .ck-table-resized{ + table-layout:fixed; +} + +.ck-content .table td, .ck-content .table th{ + overflow-wrap:break-word; +} \ No newline at end of file diff --git a/apps/edit-docs/src/edit-demo.ts b/apps/edit-docs/src/edit-demo.ts index 62ea490142..f11d68685b 100644 --- a/apps/edit-docs/src/edit-demo.ts +++ b/apps/edit-docs/src/edit-demo.ts @@ -17,20 +17,29 @@ async function main() { await initializeTranslations(); await initializeDatabase(true); + + // Wait for becca to be loaded before importing data + const beccaLoader = await import("@triliumnext/server/src/becca/becca_loader.js"); + await beccaLoader.beccaLoaded; + cls.init(async () => { await importData(DEMO_ZIP_DIR_PATH); setOptions(); initializedPromise.resolve(); }); - - initializedPromise.resolve(); } async function setOptions() { const optionsService = (await import("@triliumnext/server/src/services/options.js")).default; + const sql = (await import("@triliumnext/server/src/services/sql.js")).default; + optionsService.setOption("eraseUnusedAttachmentsAfterSeconds", 10); optionsService.setOption("eraseUnusedAttachmentsAfterTimeScale", 60); optionsService.setOption("compressImages", "false"); + + // Set initial note to the first visible child of root (not _hidden) + const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root"; + optionsService.setOption("openNoteContexts", JSON.stringify([{ notePath: startNoteId, active: true }])); } async function registerHandlers() { diff --git a/apps/edit-docs/src/edit-docs.ts b/apps/edit-docs/src/edit-docs.ts index 40163330d0..49846b9eb6 100644 --- a/apps/edit-docs/src/edit-docs.ts +++ b/apps/edit-docs/src/edit-docs.ts @@ -141,9 +141,15 @@ async function main() { async function setOptions() { const optionsService = (await import("@triliumnext/server/src/services/options.js")).default; + const sql = (await import("@triliumnext/server/src/services/sql.js")).default; + optionsService.setOption("eraseUnusedAttachmentsAfterSeconds", 10); optionsService.setOption("eraseUnusedAttachmentsAfterTimeScale", 60); optionsService.setOption("compressImages", "false"); + + // Set initial note to the first visible child of root (not _hidden) + const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root"; + optionsService.setOption("openNoteContexts", JSON.stringify([{ notePath: startNoteId, active: true }])); } async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set) { diff --git a/apps/server/src/assets/db/demo.zip b/apps/server/src/assets/db/demo.zip index c84c99383a..acb4c966c3 100644 Binary files a/apps/server/src/assets/db/demo.zip and b/apps/server/src/assets/db/demo.zip differ diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Backup.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Backup.html index c4677663ce..800b0a2db4 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Backup.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Backup.html @@ -18,6 +18,9 @@

    Note that Synchronization provides also some backup capabilities by its nature of distributing the data to other computers.

    +

    Downloading backup

    +

    You can download a existing backup by going to Settings > Backup > + Existing backups > Download

    Restoring backup

    Let's assume you want to restore the weekly backup, here's how to do it:

      diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.html index 52a8a2d30d..589d0989b1 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.html @@ -80,6 +80,9 @@ class WordCountWidget extends api.NoteContextAwareWidget { module.exports = new WordCountWidget();

      After you make changes it is necessary to restart Trilium so that the layout can be rebuilt.

      +

      The widget only activates on text notes that have the #wordCount label. + This label can be a reference link to + enable the widget for an entire subtree.

      At the bottom of the note you can see the resulting widget:

      diff --git a/apps/server/src/assets/translations/en/server.json b/apps/server/src/assets/translations/en/server.json index 95c5a74663..87e9f0bbcc 100644 --- a/apps/server/src/assets/translations/en/server.json +++ b/apps/server/src/assets/translations/en/server.json @@ -445,5 +445,15 @@ }, "desktop": { "instance_already_running": "There's already an instance running, focusing that instance instead." + }, + "search": { + "error": { + "in-context": "Error in {{- context}}: {{- message}}", + "reserved-keyword": "\"{{- token}}\" is a reserved keyword. To search for a literal value, use quotes: \"{{- token}}\"", + "cannot-compare-with": "cannot compare with \"{{- token}}\". To search for a literal value, use quotes: \"{{- token}}\"", + "misplaced-expression": "Misplaced or incomplete expression \"{{- token}}\"", + "fulltext-after-expression": "\"{{- token}}\" is not a valid expression. To search for text, place it before attribute filters (e.g., \"{{- token}} #label\" instead of \"#label {{- token}}\").", + "unrecognized-expression": "Unrecognized expression \"{{- token}}\"" + } } } diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index 9e21dcb7b7..912fef055e 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -32,6 +32,7 @@ const ALLOWED_OPTIONS = new Set([ "codeNoteTheme", "syncServerHost", "syncServerTimeout", + "syncServerTimeoutTimeScale", "syncProxy", "hoistedNoteId", "mainFontSize", diff --git a/apps/server/src/services/options_init.spec.ts b/apps/server/src/services/options_init.spec.ts new file mode 100644 index 0000000000..ddad092446 --- /dev/null +++ b/apps/server/src/services/options_init.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { migrateSyncTimeoutFromMilliseconds } from "./options_init.js"; + +describe("migrateSyncTimeoutFromMilliseconds", () => { + it("returns null when no migration is needed (values < 1000 are already in seconds)", () => { + expect(migrateSyncTimeoutFromMilliseconds(120)).toBeNull(); + expect(migrateSyncTimeoutFromMilliseconds(500)).toBeNull(); + expect(migrateSyncTimeoutFromMilliseconds(999)).toBeNull(); + expect(migrateSyncTimeoutFromMilliseconds(NaN)).toBeNull(); + }); + + it("converts milliseconds to seconds and sets display scale", () => { + // Value is always stored in seconds; scale is for display only + // Divisible by 60 → display as minutes + expect(migrateSyncTimeoutFromMilliseconds(60000)).toEqual({ value: 60, scale: 60 }); // 60s, display as 1 min + expect(migrateSyncTimeoutFromMilliseconds(120000)).toEqual({ value: 120, scale: 60 }); // 120s, display as 2 min + expect(migrateSyncTimeoutFromMilliseconds(3600000)).toEqual({ value: 3600, scale: 60 }); // 3600s, display as 60 min + + // Not divisible by 60 → display as seconds + expect(migrateSyncTimeoutFromMilliseconds(1000)).toEqual({ value: 1, scale: 1 }); + expect(migrateSyncTimeoutFromMilliseconds(45000)).toEqual({ value: 45, scale: 1 }); + expect(migrateSyncTimeoutFromMilliseconds(90000)).toEqual({ value: 90, scale: 1 }); + + // Rounds to nearest second + expect(migrateSyncTimeoutFromMilliseconds(120500)).toEqual({ value: 121, scale: 1 }); + }); +}); diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index 92912222d0..22c39fbe2a 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -66,14 +66,49 @@ async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts = optionService.createOption("textNoteEditorType", "ckeditor-classic", true); optionService.createOption("syncServerHost", opts.syncServerHost || "", false); - optionService.createOption("syncServerTimeout", "120000", false); + optionService.createOption("syncServerTimeout", "120", false); // 120 seconds (2 minutes) optionService.createOption("syncProxy", opts.syncProxy || "", false); } +/** + * Migrates a sync timeout value from milliseconds to seconds. + * Values >= 1000 are assumed to be in milliseconds (since 1000+ seconds = 16+ minutes is unlikely). + * TimeSelector stores values in seconds; the scale is only used for display. + * + * @returns The value in seconds and preferred display scale, or null if no migration is needed. + */ +export function migrateSyncTimeoutFromMilliseconds(milliseconds: number): { value: number; scale: number } | null { + if (isNaN(milliseconds) || milliseconds < 1000) { + return null; + } + + const seconds = Math.round(milliseconds / 1000); + + // Value is always stored in seconds; scale determines display unit + if (seconds >= 60 && seconds % 60 === 0) { + return { value: seconds, scale: 60 }; // display as minutes + } + return { value: seconds, scale: 1 }; // display as seconds +} + /** * Contains all the default options that must be initialized on new and existing databases (at startup). The value can also be determined based on other options, provided they have already been initialized. */ const defaultOptions: DefaultOption[] = [ + { + name: "syncServerTimeoutTimeScale", + value: (optionsMap) => { + const timeout = parseInt(optionsMap.syncServerTimeout || "120", 10); + const migrated = migrateSyncTimeoutFromMilliseconds(timeout); + if (migrated) { + optionService.setOption("syncServerTimeout", String(migrated.value)); + log.info(`Migrated syncServerTimeout from ${timeout}ms to ${migrated.value}s`); + return String(migrated.scale); + } + return "60"; // default to minutes + }, + isSynced: false + }, { name: "revisionSnapshotTimeInterval", value: "600", isSynced: true }, { name: "revisionSnapshotTimeIntervalTimeScale", value: "60", isSynced: true }, // default to Minutes { name: "revisionSnapshotNumberLimit", value: "-1", isSynced: true }, diff --git a/apps/server/src/services/search/services/parse.spec.ts b/apps/server/src/services/search/services/parse.spec.ts index cd59ce7f09..ccf4925499 100644 --- a/apps/server/src/services/search/services/parse.spec.ts +++ b/apps/server/src/services/search/services/parse.spec.ts @@ -285,7 +285,7 @@ describe("Invalid expressions", () => { searchContext }); - expect(searchContext.error).toEqual(`Error near token "#second" in "#first = #second", it's possible to compare with constant only.`); + expect(searchContext.error).toEqual(`Error in "#first = #second": cannot compare with "#second". To search for a literal value, use quotes: "#second"`); searchContext = new SearchContext(); searchContext.originalQuery = "#first = note.relations.second"; @@ -296,7 +296,7 @@ describe("Invalid expressions", () => { searchContext }); - expect(searchContext.error).toEqual(`Error near token "note" in "#first = note.relations.second", it's possible to compare with constant only.`); + expect(searchContext.error).toEqual(`Error in "#first = note.relations.second": "note" is a reserved keyword. To search for a literal value, use quotes: "note"`); const rootExp = parse( { @@ -317,6 +317,27 @@ describe("Invalid expressions", () => { expect(labelComparisonExp.attributeType).toEqual("label"); expect(labelComparisonExp.attributeName).toEqual("first"); expect(labelComparisonExp.comparator).toBeTruthy(); + + // Verify that quoted "note" keyword works (issue #8850) + const rootExp2 = parse( + { + fulltextTokens: [], + expressionTokens: [ + { token: "#clipType", inQuotes: false }, + { token: "=", inQuotes: false }, + { token: "note", inQuotes: true } + ], + searchContext: new SearchContext() + }, + AndExp + ); + + assertIsArchived(rootExp2.subExpressions[0]); + + const labelComparisonExp2 = expectExpression(rootExp2.subExpressions[2], LabelComparisonExp); + expect(labelComparisonExp2.attributeType).toEqual("label"); + expect(labelComparisonExp2.attributeName).toEqual("cliptype"); + expect(labelComparisonExp2.comparator).toBeTruthy(); }); it("searching by relation without note property", () => { diff --git a/apps/server/src/services/search/services/parse.ts b/apps/server/src/services/search/services/parse.ts index a117bf9928..2d6092280b 100644 --- a/apps/server/src/services/search/services/parse.ts +++ b/apps/server/src/services/search/services/parse.ts @@ -1,6 +1,5 @@ - - import { dayjs } from "@triliumnext/commons"; +import { t } from "i18next"; import { removeDiacritic } from "../../utils.js"; import AncestorExp from "../expressions/ancestor.js"; @@ -98,7 +97,10 @@ function getExpression(tokens: TokenData[], searchContext: SearchContext, level const operand = tokens[i]; if (!operand.inQuotes && (operand.token.startsWith("#") || operand.token.startsWith("~") || operand.token === "note")) { - searchContext.addError(`Error near token "${operand.token}" in ${context(i)}, it's possible to compare with constant only.`); + const hint = operand.token === "note" + ? t("search.error.reserved-keyword", { token: operand.token }) + : t("search.error.cannot-compare-with", { token: operand.token }); + searchContext.addError(t("search.error.in-context", { context: context(i), message: hint })); return null; } @@ -436,9 +438,15 @@ function getExpression(tokens: TokenData[], searchContext: SearchContext, level searchContext.addError("Mixed usage of AND/OR - always use parenthesis to group AND/OR expressions."); } } else if (isOperator({ token })) { - searchContext.addError(`Misplaced or incomplete expression "${token}"`); + searchContext.addError(t("search.error.misplaced-expression", { token })); } else { - searchContext.addError(`Unrecognized expression "${token}"`); + // Check if this looks like a fulltext search term placed after attribute filters + const looksLikeFulltext = !token.startsWith("#") && !token.startsWith("~") && !token.startsWith("note."); + if (looksLikeFulltext) { + searchContext.addError(t("search.error.fulltext-after-expression", { token })); + } else { + searchContext.addError(t("search.error.unrecognized-expression", { token })); + } } if (!op && expressions.length > 1) { diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts index c6a084a72b..5212cfb6e5 100644 --- a/apps/server/src/services/sql_init.ts +++ b/apps/server/src/services/sql_init.ts @@ -141,7 +141,7 @@ async function createInitialDatabase(skipDemoDb?: boolean) { // the previous solution was to move option initialization here, but then the important parts of initialization // are not all in one transaction (because ZIP import is async and thus not transactional) - const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 ORDER BY notePosition"); + const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root"; optionService.setOption( "openNoteContexts", diff --git a/apps/server/src/services/sync_options.spec.ts b/apps/server/src/services/sync_options.spec.ts new file mode 100644 index 0000000000..16d048b683 --- /dev/null +++ b/apps/server/src/services/sync_options.spec.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the dependencies before importing the module +vi.mock("./config.js", () => ({ default: { Sync: {} } })); +vi.mock("./options.js", () => ({ default: { getOption: vi.fn() } })); + +import config from "./config.js"; +import optionService from "./options.js"; +import syncOptions from "./sync_options.js"; + +describe("syncOptions.getSyncTimeout", () => { + beforeEach(() => { + (config as any).Sync = {}; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("converts database value from seconds to milliseconds", () => { + // TimeSelector stores value in seconds (displayed value × scale) + // Scale is UI-only, not used in backend calculation + vi.mocked(optionService.getOption).mockReturnValue("120"); // 120 seconds = 2 minutes + expect(syncOptions.getSyncTimeout()).toBe(120000); + + vi.mocked(optionService.getOption).mockReturnValue("30"); // 30 seconds + expect(syncOptions.getSyncTimeout()).toBe(30000); + + vi.mocked(optionService.getOption).mockReturnValue("3600"); // 3600 seconds = 1 hour + expect(syncOptions.getSyncTimeout()).toBe(3600000); + }); + + it("treats config override as raw milliseconds for backward compatibility", () => { + (config as any).Sync = { syncServerTimeout: "60000" }; // 60 seconds in ms + + // Config value takes precedence, db value is ignored + vi.mocked(optionService.getOption).mockReturnValue("9999"); + expect(syncOptions.getSyncTimeout()).toBe(60000); + }); + + it("uses safe defaults for invalid values", () => { + vi.mocked(optionService.getOption).mockReturnValue(""); + expect(syncOptions.getSyncTimeout()).toBe(120000); // default 120 seconds + + (config as any).Sync = { syncServerTimeout: "invalid" }; + expect(syncOptions.getSyncTimeout()).toBe(120000); // fallback for invalid config + }); +}); diff --git a/apps/server/src/services/sync_options.ts b/apps/server/src/services/sync_options.ts index 657c1b2149..1e98d105da 100644 --- a/apps/server/src/services/sync_options.ts +++ b/apps/server/src/services/sync_options.ts @@ -1,7 +1,5 @@ -"use strict"; - -import optionService from "./options.js"; import config from "./config.js"; +import optionService from "./options.js"; import { normalizeUrl } from "./utils.js"; /* @@ -29,6 +27,17 @@ export default { // and we need to override it with config from config.ini return !!syncServerHost && syncServerHost !== "disabled"; }, - getSyncTimeout: () => parseInt(get("syncServerTimeout")) || 120000, + // Value is stored in seconds (TimeSelector saves displayed value × scale). + // Config file overrides are treated as raw milliseconds for backward compatibility. + getSyncTimeout: () => { + const configValue = config["Sync"]?.syncServerTimeout; + if (configValue) { + // Config override: treat as raw milliseconds (backward compatible) + return parseInt(configValue, 10) || 120000; + } + // Database option: stored in seconds, convert to milliseconds + const seconds = parseInt(optionService.getOption("syncServerTimeout"), 10) || 120; + return seconds * 1000; + }, getSyncProxy: () => get("syncProxy") }; diff --git a/docs/Developer Guide/Developer Guide/Documentation.md b/docs/Developer Guide/Developer Guide/Documentation.md index 0614c5ab4e..0ebd70a6c6 100644 --- a/docs/Developer Guide/Developer Guide/Documentation.md +++ b/docs/Developer Guide/Developer Guide/Documentation.md @@ -1,5 +1,5 @@ # Documentation -There are multiple types of documentation for Trilium: +There are multiple types of documentation for Trilium: * The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing F1. * The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers. diff --git a/docs/User Guide/!!!meta.json b/docs/User Guide/!!!meta.json index df8794a49c..b04008c1a1 100644 --- a/docs/User Guide/!!!meta.json +++ b/docs/User Guide/!!!meta.json @@ -16459,6 +16459,13 @@ "value": "word-count", "isInheritable": false, "position": 40 + }, + { + "type": "relation", + "name": "internalLink", + "value": "hrZ1D00cLbal", + "isInheritable": false, + "position": 50 } ], "format": "markdown", diff --git a/docs/User Guide/User Guide/Installation & Setup/Backup.md b/docs/User Guide/User Guide/Installation & Setup/Backup.md index 0204da0b2d..dca45fbff7 100644 --- a/docs/User Guide/User Guide/Installation & Setup/Backup.md +++ b/docs/User Guide/User Guide/Installation & Setup/Backup.md @@ -14,7 +14,7 @@ Note that Synchronization Backup > Existing backups > Download +You can download an existing backup by going to Settings > Backup > Existing backups > Download ## Restoring backup diff --git a/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.md b/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.md index 8b6be5684f..137e7ec414 100644 --- a/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.md +++ b/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.md @@ -84,6 +84,8 @@ module.exports = new WordCountWidget(); After you make changes it is necessary to [restart Trilium](../../../Troubleshooting/Refreshing%20the%20application.md) so that the layout can be rebuilt. +The widget only activates on text notes that have the `#wordCount` label. This label can be a [reference link](../../../Note%20Types/Text/Links/Internal%20\(reference\)%20links.md) to enable the widget for an entire subtree. + At the bottom of the note you can see the resulting widget:
      \ No newline at end of file diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index 590e3436dc..08ab1ea45f 100644 --- a/packages/commons/src/lib/options_interface.ts +++ b/packages/commons/src/lib/options_interface.ts @@ -23,6 +23,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions