feat: refine mobile prototype editing flows

This commit is contained in:
lucaronin
2026-05-05 16:16:29 +02:00
parent 8fc27e15da
commit 7a82caa80c
17 changed files with 384 additions and 365 deletions

View File

@@ -44,7 +44,7 @@ import { NamedIcon, type IconName } from './NamedIcon'
import { SwipeSurface } from './SwipeSurface'
import { styles } from './styles'
import { colors } from './theme'
import { MobileNoteCreatePrompt } from './MobileNoteCreatePrompt'
import { MobileEditorBreadcrumb } from './MobileEditorBreadcrumb'
import { MobilePropertiesPanel } from './MobilePropertiesPanel'
import { MobileVaultManagementCard } from './MobileVaultManagementCard'
import { MobileVaultRemotePrompt } from './MobileVaultRemotePrompt'
@@ -170,16 +170,11 @@ export function MobileApp() {
notes={availableNotes}
selectedNoteId={compactNavigation.selectedNoteId}
createNoteFailed={createFlow.failed}
createNoteTitle={createFlow.title}
isCreatePromptOpen={createFlow.isPromptOpen}
isCreatingNote={createFlow.isCreating}
runtimeLoadFailed={runtimeLoader.failed}
onCancelCreateNote={createFlow.cancel}
onChangeCreateNoteTitle={createFlow.setTitle}
onOpenCreateNote={createFlow.open}
onCreateNote={createFlow.create}
onGitSyncAction={gitSyncFlow.runPrimaryAction}
onRetryRuntimeLoad={runtimeLoader.retry}
onSubmitCreateNote={createFlow.submit}
onSelectNote={selectNote}
/>
<EditorPanel
@@ -210,16 +205,11 @@ export function MobileApp() {
onDeleteNote={deleteFlow.canDelete ? deleteFlow.deleteSelectedNote : undefined}
onDraftChange={saveDraft}
createNoteFailed={createFlow.failed}
createNoteTitle={createFlow.title}
isCreatePromptOpen={createFlow.isPromptOpen}
isCreatingNote={createFlow.isCreating}
runtimeLoadFailed={runtimeLoader.failed}
onCancelCreateNote={createFlow.cancel}
onChangeCreateNoteTitle={createFlow.setTitle}
onOpenCreateNote={createFlow.open}
onCreateNote={createFlow.create}
onGitSyncAction={gitSyncFlow.runPrimaryAction}
onRetryRuntimeLoad={runtimeLoader.retry}
onSubmitCreateNote={createFlow.submit}
onSelectNote={selectNote}
propertiesFailed={propertiesFlow.failed}
isSavingProperties={propertiesFlow.isSaving}
@@ -254,18 +244,13 @@ function CompactShell({
onDeleteNote,
onDraftChange,
createNoteFailed,
createNoteTitle,
isCreatePromptOpen,
isCreatingNote,
runtimeLoadFailed,
onCancelCreateNote,
onChangeCreateNoteTitle,
onOpenCreateNote,
onCreateNote,
onGitSyncAction,
onChangeProperties,
onOpenRemoteSetup,
onRetryRuntimeLoad,
onSubmitCreateNote,
onSelectNote,
propertiesFailed,
isSavingProperties,
@@ -278,21 +263,16 @@ function CompactShell({
notes: MobileNote[]
saveState: MobileEditorSaveState
createNoteFailed: boolean
createNoteTitle: string
isCreatePromptOpen: boolean
isCreatingNote: boolean
runtimeLoadFailed: boolean
onNavigate: (event: CompactNavigationEvent) => void
onDeleteNote?: () => void
onDraftChange: (draft: MobileEditorDraft) => void
onCancelCreateNote: () => void
onChangeCreateNoteTitle: (title: string) => void
onOpenCreateNote: () => void
onCreateNote: () => void
onGitSyncAction: () => void
onChangeProperties: (patch: MobileNotePropertyPatch) => void
onOpenRemoteSetup: () => void
onRetryRuntimeLoad: () => void
onSubmitCreateNote: () => void
onSelectNote: (note: MobileNote) => void
propertiesFailed: boolean
isSavingProperties: boolean
@@ -346,18 +326,13 @@ function CompactShell({
notes={notes}
selectedNoteId={selectedNoteId}
createNoteFailed={createNoteFailed}
createNoteTitle={createNoteTitle}
isCreatePromptOpen={isCreatePromptOpen}
isCreatingNote={isCreatingNote}
runtimeLoadFailed={runtimeLoadFailed}
onCancelCreateNote={onCancelCreateNote}
onChangeCreateNoteTitle={onChangeCreateNoteTitle}
onGitSyncAction={onGitSyncAction}
onOpenCreateNote={onOpenCreateNote}
onCreateNote={onCreateNote}
onOpenSidebar={() => onNavigate({ type: 'openSidebar' })}
onRetryRuntimeLoad={onRetryRuntimeLoad}
onSelectNote={onSelectNote}
onSubmitCreateNote={onSubmitCreateNote}
/>
</SwipeSurface>
)
@@ -408,35 +383,25 @@ function NoteListPanel({
gitSyncPlan,
notes,
createNoteFailed,
createNoteTitle,
isCreatePromptOpen,
isCreatingNote,
runtimeLoadFailed,
onCancelCreateNote,
onChangeCreateNoteTitle,
onGitSyncAction,
onOpenCreateNote,
onCreateNote,
onOpenSidebar,
onRetryRuntimeLoad,
onSelectNote,
onSubmitCreateNote,
selectedNoteId,
}: {
gitSyncPlan: MobileGitSyncPlan
notes: MobileNote[]
createNoteFailed: boolean
createNoteTitle: string
isCreatePromptOpen: boolean
isCreatingNote: boolean
runtimeLoadFailed: boolean
onCancelCreateNote: () => void
onChangeCreateNoteTitle: (title: string) => void
onGitSyncAction: () => void
onOpenCreateNote: () => void
onCreateNote: () => void
onOpenSidebar?: () => void
onRetryRuntimeLoad: () => void
onSelectNote: (note: MobileNote) => void
onSubmitCreateNote: () => void
selectedNoteId: string
}) {
return (
@@ -477,20 +442,11 @@ function NoteListPanel({
</Pressable>
)}
/>
{isCreatePromptOpen ? (
<MobileNoteCreatePrompt
failed={createNoteFailed}
isCreating={isCreatingNote}
onCancel={onCancelCreateNote}
onChangeTitle={onChangeCreateNoteTitle}
onSubmit={onSubmitCreateNote}
title={createNoteTitle}
/>
) : null}
{createNoteFailed ? <Text style={styles.propertyError}>Could not create note.</Text> : null}
<Pressable
accessibilityLabel="Create note"
disabled={isCreatingNote}
onPress={onOpenCreateNote}
onPress={onCreateNote}
style={({ pressed }) => [
styles.composeButton,
isCreatingNote ? styles.composeButtonDisabled : null,
@@ -547,12 +503,12 @@ function EditorPanel({
<View style={styles.editor}>
<Toolbar>
{onBack ? <IconButton icon={<CaretLeft size={25} color={colors.textSoft} />} onPress={onBack} /> : null}
<View style={styles.toolbarSpacer} />
<MobileEditorBreadcrumb note={note} saveState={saveState ?? idleMobileEditorSaveState} />
{onOpenProperties ? <IconButton icon={<Info size={23} color={colors.textSoft} />} onPress={onOpenProperties} /> : null}
{onDeleteNote ? <IconButton icon={<Trash size={23} color={colors.textSoft} />} onPress={onDeleteNote} /> : null}
<IconButton icon={<DotsThreeVertical size={23} color={colors.textSoft} />} />
</Toolbar>
<MobileEditorAdapter note={note} saveState={saveState} onDraftChange={onDraftChange} />
<MobileEditorAdapter note={note} onDraftChange={onDraftChange} />
</View>
)
}

View File

@@ -1,14 +1,16 @@
import { CaretDown, CaretRight } from 'phosphor-react-native'
import type { ReactNode } from 'react'
import { Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native'
import { useState, type ReactNode } from 'react'
import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from 'react-native'
import type { MobileNote } from './demoData'
import { NamedIcon, type IconName } from './NamedIcon'
import {
formatMobileNoteTags,
isMobileNotePropertySelected,
mobileNoteIconOptions,
mobileNoteStatusOptions,
mobileNoteTagOptions,
mobileNoteTypeOptions,
parseMobileNoteTags,
toggleMobileNoteTag,
type MobileNotePropertyPatch,
} from './mobileNoteProperties'
@@ -29,72 +31,199 @@ export function MobileEditablePropertyPickers({
onSelectPicker: (selected: MobilePropertyPickerKey) => void
openPicker: MobilePropertyPickerKey | null
}) {
const today = formatMobilePropertyDate(new Date())
return (
<>
<PropertyPickerSection
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'type'}
key={`type:${note.id}:${note.type}`}
label="Type"
value={mobilePropertyDisplayValue({ value: note.type })}
placeholder="Type"
suggestions={mobileNoteTypeOptions}
value={note.type}
onCommit={(type) => onChangeProperties?.({ type })}
onOpen={() => onSelectPicker('type')}
>
<PropertyChipOptions>
{mobileNoteTypeOptions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option}
option={option}
value={note.type}
onSelect={(type) => onChangeProperties?.({ type })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
<PropertyPickerSection
/>
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'status'}
key={`status:${note.id}:${note.status ?? ''}`}
label="Status"
value={mobilePropertyDisplayValue({ value: note.status })}
placeholder="Status"
suggestions={mobileNoteStatusOptions}
value={note.status ?? ''}
onCommit={(status) => onChangeProperties?.({ status })}
onOpen={() => onSelectPicker('status')}
>
/>
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'date'}
key={`date:${note.id}:${note.date}`}
label="Date"
placeholder="Date"
suggestions={[today, '']}
value={note.date}
onCommit={(date) => onChangeProperties?.({ date })}
onOpen={() => onSelectPicker('date')}
/>
<IconProperty
disabled={disabled}
isOpen={openPicker === 'icon'}
note={note}
onChangeProperties={onChangeProperties}
onOpen={() => onSelectPicker('icon')}
/>
<TagsProperty
disabled={disabled}
isOpen={openPicker === 'tags'}
key={`tags:${note.id}:${formatMobileNoteTags(note.tags)}`}
note={note}
onChangeProperties={onChangeProperties}
onOpen={() => onSelectPicker('tags')}
/>
</>
)
}
function EditableTextProperty({
disabled,
isOpen,
label,
onCommit,
onOpen,
placeholder,
suggestions,
value,
}: {
disabled: boolean
isOpen: boolean
label: string
onCommit: (value: string) => void
onOpen: () => void
placeholder: string
suggestions: readonly string[]
value: string
}) {
const [draft, setDraft] = useState(value)
const commitDraft = () => commitTextValue({ current: value, next: draft, onCommit })
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label={label}
value={mobilePropertyDisplayValue({ value })}
onOpen={onOpen}
>
<View style={styles.propertyPickerOptions}>
<TextInput
autoCapitalize="sentences"
editable={!disabled}
keyboardType="default"
onBlur={commitDraft}
onChangeText={setDraft}
onSubmitEditing={commitDraft}
placeholder={placeholder}
placeholderTextColor={colors.mutedText}
returnKeyType="done"
style={styles.propertyTextInput}
value={draft}
/>
<PropertyChipOptions>
{mobileNoteStatusOptions.map((option) => (
{suggestions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option || 'none'}
option={option}
value={note.status}
onSelect={(status) => onChangeProperties?.({ status })}
value={value}
onSelect={(selected) => {
setDraft(selected)
onCommit(selected)
}}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
<PropertyPickerSection
disabled={disabled}
isOpen={openPicker === 'icon'}
label="Icon"
value={mobilePropertyDisplayValue({ value: note.icon })}
onOpen={() => onSelectPicker('icon')}
>
<PropertyChipOptions>
{mobileNoteIconOptions.map((option) => (
<PropertyIconChip
disabled={disabled}
key={option}
option={option}
value={note.icon}
onSelect={(icon) => onChangeProperties?.({ icon })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
<PropertyPickerSection
disabled={disabled}
isOpen={openPicker === 'tags'}
label="Tags"
value={note.tags.length > 0 ? `${note.tags.length} selected` : 'None'}
onOpen={() => onSelectPicker('tags')}
>
</View>
</PropertyPickerSection>
)
}
function IconProperty({
disabled,
isOpen,
note,
onChangeProperties,
onOpen,
}: {
disabled: boolean
isOpen: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onOpen: () => void
}) {
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label="Icon"
value={mobilePropertyDisplayValue({ value: note.icon })}
onOpen={onOpen}
>
<PropertyChipOptions>
{mobileNoteIconOptions.map((option) => (
<PropertyIconChip
disabled={disabled}
key={option}
option={option}
value={note.icon}
onSelect={(icon) => onChangeProperties?.({ icon })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
)
}
function TagsProperty({
disabled,
isOpen,
note,
onChangeProperties,
onOpen,
}: {
disabled: boolean
isOpen: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onOpen: () => void
}) {
const [draft, setDraft] = useState(formatMobileNoteTags(note.tags))
const commitDraft = () => onChangeProperties?.({ tags: parseMobileNoteTags(draft) })
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label="Tags"
value={note.tags.length > 0 ? formatMobileNoteTags(note.tags) : 'None'}
onOpen={onOpen}
>
<View style={styles.propertyPickerOptions}>
<TextInput
autoCapitalize="none"
editable={!disabled}
keyboardType="default"
onBlur={commitDraft}
onChangeText={setDraft}
onSubmitEditing={commitDraft}
placeholder="Tags"
placeholderTextColor={colors.mutedText}
returnKeyType="done"
style={styles.propertyTextInput}
value={draft}
/>
<PropertyChipOptions>
{mobileNoteTagOptions.map((option) => (
<PropertyTextChip
@@ -102,12 +231,16 @@ export function MobileEditablePropertyPickers({
key={option}
option={option}
value={note.tags}
onSelect={(tag) => onChangeProperties?.({ tags: toggleMobileNoteTag(note.tags, tag) })}
onSelect={(tag) => {
const tags = toggleMobileNoteTag(note.tags, tag)
setDraft(formatMobileNoteTags(tags))
onChangeProperties?.({ tags })
}}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
</>
</View>
</PropertyPickerSection>
)
}
@@ -156,7 +289,7 @@ function PropertyPickerRow({
style={({ pressed }) => [styles.propertyRow, disabled ? styles.propertyDisabled : null, pressed ? styles.pressed : null]}
>
<Text style={styles.propertyLabel}>{label}</Text>
<Text style={styles.propertyValue}>{value}</Text>
<Text numberOfLines={1} style={styles.propertyValue}>{value}</Text>
<Caret size={16} color={colors.textSoft} />
</Pressable>
)
@@ -189,10 +322,8 @@ function PropertyIconChip({
function PropertyChipOptions({ children }: { children: ReactNode }) {
return (
<View style={styles.propertyPickerOptions}>
<View style={styles.propertyChipRow}>
{children}
</View>
<View style={styles.propertyChipRow}>
{children}
</View>
)
}
@@ -254,3 +385,26 @@ function SelectablePropertyChip({
</Pressable>
)
}
function commitTextValue({
current,
next,
onCommit,
}: {
current: string
next: string
onCommit: (value: string) => void
}) {
const normalized = next.trim()
if (normalized !== current) {
onCommit(normalized)
}
}
function formatMobilePropertyDate(date: Date) {
return new Intl.DateTimeFormat(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}

View File

@@ -1,9 +1,8 @@
import { useEffect, useMemo, useRef } from 'react'
import { KeyboardAvoidingView, Platform, Text, View } from 'react-native'
import { KeyboardAvoidingView, Platform, View } from 'react-native'
import { RichText, Toolbar, useEditorBridge } from '@10play/tentap-editor'
import type { MobileNote } from './mobileNoteProjection'
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
import { idleMobileEditorSaveState, type MobileEditorSaveState } from './mobileEditorSaveState'
import {
createMobileEditorDocument,
createMobileEditorHtml,
@@ -13,11 +12,9 @@ import { styles } from './styles'
export function MobileEditorAdapter({
note,
onDraftChange,
saveState = idleMobileEditorSaveState,
}: {
note: MobileNote
onDraftChange?: (draft: MobileEditorDraft) => void
saveState?: MobileEditorSaveState
}) {
const document = useMemo(() => createMobileEditorDocument(note), [note])
const initialContent = useMemo(() => createMobileEditorHtml(document), [document])
@@ -35,15 +32,17 @@ export function MobileEditorAdapter({
})
},
})
useEffect(() => {
const timer = setTimeout(() => {
editor.injectCSS(mobileEditorCss, 'tolaria-mobile-editor')
editor.injectJS('document.documentElement.lang = navigator.language || "en"; true;')
}, 100)
return () => clearTimeout(timer)
}, [editor, note.id])
return (
<View style={styles.editorAdapterContent}>
<View style={styles.breadcrumbRow}>
<Text style={styles.breadcrumbText}>{note.type}</Text>
<Text style={styles.breadcrumbDivider}>/</Text>
<Text style={styles.breadcrumbText}>{note.id}</Text>
<Text style={[styles.editorSaveState, saveStateStyle(saveState)]}>{saveState.label}</Text>
</View>
<View style={styles.tentapEditor}>
<RichText key={note.id} editor={editor} />
</View>
@@ -57,19 +56,33 @@ export function MobileEditorAdapter({
)
}
function saveStateStyle(saveState: MobileEditorSaveState) {
switch (saveState.state) {
case 'blocked':
return styles.editorSaveState_blocked
case 'failed':
return styles.editorSaveState_failed
case 'queued':
return styles.editorSaveState_queued
case 'saved':
return styles.editorSaveState_saved
case 'saving':
return styles.editorSaveState_saving
default:
return styles.editorSaveState_idle
const mobileEditorCss = `
html,
body,
#root,
.ProseMirror {
color: #292825;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
font-size: 18px;
line-height: 1.55;
}
}
.ProseMirror {
padding: 0;
}
.ProseMirror h1 {
font-family: inherit;
font-size: 42px;
font-weight: 760;
letter-spacing: 0;
line-height: 1.08;
margin: 18px 0 28px;
}
.ProseMirror p,
.ProseMirror li,
.ProseMirror blockquote {
font-family: inherit;
}
`

View File

@@ -0,0 +1,38 @@
import { Text, View } from 'react-native'
import type { MobileNote } from './demoData'
import type { MobileEditorSaveState } from './mobileEditorSaveState'
import { styles } from './styles'
export function MobileEditorBreadcrumb({
note,
saveState,
}: {
note: MobileNote
saveState: MobileEditorSaveState
}) {
return (
<View style={styles.editorBreadcrumb}>
<Text numberOfLines={1} style={styles.editorBreadcrumbText}>{note.type}</Text>
<Text style={styles.editorBreadcrumbDivider}>/</Text>
<Text numberOfLines={1} style={styles.editorBreadcrumbTitle}>{note.id}</Text>
<Text style={[styles.editorSaveState, saveStateStyle(saveState)]}>{saveState.label}</Text>
</View>
)
}
function saveStateStyle(saveState: MobileEditorSaveState) {
switch (saveState.state) {
case 'blocked':
return styles.editorSaveState_blocked
case 'failed':
return styles.editorSaveState_failed
case 'queued':
return styles.editorSaveState_queued
case 'saved':
return styles.editorSaveState_saved
case 'saving':
return styles.editorSaveState_saving
default:
return styles.editorSaveState_idle
}
}

View File

@@ -1,66 +0,0 @@
import { Pressable, Text, TextInput, View } from 'react-native'
import { styles } from './styles'
export function MobileNoteCreatePrompt({
failed,
isCreating,
onCancel,
onChangeTitle,
onSubmit,
title,
}: {
failed: boolean
isCreating: boolean
onCancel: () => void
onChangeTitle: (title: string) => void
onSubmit: () => void
title: string
}) {
return (
<View style={styles.createNotePrompt}>
<TextInput
accessibilityLabel="New note title"
autoCapitalize="sentences"
editable={!isCreating}
onChangeText={onChangeTitle}
onSubmitEditing={onSubmit}
placeholder="Note title"
returnKeyType="done"
style={styles.createNoteInput}
value={title}
/>
{failed ? <Text style={styles.createNoteError}>Could not create note</Text> : null}
<View style={styles.createNoteActions}>
<PromptButton label="Cancel" onPress={onCancel} />
<PromptButton label={isCreating ? 'Creating' : 'Create'} onPress={onSubmit} disabled={isCreating} primary />
</View>
</View>
)
}
function PromptButton({
disabled,
label,
onPress,
primary,
}: {
disabled?: boolean
label: string
onPress: () => void
primary?: boolean
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.createNoteAction,
primary ? styles.createNoteActionPrimary : null,
disabled ? styles.createNoteActionDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.createNoteActionText, primary ? styles.createNoteActionTextPrimary : null]}>{label}</Text>
</Pressable>
)
}

View File

@@ -21,7 +21,6 @@ export function MobilePropertiesPanel({
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onClose?: () => void
}) {
const today = formatMobilePropertyDate(new Date())
const [openPicker, setOpenPicker] = useState<MobilePropertyPickerKey | null>(null)
const selectPicker = (selected: MobilePropertyPickerKey) => {
setOpenPicker((current) => nextMobilePropertyPicker({ current, selected }))
@@ -39,13 +38,6 @@ export function MobilePropertiesPanel({
onChangeProperties={onChangeProperties}
onSelectPicker={selectPicker}
/>
<PropertyRow
actionLabel="Today"
disabled={isSaving}
label="Date"
value={note.date || 'None'}
onPress={() => onChangeProperties?.({ date: today })}
/>
<PropertyRow label="Words" value={String(note.words)} />
<PropertyRow label="Modified" value={note.modified} />
<Text style={styles.propertyGroupTitle}>History</Text>
@@ -70,44 +62,17 @@ function PanelToolbar({ onClose }: { onClose?: () => void }) {
)
}
function formatMobilePropertyDate(date: Date) {
return new Intl.DateTimeFormat('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
function PropertyRow({
actionLabel,
disabled = false,
label,
onPress,
value,
}: {
actionLabel?: string
disabled?: boolean
label: string
onPress?: () => void
value: string
}) {
const content = (
<>
return (
<View style={styles.propertyRow}>
<Text style={styles.propertyLabel}>{label}</Text>
<Text style={styles.propertyValue}>{value}</Text>
{actionLabel ? <Text style={styles.propertyAction}>{actionLabel}</Text> : null}
</>
)
return onPress ? (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [styles.propertyRow, disabled ? styles.propertyDisabled : null, pressed ? styles.pressed : null]}
>
{content}
</Pressable>
) : (
<View style={styles.propertyRow}>{content}</View>
</View>
)
}

View File

@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
import { notes } from './demoData'
import {
createMobileNoteFrontmatterPatch,
formatMobileNoteTags,
isMobileNotePropertySelected,
parseMobileNoteTags,
toggleMobileNoteTag,
} from './mobileNoteProperties'
@@ -41,3 +43,10 @@ describe('toggleMobileNoteTag', () => {
expect(toggleMobileNoteTag(['Tolaria MVP', 'mobile'], 'mobile')).toEqual(['Tolaria MVP'])
})
})
describe('mobile note tag text helpers', () => {
it('formats and parses keyboard-entered tag lists', () => {
expect(formatMobileNoteTags(['Tolaria MVP', 'mobile'])).toBe('Tolaria MVP, mobile')
expect(parseMobileNoteTags(' Tolaria MVP, mobile, mobile, ')).toEqual(['Tolaria MVP', 'mobile'])
})
})

View File

@@ -37,3 +37,14 @@ export function isMobileNotePropertySelected({
export function toggleMobileNoteTag(tags: string[], tag: string) {
return tags.includes(tag) ? tags.filter((item) => item !== tag) : [...tags, tag]
}
export function formatMobileNoteTags(tags: readonly string[]) {
return tags.join(', ')
}
export function parseMobileNoteTags(value: string) {
return value
.split(',')
.map((tag) => tag.trim())
.filter((tag, index, tags) => tag.length > 0 && tags.indexOf(tag) === index)
}

View File

@@ -1,4 +1,4 @@
export type MobilePropertyPickerKey = 'icon' | 'status' | 'tags' | 'type'
export type MobilePropertyPickerKey = 'date' | 'icon' | 'status' | 'tags' | 'type'
export function nextMobilePropertyPicker({
current,

View File

@@ -1,8 +1,8 @@
import { breadcrumbStyles } from './styles/breadcrumbStyles'
import { commonStyles } from './styles/commonStyles'
import { editorSaveStateStyles } from './styles/editorSaveStateStyles'
import { editorStyles } from './styles/editorStyles'
import { gitSyncStyles } from './styles/gitSyncStyles'
import { noteCreateStyles } from './styles/noteCreateStyles'
import { noteListStyles } from './styles/noteListStyles'
import { propertiesStyles } from './styles/propertiesStyles'
import { propertyChipStyles } from './styles/propertyChipStyles'
@@ -12,11 +12,11 @@ import { vaultManagementStyles } from './styles/vaultManagementStyles'
import { vaultLoadStyles } from './styles/vaultLoadStyles'
export const styles = {
...breadcrumbStyles,
...commonStyles,
...editorStyles,
...editorSaveStateStyles,
...gitSyncStyles,
...noteCreateStyles,
...noteListStyles,
...propertiesStyles,
...propertyChipStyles,

View File

@@ -0,0 +1,28 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const breadcrumbStyles = StyleSheet.create({
editorBreadcrumb: {
minWidth: 0,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
editorBreadcrumbText: {
color: colors.mutedText,
fontSize: 14,
fontWeight: '600',
},
editorBreadcrumbDivider: {
color: colors.border,
fontSize: 14,
},
editorBreadcrumbTitle: {
minWidth: 0,
flexShrink: 1,
color: colors.textSoft,
fontSize: 14,
fontWeight: '700',
},
})

View File

@@ -12,6 +12,7 @@ export const editorStyles = StyleSheet.create({
maxWidth: 760,
alignSelf: 'center',
paddingHorizontal: spacing.xl,
paddingTop: spacing.xl,
paddingBottom: 96,
},
editorAdapterContent: {
@@ -22,21 +23,6 @@ export const editorStyles = StyleSheet.create({
paddingHorizontal: spacing.xl,
paddingBottom: 96,
},
breadcrumbRow: {
marginBottom: spacing.xl,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
breadcrumbText: {
color: colors.mutedText,
fontSize: 14,
fontWeight: '600',
},
breadcrumbDivider: {
color: colors.border,
fontSize: 14,
},
editorTitle: {
marginBottom: spacing.xl,
color: colors.text,

View File

@@ -1,64 +0,0 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const noteCreateStyles = StyleSheet.create({
composeButtonDisabled: {
opacity: 0.55,
},
createNoteAction: {
minWidth: 82,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 16,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.primarySoft,
},
createNoteActionDisabled: {
opacity: 0.55,
},
createNoteActionPrimary: {
backgroundColor: colors.primary,
},
createNoteActionText: {
color: colors.primary,
fontSize: 14,
fontWeight: '700',
},
createNoteActionTextPrimary: {
color: colors.canvas,
},
createNoteActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
createNoteError: {
color: colors.textSoft,
fontSize: 13,
fontWeight: '600',
},
createNoteInput: {
minHeight: 44,
borderColor: colors.border,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 14,
paddingHorizontal: spacing.md,
color: colors.text,
backgroundColor: colors.canvas,
fontSize: 16,
fontWeight: '600',
},
createNotePrompt: {
position: 'absolute',
right: spacing.xl,
bottom: spacing.xl + 76,
width: 280,
gap: spacing.sm,
borderColor: colors.border,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 18,
padding: spacing.md,
backgroundColor: colors.canvas,
},
})

View File

@@ -66,4 +66,7 @@ export const noteListStyles = StyleSheet.create({
borderRadius: 32,
backgroundColor: colors.primary,
},
composeButtonDisabled: {
opacity: 0.55,
},
})

View File

@@ -2,12 +2,6 @@ import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const propertyChipStyles = StyleSheet.create({
propertyAction: {
marginLeft: spacing.sm,
color: colors.primary,
fontSize: 13,
fontWeight: '700',
},
propertyDisabled: {
opacity: 0.55,
},
@@ -17,12 +11,6 @@ export const propertyChipStyles = StyleSheet.create({
fontSize: 13,
fontWeight: '700',
},
propertyOptionGroup: {
minHeight: 58,
paddingVertical: spacing.sm,
borderBottomColor: colors.border,
borderBottomWidth: StyleSheet.hairlineWidth,
},
propertyChipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
@@ -34,6 +22,17 @@ export const propertyChipStyles = StyleSheet.create({
borderBottomColor: colors.border,
borderBottomWidth: StyleSheet.hairlineWidth,
},
propertyTextInput: {
minHeight: 38,
marginTop: spacing.sm,
borderColor: colors.border,
borderRadius: 10,
borderWidth: StyleSheet.hairlineWidth,
color: colors.text,
fontSize: 15,
fontWeight: '600',
paddingHorizontal: spacing.md,
},
propertyChip: {
minHeight: 30,
justifyContent: 'center',

View File

@@ -10,36 +10,17 @@ export function useMobileNoteCreateFlow({
}) {
const [failed, setFailed] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [isPromptOpen, setIsPromptOpen] = useState(false)
const [title, setTitle] = useState('')
const cancel = useCallback(() => {
if (isCreating) {
return
}
setFailed(false)
setIsPromptOpen(false)
setTitle('')
}, [isCreating])
const open = useCallback(() => {
setFailed(false)
setIsPromptOpen(true)
}, [])
const submit = useCallback(() => {
const create = useCallback(() => {
if (isCreating) {
return
}
setFailed(false)
setIsCreating(true)
void createNote(title)
void createNote('')
.then((note) => {
if (note) {
setIsPromptOpen(false)
setTitle('')
onCreated(note)
}
})
@@ -49,16 +30,11 @@ export function useMobileNoteCreateFlow({
.finally(() => {
setIsCreating(false)
})
}, [createNote, isCreating, onCreated, title])
}, [createNote, isCreating, onCreated])
return {
cancel,
create,
failed,
isCreating,
isPromptOpen,
open,
setTitle,
submit,
title,
}
}

View File

@@ -106,6 +106,12 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Added the optional Expo native-module resolver for `TolariaGit`, so the JavaScript Git transport automatically binds to future development builds that include the native module and keeps the current unavailable-module failure everywhere else.
- Created [ADR-0115](./adr/0115-expo-native-module-boundary-for-mobile-git.md) for the mobile Git native module discovery boundary.
- Added remote host and auth strategy to the native Git transport request so native Git can select credential callbacks without reparsing remote URLs.
- Reworked the prototype compose flow so tapping the pencil immediately creates and opens an app-local `Untitled` note instead of blocking on a title modal.
- Moved the editor breadcrumb and save state into the top editor toolbar, matching the desktop/Bear-style bar placement instead of rendering it inside the editor body.
- Injected system-font CSS into TenTap so the editor uses iOS-native typography instead of the browser/WebView serif default, while leaving keyboard layout to the OS/simulator input source.
- Upgraded the mobile properties prototype so type, status, date, and tags can be entered from the keyboard with quick chips as shortcuts, while icon editing stays chip-based.
- Removed the now-unused title prompt component and kept the compose-button disabled state with the note-list styles.
- Split new breadcrumb styles out of the shared toolbar styles and removed stale editor/property style entries so the CodeScene pre-commit safeguard stays green.
## Next Action
@@ -408,6 +414,11 @@ Continue Phase 4 with editor durability:
- `pnpm --filter @tolaria/mobile typecheck` passed after adding remote host/auth strategy to native Git requests.
- CodeScene after adding remote host/auth strategy to native Git requests: `apps/mobile/src/mobileNativeGitTransport.ts` and `apps/mobile/src/mobileNativeGitTransport.test.ts` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after adding remote host/auth strategy to native Git requests; Metro recovered from a cache deserialize warning by doing a full crawl.
- `pnpm --filter @tolaria/mobile test` passed after the direct-create/properties editability update: 46 files / 153 tests.
- `pnpm --filter @tolaria/mobile typecheck` passed after the direct-create/properties editability update.
- CodeScene after the direct-create/properties editability update: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileEditablePropertyPickers.tsx`, `apps/mobile/src/MobileEditorAdapter.tsx`, `apps/mobile/src/MobileEditorBreadcrumb.tsx`, `apps/mobile/src/MobilePropertiesPanel.tsx`, `apps/mobile/src/mobileNoteProperties.ts`, `apps/mobile/src/mobilePropertyPicker.ts`, `apps/mobile/src/styles/breadcrumbStyles.ts`, `apps/mobile/src/styles/commonStyles.ts`, `apps/mobile/src/styles/editorStyles.ts`, `apps/mobile/src/styles/noteListStyles.ts`, `apps/mobile/src/styles/propertyChipStyles.ts`, and `apps/mobile/src/useMobileNoteCreateFlow.ts` scored `10`; `apps/mobile/src/styles.ts` reported no scorable score.
- CodeScene pre-commit safeguard passed after splitting the new styles.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after the direct-create/properties editability update.
## Risks / Watch Items