Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b92f559
feat(mobile): basic drill-down note navigation
eliandoran Apr 20, 2026
c110930
chore(mobile): enable drill-down only for standalone for now
eliandoran Apr 20, 2026
9e681a7
fix(mobile/nav): initial tree is not shown
eliandoran Apr 20, 2026
e2bce67
feat(mobile/nav): add preview for current note
eliandoran Apr 20, 2026
1714adc
feat(mobile/nav): special handling for leaf children
eliandoran Apr 20, 2026
24469b3
feat(mobile/nav): avoid flicker caused by preview rendering
eliandoran Apr 20, 2026
5d6f4cb
feat(mobile/nav): reduce flickering by showing spinner inline
eliandoran Apr 20, 2026
2e6a643
feat(mobile/nav): reduce flickering when navigating
eliandoran Apr 20, 2026
449a9f1
fix(mobile/nav): no children flickering while navigating
eliandoran Apr 20, 2026
fd3ea7a
feat(mobile/nav): add a short transition
eliandoran Apr 20, 2026
bf6bc53
feat(mobile/nav): use different presentation for note content
eliandoran Apr 20, 2026
17fdbf0
feat(mobile/nav): display previous note in the back arrow
eliandoran Apr 20, 2026
ac49c97
feat(mobile/nav): improve handling of empty notes
eliandoran Apr 20, 2026
9784051
feat(mobile/nav): add basic long press menu
eliandoran Apr 21, 2026
6c6d331
feat(mobile/nav): basic support for note colors
eliandoran Apr 21, 2026
167a400
fix(mobile/nav): colors not refreshing
eliandoran Apr 21, 2026
39d85d3
chore(mobile/nav): use reactive note titles
eliandoran Apr 21, 2026
51e687c
fix(mobile/nav): preview gradient shows even when not needed
eliandoran Apr 21, 2026
ac7ccbc
feat(mobile/nav): integrate full context menu for mobile
eliandoran Apr 21, 2026
2afea94
feat(mobile/nav): integrate clipboard and other options
eliandoran Apr 21, 2026
4fabe3c
chore(mobile/nav): hide collapse/expand subtree
eliandoran Apr 21, 2026
547f005
feat(mobile/nav): basic handling of hidden subtree
eliandoran Apr 21, 2026
73af598
fix(mobile/nav): basic handling of hidden subtree
eliandoran Apr 21, 2026
41f5a32
fix(mobile/nav): hoisting doesn't work
eliandoran Apr 21, 2026
4b40ada
feat(mobile/nav): hoisted badge indicator
eliandoran Apr 21, 2026
c509371
chore(mobile/nav): always enable new widget
eliandoran Apr 21, 2026
8d00975
feat(mobile/nav): functional +/[...] buttons for header
eliandoran Apr 21, 2026
2f7f4e9
feat(mobile/nav): improve layout for root
eliandoran Apr 21, 2026
b5735c1
fix(mobile/nav): tree not reacting to changes
eliandoran Apr 21, 2026
885b0b9
chore(mobile/nav): use a thinner border
eliandoran Apr 21, 2026
f53cb43
Merge remote-tracking branch 'origin/standalone' into standalone_mobi…
eliandoran Apr 21, 2026
34f09ae
e2e(support): failing after changes in mobile structure
eliandoran Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/client/src/components/app_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,16 @@ export interface CommandData {
* Represents a set of commands that are triggered from the context menu, providing information such as the selected note.
*/
export interface ContextMenuCommandData extends CommandData {
node: Fancytree.FancytreeNode;
/**
* Fancytree node for the target when the command originated from the
* Fancytree-based note tree. Omitted when dispatched from node-free UIs
* (e.g. the mobile drill-down navigator) — handlers should prefer the
* explicit `noteId` / `branchId` / `notePath` fields below.
*/
node?: Fancytree.FancytreeNode;
notePath?: string;
noteId?: string;
branchId?: string;
selectedOrActiveBranchIds: string[];
selectedOrActiveNoteIds?: string[];
}
Expand Down
100 changes: 98 additions & 2 deletions apps/client/src/components/main_tree_executors.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import appContext, { type EventData } from "./app_context.js";
import appContext, { type CommandListenerData, type EventData } from "./app_context.js";
import branchService from "../services/branches.js";
import clipboard from "../services/clipboard.js";
import froca from "../services/froca.js";
import hoistedNoteService from "../services/hoisted_note.js";
import noteCreateService from "../services/note_create.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import protectedSessionService from "../services/protected_session.js";
import treeService from "../services/tree.js";
import hoistedNoteService from "../services/hoisted_note.js";
import Component from "./component.js";

/**
* This class contains command executors which logically belong to the NoteTree widget, but for better user experience,
* the keyboard shortcuts must be active on the whole screen and not just on the widget itself, so the executors
* must be at the root of the component tree.
*
* Many of these commands are invoked from the tree's context menu (desktop) and the mobile drill-down navigator's
* context menu (mobile). Because the mobile navigator doesn't contain the `NoteTreeWidget`, command handlers
* that ought to run regardless of which UI triggered them live here instead of on the widget.
*/
export default class MainTreeExecutors extends Component {
/**
Expand Down Expand Up @@ -79,4 +88,91 @@ export default class MainTreeExecutors extends Component {
saveSelection: false
});
}

async deleteNotesCommand({ selectedOrActiveBranchIds }: CommandListenerData<"deleteNotes">) {
const branchIds = selectedOrActiveBranchIds.filter((branchId) => !branchId.startsWith("virt-")); // search results can't be deleted

if (!branchIds.length) {
return;
}

await branchService.deleteNotes(branchIds);

this.tree?.clearSelectedNodes();
}

async editBranchPrefixCommand({ node, selectedOrActiveBranchIds }: CommandListenerData<"editBranchPrefix">) {
const branchIds = selectedOrActiveBranchIds.filter((branchId) => !branchId.startsWith("virt-"));

if (!branchIds.length) {
return;
}

appContext.triggerEvent("editBranchPrefix", {
selectedOrActiveBranchIds: branchIds,
node
});
}

copyNotesToClipboardCommand({ selectedOrActiveBranchIds }: CommandListenerData<"copyNotesToClipboard">) {
clipboard.copy(selectedOrActiveBranchIds);
}

cutNotesToClipboardCommand({ selectedOrActiveBranchIds }: CommandListenerData<"cutNotesToClipboard">) {
clipboard.cut(selectedOrActiveBranchIds);
}

pasteNotesFromClipboardCommand({ node, branchId }: CommandListenerData<"pasteNotesFromClipboard">) {
const targetBranchId = branchId ?? node?.data.branchId;
if (!targetBranchId) return;
clipboard.pasteInto(targetBranchId);
}

pasteNotesAfterFromClipboardCommand({ node, branchId }: CommandListenerData<"pasteNotesAfterFromClipboard">) {
const targetBranchId = branchId ?? node?.data.branchId;
if (!targetBranchId) return;
clipboard.pasteAfter(targetBranchId);
}

async exportNoteCommand({ node, notePath }: CommandListenerData<"exportNote">) {
const path = notePath ?? (node ? treeService.getNotePath(node) : undefined);
if (!path) return;

this.triggerCommand("showExportDialog", { notePath: path, defaultType: "subtree" });
}

async importIntoNoteCommand({ node, noteId }: CommandListenerData<"importIntoNote">) {
const targetNoteId = noteId ?? node?.data.noteId;
if (!targetNoteId) return;
this.triggerCommand("showImportDialog", { noteId: targetNoteId });
}

protectSubtreeCommand({ node, noteId }: CommandListenerData<"protectSubtree">) {
const targetNoteId = noteId ?? node?.data.noteId;
if (!targetNoteId) return;
protectedSessionService.protectNote(targetNoteId, true, true);
}

unprotectSubtreeCommand({ node, noteId }: CommandListenerData<"unprotectSubtree">) {
const targetNoteId = noteId ?? node?.data.noteId;
if (!targetNoteId) return;
protectedSessionService.protectNote(targetNoteId, false, true);
}

async duplicateSubtreeCommand({ selectedOrActiveBranchIds }: CommandListenerData<"duplicateSubtree">) {
for (const branchId of selectedOrActiveBranchIds) {
const branch = froca.getBranch(branchId);
if (!branch) continue;
const note = await froca.getNote(branch.noteId);
if (!note) continue;
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) continue;
noteCreateService.duplicateSubtree(branch.noteId, branch.parentNoteId);
}
}

async recentChangesInSubtreeCommand({ node, noteId }: CommandListenerData<"recentChangesInSubtree">) {
const ancestorNoteId = noteId ?? node?.data.noteId;
if (!ancestorNoteId) return;
this.triggerCommand("showRecentChanges", { ancestorNoteId });
}
}
47 changes: 0 additions & 47 deletions apps/client/src/layouts/mobile_layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,50 +27,3 @@ kbd {
max-width: 350px;
}

/* #region Tree */
.tree-wrapper {
max-height: 100%;
margin-top: 0px;
overflow-y: auto;
contain: content;
padding-inline-start: 10px;
}

.fancytree-title {
margin-inline-start: 0.6em !important;
}

.fancytree-node {
padding: 5px;
}

span.fancytree-expander {
width: 24px !important;
margin-inline-end: 5px;
}

.fancytree-loading span.fancytree-expander {
width: 24px;
height: 32px;
}

.fancytree-loading span.fancytree-expander:after {
width: 20px;
height: 20px;
margin-top: 4px;
border-width: 2px;
border-style: solid;
}

.tree-wrapper .collapse-tree-button,
.tree-wrapper .scroll-to-active-note-button,
.tree-wrapper .tree-settings-button {
position: fixed;
margin-inline-end: 16px;
display: none;
}

.tree-wrapper .unhoist-button {
font-size: 200%;
}
/* #endregion */
12 changes: 9 additions & 3 deletions apps/client/src/layouts/mobile_layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,16 +16,15 @@ 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";
import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteTitleWidget from "../widgets/note_title.js";
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";
Expand All @@ -48,7 +48,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(<MobileNoteNavigator />)
)
)
.child(
new ScreenContainer("detail", "row")
Expand Down
9 changes: 6 additions & 3 deletions apps/client/src/menus/link_context_menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,13 @@ function getNtxId(e: ContextMenuEvent | LeafletMouseEvent) {
if (!subContexts) return null;
return subContexts[subContexts.length - 1].ntxId;
} else if (e.target instanceof HTMLElement) {
return getClosestNtxId(e.target);
const closest = getClosestNtxId(e.target);
if (closest) return closest;
}
return null;

// Fallback: when the event originates outside any note-context DOM
// (e.g. mobile sidebar), target the currently active context so downstream
// lookups don't fail with a null ntxId.
return appContext.tabManager.activeNtxId ?? null;
}

export default {
Expand Down
Loading
Loading