mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 09:17:35 +02:00
Compare commits
26 Commits
standalone
...
standalone
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd3ea7a1f7 | ||
|
|
449a9f1436 | ||
|
|
2e6a643ead | ||
|
|
5d6f4cb50a | ||
|
|
24469b32ea | ||
|
|
1714adc4e2 | ||
|
|
e2bce67d28 | ||
|
|
9e681a7ec7 | ||
|
|
c110930bde | ||
|
|
b92f5598f5 | ||
|
|
941d75b5b4 | ||
|
|
baf6d333c1 | ||
|
|
683e5ce985 | ||
|
|
a988543487 | ||
|
|
d625565830 | ||
|
|
ef7facc3f0 | ||
|
|
b20d2436fa | ||
|
|
516a177bfb | ||
|
|
26efc3a8ff | ||
|
|
91e989719b | ||
|
|
390877931b | ||
|
|
f794a34132 | ||
|
|
67f5fc3dbc | ||
|
|
753475ee46 | ||
|
|
90e4e73316 | ||
|
|
b5a4956188 |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(gh issue *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
|
||||
209
apps/client/src/widgets/mobile_widgets/MobileNoteNavigator.css
Normal file
209
apps/client/src/widgets/mobile_widgets/MobileNoteNavigator.css
Normal 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;
|
||||
}
|
||||
368
apps/client/src/widgets/mobile_widgets/MobileNoteNavigator.tsx
Normal file
368
apps/client/src/widgets/mobile_widgets/MobileNoteNavigator.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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&attachmentId=${txt!.attachmentId}"`);
|
||||
expect(content).toContain(`class="reference-link" href="#root/${test1!.noteId}?viewMode=attachments&attachmentId=${bin!.attachmentId}"`);
|
||||
});
|
||||
|
||||
460
pnpm-lock.yaml
generated
460
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user