From 00c272574405813050b13b4b1b89979fdaa374c3 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 4 Sep 2026 18:42:29 -0500 Subject: [PATCH 1/3] chore: pin ZenNotes 2.45.0 and bump to 1.1.16 The arrow keys work in the [[, @ and / menus again in app-core, which the shell inherits; nothing else in the shell changes. Claude-Session: https://claude.ai/code/session_015HNdWonTE8g6dPY2SkdsRS --- .zennotes-commit | 2 +- android/app/build.gradle | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- src/bridge/mobile-bridge.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.zennotes-commit b/.zennotes-commit index a8ef19a..a72789a 100644 --- a/.zennotes-commit +++ b/.zennotes-commit @@ -1 +1 @@ -f156bb1398b82f7f590d669fce5371783500f061 +3301a29d6564c13df4d85a5acb59789c5fc7e200 diff --git a/android/app/build.gradle b/android/app/build.gradle index 9435139..1a3876c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 17 - versionName "1.1.15" + versionCode 18 + versionName "1.1.16" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/package-lock.json b/package-lock.json index 0612d1f..c5c5396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.15", + "version": "1.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.15", + "version": "1.1.16", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@capacitor/android": "^8.5.1", diff --git a/package.json b/package.json index 143109b..88d656d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.15", + "version": "1.1.16", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index e6192dc..3eed9ef 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -130,7 +130,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.15' +let appVersion = '1.1.16' export async function loadNativeAppVersion(): Promise { try { From c1b5d3c1448f6e0f38b8b5d4bab1d68402133a07 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 12:51:42 -0500 Subject: [PATCH 2/3] feat(mobile): note gestures everywhere, and a Home that survives rescans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the iPhone shell's 1.9.7 round (zennotesiphone 2cc9b8f + 550e246), so the two phones stay in step. Every note row now answers the drawer rows' four gestures: tap opens, long-press opens the note options sheet, swipe left reveals Archive / Delete (Restore / Delete on Archive and Trash rows), swipe right pins or unpins. - note-actions.tsx: the one options sheet plus its action helpers, lifted out of the drawer so app-core's lists share it; pins keyed by the vault root as everywhere in this shell. It carries app-core's data-ctx-menu marker so the focus heal never refocuses the editor under it, and the drawer is non-selectable so a long-press can't start a selection. - note-row-gestures.ts: the gestures layered onto app-core's DOM rows (Home Recent, Quick Notes, Tags, Archive, Trash). Chromium's real contextmenu on long-press is swallowed by its capture handler, and the shell's own long-press hook leaves note rows alone. - nav.ts installHomeGuard, installed first thing in main.tsx: app-core's rewritePathsInTree falls back to the leaf's first tab whenever the active tab is null, and refreshNotes runs it on every rescan, so Home flipped back to the first open tab (Tasks) within a second of arriving. - EditorToolbar: Bullet / Checkbox / Heading insert their marker on a blank line (app-core's setBlockType skips blank lines), and the toolbar re-reveals the caret when it mounts. - editor-keyboard-scroll: every editor view gets a scrollMargins source for the toolbar's height, and the caret is revealed as the keyboard settles — the last lines of a long note sat under the toolbar. - editor-native-typing: CodeMirror's content defaults turn the keyboard's suggestions, autocorrect and capitalization off; overridden per view via contentAttributes so the note body types like any text field. --- src/main.tsx | 7 + src/ui-mobile/EditorToolbar.tsx | 58 ++- src/ui-mobile/MobileDrawer.tsx | 137 +----- src/ui-mobile/MobileShell.tsx | 31 ++ src/ui-mobile/editor-keyboard-scroll.ts | 110 +++++ src/ui-mobile/editor-native-typing.ts | 56 +++ src/ui-mobile/mobile.css | 91 ++++ src/ui-mobile/nav.ts | 37 ++ src/ui-mobile/note-actions.tsx | 267 ++++++++++++ src/ui-mobile/note-row-gestures.ts | 537 ++++++++++++++++++++++++ 10 files changed, 1196 insertions(+), 135 deletions(-) create mode 100644 src/ui-mobile/editor-keyboard-scroll.ts create mode 100644 src/ui-mobile/editor-native-typing.ts create mode 100644 src/ui-mobile/note-actions.tsx create mode 100644 src/ui-mobile/note-row-gestures.ts diff --git a/src/main.tsx b/src/main.tsx index f86dbb9..304114a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,7 @@ import { import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth' import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding' import { mountMobileShell } from './ui-mobile/MobileShell' +import { installHomeGuard } from './ui-mobile/nav' import { refreshVault } from './ui-mobile/refresh' import { isPhoneViewport, watchPhoneClass } from './viewport' import './ui-mobile/mobile.css' @@ -74,6 +75,12 @@ async function boot(): Promise { const appVersion = await loadNativeAppVersion() await configureMobileCloudAuth(appVersion) installMobileBridge() + // FIRST store subscriber, ahead of everything React mounts: the guard + // undoes app-core's null-active-tab fallback inside the same notification + // pass, and zustand notifies in subscription order — installed any later, + // earlier subscribers (the drawer's auto-close on selection, for one) act + // on the transient tab before the guard puts Home back. + installHomeGuard() wireKeyboard() wireForegroundRescan() diff --git a/src/ui-mobile/EditorToolbar.tsx b/src/ui-mobile/EditorToolbar.tsx index 8d5ffc0..0d357f7 100644 --- a/src/ui-mobile/EditorToolbar.tsx +++ b/src/ui-mobile/EditorToolbar.tsx @@ -16,8 +16,14 @@ import { indentLess, indentMore, redo, undo } from '@codemirror/commands' import { openSearchPanel } from '@codemirror/search' import { EditorSelection } from '@codemirror/state' import { useStore } from '@zennotes/app-core/store' -import { setBlockType, toggleWrap, wrapLink } from '@zennotes/app-core/lib/cm-format' +import { + setBlockType, + toggleWrap, + wrapLink, + type BlockType +} from '@zennotes/app-core/lib/cm-format' import { promptAttachFiles } from './attach' +import { revealCaretAboveKeyboardSoon } from './editor-keyboard-scroll' function view(): EditorView | null { return useStore.getState().editorViewRef @@ -39,13 +45,51 @@ function insertSnippet(v: EditorView, text: string, caretOffset: number): void { }) } +/** + * Markers app-core's blockPrefix would put on these block types. app-core's + * setBlockType converts existing lines and deliberately skips blank ones, so + * on a fresh line Bullet / Checkbox / Heading did nothing until something was + * typed (Adib, iPhone device testing 2026-09-08; same code here). Desktop + * users just type the marker; on the phone the button IS the way to start a + * list. + */ +const BLANK_LINE_MARKERS: Partial> = { + bullet: '- ', + todo: '- [ ] ', + h1: '# ', + h2: '## ', + h3: '### ' +} + +/** + * setBlockType, plus the blank-line case it skips: a collapsed cursor on an + * empty (or whitespace-only) line gets the marker inserted after the existing + * indentation, caret after the marker. Selections and non-blank lines go + * through setBlockType unchanged. + */ +function applyBlockType(v: EditorView, type: BlockType): void { + const { from, to } = v.state.selection.main + const line = v.state.doc.lineAt(from) + const marker = BLANK_LINE_MARKERS[type] + if (marker !== undefined && from === to && line.text.trim() === '') { + // line.text is whitespace-only here, so it doubles as the indent. + const insert = line.text + marker + v.dispatch({ + changes: { from: line.from, to: line.to, insert }, + selection: EditorSelection.cursor(line.from + insert.length) + }) + return + } + setBlockType(v, type) +} + /** Cycle the current line's heading level: none → # → ## → ### → none. */ function cycleHeading(v: EditorView): void { const line = v.state.doc.lineAt(v.state.selection.main.from) const m = line.text.match(/^(#{1,6})\s/) const level = m ? m[1]!.length : 0 const next = level >= 3 ? 'paragraph' : (['h1', 'h2', 'h3'] as const)[level]! - setBlockType(v, next) + applyBlockType(v, next) } interface ToolButton { @@ -95,13 +139,13 @@ const BUTTONS: ToolButton[] = [ key: 'todo', label: 'Checkbox', d: 'M9 11l3 3L22 4M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11', - run: () => withView((v) => setBlockType(v, 'todo')) + run: () => withView((v) => applyBlockType(v, 'todo')) }, { key: 'bullet', label: 'Bullet list', d: 'M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01', - run: () => withView((v) => setBlockType(v, 'bullet')) + run: () => withView((v) => applyBlockType(v, 'bullet')) }, { key: 'heading', @@ -223,6 +267,12 @@ export function MobileEditorToolbar(): React.JSX.Element | null { return () => window.clearTimeout(t) }, [kbOpen, editing]) + // The toolbar overlays the bottom of the editor: once it's in the DOM, make + // sure the caret isn't under it (editor-keyboard-scroll.ts measures it). + useEffect(() => { + if (visible) revealCaretAboveKeyboardSoon() + }, [visible]) + if (!visible) return null return ( diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 71b1918..7d0d7a6 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -11,7 +11,6 @@ import { useStore } from '@zennotes/app-core/store' import type { NoteSortOrder } from '@zennotes/app-core/store' import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' import { promptApp } from '@zennotes/app-core/lib/prompt-requests' -import { buildMoveNotePrompt, parseMoveNoteTarget } from '@zennotes/app-core/lib/move-note' import { notePathWithinFolder } from '@zennotes/app-core/lib/vault-layout' import { resolveFolderPath } from '@zennotes/shared-domain/system-folder-paths' import { @@ -26,6 +25,7 @@ import { openMobileSheet } from './sheet-state' import { goHome } from './nav' import { dirOf, noteComparator, pinnedFirst } from './note-order' import { usePins, toggleNotePin, toggleFolderPin } from './pins' +import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' import { SwipeRow } from './SwipeRow' import { getStoragePref } from '../bridge/icloud' @@ -954,11 +954,11 @@ function MobileDrawerBody(props: { const lp = useLongPress() const [sortOpen, setSortOpen] = useState(false) // Long-pressing a row opens its action sheet — the phone's right-click - // (Discord folder feedback). Notes mirror the ••• sheet's actions; folders - // get Rename/Delete. Prompts overlay the open drawer (Modal layers above + // (Discord folder feedback). Notes open the shell-wide note sheet + // (note-actions.tsx, shared with app-core's lists); folders get + // Rename/Delete here. Prompts overlay the open drawer (Modal layers above // z-49), so the drawer stays put and its list refreshes in place via the // vault change events. - const [noteMenu, setNoteMenu] = useState<{ path: string; title: string } | null>(null) const [folderMenu, setFolderMenu] = useState<{ subpath: string; name: string } | null>(null) const pinNote = (notePath: string): void => { @@ -1039,46 +1039,6 @@ function MobileDrawerBody(props: { } }, [refreshing]) - const moveNoteFromDrawer = (notePath: string): void => { - setNoteMenu(null) - void (async () => { - const st = s() - const meta = st.notes.find((n) => n.path === notePath) - if (!meta) return - const target = await promptApp(buildMoveNotePrompt(meta, st.folders)) - if (!target) return - const dest = parseMoveNoteTarget(target) - await s().moveNote(meta.path, dest.folder, dest.subpath) - })() - } - - const renameNoteFromDrawer = (notePath: string, title: string): void => { - setNoteMenu(null) - void (async () => { - const next = await promptApp({ - title: 'Rename note', - initialValue: title, - okLabel: 'Rename', - validate: (v: string) => (/[\\/]/.test(v) ? 'Title cannot contain / or \\' : null) - }) - if (!next || next === title) return - await s().renameNote(notePath, next) - })() - } - - const archiveNoteFromDrawer = (notePath: string): void => { - setNoteMenu(null) - void (async () => { - if (!(await s().confirmArchiveNotes([notePath]))) return - await window.zen.archiveNote(notePath) - })() - } - - const copyWikilinkFromDrawer = (title: string): void => { - setNoteMenu(null) - void navigator.clipboard.writeText(`[[${title}]]`).catch(() => {}) - } - const renameFolderFromDrawer = (subpath: string, name: string): void => { setFolderMenu(null) void (async () => { @@ -1114,18 +1074,6 @@ function MobileDrawerBody(props: { })() } - const trashNote = (notePath: string, title: string): void => { - void (async () => { - const ok = await confirmApp({ - title: `Delete "${title}"?`, - description: 'It will move to the trash.', - confirmLabel: 'Delete', - danger: true - }) - if (ok) await window.zen.moveToTrash(notePath) - })() - } - const deleteDatabase = (subpath: string, title: string): void => { void (async () => { const ok = await confirmApp({ @@ -1331,7 +1279,7 @@ function MobileDrawerBody(props: { { label: 'Archive', icon: , - onAction: () => archiveNoteFromDrawer(n.path) + onAction: () => archiveNote(n.path) }, { label: 'Delete', @@ -1346,7 +1294,7 @@ function MobileDrawerBody(props: { - - - - - - - - - - )} - {folderMenu && ( <>
{}) suppressNextClickUntil = Date.now() + 700 @@ -2855,6 +2863,25 @@ function useAboutGitHubLinks(): void { }, []) } +/** + * Long-press, swipe-left actions and swipe-right pin on app-core's note rows + * (Home, Quick Notes, Tags, Archive, Trash) — the drawer rows' gestures + * everywhere a note is listed (note-row-gestures.ts). + */ +function useNoteRowGestures(): void { + useEffect(() => installNoteRowGestures(), []) +} + +/** Caret stays above the keyboard's formatting toolbar (editor-keyboard-scroll.ts). */ +function useEditorKeyboardScroll(): void { + useEffect(() => installEditorKeyboardScroll(), []) +} + +/** The keyboard's autocorrect / suggestions in the note body (editor-native-typing.ts). */ +function useEditorNativeTyping(): void { + useEffect(() => installEditorNativeTyping(), []) +} + function MobileShellRoot(): React.JSX.Element { usePhoneLayoutBoot() useDrawerAutoClose() @@ -2862,6 +2889,9 @@ function MobileShellRoot(): React.JSX.Element { useWikilinkTapNavigation() useBreadcrumbDrawerNav() useLongPressContextMenu() + useNoteRowGestures() + useEditorKeyboardScroll() + useEditorNativeTyping() usePlaceholderCleanup() useTagsEmptyStateHint() useEdgeSwipeDrawer() @@ -2888,6 +2918,7 @@ function MobileShellRoot(): React.JSX.Element { <> + {sheet === 'vaults' && } diff --git a/src/ui-mobile/editor-keyboard-scroll.ts b/src/ui-mobile/editor-keyboard-scroll.ts new file mode 100644 index 0000000..e2b50ce --- /dev/null +++ b/src/ui-mobile/editor-keyboard-scroll.ts @@ -0,0 +1,110 @@ +/** + * Keep the caret above the soft keyboard's formatting toolbar (Adib, device + * testing 2026-09-08: "when the page becomes long enough I can't see what's + * under the keyboard anymore, as if it's stuck"). + * + * Two things conspired on phones. Under Native keyboard resize the WebView + * shrinks only AFTER the keyboard animation, and nothing re-reveals the caret + * then: WebKit scrolled it into view at focus time against the tall + * viewport, CodeMirror only scrolls on its own transactions, so a caret in + * the lower half of the screen ends up under the keyboard until the user + * scrolls by hand. And the formatting toolbar (EditorToolbar) is a fixed + * overlay on the bottom 52px of the shrunken editor that CodeMirror knows + * nothing about, so even its own scroll-into-view on typing parks the caret + * line exactly under the toolbar — the last line of a long note could never + * be seen while editing it, which is the "stuck" feel; blank lines pushed + * the real text up past the overlay, hence the workaround. + * + * Fix, layered on from the shell (no app-core change): + * - Every editor view gets an `EditorView.scrollMargins` source appended to + * its config (StateEffect.appendConfig is CodeMirror's public hook for + * this) that reports the toolbar's live height as bottom clearance while + * the toolbar is showing, and nothing otherwise — so CodeMirror's own + * scroll-into-view on typing keeps the caret above the overlay. The + * scroller's 20vh end padding (app-core) gives the last line room to move. + * - When the keyboard has finished showing (the WebView has resized by + * then) and when the toolbar mounts, the caret is scrolled into view + * explicitly, using those same margins. + */ +import { Keyboard } from '@capacitor/keyboard' +import { EditorView } from '@codemirror/view' +import { StateEffect } from '@codemirror/state' +import { useStore } from '@zennotes/app-core/store' + +const TOOLBAR = '.zn-editor-toolbar' + +/** Bottom clearance CodeMirror must keep clear: the toolbar's height while it + * is mounted (it renders only while the keyboard is up over the editor). */ +function toolbarClearance(): number { + const bar = document.querySelector(TOOLBAR) + if (!bar) return 0 + // Extra so the caret line isn't flush against the toolbar's top edge. + return Math.round(bar.getBoundingClientRect().height) + 8 +} + +function marginSource(): { bottom: number } | null { + const bottom = toolbarClearance() + return bottom > 0 ? { bottom } : null +} + +const marginsExtension = EditorView.scrollMargins.of(marginSource) + +function ensureMargins(view: EditorView): void { + // Checked against the live facet (not a seen-set): a full reconfigure + // upstream would drop appended config, and this re-appends on the next + // sighting instead of silently losing the margin. + if (view.state.facet(EditorView.scrollMargins).includes(marginSource)) return + view.dispatch({ effects: StateEffect.appendConfig.of(marginsExtension) }) +} + +/** Scroll the caret into view honoring the toolbar clearance, if the editor + * is the focused element. A no-op when the caret already sits clear of the + * toolbar (`nearest`), so repeating it is free. */ +export function revealCaretAboveKeyboard(): void { + const view = useStore.getState().editorViewRef + if (!view || !view.hasFocus) return + ensureMargins(view) + view.dispatch({ + effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'nearest' }) + }) +} + +/** + * The keyboard's final geometry lands in stages — the predictive bar joins + * after the keys and grows the keyboard, the WebView's Native resize follows + * the animation, the toolbar mounts on its own debounce — and a single + * reveal measured against an intermediate state left the caret under the + * toolbar on the first keyboard of a freshly opened note (seen once while + * recording, 2026-09-08). Re-run it over a short window; each pass is a + * no-op once the caret is clear. + */ +export function revealCaretAboveKeyboardSoon(): void { + requestAnimationFrame(revealCaretAboveKeyboard) + for (const ms of [150, 400, 800]) window.setTimeout(revealCaretAboveKeyboard, ms) +} + +/** Wire the margin source to every editor view and the reveal to the + * keyboard lifecycle. Returns the uninstaller. */ +export function installEditorKeyboardScroll(): () => void { + const initial = useStore.getState().editorViewRef + if (initial) ensureMargins(initial) + const unsubscribe = useStore.subscribe((state, prev) => { + if (state.editorViewRef && state.editorViewRef !== prev.editorViewRef) { + ensureMargins(state.editorViewRef) + } + }) + // keyboardDidShow lands after the slide, by which point Native resize has + // shrunk the WebView — the geometry the reveal must be measured against. + const didShow = Keyboard.addListener('keyboardDidShow', revealCaretAboveKeyboardSoon) + // A resize with the keyboard up (rotation, or the WebView shrinking late) + // moves the keyboard-relative geometry too. + const onResize = (): void => { + if (document.documentElement.classList.contains('zn-kb-open')) revealCaretAboveKeyboardSoon() + } + window.addEventListener('resize', onResize) + return () => { + unsubscribe() + void didShow.then((h) => h.remove()).catch(() => {}) + window.removeEventListener('resize', onResize) + } +} diff --git a/src/ui-mobile/editor-native-typing.ts b/src/ui-mobile/editor-native-typing.ts new file mode 100644 index 0000000..a816b3a --- /dev/null +++ b/src/ui-mobile/editor-native-typing.ts @@ -0,0 +1,56 @@ +/** + * The keyboard's autocorrect, suggestion strip and sentence capitalization + * in the note body — Gboard and friends on Android, the QuickType bar on + * iOS (Adib, iPhone device testing 2026-09-08: "autocorrect and predictive + * bar is still not showing"; ported for parity). + * + * CodeMirror stamps `spellcheck="false" autocorrect="off" autocapitalize="off" + * writingsuggestions="false"` on its content element by default — right for + * code, but on a phone it makes the note body the one text field on the + * device without the system's typing help: no suggestion strip, no + * corrections, no capitalization (Chromium maps spellcheck=false and + * autocorrect=off to the IME's no-suggestions flag). app-core doesn't + * touch these, so the shell overrides them per editor view through + * CodeMirror's own `contentAttributes` facet, appended with + * StateEffect.appendConfig (the public hook for adding config to a live + * view); any facet value wins over CodeMirror's base defaults. WebKit reads + * the traits when the element gains focus (Chromium builds the IME's + * EditorInfo the same way), and the view is configured the moment app-core + * publishes it — before the first tap lands — so the keyboard comes up + * with them from the start. + */ +import { EditorView } from '@codemirror/view' +import { StateEffect } from '@codemirror/state' +import { useStore } from '@zennotes/app-core/store' + +/** What a native text field gets by default. `writingsuggestions` is + * WebKit-only (iOS inline predictions); Chromium ignores it. Kept so the + * two shells stay identical. */ +const NATIVE_TYPING: Record = { + autocorrect: 'on', + autocapitalize: 'sentences', + spellcheck: 'true', + writingsuggestions: 'true' +} + +const nativeTypingExtension = EditorView.contentAttributes.of(NATIVE_TYPING) + +function ensureNativeTyping(view: EditorView): void { + // Checked against the live facet, not a seen-set: a full reconfigure + // upstream would drop appended config, and this re-appends on the next + // sighting instead of silently losing the traits. + if (view.state.facet(EditorView.contentAttributes).includes(NATIVE_TYPING)) return + view.dispatch({ effects: StateEffect.appendConfig.of(nativeTypingExtension) }) +} + +/** Apply to the current editor view and every one app-core publishes after + * it. Returns the uninstaller. */ +export function installEditorNativeTyping(): () => void { + const initial = useStore.getState().editorViewRef + if (initial) ensureNativeTyping(initial) + return useStore.subscribe((state, prev) => { + if (state.editorViewRef && state.editorViewRef !== prev.editorViewRef) { + ensureNativeTyping(state.editorViewRef) + } + }) +} diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index 2a6ec27..62c5757 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -630,6 +630,22 @@ display: none !important; } +/* The drawer's rows answer a custom long-press (action sheet) and swipes; + the OS must not start a text selection on the same press — besides the + selection handles, a selection gesture can refocus the WebView and + app-core's focus heal then refocuses the editor (keyboard up under the + sheet, seen on iOS). The search field keeps normal selection. */ +.zn-phone .zn-mobile-drawer { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +.zn-phone .zn-mobile-drawer input { + -webkit-user-select: text; + user-select: text; +} + /* The shell's own navigation drawer. */ .zn-phone .zn-mobile-drawer { position: fixed; @@ -909,6 +925,81 @@ background: rgb(var(--z-danger, 220 80 80)); } +/* ---- app-core note rows: swipe + long-press (note-row-gestures.ts) -------- + Home's Recent list, Quick Notes, Tags, Archive and Trash rows get the + drawer rows' gestures from outside. The frame class lands only while a + row is displaced, so resting rows keep app-core's exact layout; the + action / pin layers are appended into the frame and slide like + .zn-swipe-actions / .zn-swipe-pin. */ +.zn-mobile [data-quick-row], +.zn-mobile [data-tag-row], +.zn-mobile [data-archive-row], +.zn-mobile [data-trash-row], +.zn-mobile li > button[data-home-item] { + /* Long-press opens the sheet; the OS must not start a text selection or + its callout on the same press. */ + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +.zn-mobile .zn-row-swipe { + position: relative; + overflow: hidden; + /* Vertical panning stays native; the tracker claims horizontal itself. */ + touch-action: pan-y; +} + +.zn-mobile .zn-row-swipe.is-settling > * { + transition: transform 180ms ease-out; +} + +.zn-mobile .zn-row-swipe-actions { + position: absolute; + inset: 0 0 0 auto; + display: flex; + z-index: 2; + will-change: transform; +} + +.zn-mobile .zn-row-swipe-actions > button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + width: 72px; + font-size: 0.75rem; + color: rgb(var(--z-fg)); + background: rgb(var(--z-bg-3)); +} + +.zn-mobile .zn-row-swipe-actions > button svg { + width: 19px; + height: 19px; +} + +.zn-mobile .zn-row-swipe-actions > button.zn-danger { + color: #fff; + background: rgb(var(--z-danger, 220 80 80)); +} + +.zn-mobile .zn-row-swipe-pin { + position: absolute; + inset: 0 auto 0 0; + display: flex; + align-items: center; + padding-left: 1rem; + color: rgb(var(--z-accent)); + font-size: 0.8125rem; + font-weight: 600; + transition: opacity 120ms; +} + +.zn-mobile .zn-row-swipe-pin .is-armed { + transform: scale(1.15); +} + .zn-phone .zn-swipe-pin { position: absolute; inset: 0 auto 0 0; diff --git a/src/ui-mobile/nav.ts b/src/ui-mobile/nav.ts index aa82188..694994c 100644 --- a/src/ui-mobile/nav.ts +++ b/src/ui-mobile/nav.ts @@ -27,3 +27,40 @@ export function goHome(): void { activeDirty: false }) } + +/** + * Keep Home across app-core's pane-tree rewrites (Adib, 2026-09-08: "the + * drawer's Home row does not leave the Tasks view"). + * + * Home is a state desktop never reaches, and app-core's `rewritePathsInTree` + * — run by refreshNotes on every rescan, and by rename / move / delete — + * falls back to the leaf's FIRST tab when the active tab is null. A rescan + * lands within a second of reaching Home (the drawer's close and every + * vault change event trigger one), so the first open tab came straight + * back on screen: Tasks, whenever it had been opened in the session. + * + * The fallback has a signature no navigation shares: in one store update + * the active leaf goes from a null active tab to its first tab AND `notes` + * is replaced (a rescan or a vault mutation). Opening a note is a separate + * update that leaves `notes` alone, and a new note appends its tab at the + * end, so neither matches. When the signature does match, put Home back — + * synchronously inside the subscriber, before React renders, so the tab + * never shows. Returns the unsubscriber. + */ +export function installHomeGuard(): () => void { + return useStore.subscribe((state, prev) => { + if (state.notes === prev.notes || state.paneLayout === prev.paneLayout) return + const leaf = findLeaf(state.paneLayout, state.activePaneId) + const before = findLeaf(prev.paneLayout, state.activePaneId) + if (!leaf || !before || before.activeTab !== null || before.tabs.length === 0) return + if (leaf.activeTab === null || leaf.activeTab !== leaf.tabs[0]) return + const next = updateLeaf(state.paneLayout, leaf.id, (l) => ({ ...l, activeTab: null })) + if (!next) return + useStore.setState({ + paneLayout: next, + selectedPath: null, + activeNote: null, + activeDirty: false + }) + }) +} diff --git a/src/ui-mobile/note-actions.tsx b/src/ui-mobile/note-actions.tsx new file mode 100644 index 0000000..e409dee --- /dev/null +++ b/src/ui-mobile/note-actions.tsx @@ -0,0 +1,267 @@ +/** + * The phone's note options: one bottom sheet for every note row on the + * phone — the drawer's rows, app-core's Home / Quick Notes / Tags lists and + * the Archive / Trash views — opened by a long-press (MobileDrawer's rows, + * note-row-gestures.ts for app-core's) so "long-press a note, see its + * options" holds everywhere (Adib, 2026-09-08). The sheet used to live + * inside the drawer; it moved here so the app-core lists could share it, and + * the drawer's swipe actions go through the same helpers. + * + * Ported from the iPhone shell (note-actions.tsx there); only the pin key + * differs. + * + * Kinds: 'note' mirrors the ••• sheet (Pin, Rename, Move to…, Copy wikilink, + * Archive, Delete); 'archived' is Restore / Delete; 'trashed' is Restore / + * Delete permanently — the sets app-core's own views expose on desktop. + * Prompts and confirms overlay whatever is open (Modal layers above the + * drawer and the sheet), and the lists refresh in place via the vault + * change events every mutating bridge call emits. + */ +import React, { useSyncExternalStore } from 'react' +import { Keyboard } from '@capacitor/keyboard' +import { useStore } from '@zennotes/app-core/store' +import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' +import { promptApp } from '@zennotes/app-core/lib/prompt-requests' +import { buildMoveNotePrompt, parseMoveNoteTarget } from '@zennotes/app-core/lib/move-note' +import { getPinnedNotes, toggleNotePin, usePins } from './pins' + +export type NoteRowKind = 'note' | 'archived' | 'trashed' + +export interface NoteMenuTarget { + path: string + title: string + kind: NoteRowKind +} + +const s = (): ReturnType => useStore.getState() + +/** Pins are keyed by the vault root on Android (pins.ts, same as the drawer). */ +const vaultKey = (): string | null => s().vault?.root ?? null + +// --------------------------------------------------------------------------- +// Sheet state (module-wide, like sheet-state.ts, so any surface can summon it) +// --------------------------------------------------------------------------- + +let current: NoteMenuTarget | null = null +const subscribers = new Set<() => void>() + +function notify(): void { + for (const cb of subscribers) cb() +} + +export function openNoteMenu(target: NoteMenuTarget): void { + current = target + // Summoned over a live editing session the keyboard would stay up under + // the sheet (same treatment as sheet-state / drawer-state). + ;(document.activeElement as HTMLElement | null)?.blur?.() + void Keyboard.hide().catch(() => {}) + notify() +} + +export function closeNoteMenu(): void { + if (current === null) return + current = null + notify() +} + +export function isNoteMenuOpen(): boolean { + return current !== null +} + +function useNoteMenu(): NoteMenuTarget | null { + return useSyncExternalStore( + (cb) => { + subscribers.add(cb) + return () => subscribers.delete(cb) + }, + () => current + ) +} + +// --------------------------------------------------------------------------- +// Actions (shared by the sheet, the drawer's SwipeRow and note-row-gestures) +// --------------------------------------------------------------------------- + +export function isNotePinned(path: string): boolean { + return getPinnedNotes(vaultKey()).includes(path) +} + +export function pinNote(path: string): void { + const key = vaultKey() + if (!key) return + toggleNotePin( + key, + path, + s().notes.map((n) => n.path) + ) +} + +export function renameNote(path: string, title: string): void { + void (async () => { + const next = await promptApp({ + title: 'Rename note', + initialValue: title, + okLabel: 'Rename', + validate: (v: string) => (/[\\/]/.test(v) ? 'Title cannot contain / or \\' : null) + }) + if (!next || next === title) return + await s().renameNote(path, next) + })() +} + +export function moveNote(path: string): void { + void (async () => { + const st = s() + const meta = st.notes.find((n) => n.path === path) + if (!meta) return + const target = await promptApp(buildMoveNotePrompt(meta, st.folders)) + if (!target) return + const dest = parseMoveNoteTarget(target) + await s().moveNote(meta.path, dest.folder, dest.subpath) + })() +} + +export function copyWikilink(title: string): void { + void navigator.clipboard.writeText(`[[${title}]]`).catch(() => {}) +} + +export function archiveNote(path: string): void { + void (async () => { + if (!(await s().confirmArchiveNotes([path]))) return + await window.zen.archiveNote(path) + })() +} + +export function trashNote(path: string, title: string): void { + void (async () => { + const ok = await confirmApp({ + title: `Delete "${title}"?`, + description: 'It will move to the trash.', + confirmLabel: 'Delete', + danger: true + }) + if (ok) await window.zen.moveToTrash(path) + })() +} + +export function restoreNote(path: string, from: 'archived' | 'trashed'): void { + void (from === 'archived' ? window.zen.unarchiveNote(path) : window.zen.restoreFromTrash(path)) +} + +export function deleteNoteForever(path: string, title: string): void { + void (async () => { + const ok = await confirmApp({ + title: `Delete "${title}" permanently?`, + description: 'This cannot be undone.', + confirmLabel: 'Delete', + danger: true + }) + if (ok) await window.zen.deleteNote(path) + })() +} + +// --------------------------------------------------------------------------- +// The sheet +// --------------------------------------------------------------------------- + +const D = { + pin: 'M12 17v5M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z', + rename: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z', + move: 'M5 8V6a2 2 0 012-2h3l2 2h7a2 2 0 012 2v10a2 2 0 01-2 2H7a2 2 0 01-2-2v-4M2 13h9m0 0l-3-3m3 3l-3 3', + link: 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', + archive: 'M21 8v13H3V8M1 3h22v5H1zM10 12h4', + trash: 'M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6', + restore: 'M3 12a9 9 0 109-9 9 9 0 00-6.36 2.64L3 8M3 3v5h5' +} + +function Icon({ d }: { d: string }): React.JSX.Element { + return ( + + ) +} + +interface SheetRow { + label: string + icon: string + danger?: boolean + run: () => void +} + +function rowsFor(target: NoteMenuTarget, pinned: boolean): SheetRow[] { + const { path, title, kind } = target + if (kind === 'archived') { + return [ + { label: 'Restore', icon: D.restore, run: () => restoreNote(path, 'archived') }, + { label: 'Delete', icon: D.trash, danger: true, run: () => trashNote(path, title) } + ] + } + if (kind === 'trashed') { + return [ + { label: 'Restore', icon: D.restore, run: () => restoreNote(path, 'trashed') }, + { + label: 'Delete permanently', + icon: D.trash, + danger: true, + run: () => deleteNoteForever(path, title) + } + ] + } + return [ + { label: pinned ? 'Unpin' : 'Pin', icon: D.pin, run: () => pinNote(path) }, + { label: 'Rename', icon: D.rename, run: () => renameNote(path, title) }, + { label: 'Move to…', icon: D.move, run: () => moveNote(path) }, + { label: 'Copy wikilink', icon: D.link, run: () => copyWikilink(title) }, + { label: 'Archive', icon: D.archive, run: () => archiveNote(path) }, + { label: 'Delete', icon: D.trash, danger: true, run: () => trashNote(path, title) } + ] +} + +/** Mounted once by the shell root; renders nothing until openNoteMenu. */ +export function NoteActionSheet(): React.JSX.Element | null { + const target = useNoteMenu() + const pins = usePins(vaultKey()) + if (!target) return null + const rows = rowsFor(target, pins.notes.includes(target.path)) + return ( + <> +
+ {/* data-ctx-menu: app-core's isAppOverlayOpen() marker for its context + menus. While the sheet is up, app-core's focus "heal" (App.tsx, on + window focus — which a native long-press on the drawer triggers) + must not pull focus back into the editor and raise the keyboard + under the sheet, and the list views' keyboard shortcuts must not + fire through it — exactly what the marker gates for its own menus. */} +
+
{target.title}
+
+
+ {rows.map((row) => ( + + ))} +
+
+
+ + ) +} diff --git a/src/ui-mobile/note-row-gestures.ts b/src/ui-mobile/note-row-gestures.ts new file mode 100644 index 0000000..9b0b997 --- /dev/null +++ b/src/ui-mobile/note-row-gestures.ts @@ -0,0 +1,537 @@ +/** + * The phone's note-row gestures, layered onto app-core's lists (Adib, + * 2026-09-08: "when I long press a note, we see options … all the common + * gestures to work as expected"). + * + * The drawer's own rows (SwipeRow + MobileDrawer's long-press) already answer + * the full set; this module gives app-core's DOM rows the same four, with the + * same feel and coexistence rules: + * + * - tap opens (app-core's own onClick, untouched); + * - long-press (450ms, 10px slop, haptic) opens the note options sheet + * (note-actions.tsx) — the tap that ends it is swallowed so the note + * doesn't also open; + * - swipe left reveals actions that stay open until one is tapped, the row + * is tapped, or another row swipes: Archive / Delete on notes, Restore / + * Delete on archived and trashed ones. The content nudges by a sixth, the + * buttons slide in over the right edge; + * - swipe right past 64px pins / unpins on release, with the accent "Pin" + * chip under the row as its confirmation (notes only). + * + * Vertical scrolling wins: a gesture claims the touch only once |dx| beats + * both |dy| and a 12px slop — which is ≥ the long-press slop, so the timer + * is already cancelled by the time a drag claims. Touches in the drawer's + * left-edge zone are left to the drawer. Cancelled or multi-touch gestures + * revert and never commit anything. + * + * Rows are identified by the data attributes app-core's views put on them; + * Home's recent list has none, so its rows are resolved by position against + * the same recent ordering HomeView computes, and cross-checked by title. + * The action / pin layers are appended into a "frame" (the row itself, or + * the
  • around Home's button rows) and removed again when the row + * settles closed, so React only ever sees an extra trailing child while a + * row is engaged — React never reconciles children it didn't create, and a + * row unmounting takes the layers with it. + */ +import { Haptics, ImpactStyle } from '@capacitor/haptics' +import { useStore } from '@zennotes/app-core/store' +import { + archiveNote, + deleteNoteForever, + isNotePinned, + openNoteMenu, + pinNote, + restoreNote, + trashNote, + type NoteRowKind +} from './note-actions' + +const ACTION_WIDTH = 72 // px per revealed action button (SwipeRow's) +// SwipeRow nudges its content by a third; app-core's rows start their text +// only 12–16px in (no leading icon on the phone), so any nudge past that +// clips the first letter under the frame's overflow. Follow by a sixth, +// capped below the content's own left padding (see nudgeLimit). +const CONTENT_FOLLOW = 1 / 6 +const CONTENT_NUDGE_MAX = 12 +const CONTENT_NUDGE_INSET = 4 +const PIN_TRIGGER = 64 // px of right-swipe that commits a pin toggle +// MUST stay >= LONG_PRESS_SLOP: a smaller value opens a band where the row +// is mid-swipe while the long-press timer is still armed. +const CLAIM = 12 +const LONG_PRESS_MS = 450 +const LONG_PRESS_SLOP = 10 +// MobileShell's drawer-open edge swipe zone; a touch starting there is the +// drawer's, not the row's. +const EDGE = 28 +const SETTLE_MS = 200 // > the 180ms CSS transition, so cleanup runs after it +const CLICK_SUPPRESS_MS = 700 + +const FRAME_CLASS = 'zn-row-swipe' +const SETTLING_CLASS = 'is-settling' +const ACTIONS_CLASS = 'zn-row-swipe-actions' +const PIN_CLASS = 'zn-row-swipe-pin' +const LAYER_SELECTOR = `.${ACTIONS_CLASS}, .${PIN_CLASS}` + +const ICONS = { + archive: 'M21 8v13H3V8M1 3h22v5H1zM10 12h4', + trash: 'M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6', + restore: 'M3 12a9 9 0 109-9 9 9 0 00-6.36 2.64L3 8M3 3v5h5' +} + +interface RowAction { + label: string + icon: keyof typeof ICONS + danger?: boolean + run: (path: string, title: string) => void +} + +interface RowKind { + /** Matches the element the finger lands in (via closest). */ + selector: string + kind: NoteRowKind + path: (row: HTMLElement) => string | null + /** Hosts the sliding layers: position relative + overflow hidden. */ + frame: (row: HTMLElement) => HTMLElement + /** Elements translated with the finger. */ + content: (frame: HTMLElement) => HTMLElement[] + actions: RowAction[] + pinnable: boolean +} + +const NOTE_ACTIONS: RowAction[] = [ + { label: 'Archive', icon: 'archive', run: (path) => archiveNote(path) }, + { label: 'Delete', icon: 'trash', danger: true, run: (path, title) => trashNote(path, title) } +] +const ARCHIVED_ACTIONS: RowAction[] = [ + { label: 'Restore', icon: 'restore', run: (path) => restoreNote(path, 'archived') }, + { label: 'Delete', icon: 'trash', danger: true, run: (path, title) => trashNote(path, title) } +] +const TRASHED_ACTIONS: RowAction[] = [ + { label: 'Restore', icon: 'restore', run: (path) => restoreNote(path, 'trashed') }, + { + label: 'Delete', + icon: 'trash', + danger: true, + run: (path, title) => deleteNoteForever(path, title) + } +] + +const self = (row: HTMLElement): HTMLElement => row +const ownChildren = (frame: HTMLElement): HTMLElement[] => + Array.from(frame.children).filter( + (c): c is HTMLElement => c instanceof HTMLElement && !c.matches(LAYER_SELECTOR) + ) + +/** + * HomeView renders its Recent list as `ul > li > button[data-home-item]` + * (one button per li; task rows put two buttons in their li) in the same + * order as its `recent` memo: every non-trash, non-archive note by updatedAt + * descending. Resolve the row by position and confirm by title. + */ +function homeRecentPath(row: HTMLElement): string | null { + const li = row.parentElement + // Count the li's own children only: while a row is engaged the action / + // pin layers are appended into this very li, and counting them made an + // open row unrecognisable — its closing tap then opened the note. + if (!li || li.tagName !== 'LI' || ownChildren(li).length !== 1) return null + const list = li.parentElement + if (!list || list.tagName !== 'UL') return null + const index = Array.prototype.indexOf.call(list.children, li) + if (index < 0) return null + const recent = useStore + .getState() + .notes.filter((n) => n.folder !== 'trash' && n.folder !== 'archive') + .slice() + .sort((a, b) => b.updatedAt - a.updatedAt) + const note = recent[index] + if (!note) return null + const shown = row.textContent?.trim() ?? '' + return shown.startsWith(note.title || 'Untitled') ? note.path : null +} + +const KINDS: RowKind[] = [ + { + selector: '[data-quick-row]', + kind: 'note', + path: (row) => row.dataset.quickRow ?? null, + frame: self, + content: ownChildren, + actions: NOTE_ACTIONS, + pinnable: true + }, + { + selector: '[data-tag-row]', + kind: 'note', + path: (row) => row.dataset.tagRow ?? null, + frame: self, + content: ownChildren, + actions: NOTE_ACTIONS, + pinnable: true + }, + { + selector: 'li > button[data-home-item]', + kind: 'note', + path: homeRecentPath, + frame: (row) => row.parentElement as HTMLElement, + content: ownChildren, + actions: NOTE_ACTIONS, + pinnable: true + }, + { + selector: '[data-archive-row]', + kind: 'archived', + path: (row) => row.dataset.archiveRow ?? null, + frame: self, + content: ownChildren, + actions: ARCHIVED_ACTIONS, + pinnable: false + }, + { + selector: '[data-trash-row]', + kind: 'trashed', + path: (row) => row.dataset.trashRow ?? null, + frame: self, + content: ownChildren, + actions: TRASHED_ACTIONS, + pinnable: false + } +] + +/** Every row selector, for other shell gestures to carve out. */ +export const NOTE_ROW_SELECTOR = KINDS.map((k) => k.selector).join(', ') + +interface Hit { + kind: RowKind + row: HTMLElement + frame: HTMLElement + path: string +} + +function hitTest(target: EventTarget | null): Hit | null { + if (!(target instanceof Element)) return null + for (const kind of KINDS) { + const row = target.closest(kind.selector) + if (!(row instanceof HTMLElement)) continue + const path = kind.path(row) + if (!path) return null + return { kind, row, frame: kind.frame(row), path } + } + return null +} + +function titleOf(path: string, row: HTMLElement): string { + const note = useStore.getState().notes.find((n) => n.path === path) + return note?.title || row.textContent?.trim() || path +} + +function svg(icon: keyof typeof ICONS): string { + return ( + '` + ) +} + +// --------------------------------------------------------------------------- +// Displacement + layers +// --------------------------------------------------------------------------- + +interface Engaged { + hit: Hit + dx: number +} + +/** Current displacement per frame (absent when at rest). */ +const position = new WeakMap() +let openFrame: Engaged | null = null + +function dxOf(frame: HTMLElement): number { + return position.get(frame) ?? 0 +} + +function actionsLayer(hit: Hit): HTMLElement { + const existing = hit.frame.querySelector(`:scope > .${ACTIONS_CLASS}`) + if (existing) return existing + const layer = document.createElement('div') + layer.className = ACTIONS_CLASS + layer.style.width = `${hit.kind.actions.length * ACTION_WIDTH}px` + for (const action of hit.kind.actions) { + const button = document.createElement('button') + button.type = 'button' + button.className = action.danger ? 'zn-danger' : '' + button.innerHTML = `${svg(action.icon)}${action.label}` + button.addEventListener('click', (e) => { + // The row's own onClick would open the note. + e.preventDefault() + e.stopPropagation() + settle(hit, 0) + action.run(hit.path, titleOf(hit.path, hit.row)) + }) + layer.appendChild(button) + } + hit.frame.appendChild(layer) + return layer +} + +function pinLayer(hit: Hit): HTMLElement { + const existing = hit.frame.querySelector(`:scope > .${PIN_CLASS}`) + if (existing) return existing + const layer = document.createElement('div') + layer.className = PIN_CLASS + layer.setAttribute('aria-hidden', 'true') + const label = document.createElement('span') + label.textContent = isNotePinned(hit.path) ? 'Unpin' : 'Pin' + layer.appendChild(label) + hit.frame.appendChild(layer) + return layer +} + +/** How far the content may slide left before its first letter would leave + * the frame: its own left padding less a small inset, at most 12px. */ +function nudgeLimit(content: HTMLElement[]): number { + const first = content[0] + if (!first) return CONTENT_NUDGE_MAX + const padding = parseFloat(getComputedStyle(first).paddingLeft) || 0 + return Math.max(0, Math.min(CONTENT_NUDGE_MAX, padding - CONTENT_NUDGE_INSET)) +} + +function paint(hit: Hit, dx: number, settling: boolean): void { + const { frame, kind } = hit + position.set(frame, dx) + frame.classList.add(FRAME_CLASS) + frame.classList.toggle(SETTLING_CLASS, settling) + // Rightward (pin) swipes move the content 1:1 to uncover the chip; + // leftward swipes move it by CONTENT_FOLLOW while the actions slide in. + const content = kind.content(frame) + const contentX = dx >= 0 ? dx : Math.max(dx * CONTENT_FOLLOW, -nudgeLimit(content)) + for (const el of content) el.style.transform = `translateX(${contentX}px)` + if (kind.actions.length > 0) { + const width = kind.actions.length * ACTION_WIDTH + actionsLayer(hit).style.transform = `translateX(${Math.max(0, width + Math.min(0, dx))}px)` + } + if (kind.pinnable) { + const chip = pinLayer(hit) + chip.style.opacity = dx > 8 ? '1' : '0' + chip.firstElementChild?.classList.toggle('is-armed', dx > PIN_TRIGGER) + } +} + +function rest(hit: Hit): void { + const { frame, kind } = hit + position.delete(frame) + frame.classList.remove(FRAME_CLASS, SETTLING_CLASS) + for (const el of kind.content(frame)) el.style.removeProperty('transform') + frame.querySelectorAll(`:scope > .${ACTIONS_CLASS}, :scope > .${PIN_CLASS}`).forEach((l) => l.remove()) +} + +function settle(hit: Hit, target: number): void { + if (!hit.frame.isConnected) { + if (openFrame?.hit.frame === hit.frame) openFrame = null + return + } + paint(hit, target, true) + if (target === 0) { + if (openFrame?.hit.frame === hit.frame) openFrame = null + window.setTimeout(() => { + // Only tidy up if nothing re-engaged the row meanwhile. + if (dxOf(hit.frame) === 0 && !(track?.hit.frame === hit.frame && track.claimed)) rest(hit) + }, SETTLE_MS) + } else { + if (openFrame && openFrame.hit.frame !== hit.frame) settle(openFrame.hit, 0) + openFrame = { hit, dx: target } + } +} + +// --------------------------------------------------------------------------- +// Touch tracking +// --------------------------------------------------------------------------- + +interface Track { + hit: Hit + x: number + y: number + claimed: boolean + dead: boolean + fromDx: number + timer: number | null +} + +let track: Track | null = null +let live = 0 +let suppressClicksUntil = 0 + +function cancelLongPress(t: Track): void { + if (t.timer !== null) { + window.clearTimeout(t.timer) + t.timer = null + } +} + +function fireLongPress(t: Track): void { + t.timer = null + t.dead = true + if (!t.hit.row.isConnected) return + void Haptics.impact({ style: ImpactStyle.Medium }).catch(() => {}) + // The finger lift emits a click; swallow it or the note opens under the sheet. + suppressClicksUntil = Date.now() + CLICK_SUPPRESS_MS + if (openFrame) settle(openFrame.hit, 0) + openNoteMenu({ path: t.hit.path, title: titleOf(t.hit.path, t.hit.row), kind: t.hit.kind.kind }) +} + +function onTouchStart(e: TouchEvent): void { + if (e.touches.length !== 1) { + // A second finger mid-gesture: abandon and revert, never leave a row + // half-open or fire a press. + const t = track + track = null + if (t) { + cancelLongPress(t) + if (t.claimed) settle(t.hit, t.fromDx) + } + return + } + const touch = e.touches[0]! + const hit = hitTest(e.target) + const isOpenRow = !!hit && !!openFrame && openFrame.hit.frame === hit.frame + // A touch anywhere but the open row closes it (the open row's own tap is + // handled at click time so the note doesn't open underneath). + if (openFrame && !isOpenRow) settle(openFrame.hit, 0) + track = null + if (!hit || touch.clientX <= EDGE) return + if (e.target instanceof Element && e.target.closest(LAYER_SELECTOR)) return + const t: Track = { + hit, + x: touch.clientX, + y: touch.clientY, + claimed: false, + dead: false, + fromDx: isOpenRow ? -(hit.kind.actions.length * ACTION_WIDTH) : 0, + timer: null + } + // No long-press on a row that's showing its actions — tapping closes it. + if (!isOpenRow) t.timer = window.setTimeout(() => fireLongPress(t), LONG_PRESS_MS) + track = t +} + +function onTouchMove(e: TouchEvent): void { + const t = track + if (!t || t.dead) return + if (e.touches.length !== 1) { + t.dead = true + cancelLongPress(t) + if (t.claimed) settle(t.hit, t.fromDx) + return + } + const touch = e.touches[0]! + const mx = touch.clientX - t.x + const my = touch.clientY - t.y + if (t.timer !== null && (Math.abs(mx) > LONG_PRESS_SLOP || Math.abs(my) > LONG_PRESS_SLOP)) { + cancelLongPress(t) + } + if (!t.claimed) { + if (Math.abs(my) > Math.abs(mx) && Math.abs(my) > CLAIM) { + t.dead = true // vertical scroll wins + return + } + // A rightward drag has nothing to reveal on rows that can't pin. + if (mx > 0 && t.fromDx === 0 && !t.hit.kind.pinnable) { + t.dead = true + return + } + if (Math.abs(mx) <= CLAIM || Math.abs(mx) <= Math.abs(my)) return + if (!t.hit.frame.isConnected) { + t.dead = true + return + } + t.claimed = true + cancelLongPress(t) + } + e.preventDefault() + let next = t.fromDx + mx + const min = -(t.hit.kind.actions.length * ACTION_WIDTH) + const max = t.hit.kind.pinnable ? PIN_TRIGGER + 24 : 0 + // Clamp with rubber-banding past the functional range. + if (next < min) next = min + (next - min) / 3 + if (next > max) next = max + (next - max) / 3 + live = next + paint(t.hit, next, false) +} + +function onTouchEnd(): void { + const t = track + track = null + if (!t) return + cancelLongPress(t) + if (t.dead || !t.claimed) return + const width = t.hit.kind.actions.length * ACTION_WIDTH + if (t.hit.kind.pinnable && live > PIN_TRIGGER) { + pinNote(t.hit.path) + settle(t.hit, 0) + } else if (width > 0 && live < -width / 2) { + settle(t.hit, -width) + } else { + settle(t.hit, 0) + } +} + +function onTouchCancel(): void { + // The system stole the touch (edge swipe, call banner, app switch) — + // revert to where the gesture started; a cancel must never commit. + const t = track + track = null + if (!t) return + cancelLongPress(t) + if (t.claimed) settle(t.hit, t.fromDx) +} + +function onClickCapture(e: MouseEvent): void { + if (Date.now() < suppressClicksUntil) { + // The tap that ended a long-press. + e.preventDefault() + e.stopPropagation() + suppressClicksUntil = 0 + return + } + const open = openFrame + if (!open) return + const target = e.target + if (!(target instanceof Element) || !open.hit.frame.contains(target)) return + if (target.closest(LAYER_SELECTOR)) return + // Tapping a swiped-open row closes it instead of opening the note. + e.preventDefault() + e.stopPropagation() + settle(open.hit, 0) +} + +function onContextMenuCapture(e: MouseEvent): void { + // The sheet is the phone's context menu for these rows; keep app-core's + // desktop menus (TagView, ArchiveView) from opening on top of it. + if (hitTest(e.target)) { + e.preventDefault() + e.stopPropagation() + } +} + +/** Install the gestures document-wide; returns the uninstaller. */ +export function installNoteRowGestures(): () => void { + // Capture phase so the tracker sees the touch before app-core's React + // handlers; touchmove is non-passive because a claimed swipe must stop + // the list from scrolling under it. + document.addEventListener('touchstart', onTouchStart, { passive: true, capture: true }) + document.addEventListener('touchmove', onTouchMove, { passive: false, capture: true }) + document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true }) + document.addEventListener('touchcancel', onTouchCancel, { passive: true, capture: true }) + document.addEventListener('click', onClickCapture, { capture: true }) + document.addEventListener('contextmenu', onContextMenuCapture, { capture: true }) + return () => { + document.removeEventListener('touchstart', onTouchStart, { capture: true } as never) + document.removeEventListener('touchmove', onTouchMove, { capture: true } as never) + document.removeEventListener('touchend', onTouchEnd, { capture: true } as never) + document.removeEventListener('touchcancel', onTouchCancel, { capture: true } as never) + document.removeEventListener('click', onClickCapture, { capture: true } as never) + document.removeEventListener('contextmenu', onContextMenuCapture, { capture: true } as never) + if (track) cancelLongPress(track) + track = null + if (openFrame) rest(openFrame.hit) + openFrame = null + } +} From d04376a474b78aee94d798bf7aa34f7202641bd2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 12:51:42 -0500 Subject: [PATCH 3/3] =?UTF-8?q?feat(mobile):=20Start=20screen=20setting=20?= =?UTF-8?q?=E2=80=94=20Home,=20or=20where=20you=20left=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Appearance › Start screen (phones): "Where I left off" keeps the cold-launch landing as it was; "Home" lands on Home on every cold launch. Switching to another app and back is not a launch and never moves the user. Stored like the gesture prefs and mirrored natively in bootstrap.ts so it is back in localStorage before main.tsx reads it. The Play review of 1.1.15 asked for exactly this; same setting as iPhone 1.9.7 (zennotesiphone 45a72f8). --- src/bootstrap.ts | 16 ++++++++-- src/ui-mobile/MobileShell.tsx | 51 ++++++++++++++++++++++++++++-- src/ui-mobile/start-screen.test.ts | 34 ++++++++++++++++++++ src/ui-mobile/start-screen.ts | 37 ++++++++++++++++++++++ src/viewport.ts | 5 +++ 5 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/ui-mobile/start-screen.test.ts create mode 100644 src/ui-mobile/start-screen.ts diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 9819b8d..ec7912a 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,5 +1,5 @@ import { Preferences } from '@capacitor/preferences' -import { GESTURES_KEY, HIDE_STATUS_BAR_KEY, LAYOUT_MODE_KEY } from './viewport' +import { GESTURES_KEY, HIDE_STATUS_BAR_KEY, LAYOUT_MODE_KEY, START_SCREEN_KEY } from './viewport' const WEB_PREFERENCES_KEY = 'zen:prefs:v2' const NATIVE_PREFERENCES_KEY = 'zn-app-preferences-v2' @@ -10,11 +10,15 @@ const NATIVE_LAYOUT_MODE_KEY = 'zn-layout-mode' const NATIVE_HIDE_STATUS_BAR_KEY = 'zn-hide-status-bar' // The swipe-gesture assignments (#24) ride along too. const NATIVE_GESTURES_KEY = 'zn-gestures' +// And the Start screen choice (start-screen.ts): read at cold launch, so it +// must be back in localStorage before main.tsx evaluates. +const NATIVE_START_SCREEN_KEY = 'zn-start-screen' const MIRRORED_KEYS: Record = { [WEB_PREFERENCES_KEY]: NATIVE_PREFERENCES_KEY, [LAYOUT_MODE_KEY]: NATIVE_LAYOUT_MODE_KEY, [HIDE_STATUS_BAR_KEY]: NATIVE_HIDE_STATUS_BAR_KEY, - [GESTURES_KEY]: NATIVE_GESTURES_KEY + [GESTURES_KEY]: NATIVE_GESTURES_KEY, + [START_SCREEN_KEY]: NATIVE_START_SCREEN_KEY } let persistenceQueue = Promise.resolve() @@ -84,6 +88,14 @@ async function restoreNativePreferences(): Promise { } else if (webGestures) { await Preferences.set({ key: NATIVE_GESTURES_KEY, value: webGestures }) } + + const nativeStartScreen = await Preferences.get({ key: NATIVE_START_SCREEN_KEY }) + const webStartScreen = localStorage.getItem(START_SCREEN_KEY) + if (nativeStartScreen.value) { + localStorage.setItem(START_SCREEN_KEY, nativeStartScreen.value) + } else if (webStartScreen) { + await Preferences.set({ key: NATIVE_START_SCREEN_KEY, value: webStartScreen }) + } } catch { // Continue with WebView storage when native preferences are unavailable. } diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index a330df9..24543fc 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -46,6 +46,7 @@ import { installNoteRowGestures, NOTE_ROW_SELECTOR } from './note-row-gestures' import { NoteActionSheet } from './note-actions' import { installEditorKeyboardScroll } from './editor-keyboard-scroll' import { installEditorNativeTyping } from './editor-native-typing' +import { getStartScreen, setStartScreen, type StartScreen } from './start-screen' import { useYouTubeLiteEmbeds } from './youtube-embed-shim' import { VaultsSheet, promptNewVault } from './MobileDrawer' import { @@ -673,8 +674,9 @@ function usePhoneLayoutBoot(): void { // activeTab (or no snapshot, or an unreadable one) means Home; // anything else keeps the restored note/view on screen. `null` — the // witness read somehow still in flight — falls back to Home, the - // pre-#2 behavior, rather than guessing a note. - if (persistedHome !== false) goHome() + // pre-#2 behavior, rather than guessing a note. The Start screen + // setting (start-screen.ts) overrides all of that with Home. + if (persistedHome !== false || getStartScreen() === 'home') goHome() // First run only: land IN the seeded welcome note (reading mode — no // keyboard) instead of on a Home screen with nothing to do. Home stays // one Back tap away. The pane mode is set through the store before the @@ -2739,6 +2741,50 @@ function SettingsLayoutRow(): React.JSX.Element { ) } +const START_SCREEN_CHOICES: Array<{ value: StartScreen; label: string }> = [ + { value: 'last', label: 'Where I left off' }, + { value: 'home', label: 'Home' } +] + +/** + * Settings → Appearance → Start screen (island beside Layout / Swipe + * gestures, phones only — the landing logic is usePhoneLayoutBoot's). A + * user asked for Home instead of the last note after quitting the app. + */ +function SettingsStartScreenRow(): React.JSX.Element { + const [value, setValue] = useState(() => getStartScreen()) + const choose = (next: StartScreen): void => { + setStartScreen(next) + setValue(next) + } + return ( +
    +
    +
    Start screen
    +
    + {value === 'home' + ? 'Opening the app lands on Home. Switching to another app and back keeps your place.' + : 'Opening the app returns to the note or view you left. Choose Home to start fresh every time.'} +
    +
    +
    + {START_SCREEN_CHOICES.map((choice) => ( + + ))} +
    +
    + ) +} + function useLayoutSettingsRow(): void { useEffect(() => { let container: HTMLElement | null = null @@ -2777,6 +2823,7 @@ function useLayoutSettingsRow(): void { <> + {isPhoneWidth() && } {isPhoneWidth() && } ) diff --git a/src/ui-mobile/start-screen.test.ts b/src/ui-mobile/start-screen.test.ts new file mode 100644 index 0000000..8566df9 --- /dev/null +++ b/src/ui-mobile/start-screen.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +// The module reads localStorage lazily (inside the functions), so a stub +// installed before the first call is all node needs. +const store = new Map() +;(globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key) +} + +const { DEFAULT_START_SCREEN, getStartScreen, setStartScreen } = await import('./start-screen.ts') +const { START_SCREEN_KEY } = await import('../viewport.ts') + +test('nothing stored means where the user left off', () => { + store.delete(START_SCREEN_KEY) + assert.equal(getStartScreen(), 'last') + assert.equal(DEFAULT_START_SCREEN, 'last') +}) + +test('an unknown stored value falls back to the default', () => { + store.set(START_SCREEN_KEY, 'jetpack') + assert.equal(getStartScreen(), 'last') +}) + +test('home persists; the default removes the key', () => { + setStartScreen('home') + assert.equal(store.get(START_SCREEN_KEY), 'home') + assert.equal(getStartScreen(), 'home') + setStartScreen('last') + assert.equal(store.has(START_SCREEN_KEY), false) + assert.equal(getStartScreen(), 'last') +}) diff --git a/src/ui-mobile/start-screen.ts b/src/ui-mobile/start-screen.ts new file mode 100644 index 0000000..897fd49 --- /dev/null +++ b/src/ui-mobile/start-screen.ts @@ -0,0 +1,37 @@ +/** + * Where a cold launch lands (user request relayed by Adib, 2026-09-08: "I + * wish there was a feature to set the home screen as the start screen + * instead of the last note when turning the app off and on again"). + * + * 'last' (the default) keeps the #2 behaviour: the note or view that was + * open when iOS killed the app comes back, and Home comes back if that is + * where the user left. 'home' lands on Home on every cold launch. Switching + * away and back is not a launch and never moves the user — the landing + * logic in usePhoneLayoutBoot runs once per process. + * + * Read lazily, like gestures.ts; the Settings card writes through + * setStartScreen and the next launch picks it up. The default removes the + * key so a fresh install and a reset look identical. + */ +import { START_SCREEN_KEY } from '../viewport.ts' + +export type StartScreen = 'last' | 'home' + +export const DEFAULT_START_SCREEN: StartScreen = 'last' + +export function getStartScreen(): StartScreen { + try { + return localStorage.getItem(START_SCREEN_KEY) === 'home' ? 'home' : DEFAULT_START_SCREEN + } catch { + return DEFAULT_START_SCREEN + } +} + +export function setStartScreen(next: StartScreen): void { + try { + if (next === DEFAULT_START_SCREEN) localStorage.removeItem(START_SCREEN_KEY) + else localStorage.setItem(START_SCREEN_KEY, next) + } catch { + // Storage unavailable: the choice applies to this session only. + } +} diff --git a/src/viewport.ts b/src/viewport.ts index 547b449..89a8928 100644 --- a/src/viewport.ts +++ b/src/viewport.ts @@ -94,6 +94,11 @@ export const HIDE_STATUS_BAR_KEY = 'zn:hide-status-bar' * ui-mobile/gestures.ts. Mirrored natively for the same reason as above. */ export const GESTURES_KEY = 'zn:gestures' +/** localStorage key for where a cold launch lands ('home'; absent = where + * the user left off), see ui-mobile/start-screen.ts. Mirrored natively + * like the other shell prefs. */ +export const START_SCREEN_KEY = 'zn:start-screen' + /** Publish the current decision to CSS. Safe to call repeatedly. */ export function syncPhoneClass(): void {