Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .zennotes-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
f156bb1398b82f7f590d669fce5371783500f061
3301a29d6564c13df4d85a5acb59789c5fc7e200
16 changes: 8 additions & 8 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
Expand All @@ -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 = "";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -90,6 +91,12 @@ async function boot(): Promise<void> {
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()
Expand Down
57 changes: 53 additions & 4 deletions src/ui-mobile/EditorToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Record<BlockType, string>> = {
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 {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 (
Expand Down
141 changes: 8 additions & 133 deletions src/ui-mobile/MobileDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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'
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -1176,46 +1176,6 @@ function MobileDrawerBody(props: {

const scrollRef = useRef<HTMLDivElement | null>(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 () => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -1459,7 +1407,7 @@ function MobileDrawerBody(props: {
{
label: 'Archive',
icon: <Icon d={D.archive} />,
onAction: () => archiveNoteFromDrawer(n.path)
onAction: () => archiveNote(n.path)
},
{
label: 'Delete',
Expand All @@ -1474,7 +1422,7 @@ function MobileDrawerBody(props: {
<button
type="button"
onClick={() => go(() => s().selectNote(n.path))}
{...lp(() => setNoteMenu({ path: n.path, title: n.title }))}
{...lp(() => openNoteMenu({ path: n.path, title: n.title, kind: 'note' }))}
>
<Icon d={D.note} />
<span className="zn-truncate">{n.title}</span>
Expand All @@ -1501,79 +1449,6 @@ function MobileDrawerBody(props: {
</div>
</div>

{noteMenu && (
<>
<div
className="zn-mobile-sheet-backdrop"
onClick={() => setNoteMenu(null)}
role="presentation"
/>
<div className="zn-mobile-sheet" role="menu" aria-label="Note actions">
<div className="zn-mobile-sheet-title zn-truncate">{noteMenu.title}</div>
<div className="zn-mobile-sheet-scroll">
<div className="zn-mobile-sheet-group">
<button
type="button"
className="zn-mobile-sheet-row"
onClick={() => {
const p = noteMenu.path
setNoteMenu(null)
pinNote(p)
}}
>
<Icon d={D.pin} />
{pinnedNotes.includes(noteMenu.path) ? 'Unpin' : 'Pin'}
</button>
<button
type="button"
className="zn-mobile-sheet-row"
onClick={() => renameNoteFromDrawer(noteMenu.path, noteMenu.title)}
>
<Icon d={D.rename} />
Rename
</button>
<button
type="button"
className="zn-mobile-sheet-row"
onClick={() => moveNoteFromDrawer(noteMenu.path)}
>
<Icon d={D.move} />
Move to…
</button>
<button
type="button"
className="zn-mobile-sheet-row"
onClick={() => copyWikilinkFromDrawer(noteMenu.title)}
>
<Icon d={D.link} />
Copy wikilink
</button>
<button
type="button"
className="zn-mobile-sheet-row"
onClick={() => archiveNoteFromDrawer(noteMenu.path)}
>
<Icon d={D.archive} />
Archive
</button>
<button
type="button"
className="zn-mobile-sheet-row zn-danger"
onClick={() => {
const { path: p, title } = noteMenu
setNoteMenu(null)
trashNote(p, title)
}}
>
<Icon d={D.trash} />
Delete
</button>
</div>
</div>
</div>
</>
)}

{folderMenu && (
<>
<div
Expand Down
Loading