From ae4746d5a42b146a23c15ee28a093d5da40707ad Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 4 Sep 2026 18:42:27 -0500 Subject: [PATCH 1/4] chore: pin ZenNotes 2.45.0 and bump to 1.9.7 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 +- ios/App/App.xcodeproj/project.pbxproj | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 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/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 3076906..f6b48e8 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -566,12 +566,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.6; + MARKETING_VERSION = 1.9.7; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -588,12 +588,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.6; + MARKETING_VERSION = 1.9.7; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -608,12 +608,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.6; + MARKETING_VERSION = 1.9.7; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -646,12 +646,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.6; + MARKETING_VERSION = 1.9.7; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 2cc9b8f0dde2550d8dc4cd2bfba1b455bb375ed0 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 08:27:51 -0500 Subject: [PATCH 2/4] fix(mobile): phone editing round from 1.9.7 device testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bullet / Checkbox / Heading toolbar buttons work on a blank line: app-core's setBlockType converts existing text and skips blank lines, so the toolbar inserts the marker itself there (EditorToolbar). - The caret stays above the keyboard's formatting toolbar: every editor view gets an EditorView.scrollMargins source for the toolbar's live height, and the caret is re-revealed once the WebView has resized for the keyboard — nothing did that before, so the last lines of a long note sat under the overlay while typing (editor-keyboard-scroll). - iOS autocorrect, the predictive bar, inline predictions and sentence capitalization in the note body: CodeMirror's content defaults turn them all off; overridden per view via contentAttributes (editor-native-typing). - Quick Notes rows swipe left to Delete, the drawer rows' gesture layered onto app-core's DOM, with the same confirm-then-trash flow (quick-note-swipe). --- src/ui-mobile/EditorToolbar.tsx | 57 +++++- src/ui-mobile/MobileShell.tsx | 25 +++ src/ui-mobile/editor-keyboard-scroll.ts | 99 +++++++++ src/ui-mobile/editor-native-typing.ts | 52 +++++ src/ui-mobile/mobile.css | 41 ++++ src/ui-mobile/quick-note-swipe.ts | 257 ++++++++++++++++++++++++ 6 files changed, 527 insertions(+), 4 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/quick-note-swipe.ts diff --git a/src/ui-mobile/EditorToolbar.tsx b/src/ui-mobile/EditorToolbar.tsx index 4fcea0e..8f8188b 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 { revealCaretAboveKeyboard } from './editor-keyboard-scroll' function view(): EditorView | null { return useStore.getState().editorViewRef @@ -39,13 +45,50 @@ 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, device testing 2026-09-08). 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 +138,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 +266,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) requestAnimationFrame(revealCaretAboveKeyboard) + }, [visible]) + if (!visible) return null return ( diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 698f1fc..bcdd4f3 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -73,6 +73,9 @@ import { import { siblingNotesInDrawerOrder } from './note-order' import { getPinnedNotes, loadPins } from './pins' import { isSwipeRowGestureActive } from './SwipeRow' +import { installQuickNoteSwipe } from './quick-note-swipe' +import { installEditorKeyboardScroll } from './editor-keyboard-scroll' +import { installEditorNativeTyping } from './editor-native-typing' import { vaultSettingsAccessForLayout } from './vault-settings-access' // Phone-only behaviours gate on this. Smallest-side based, so rotating a phone // into landscape no longer disables the whole mobile shell (Android #12 — the @@ -2752,6 +2755,25 @@ function useAboutGitHubLinks(): void { }, []) } +/** + * Swipe-left → Delete on app-core's Quick Notes rows (quick-note-swipe.ts): + * the list has no delete affordance of its own, and the drawer's note rows + * already answer the same gesture. + */ +function useQuickNoteSwipe(): void { + useEffect(() => installQuickNoteSwipe(), []) +} + +/** Caret stays above the keyboard's formatting toolbar (editor-keyboard-scroll.ts). */ +function useEditorKeyboardScroll(): void { + useEffect(() => installEditorKeyboardScroll(), []) +} + +/** iOS autocorrect / predictive text in the note body (editor-native-typing.ts). */ +function useEditorNativeTyping(): void { + useEffect(() => installEditorNativeTyping(), []) +} + function MobileShellRoot(): React.JSX.Element { usePhoneLayoutBoot() useDrawerAutoClose() @@ -2759,6 +2781,9 @@ function MobileShellRoot(): React.JSX.Element { useWikilinkTapNavigation() useBreadcrumbDrawerNav() useLongPressContextMenu() + useQuickNoteSwipe() + useEditorKeyboardScroll() + useEditorNativeTyping() usePlaceholderCleanup() useTagsEmptyStateHint() useEdgeSwipeDrawer() diff --git a/src/ui-mobile/editor-keyboard-scroll.ts b/src/ui-mobile/editor-keyboard-scroll.ts new file mode 100644 index 0000000..b8a3b50 --- /dev/null +++ b/src/ui-mobile/editor-keyboard-scroll.ts @@ -0,0 +1,99 @@ +/** + * 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 and the toolbar is actually up. */ +export function revealCaretAboveKeyboard(): void { + const view = useStore.getState().editorViewRef + if (!view || !view.hasFocus || toolbarClearance() === 0) return + ensureMargins(view) + view.dispatch({ + effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'nearest' }) + }) +} + +/** 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', () => { + requestAnimationFrame(revealCaretAboveKeyboard) + }) + // 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')) { + requestAnimationFrame(revealCaretAboveKeyboard) + } + } + 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..a4bedb2 --- /dev/null +++ b/src/ui-mobile/editor-native-typing.ts @@ -0,0 +1,52 @@ +/** + * iOS autocorrect, the QuickType predictive bar, inline predictions and + * sentence capitalization in the note body (Adib, device testing + * 2026-09-08: "Apple autocorrect and predictive bar is still not showing"). + * + * 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 predictive bar, no + * corrections, no capitalization, no inline suggestions. 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, 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 UITextView gets by default; `writingsuggestions` is WebKit's + * attribute for iOS's inline predictions. */ +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 385c5f6..ef012c1 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -922,6 +922,47 @@ background: rgb(var(--z-danger, 220 80 80)); } +/* ---- Quick Notes list: swipe-left → Delete (quick-note-swipe.ts) -------- + app-core's rows ([data-quick-row]) get the drawer rows' gesture from + outside. The engaged class lands only while a row is displaced, so resting + rows keep app-core's exact layout; the Delete button is appended into the + row and slides in over its right edge like .zn-swipe-actions. */ +.zn-mobile [data-quick-row].zn-quick-swipe { + position: relative; + overflow: hidden; + /* Vertical panning stays native; the tracker claims horizontal itself. */ + touch-action: pan-y; +} + +.zn-mobile [data-quick-row].zn-quick-swipe.is-settling > * { + transition: transform 180ms ease-out; +} + +.zn-mobile .zn-quick-swipe-actions { + position: absolute; + inset: 0 0 0 auto; + display: flex; + z-index: 2; + will-change: transform; +} + +.zn-mobile .zn-quick-swipe-actions > button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + width: 72px; + font-size: 0.75rem; + color: #fff; + background: rgb(var(--z-danger, 220 80 80)); +} + +.zn-mobile .zn-quick-swipe-actions > button svg { + width: 19px; + height: 19px; +} + .zn-phone .zn-swipe-pin { position: absolute; inset: 0 auto 0 0; diff --git a/src/ui-mobile/quick-note-swipe.ts b/src/ui-mobile/quick-note-swipe.ts new file mode 100644 index 0000000..57dca68 --- /dev/null +++ b/src/ui-mobile/quick-note-swipe.ts @@ -0,0 +1,257 @@ +/** + * Swipe-left → Delete on the Quick Notes list (Adib, device testing + * 2026-09-08: "I can't delete a quicknote, not seeing gestures"). + * + * app-core's QuickNotesView (opened from the drawer's Quick Notes row) has no + * delete affordance of its own — on desktop a quick note is deleted from the + * editor's note menu once it's open, and the list is keyboard-driven. The + * drawer's note rows already swipe left for Archive / Delete (SwipeRow), so + * the phone gets the same gesture here. The rows are app-core's DOM, so it's + * layered on from outside: a document-level touch tracker over + * `[data-quick-row]` with SwipeRow's exact feel and coexistence rules — + * claim only once |dx| beats both the 12px slop and |dy| (vertical scrolling + * wins), the row content follows the finger by a third while a Delete button + * slides in over the right edge, release past half the button width settles + * it open, a touch anywhere else closes it, and a tap on the open row closes + * it instead of opening the note. Touches in the drawer's left-edge zone are + * left to the drawer. + * + * The button is appended INTO the row (position: relative + overflow hidden, + * mobile.css) and removed again once 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 + * button with it. Deleting mirrors the drawer's trashNote: confirm, then + * `moveToTrash`; the bridge's rescan drops the row from the list. + */ +import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' +import { useStore } from '@zennotes/app-core/store' + +const ROW = '[data-quick-row]' +const ACTIONS_CLASS = 'zn-quick-swipe-actions' +const ENGAGED_CLASS = 'zn-quick-swipe' +const SETTLING_CLASS = 'is-settling' + +const ACTION_WIDTH = 72 // px, matches SwipeRow's ACTION_WIDTH +// SwipeRow nudges its content by a third; these rows start their title only +// 16px in (no leading icon on the phone), so a third clipped the first +// letter under the row's overflow — a sixth (12px fully open) stays inside. +const CONTENT_FOLLOW = 1 / 6 +// MUST stay >= MobileShell's long-press slop (12px) for the same reason as +// SwipeRow's CLAIM, even though quick rows carry no long-press today. +const CLAIM = 12 +// 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 TRASH_SVG = + '' + +interface Track { + row: HTMLElement + x: number + y: number + claimed: boolean + dead: boolean + fromDx: number +} + +/** Current displacement per row (0 when at rest). */ +const position = new WeakMap() +let openRow: HTMLElement | null = null +let track: Track | null = null +let live = 0 + +function dxOf(row: HTMLElement): number { + return position.get(row) ?? 0 +} + +function contentOf(row: HTMLElement): HTMLElement[] { + return Array.from(row.children).filter( + (c): c is HTMLElement => c instanceof HTMLElement && !c.classList.contains(ACTIONS_CLASS) + ) +} + +function actionsOf(row: HTMLElement): HTMLElement { + const existing = row.querySelector(`:scope > .${ACTIONS_CLASS}`) + if (existing) return existing + const el = document.createElement('div') + el.className = ACTIONS_CLASS + const button = document.createElement('button') + button.type = 'button' + button.className = 'zn-danger' + button.innerHTML = `${TRASH_SVG}Delete` + button.addEventListener('click', (e) => { + // The row's own onClick would open the note. + e.preventDefault() + e.stopPropagation() + const path = row.dataset.quickRow + settle(row, 0) + if (path) void deleteQuickNote(path) + }) + el.appendChild(button) + row.appendChild(el) + return el +} + +function paint(row: HTMLElement, dx: number, settling: boolean): void { + position.set(row, dx) + row.classList.add(ENGAGED_CLASS) + row.classList.toggle(SETTLING_CLASS, settling) + const contentX = dx * CONTENT_FOLLOW + for (const child of contentOf(row)) child.style.transform = `translateX(${contentX}px)` + actionsOf(row).style.transform = `translateX(${Math.max(0, ACTION_WIDTH + dx)}px)` +} + +function rest(row: HTMLElement): void { + position.delete(row) + row.classList.remove(ENGAGED_CLASS, SETTLING_CLASS) + for (const child of contentOf(row)) child.style.removeProperty('transform') + row.querySelector(`:scope > .${ACTIONS_CLASS}`)?.remove() +} + +function settle(row: HTMLElement, target: number): void { + if (!row.isConnected) { + if (openRow === row) openRow = null + return + } + paint(row, target, true) + if (target === 0) { + if (openRow === row) openRow = null + window.setTimeout(() => { + // Only tidy up if nothing re-engaged the row meanwhile. + if (dxOf(row) === 0 && !(track?.row === row && track.claimed)) rest(row) + }, SETTLE_MS) + } else { + if (openRow && openRow !== row) settle(openRow, 0) + openRow = row + } +} + +async function deleteQuickNote(path: string): Promise { + const note = useStore.getState().notes.find((n) => n.path === path) + const ok = await confirmApp({ + title: `Delete "${note?.title ?? path}"?`, + description: 'It will move to the trash.', + confirmLabel: 'Delete', + danger: true + }) + if (ok) await window.zen.moveToTrash(path) +} + +function onTouchStart(e: TouchEvent): void { + if (e.touches.length !== 1) { + // A second finger mid-gesture: abandon and revert, never leave a row + // half-open. + const t = track + track = null + if (t?.claimed) settle(t.row, t.fromDx) + return + } + const touch = e.touches[0]! + const target = e.target as HTMLElement | null + const row = target?.closest?.(ROW) + const rowEl = row instanceof HTMLElement ? row : null + // 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 (openRow && rowEl !== openRow) settle(openRow, 0) + track = null + if (!rowEl || touch.clientX <= EDGE) return + if (target?.closest?.(`.${ACTIONS_CLASS}`)) return + track = { + row: rowEl, + x: touch.clientX, + y: touch.clientY, + claimed: false, + dead: false, + fromDx: rowEl === openRow ? -ACTION_WIDTH : 0 + } +} + +function onTouchMove(e: TouchEvent): void { + const t = track + if (!t || t.dead) return + if (e.touches.length !== 1) { + t.dead = true + if (t.claimed) settle(t.row, t.fromDx) + return + } + const touch = e.touches[0]! + const mx = touch.clientX - t.x + const my = touch.clientY - t.y + if (!t.claimed) { + if (Math.abs(my) > Math.abs(mx) && Math.abs(my) > CLAIM) { + t.dead = true // vertical scroll wins + return + } + // A rightward drag on a closed row has nothing to reveal. + if (mx > 0 && t.fromDx === 0) { + t.dead = true + return + } + if (Math.abs(mx) <= CLAIM || Math.abs(mx) <= Math.abs(my)) return + if (!t.row.isConnected) { + t.dead = true + return + } + t.claimed = true + } + e.preventDefault() + let next = t.fromDx + mx + if (next > 0) next = 0 + // Rubber-band past the button. + if (next < -ACTION_WIDTH) next = -ACTION_WIDTH + (next + ACTION_WIDTH) / 3 + live = next + paint(t.row, next, false) +} + +function onTouchEnd(): void { + const t = track + track = null + if (!t || t.dead || !t.claimed) return + settle(t.row, live < -ACTION_WIDTH / 2 ? -ACTION_WIDTH : 0) +} + +function onTouchCancel(): void { + // The system stole the touch (edge swipe, call banner, app switch). + const t = track + track = null + if (t?.claimed) settle(t.row, t.fromDx) +} + +function onClickCapture(e: MouseEvent): void { + const row = openRow + if (!row) return + const target = e.target as HTMLElement | null + if (!target || !row.contains(target)) return + if (target.closest(`.${ACTIONS_CLASS}`)) return + // Tapping a swiped-open row closes it instead of opening the note. + e.preventDefault() + e.stopPropagation() + settle(row, 0) +} + +/** Install the gesture document-wide; returns the uninstaller. */ +export function installQuickNoteSwipe(): () => 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 }) + 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) + if (openRow) rest(openRow) + openRow = null + track = null + } +} From 550e246e025cc0a0b0f47801d4b65165f03b457e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 11:03:41 -0500 Subject: [PATCH 3/4] feat(mobile): note gestures everywhere, and a Home that survives rescans Every note row on the phone 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. It carries app-core's data-ctx-menu marker: a native long-press on selectable drawer text made the WebView first responder, and app-core's focus heal (App.tsx, on window focus) refocused the editor and raised the keyboard under the sheet. The drawer is non-selectable for the same reason. - note-row-gestures.ts: the gestures layered onto app-core's DOM rows (Home Recent, Quick Notes, Tags, Archive, Trash), replacing the quick-note-only swipe. Home rows resolve by position against HomeView's recent ordering, ignoring the layers the engine appends; the content nudge is capped below the row's own padding. - 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. The guard re-nulls it inside the same notification pass, ahead of the drawer's auto-close subscriber, which otherwise acted on the transient tab. - editor-keyboard-scroll: the caret reveal re-runs over a short window; the predictive bar grows the keyboard after the keys, and a single early reveal left the caret under the toolbar once. --- src/main.tsx | 7 + src/ui-mobile/EditorToolbar.tsx | 4 +- src/ui-mobile/MobileDrawer.tsx | 141 +------ src/ui-mobile/MobileShell.tsx | 20 +- src/ui-mobile/editor-keyboard-scroll.ts | 27 +- src/ui-mobile/mobile.css | 74 +++- src/ui-mobile/nav.ts | 37 ++ src/ui-mobile/note-actions.tsx | 262 ++++++++++++ src/ui-mobile/note-row-gestures.ts | 537 ++++++++++++++++++++++++ src/ui-mobile/quick-note-swipe.ts | 257 ------------ 10 files changed, 947 insertions(+), 419 deletions(-) create mode 100644 src/ui-mobile/note-actions.tsx create mode 100644 src/ui-mobile/note-row-gestures.ts delete mode 100644 src/ui-mobile/quick-note-swipe.ts diff --git a/src/main.tsx b/src/main.tsx index c98602b..c602ed2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,7 @@ import { syncKeyboardBackdrop } from './bridge/keyboard-backdrop' 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, wireICloudLiveRefresh } from './ui-mobile/refresh' import { isPhoneDevice, watchPhoneClass } from './viewport' import './ui-mobile/mobile.css' @@ -90,6 +91,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() wireICloudLiveRefresh() diff --git a/src/ui-mobile/EditorToolbar.tsx b/src/ui-mobile/EditorToolbar.tsx index 8f8188b..c06ede0 100644 --- a/src/ui-mobile/EditorToolbar.tsx +++ b/src/ui-mobile/EditorToolbar.tsx @@ -23,7 +23,7 @@ import { type BlockType } from '@zennotes/app-core/lib/cm-format' import { promptAttachFiles } from './attach' -import { revealCaretAboveKeyboard } from './editor-keyboard-scroll' +import { revealCaretAboveKeyboardSoon } from './editor-keyboard-scroll' function view(): EditorView | null { return useStore.getState().editorViewRef @@ -269,7 +269,7 @@ export function MobileEditorToolbar(): React.JSX.Element | null { // 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) requestAnimationFrame(revealCaretAboveKeyboard) + if (visible) revealCaretAboveKeyboardSoon() }, [visible]) if (!visible) return null diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index b244279..09e44d9 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, icloudStatus } from '../bridge/icloud' @@ -1142,11 +1142,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, ported from the Android shell). Notes mirror - // the ••• sheet's actions; folders get Rename/Delete. Prompts overlay the - // open drawer (Modal layers above it), 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) + // (Discord folder feedback, ported from the Android shell). 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 it), so the drawer stays put and its list refreshes in + // place via the vault change events. const [folderMenu, setFolderMenu] = useState<{ subpath: string; name: string } | null>(null) const pinNote = (notePath: string): void => { @@ -1176,46 +1176,6 @@ function MobileDrawerBody(props: { const scrollRef = useRef(null) - 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 () => { @@ -1251,18 +1211,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({ @@ -1459,7 +1407,7 @@ function MobileDrawerBody(props: { { label: 'Archive', icon: , - onAction: () => archiveNoteFromDrawer(n.path) + onAction: () => archiveNote(n.path) }, { label: 'Delete', @@ -1474,7 +1422,7 @@ function MobileDrawerBody(props: { - - - - - - - - - - )} - {folderMenu && ( <>
installQuickNoteSwipe(), []) +function useNoteRowGestures(): void { + useEffect(() => installNoteRowGestures(), []) } /** Caret stays above the keyboard's formatting toolbar (editor-keyboard-scroll.ts). */ @@ -2781,7 +2786,7 @@ function MobileShellRoot(): React.JSX.Element { useWikilinkTapNavigation() useBreadcrumbDrawerNav() useLongPressContextMenu() - useQuickNoteSwipe() + useNoteRowGestures() useEditorKeyboardScroll() useEditorNativeTyping() usePlaceholderCleanup() @@ -2808,6 +2813,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 index b8a3b50..e2b50ce 100644 --- a/src/ui-mobile/editor-keyboard-scroll.ts +++ b/src/ui-mobile/editor-keyboard-scroll.ts @@ -58,16 +58,31 @@ function ensureMargins(view: EditorView): void { } /** Scroll the caret into view honoring the toolbar clearance, if the editor - * is the focused element and the toolbar is actually up. */ + * 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 || toolbarClearance() === 0) return + 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 { @@ -80,15 +95,11 @@ export function installEditorKeyboardScroll(): () => void { }) // 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', () => { - requestAnimationFrame(revealCaretAboveKeyboard) - }) + 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')) { - requestAnimationFrame(revealCaretAboveKeyboard) - } + if (document.documentElement.classList.contains('zn-kb-open')) revealCaretAboveKeyboardSoon() } window.addEventListener('resize', onResize) return () => { diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index ef012c1..63f338f 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -635,6 +635,22 @@ } /* The shell's own navigation drawer. */ +/* The drawer's rows answer a custom long-press (action sheet) and swipes; + iOS must not start a text selection on the same press — besides the + callout, WebKit's selection gesture makes the web view first responder, + which fires window focus and app-core's focus heal refocuses the editor + (keyboard up under the sheet). 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; +} + .zn-phone .zn-mobile-drawer { position: fixed; top: 0; @@ -922,23 +938,36 @@ background: rgb(var(--z-danger, 220 80 80)); } -/* ---- Quick Notes list: swipe-left → Delete (quick-note-swipe.ts) -------- - app-core's rows ([data-quick-row]) get the drawer rows' gesture from - outside. The engaged class lands only while a row is displaced, so resting - rows keep app-core's exact layout; the Delete button is appended into the - row and slides in over its right edge like .zn-swipe-actions. */ -.zn-mobile [data-quick-row].zn-quick-swipe { +/* ---- 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; iOS 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 [data-quick-row].zn-quick-swipe.is-settling > * { +.zn-mobile .zn-row-swipe.is-settling > * { transition: transform 180ms ease-out; } -.zn-mobile .zn-quick-swipe-actions { +.zn-mobile .zn-row-swipe-actions { position: absolute; inset: 0 0 0 auto; display: flex; @@ -946,7 +975,7 @@ will-change: transform; } -.zn-mobile .zn-quick-swipe-actions > button { +.zn-mobile .zn-row-swipe-actions > button { display: flex; flex-direction: column; align-items: center; @@ -954,15 +983,36 @@ gap: 0.25rem; width: 72px; font-size: 0.75rem; - color: #fff; - background: rgb(var(--z-danger, 220 80 80)); + color: rgb(var(--z-fg)); + background: rgb(var(--z-bg-3)); } -.zn-mobile .zn-quick-swipe-actions > button svg { +.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..049e2bb --- /dev/null +++ b/src/ui-mobile/note-actions.tsx @@ -0,0 +1,262 @@ +/** + * 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. + * + * 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 { activeVaultStateKey } from '../bridge/mobile-bridge' +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() + +// --------------------------------------------------------------------------- +// 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(activeVaultStateKey()).includes(path) +} + +export function pinNote(path: string): void { + const key = activeVaultStateKey() + 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(activeVaultStateKey()) + 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 + } +} diff --git a/src/ui-mobile/quick-note-swipe.ts b/src/ui-mobile/quick-note-swipe.ts deleted file mode 100644 index 57dca68..0000000 --- a/src/ui-mobile/quick-note-swipe.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Swipe-left → Delete on the Quick Notes list (Adib, device testing - * 2026-09-08: "I can't delete a quicknote, not seeing gestures"). - * - * app-core's QuickNotesView (opened from the drawer's Quick Notes row) has no - * delete affordance of its own — on desktop a quick note is deleted from the - * editor's note menu once it's open, and the list is keyboard-driven. The - * drawer's note rows already swipe left for Archive / Delete (SwipeRow), so - * the phone gets the same gesture here. The rows are app-core's DOM, so it's - * layered on from outside: a document-level touch tracker over - * `[data-quick-row]` with SwipeRow's exact feel and coexistence rules — - * claim only once |dx| beats both the 12px slop and |dy| (vertical scrolling - * wins), the row content follows the finger by a third while a Delete button - * slides in over the right edge, release past half the button width settles - * it open, a touch anywhere else closes it, and a tap on the open row closes - * it instead of opening the note. Touches in the drawer's left-edge zone are - * left to the drawer. - * - * The button is appended INTO the row (position: relative + overflow hidden, - * mobile.css) and removed again once 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 - * button with it. Deleting mirrors the drawer's trashNote: confirm, then - * `moveToTrash`; the bridge's rescan drops the row from the list. - */ -import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' -import { useStore } from '@zennotes/app-core/store' - -const ROW = '[data-quick-row]' -const ACTIONS_CLASS = 'zn-quick-swipe-actions' -const ENGAGED_CLASS = 'zn-quick-swipe' -const SETTLING_CLASS = 'is-settling' - -const ACTION_WIDTH = 72 // px, matches SwipeRow's ACTION_WIDTH -// SwipeRow nudges its content by a third; these rows start their title only -// 16px in (no leading icon on the phone), so a third clipped the first -// letter under the row's overflow — a sixth (12px fully open) stays inside. -const CONTENT_FOLLOW = 1 / 6 -// MUST stay >= MobileShell's long-press slop (12px) for the same reason as -// SwipeRow's CLAIM, even though quick rows carry no long-press today. -const CLAIM = 12 -// 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 TRASH_SVG = - '' - -interface Track { - row: HTMLElement - x: number - y: number - claimed: boolean - dead: boolean - fromDx: number -} - -/** Current displacement per row (0 when at rest). */ -const position = new WeakMap() -let openRow: HTMLElement | null = null -let track: Track | null = null -let live = 0 - -function dxOf(row: HTMLElement): number { - return position.get(row) ?? 0 -} - -function contentOf(row: HTMLElement): HTMLElement[] { - return Array.from(row.children).filter( - (c): c is HTMLElement => c instanceof HTMLElement && !c.classList.contains(ACTIONS_CLASS) - ) -} - -function actionsOf(row: HTMLElement): HTMLElement { - const existing = row.querySelector(`:scope > .${ACTIONS_CLASS}`) - if (existing) return existing - const el = document.createElement('div') - el.className = ACTIONS_CLASS - const button = document.createElement('button') - button.type = 'button' - button.className = 'zn-danger' - button.innerHTML = `${TRASH_SVG}Delete` - button.addEventListener('click', (e) => { - // The row's own onClick would open the note. - e.preventDefault() - e.stopPropagation() - const path = row.dataset.quickRow - settle(row, 0) - if (path) void deleteQuickNote(path) - }) - el.appendChild(button) - row.appendChild(el) - return el -} - -function paint(row: HTMLElement, dx: number, settling: boolean): void { - position.set(row, dx) - row.classList.add(ENGAGED_CLASS) - row.classList.toggle(SETTLING_CLASS, settling) - const contentX = dx * CONTENT_FOLLOW - for (const child of contentOf(row)) child.style.transform = `translateX(${contentX}px)` - actionsOf(row).style.transform = `translateX(${Math.max(0, ACTION_WIDTH + dx)}px)` -} - -function rest(row: HTMLElement): void { - position.delete(row) - row.classList.remove(ENGAGED_CLASS, SETTLING_CLASS) - for (const child of contentOf(row)) child.style.removeProperty('transform') - row.querySelector(`:scope > .${ACTIONS_CLASS}`)?.remove() -} - -function settle(row: HTMLElement, target: number): void { - if (!row.isConnected) { - if (openRow === row) openRow = null - return - } - paint(row, target, true) - if (target === 0) { - if (openRow === row) openRow = null - window.setTimeout(() => { - // Only tidy up if nothing re-engaged the row meanwhile. - if (dxOf(row) === 0 && !(track?.row === row && track.claimed)) rest(row) - }, SETTLE_MS) - } else { - if (openRow && openRow !== row) settle(openRow, 0) - openRow = row - } -} - -async function deleteQuickNote(path: string): Promise { - const note = useStore.getState().notes.find((n) => n.path === path) - const ok = await confirmApp({ - title: `Delete "${note?.title ?? path}"?`, - description: 'It will move to the trash.', - confirmLabel: 'Delete', - danger: true - }) - if (ok) await window.zen.moveToTrash(path) -} - -function onTouchStart(e: TouchEvent): void { - if (e.touches.length !== 1) { - // A second finger mid-gesture: abandon and revert, never leave a row - // half-open. - const t = track - track = null - if (t?.claimed) settle(t.row, t.fromDx) - return - } - const touch = e.touches[0]! - const target = e.target as HTMLElement | null - const row = target?.closest?.(ROW) - const rowEl = row instanceof HTMLElement ? row : null - // 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 (openRow && rowEl !== openRow) settle(openRow, 0) - track = null - if (!rowEl || touch.clientX <= EDGE) return - if (target?.closest?.(`.${ACTIONS_CLASS}`)) return - track = { - row: rowEl, - x: touch.clientX, - y: touch.clientY, - claimed: false, - dead: false, - fromDx: rowEl === openRow ? -ACTION_WIDTH : 0 - } -} - -function onTouchMove(e: TouchEvent): void { - const t = track - if (!t || t.dead) return - if (e.touches.length !== 1) { - t.dead = true - if (t.claimed) settle(t.row, t.fromDx) - return - } - const touch = e.touches[0]! - const mx = touch.clientX - t.x - const my = touch.clientY - t.y - if (!t.claimed) { - if (Math.abs(my) > Math.abs(mx) && Math.abs(my) > CLAIM) { - t.dead = true // vertical scroll wins - return - } - // A rightward drag on a closed row has nothing to reveal. - if (mx > 0 && t.fromDx === 0) { - t.dead = true - return - } - if (Math.abs(mx) <= CLAIM || Math.abs(mx) <= Math.abs(my)) return - if (!t.row.isConnected) { - t.dead = true - return - } - t.claimed = true - } - e.preventDefault() - let next = t.fromDx + mx - if (next > 0) next = 0 - // Rubber-band past the button. - if (next < -ACTION_WIDTH) next = -ACTION_WIDTH + (next + ACTION_WIDTH) / 3 - live = next - paint(t.row, next, false) -} - -function onTouchEnd(): void { - const t = track - track = null - if (!t || t.dead || !t.claimed) return - settle(t.row, live < -ACTION_WIDTH / 2 ? -ACTION_WIDTH : 0) -} - -function onTouchCancel(): void { - // The system stole the touch (edge swipe, call banner, app switch). - const t = track - track = null - if (t?.claimed) settle(t.row, t.fromDx) -} - -function onClickCapture(e: MouseEvent): void { - const row = openRow - if (!row) return - const target = e.target as HTMLElement | null - if (!target || !row.contains(target)) return - if (target.closest(`.${ACTIONS_CLASS}`)) return - // Tapping a swiped-open row closes it instead of opening the note. - e.preventDefault() - e.stopPropagation() - settle(row, 0) -} - -/** Install the gesture document-wide; returns the uninstaller. */ -export function installQuickNoteSwipe(): () => 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 }) - 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) - if (openRow) rest(openRow) - openRow = null - track = null - } -} From 45a72f82eff4e242700d75ca1a30f51e1a3cec3f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 11:03:41 -0500 Subject: [PATCH 4/4] =?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; the default removes the key. Requested by a user: "set the home screen as the start screen instead of the last note when turning the app off and on again." --- src/ui-mobile/MobileShell.tsx | 51 ++++++++++++++++++++++++++++-- src/ui-mobile/start-screen.test.ts | 34 ++++++++++++++++++++ src/ui-mobile/start-screen.ts | 37 ++++++++++++++++++++++ src/viewport.ts | 4 +++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/ui-mobile/start-screen.test.ts create mode 100644 src/ui-mobile/start-screen.ts diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 8c26333..30d9f04 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -47,6 +47,7 @@ import { csvPathFromDatabaseTab, formDirFromCsvPath } from '@zennotes/shared-dom import { MobileDrawer } from './MobileDrawer' import { isDrawerOpen, setDrawerOpen, useDrawerOpen } from './drawer-state' import { goHome } from './nav' +import { getStartScreen, setStartScreen, type StartScreen } from './start-screen' import { useYouTubeLiteEmbeds } from './youtube-embed-shim' import { useAtlasTouchGestures } from './atlas-touch-shim' import { VaultsSheet, promptNewVault } from './MobileDrawer' @@ -674,8 +675,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 @@ -2637,6 +2639,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 @@ -2674,6 +2720,7 @@ function useLayoutSettingsRow(): void { root.render( <> + {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 7cc194a..27d4c48 100644 --- a/src/viewport.ts +++ b/src/viewport.ts @@ -123,6 +123,10 @@ export const LAYOUT_MODE_KEY = 'zn:layout-mode' * ui-mobile/gestures.ts. */ 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. */ +export const START_SCREEN_KEY = 'zn:start-screen' + export function getLayoutMode(): LayoutMode { try { const raw = localStorage.getItem(LAYOUT_MODE_KEY)