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; 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 4fcea0e..c06ede0 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,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) revealCaretAboveKeyboardSoon() + }, [visible]) + if (!visible) return null return ( 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 && ( <>
= [ + { 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 @@ -2666,6 +2720,7 @@ function useLayoutSettingsRow(): void { root.render( <> + {isPhoneWidth() && } {isPhoneWidth() && } ) @@ -2752,6 +2807,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(), []) +} + +/** 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 +2833,9 @@ function MobileShellRoot(): React.JSX.Element { useWikilinkTapNavigation() useBreadcrumbDrawerNav() useLongPressContextMenu() + useNoteRowGestures() + useEditorKeyboardScroll() + useEditorNativeTyping() usePlaceholderCleanup() useTagsEmptyStateHint() useEdgeSwipeDrawer() @@ -2783,6 +2860,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..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..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,6 +938,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; 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 .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..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/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)