Compare commits

..

26 Commits

Author SHA1 Message Date
Elian Doran
fd3ea7a1f7 feat(mobile/nav): add a short transition 2026-04-21 00:12:18 +03:00
Elian Doran
449a9f1436 fix(mobile/nav): no children flickering while navigating 2026-04-21 00:07:00 +03:00
Elian Doran
2e6a643ead feat(mobile/nav): reduce flickering when navigating 2026-04-21 00:05:49 +03:00
Elian Doran
5d6f4cb50a feat(mobile/nav): reduce flickering by showing spinner inline 2026-04-20 23:59:46 +03:00
Elian Doran
24469b32ea feat(mobile/nav): avoid flicker caused by preview rendering 2026-04-20 23:50:48 +03:00
Elian Doran
1714adc4e2 feat(mobile/nav): special handling for leaf children 2026-04-20 23:42:39 +03:00
Elian Doran
e2bce67d28 feat(mobile/nav): add preview for current note 2026-04-20 23:38:10 +03:00
Elian Doran
9e681a7ec7 fix(mobile/nav): initial tree is not shown 2026-04-20 23:31:44 +03:00
Elian Doran
c110930bde chore(mobile): enable drill-down only for standalone for now 2026-04-20 23:27:13 +03:00
Elian Doran
b92f5598f5 feat(mobile): basic drill-down note navigation 2026-04-20 23:26:50 +03:00
Elian Doran
941d75b5b4 Merge branch 'standalone' of https://github.com/TriliumNext/Trilium into standalone 2026-04-20 23:17:07 +03:00
Elian Doran
baf6d333c1 chore: add standalone:test script 2026-04-20 23:15:26 +03:00
Elian Doran
683e5ce985 Merge remote-tracking branch 'origin/main' into standalone 2026-04-20 23:12:28 +03:00
Elian Doran
a988543487 fix(share): unchecked TODOs not visible in dark theme (closes #8944) 2026-04-20 08:56:44 +03:00
Elian Doran
d625565830 Update Node.js to v24.15.0 (#9505) 2026-04-20 08:22:34 +03:00
Elian Doran
ef7facc3f0 Update dependency force-graph to v1.51.4 (#9499) 2026-04-20 08:21:49 +03:00
Elian Doran
b20d2436fa Update dependency @types/express-session to v1.19.0 (#9503) 2026-04-20 08:20:00 +03:00
Elian Doran
516a177bfb Update actions/checkout action to v6 (#9506) 2026-04-20 08:19:27 +03:00
Elian Doran
26efc3a8ff Update dependency wxt to v0.20.23 (#9502) 2026-04-20 08:18:11 +03:00
Elian Doran
91e989719b Update dependency typescript to v6.0.3 (#9501) 2026-04-20 08:17:15 +03:00
renovate[bot]
390877931b Update actions/checkout action to v6 2026-04-20 01:09:06 +00:00
renovate[bot]
f794a34132 Update Node.js to v24.15.0 2026-04-20 01:08:59 +00:00
renovate[bot]
67f5fc3dbc Update dependency @types/express-session to v1.19.0 2026-04-20 01:08:10 +00:00
renovate[bot]
753475ee46 Update dependency wxt to v0.20.23 2026-04-20 01:07:31 +00:00
renovate[bot]
90e4e73316 Update dependency typescript to v6.0.3 2026-04-20 01:06:55 +00:00
renovate[bot]
b5a4956188 Update dependency force-graph to v1.51.4 2026-04-20 01:05:37 +00:00
28 changed files with 894 additions and 258 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(gh issue *)"
]
}
}

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1

View File

@@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1

View File

@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { initializeCore, options } from "@triliumnext/core";
import schemaSql from "@triliumnext/core/src/assets/schema.sql?raw";
import HappyDomHtmlParser from "happy-dom/lib/html-parser/HTMLParser.js";
import serverEnTranslations from "../../server/src/assets/translations/en/server.json";
import { beforeAll } from "vitest";
@@ -70,17 +71,23 @@ WebAssembly.instantiateStreaming = (async (source, importObject) => {
// Per HTML5 parsing spec, a single U+000A LINE FEED immediately after a <pre>,
// <listing>, or <textarea> start tag must be ignored ("newlines at the start
// of pre blocks are ignored as an authoring convenience"). Real browsers and
// domino (which turnish uses in Node) both implement this; happy-dom does not.
// Patch at the DOMParser boundary since turnish prefers DOMParser when it's
// available — patching via module-level HTMLParser import hits a different
// happy-dom copy than the vitest env loaded.
// domino (which the server runtime uses via turnish) both implement this;
// happy-dom (as of 20.8.9) does not — it keeps the LF as a text node.
//
// That difference makes turnish's markdown export produce different output
// under happy-dom vs. production, breaking markdown.spec.ts > "exports jQuery
// code in table properly". Patch HTMLParser.parse to pre-process the string.
const LEADING_LF_IN_PRE_RE = /(<(?:pre|listing|textarea)\b[^>]*>)(\r\n|\r|\n)/gi;
const originalParseFromString = DOMParser.prototype.parseFromString;
DOMParser.prototype.parseFromString = function (source: string, type: DOMParserSupportedType) {
const patched = typeof source === "string"
? source.replace(LEADING_LF_IN_PRE_RE, "$1")
: source;
return originalParseFromString.call(this, patched, type);
const originalHtmlParserParse = (HappyDomHtmlParser as unknown as {
prototype: { parse(html: string, rootNode?: unknown): unknown };
}).prototype.parse;
(HappyDomHtmlParser as unknown as {
prototype: { parse(html: string, rootNode?: unknown): unknown };
}).prototype.parse = function (html: string, rootNode?: unknown) {
const patched = typeof html === "string"
? html.replace(LEADING_LF_IN_PRE_RE, "$1")
: html;
return originalHtmlParserParse.call(this, patched, rootNode);
};
// =============================================================================

View File

@@ -51,7 +51,7 @@
"debounce": "3.0.0",
"dompurify": "3.4.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"force-graph": "1.51.4",
"htmldiff-js": "1.0.5",
"i18next": "26.0.4",
"i18next-http-backend": "3.0.4",

View File

@@ -27,7 +27,7 @@ kbd {
max-width: 350px;
}
/* #region Tree */
/* #region Tree (non-standalone mobile: Fancytree-based) */
.tree-wrapper {
max-height: 100%;
margin-top: 0px;

View File

@@ -1,6 +1,7 @@
import "./mobile_layout.css";
import type AppContext from "../components/app_context.js";
import { isMobileApp } from "../services/utils";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import CloseZenModeButton from "../widgets/close_zen_button.js";
import NoteList from "../widgets/collections/NoteList.jsx";
@@ -15,6 +16,7 @@ import NoteBadges from "../widgets/layout/NoteBadges.jsx";
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import MobileNoteNavigator from "../widgets/mobile_widgets/MobileNoteNavigator.jsx";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
@@ -24,7 +26,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import QuickSearchWidget from "../widgets/quick_search.js";
import { isMobileApp } from "../services/utils";
import ScrollPadding from "../widgets/scroll_padding";
import SearchResult from "../widgets/search_result.jsx";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
@@ -48,7 +49,13 @@ export default class MobileLayout {
.css("padding-inline-start", "0")
.css("padding-inline-end", "0")
.css("contain", "content")
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget()))
.child(
new FlexContainer("column")
.filling()
.id("mobile-sidebar-wrapper")
.child(new QuickSearchWidget())
.child(glob.isStandalone ? <MobileNoteNavigator /> : new NoteTreeWidget())
)
)
.child(
new ScreenContainer("detail", "row")

View File

@@ -1861,6 +1861,11 @@
"subtree-hidden-moved-description-collection": "This collection hides its child notes in the tree.",
"subtree-hidden-moved-description-other": "Child notes are hidden in the tree for this note."
},
"mobile_note_navigator": {
"back": "Go up one level",
"loading": "Loading...",
"empty": "This note has no children."
},
"title_bar_buttons": {
"window-on-top": "Keep Window on Top"
},

View File

@@ -207,19 +207,22 @@ function NoteAttributes({ note }: { note: FNote }) {
return <span className="note-list-attributes" ref={ref} />;
}
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes, showTextRepresentation }: {
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes, showTextRepresentation, onReady }: {
note: FNote;
trim?: boolean;
noChildrenList?: boolean;
highlightedTokens: string[] | null | undefined;
includeArchivedNotes: boolean;
showTextRepresentation?: boolean;
onReady?: () => void;
}) {
const contentRef = useRef<HTMLDivElement>(null);
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
const [ready, setReady] = useState(false);
const [noteType, setNoteType] = useState<string>("none");
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
useEffect(() => {
const contentElement = contentRef.current;
@@ -233,6 +236,7 @@ export function NoteContent({ note, trim, noChildrenList, highlightedTokens, inc
}, []);
useEffect(() => {
setReady(false);
content_renderer.getRenderedContent(note, {
trim,
noChildrenList,
@@ -250,12 +254,14 @@ export function NoteContent({ note, trim, noChildrenList, highlightedTokens, inc
highlightSearch(contentRef.current);
setNoteType(type);
setReady(true);
onReadyRef.current?.();
})
.catch(e => {
console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`);
console.error(e);
contentRef.current?.replaceChildren(t("collections.rendering_error"));
setReady(true);
onReadyRef.current?.();
});
}, [ note, highlightedTokens ]);

View File

@@ -0,0 +1,209 @@
.mobile-note-navigator {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
contain: content;
}
.mobile-navigator-toolbar {
display: flex;
align-items: center;
padding: 4px 6px;
border-bottom: 1px solid var(--main-border-color);
background: var(--main-background-color);
}
.mobile-navigator-back {
font-size: 1.6em;
min-width: 40px;
min-height: 40px;
}
.mobile-navigator-back.invisible {
visibility: hidden;
}
.mobile-navigator-scroll {
position: relative;
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
overscroll-behavior: contain;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-navigator-scroll.is-pending .mobile-navigator-body {
visibility: hidden;
}
.mobile-navigator-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8em;
color: var(--muted-text-color, var(--main-text-color));
pointer-events: none;
}
.mobile-navigator-current-tile {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
background: var(--accented-background-color, var(--hover-item-background-color));
border-bottom: 2px solid var(--main-border-color);
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
color: var(--main-text-color);
}
.mobile-navigator-current-tile:active {
filter: brightness(0.95);
}
.mobile-navigator-current-tile.is-active {
background: var(--active-item-background-color);
color: var(--active-item-text-color);
}
.mobile-navigator-current-tile.is-archived {
opacity: 0.7;
}
.mobile-navigator-current-header {
display: flex;
align-items: center;
gap: 10px;
min-height: 32px;
}
.mobile-navigator-current-icon {
flex: 0 0 auto;
font-size: 1.3em;
}
.mobile-navigator-current-title {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.15em;
font-weight: 600;
}
.mobile-navigator-current-open {
flex: 0 0 auto;
font-size: 1.1em;
opacity: 0.6;
}
.mobile-navigator-current-preview {
position: relative;
height: 140px;
overflow: hidden;
font-size: 0.92em;
line-height: 1.35;
color: var(--muted-text-color, var(--main-text-color));
pointer-events: none;
}
/* Hide stale content during the brief window between commit and the new content
being rendered into the DOM, so the preview doesn't flash the previous note's
content before the new one arrives. */
.mobile-navigator-current-preview .note-book-content:not(.note-book-content-ready) {
visibility: hidden;
}
.mobile-navigator-current-preview::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: 0;
height: 32px;
background: linear-gradient(to bottom, transparent, var(--accented-background-color, var(--hover-item-background-color)));
}
.mobile-navigator-current-preview:empty,
.mobile-navigator-current-preview .note-book-content:empty {
display: none;
}
.mobile-navigator-current-preview .note-book-content {
max-width: 100%;
}
.mobile-navigator-current-preview img,
.mobile-navigator-current-preview video {
max-width: 100%;
height: auto;
}
.mobile-navigator-list {
display: flex;
flex-direction: column;
}
.mobile-navigator-row {
display: flex;
align-items: center;
gap: 12px;
min-height: 48px;
padding: 10px 14px;
border-bottom: 1px solid var(--main-border-color);
color: var(--main-text-color);
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.mobile-navigator-row:active {
background: var(--hover-item-background-color);
}
.mobile-navigator-row.is-active {
background: var(--active-item-background-color);
color: var(--active-item-text-color);
}
.mobile-navigator-row.is-archived {
opacity: 0.55;
}
.mobile-navigator-row.is-pending {
cursor: progress;
}
.mobile-navigator-preloader {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
pointer-events: none;
visibility: hidden;
}
.mobile-navigator-row-icon {
flex: 0 0 auto;
font-size: 1.2em;
}
.mobile-navigator-row-title {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1em;
}
.mobile-navigator-row-chevron {
flex: 0 0 auto;
font-size: 1.4em;
opacity: 0.5;
}

View File

@@ -0,0 +1,368 @@
import "./MobileNoteNavigator.css";
import clsx from "clsx";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type FNote from "../../entities/fnote";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import { NoteContent } from "../collections/legacy/ListOrGridView";
import ActionButton from "../react/ActionButton";
import {
useActiveNoteContext,
useNote,
useNoteIcon,
useTriliumEvent,
useTriliumOptionBool
} from "../react/hooks";
import Icon from "../react/Icon";
import NoItems from "../react/NoItems";
/**
* A touch-native replacement for the Fancytree-based note tree on mobile. Shows one
* "column" of children at a time (iOS Files / macOS Finder style): the column's
* current note is rendered at the top with a content preview and opens on tap,
* while tapping child rows drills deeper without navigating.
*/
export default function MobileNoteNavigator() {
const { notePath: activeNotePath, hoistedNoteId, noteContext, parentComponent } = useActiveNoteContext();
const [hideArchived] = useTriliumOptionBool("hideArchivedNotes_main");
const effectiveHoistedId = hoistedNoteId ?? "root";
const [stack, setStack] = useState<string[]>([effectiveHoistedId]);
// When set, a stack we want to navigate to. We render a hidden NoteContent for its
// target to preload the preview, then commit the stack once the preview is ready.
// This keeps the previously-committed view visible during drill/back transitions
// so there's no placeholder flash.
const [nextStack, setNextStack] = useState<string[] | null>(null);
const manualStackRef = useRef(false);
// Sync stack with the active note path unless the user has manually drilled.
useEffect(() => {
if (manualStackRef.current) return;
const newStack = buildStackForActiveNote(activeNotePath, effectiveHoistedId);
setStack(newStack);
setNextStack(null);
}, [activeNotePath, effectiveHoistedId]);
// Rebuild when Froca reports structural changes that could affect the current column.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
const parentPath = stack[stack.length - 1];
const parentNoteId = getLastSegment(parentPath);
if (loadResults.getBranchRows().some((b) => b.parentNoteId === parentNoteId)) {
setStack((prev) => [...prev]);
}
});
const currentParentPath = stack[stack.length - 1];
const currentParentId = getLastSegment(currentParentPath);
const parentNote = useNote(currentParentId);
const parentIcon = useNoteIcon(parentNote);
const activeNoteId = activeNotePath ? getLastSegment(activeNotePath) : undefined;
const pendingPath = nextStack?.[nextStack.length - 1];
const pendingId = pendingPath ? getLastSegment(pendingPath) : undefined;
const pendingNote = useNote(pendingId);
const pendingChildId = nextStack && nextStack.length > stack.length ? pendingId : undefined;
const pendingPop = !!nextStack && nextStack.length < stack.length;
// Froca's initial `tree` response only returns the top of the tree; deeper levels are
// normally pulled in by Fancytree's lazy-load. The navigator doesn't use Fancytree,
// so we pull the subtree ourselves whenever the current parent changes.
const [loadedParents, setLoadedParents] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (!currentParentId || loadedParents.has(currentParentId)) return;
let cancelled = false;
froca.loadSubTree(currentParentId).then(() => {
if (cancelled) return;
setLoadedParents((prev) => {
if (prev.has(currentParentId)) return prev;
const next = new Set(prev);
next.add(currentParentId);
return next;
});
});
return () => {
cancelled = true;
};
}, [currentParentId, loadedParents]);
const isLoaded = !!currentParentId && loadedParents.has(currentParentId);
const children = useNavigatorChildren(parentNote, hideArchived, isLoaded);
const canGoBack = stack.length > 1;
// Gate the visible body on the content preview being ready to avoid a layout shift
// when the preview finishes rendering. Tied to the parent's noteId so a stale "ready"
// flag from the previous column can't leak into the new one.
const [readyForNoteId, setReadyForNoteId] = useState<string | null>(null);
const previewReady = !!parentNote && readyForNoteId === parentNote.noteId;
const bodyVisible = previewReady && isLoaded;
const onPreviewReady = useCallback(() => {
if (parentNote) setReadyForNoteId(parentNote.noteId);
}, [parentNote]);
// Full-screen spinner only for the very first render — once a body has been shown,
// subsequent navigations swap views without a global placeholder.
const hasCommittedOnceRef = useRef(false);
if (bodyVisible) hasCommittedOnceRef.current = true;
const showInitialLoader = !bodyVisible && !hasCommittedOnceRef.current;
// Preload state for the pending stack target. Once ready, commit the stack change.
const [nextReadyNoteId, setNextReadyNoteId] = useState<string | null>(null);
const onPendingReady = useCallback(() => {
if (pendingNote) setNextReadyNoteId(pendingNote.noteId);
}, [pendingNote]);
const scrollRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const directionRef = useRef<"forward" | "backward" | null>(null);
const [commitCounter, setCommitCounter] = useState(0);
useEffect(() => {
if (!nextStack || !pendingId || nextReadyNoteId !== pendingId) return;
setStack(nextStack);
setReadyForNoteId(pendingId);
setNextStack(null);
setNextReadyNoteId(null);
setCommitCounter((c) => c + 1);
// Reset scroll so the committed tile is visible at the top of the column.
if (scrollRef.current) scrollRef.current.scrollTop = 0;
}, [nextStack, pendingId, nextReadyNoteId]);
// Brief slide-in on forward / back so the swap feels directional without blocking the user.
useLayoutEffect(() => {
if (commitCounter === 0) return;
const direction = directionRef.current;
directionRef.current = null;
if (!direction || !bodyRef.current) return;
const offset = direction === "forward" ? "60%" : "-60%";
bodyRef.current.animate(
[
{ transform: `translateX(${offset})`, opacity: 0.4 },
{ transform: "translateX(0)", opacity: 1 }
],
{ duration: 180, easing: "ease-out" }
);
}, [commitCounter]);
const navigateTo = useCallback(
(newStack: string[]) => {
if (hasCommittedOnceRef.current) {
setNextStack(newStack);
} else {
setStack(newStack);
}
},
[]
);
const goBack = useCallback(() => {
if (stack.length <= 1) return;
manualStackRef.current = true;
directionRef.current = "backward";
navigateTo(stack.slice(0, -1));
}, [stack, navigateTo]);
const openNotePath = useCallback(
async (notePath: string) => {
await noteContext?.setNote(notePath);
manualStackRef.current = false;
parentComponent?.triggerCommand("setActiveScreen", { screen: "detail" });
},
[noteContext, parentComponent]
);
const openCurrent = useCallback(() => {
if (!currentParentPath) return;
openNotePath(currentParentPath);
}, [currentParentPath, openNotePath]);
const drillInto = useCallback(
(childNotePath: string) => {
manualStackRef.current = true;
directionRef.current = "forward";
navigateTo([...stack, childNotePath]);
},
[stack, navigateTo]
);
const isCurrentActive = !!activeNoteId && activeNoteId === currentParentId;
return (
<div className="mobile-note-navigator">
<div className="mobile-navigator-toolbar">
<ActionButton
className={clsx("mobile-navigator-back", !canGoBack && "invisible")}
icon={pendingPop ? "bx bx-loader bx-spin" : "bx bx-chevron-left"}
text={t("mobile_note_navigator.back")}
onClick={canGoBack && !pendingPop ? goBack : undefined}
disabled={!canGoBack || pendingPop}
/>
</div>
<div ref={scrollRef} className={clsx("mobile-navigator-scroll", showInitialLoader && "is-pending")}>
<div ref={bodyRef} className="mobile-navigator-body">
{parentNote && (
<div
className={clsx("mobile-navigator-current-tile", {
"is-active": isCurrentActive,
"is-archived": parentNote.isArchived
})}
role="button"
tabIndex={0}
onClick={openCurrent}
>
<div className="mobile-navigator-current-header">
<Icon icon={parentIcon ?? "bx bx-folder"} className="mobile-navigator-current-icon" />
<span className="mobile-navigator-current-title">{parentNote.title}</span>
<Icon icon="bx bx-link-external" className="mobile-navigator-current-open" />
</div>
<div className="mobile-navigator-current-preview">
<NoteContent
note={parentNote}
trim
noChildrenList
highlightedTokens={null}
includeArchivedNotes={!hideArchived}
onReady={onPreviewReady}
/>
</div>
</div>
)}
<div className="mobile-navigator-list">
{!isLoaded || !parentNote ? null : children.length === 0 ? (
<NoItems icon="bx bx-folder-open" text={t("mobile_note_navigator.empty")} />
) : (
children.map((child) => (
<NavigatorRow
key={child.branchId}
note={child.note}
prefix={child.prefix}
childNotePath={`${currentParentPath}/${child.note.noteId}`}
isActive={child.note.noteId === activeNoteId}
isPending={child.note.noteId === pendingChildId}
onDrill={drillInto}
onOpen={openNotePath}
/>
))
)}
</div>
</div>
{showInitialLoader && (
<div className="mobile-navigator-placeholder">
<span className="bx bx-loader bx-spin" />
</div>
)}
</div>
{/* Hidden preloader: renders NoteContent for the pending target so we can
commit the stack change only after its preview is ready. */}
{pendingNote && (
<div className="mobile-navigator-preloader" aria-hidden="true">
<NoteContent
key={pendingNote.noteId}
note={pendingNote}
trim
noChildrenList
highlightedTokens={null}
includeArchivedNotes={!hideArchived}
onReady={onPendingReady}
/>
</div>
)}
</div>
);
}
interface NavigatorRowProps {
note: FNote;
prefix?: string;
childNotePath: string;
isActive: boolean;
isPending: boolean;
onDrill: (notePath: string) => void;
onOpen: (notePath: string) => void;
}
function NavigatorRow({ note, prefix, childNotePath, isActive, isPending, onDrill, onOpen }: NavigatorRowProps) {
const icon = useNoteIcon(note);
const hasChildren = note.hasChildren();
const colorClass = note.getColorClass();
const title = prefix ? `${prefix} - ${note.title}` : note.title;
return (
<div
className={clsx("mobile-navigator-row", colorClass, {
"is-active": isActive,
"is-archived": note.isArchived,
"is-pending": isPending,
"has-children": hasChildren
})}
role="button"
tabIndex={0}
onClick={isPending ? undefined : () => (hasChildren ? onDrill(childNotePath) : onOpen(childNotePath))}
>
<Icon icon={icon ?? "bx bx-note"} className="mobile-navigator-row-icon" />
<span className="mobile-navigator-row-title">{title}</span>
{isPending ? (
<Icon icon="bx bx-loader bx-spin" className="mobile-navigator-row-chevron" />
) : hasChildren ? (
<Icon icon="bx bx-chevron-right" className="mobile-navigator-row-chevron" />
) : null}
</div>
);
}
interface NavigatorChild {
note: FNote;
branchId: string;
prefix?: string;
}
function useNavigatorChildren(parentNote: FNote | null | undefined, hideArchived: boolean, isLoaded: boolean): NavigatorChild[] {
return useMemo(() => {
if (!parentNote || !isLoaded) return [];
const result: NavigatorChild[] = [];
for (const branch of parentNote.getChildBranches()) {
if (!branch) continue;
const note = froca.getNoteFromCache(branch.noteId);
if (!note) continue;
if (note.noteId === "_hidden") continue;
if (hideArchived && note.isArchived) continue;
result.push({ note, branchId: branch.branchId, prefix: branch.prefix });
}
return result;
}, [parentNote, parentNote?.children?.join(","), hideArchived, isLoaded]);
}
function getLastSegment(notePath: string): string {
const idx = notePath.lastIndexOf("/");
return idx >= 0 ? notePath.slice(idx + 1) : notePath;
}
function buildStackForActiveNote(activeNotePath: string | null | undefined, hoistedId: string): string[] {
if (!activeNotePath) return [hoistedId];
const segments = activeNotePath.split("/");
const activeNoteId = segments[segments.length - 1];
const activeNote = froca.getNoteFromCache(activeNoteId);
// If the active note is a folder, show its own children; otherwise show its parent's.
const parentSegments = activeNote?.hasChildren() ? segments : segments.slice(0, -1);
// Clamp to the hoisted root.
let start = parentSegments.indexOf(hoistedId);
if (start < 0) start = 0;
const clamped = parentSegments.slice(start);
if (clamped.length === 0) {
return [hoistedId];
}
const stack: string[] = [];
for (let i = 0; i < clamped.length; i++) {
stack.push(clamped.slice(0, i + 1).join("/"));
}
return stack;
}

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-bullseye-slim AS builder
FROM node:24.15.0-bullseye-slim AS builder
RUN corepack enable
# Install build tools required to compile native addons (e.g. better-sqlite3) on ARM
@@ -15,7 +15,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-bullseye-slim
FROM node:24.15.0-bullseye-slim
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-alpine AS builder
FROM node:24.15.0-alpine AS builder
RUN corepack enable
# Install build tools required to compile native addons (e.g. better-sqlite3) on ARM
@@ -10,7 +10,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-alpine
FROM node:24.15.0-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-alpine AS builder
FROM node:24.15.0-alpine AS builder
RUN corepack enable
# Install build tools required to compile native addons (e.g. better-sqlite3) on ARM
@@ -10,7 +10,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-alpine
FROM node:24.15.0-alpine
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-bullseye-slim AS builder
FROM node:24.15.0-bullseye-slim AS builder
RUN corepack enable
# Install build tools required to compile native addons (e.g. better-sqlite3) on ARM
@@ -15,7 +15,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-bullseye-slim
FROM node:24.15.0-bullseye-slim
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -58,7 +58,7 @@
"@types/debounce": "1.2.4",
"@types/ejs": "3.1.5",
"@types/express-http-proxy": "1.6.7",
"@types/express-session": "1.18.2",
"@types/express-session": "1.19.0",
"@types/fs-extra": "11.0.4",
"@types/html": "1.0.4",
"@types/ini": "4.1.1",

View File

@@ -16,7 +16,7 @@
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@wxt-dev/auto-icons": "1.1.1",
"wxt": "0.20.22"
"wxt": "0.20.23"
},
"dependencies": {
"cash-dom": "8.1.5"

View File

@@ -19,7 +19,7 @@
"@preact/preset-vite": "2.10.5",
"eslint": "10.2.0",
"eslint-config-preact": "2.0.0",
"typescript": "6.0.2",
"typescript": "6.0.3",
"user-agent-data-types": "0.4.3",
"vite": "8.0.8",
"vitest": "4.1.4"

View File

@@ -20,6 +20,7 @@
"desktop:start-prod": "pnpm run --filter desktop start-prod",
"standalone:start": "pnpm run --filter client-standalone dev",
"standalone:build": "pnpm run --filter client-standalone build",
"standalone:test": "pnpm run --filter client-standalone test",
"mobile:sync": "pnpm run --filter mobile sync",
"desktop:start-prod-no-dir": "pnpm run --filter desktop start-prod-no-dir",
"edit-docs:edit-docs": "pnpm run --filter edit-docs edit-docs",
@@ -79,7 +80,7 @@
"rollup-plugin-webpack-stats": "3.1.1",
"tslib": "2.8.1",
"tsx": "4.21.0",
"typescript": "6.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.58.2",
"vite": "8.0.8",
"vite-plugin-dts": "4.5.4",

View File

@@ -27,7 +27,7 @@
"eslint-config-ckeditor5": ">=9.1.0",
"stylelint": "17.7.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"typescript": "6.0.2",
"typescript": "6.0.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.4",
"webdriverio": "9.27.0"

View File

@@ -28,7 +28,7 @@
"eslint-config-ckeditor5": ">=9.1.0",
"stylelint": "17.7.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"typescript": "6.0.2",
"typescript": "6.0.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.4",
"webdriverio": "9.27.0"

View File

@@ -30,7 +30,7 @@
"eslint-config-ckeditor5": ">=9.1.0",
"stylelint": "17.7.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"typescript": "6.0.2",
"typescript": "6.0.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.4",
"webdriverio": "9.27.0"

View File

@@ -30,7 +30,7 @@
"eslint-config-ckeditor5": ">=9.1.0",
"stylelint": "17.7.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"typescript": "6.0.2",
"typescript": "6.0.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.4",
"webdriverio": "9.27.0"

View File

@@ -30,7 +30,7 @@
"eslint-config-ckeditor5": ">=9.1.0",
"stylelint": "17.7.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"typescript": "6.0.2",
"typescript": "6.0.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.4",
"webdriverio": "9.27.0"

View File

@@ -37,6 +37,6 @@
"esbuild": "0.28.0",
"eslint": "10.2.0",
"highlight.js": "11.11.1",
"typescript": "6.0.2"
"typescript": "6.0.3"
}
}

View File

@@ -121,3 +121,8 @@ iframe.pdf-view {
.ck-content .ck-content-widget.footnote-section .ck-content-widget__type-around__button_after {
display: none;
}
.ck-content .todo-list .todo-list__label > input:before {
background-color: var(--background-primary);
border-color: var(--text-primary) !important;
}

View File

@@ -8,7 +8,6 @@ import type BNote from "../../becca/entities/bnote.js";
import { getContext } from "../context.js";
import sql_init from "../sql_init.js";
import TaskContext from "../task_context.js";
import { decodeUtf8 } from "../utils/binary.js";
import enex from "./enex.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
@@ -62,15 +61,15 @@ describe("importEnex", () => {
const txt = attachments.find(a => a.title === "attachments1.txt");
expect(txt).toBeTruthy();
expect(txt!.mime).toBe("text/plain");
expect(decodeUtf8(txt!.getContent())).toBe("111");
expect(txt!.getContent().toString()).toBe("111");
const bin = attachments.find(a => a.title === "attachments2");
expect(bin).toBeTruthy();
expect(bin!.mime).toBe("application/octet-stream");
expect(decodeUtf8(bin!.getContent())).toBe("222");
expect(bin!.getContent().toString()).toBe("222");
// The note content should contain reference links to the attachments
const content = decodeUtf8(test1!.getContent());
const content = test1!.getContent().toString();
expect(content).toContain(`class="reference-link" href="#root/${test1!.noteId}?viewMode=attachments&amp;attachmentId=${txt!.attachmentId}"`);
expect(content).toContain(`class="reference-link" href="#root/${test1!.noteId}?viewMode=attachments&amp;attachmentId=${bin!.attachmentId}"`);
});

460
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff