Skip to content

Commit a06dda9

Browse files
committed
feat(mobile): add document title editing dialog
Replace the inline contentEditable DocTitle on mobile with a tappable title button that opens a rename dialog via the global dialog system (openDialog / closeDialog from dialogStore). The TitleEditContent component reuses useUpdateDocMetadata for persistence and hocuspocusProvider.sendStateless for real-time sync. A stateless event listener keeps the displayed title in sync with remote changes from other collaborators. Extend DialogConfig with an `align` prop ('center' | 'top') and update ModalContent + GlobalDialog to support it. The title dialog uses align="top" so it appears above the iOS virtual keyboard, respecting safe-area-inset-top.
1 parent 9d8e8af commit a06dda9

4 files changed

Lines changed: 138 additions & 10 deletions

File tree

packages/webapp/src/components/TipTap/pad-title-section/MobilePadTitle.tsx

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,17 @@ import { Modal, ModalContent } from '@components/ui/Dialog'
77
import UnreadBadge from '@components/ui/UnreadBadge'
88
import { useBottomSheet } from '@hooks/useBottomSheet'
99
import { useNotificationCount } from '@hooks/useNotificationCount'
10-
import { useStore } from '@stores'
11-
import { useAuthStore } from '@stores'
10+
import useUpdateDocMetadata from '@hooks/useUpdateDocMetadata'
11+
import { useAuthStore, useStore } from '@stores'
1212
import dynamic from 'next/dynamic'
13-
import React, { useState } from 'react'
13+
import React, { useEffect, useRef, useState } from 'react'
1414
import { BiCheck, BiRedo, BiUndo } from 'react-icons/bi'
1515
import { MdMenu, MdNotifications } from 'react-icons/md'
1616

1717
const SettingsPanel = dynamic(() => import('@components/settings/SettingsPanel'), {
1818
loading: () => <SettingsPanelSkeleton />
1919
})
2020

21-
import DocTitle from '../DocTitle'
2221
import FilterBar from './FilterBar'
2322
import ReadOnlyIndicator from './ReadOnlyIndicator'
2423

@@ -128,13 +127,131 @@ const UndoRedoButtons = ({ editor, className }: UndoRedoButtonsProps) => {
128127
)
129128
}
130129

130+
// ---------------------------------------------------------------------------
131+
// TitleEditContent – rendered inside the global dialog via openDialog()
132+
// ---------------------------------------------------------------------------
133+
134+
const TitleEditContent = () => {
135+
const { metadata, hocuspocusProvider } = useStore((state) => state.settings)
136+
const setWorkspaceSetting = useStore((state) => state.setWorkspaceSetting)
137+
const closeDialog = useStore((state) => state.closeDialog)
138+
const { isLoading, mutate } = useUpdateDocMetadata()
139+
const [value, setValue] = useState('')
140+
const inputRef = useRef<HTMLInputElement>(null)
141+
142+
// Populate + auto-select on mount (dialog just opened)
143+
useEffect(() => {
144+
setValue(metadata?.title || '')
145+
const timer = setTimeout(() => inputRef.current?.select(), 120)
146+
return () => clearTimeout(timer)
147+
}, [])
148+
149+
const handleSave = () => {
150+
const trimmed = value.trim()
151+
if (!trimmed || trimmed === metadata?.title) {
152+
closeDialog()
153+
return
154+
}
155+
156+
mutate(
157+
{ title: trimmed, documentId: metadata.documentId },
158+
{
159+
onSuccess: (responseData) => {
160+
const updated = (responseData as any).data ?? responseData
161+
setWorkspaceSetting('metadata', { ...metadata, title: updated.title })
162+
hocuspocusProvider?.sendStateless(JSON.stringify({ type: 'docTitle', state: updated }))
163+
closeDialog()
164+
}
165+
}
166+
)
167+
}
168+
169+
return (
170+
<div className="p-5">
171+
<label
172+
htmlFor="mobile-doc-title-input"
173+
className="text-base-content mb-3 block text-base font-semibold">
174+
Rename Document Title
175+
</label>
176+
177+
<input
178+
ref={inputRef}
179+
id="mobile-doc-title-input"
180+
type="text"
181+
className="input input-bordered w-full"
182+
value={value}
183+
onChange={(e) => setValue(e.target.value)}
184+
onKeyDown={(e) => {
185+
if (e.key === 'Enter') handleSave()
186+
if (e.key === 'Escape') closeDialog()
187+
}}
188+
placeholder="Document title"
189+
autoComplete="off"
190+
maxLength={200}
191+
/>
192+
193+
<div className="mt-4 flex justify-end gap-2">
194+
<Button variant="ghost" size="sm" onClick={closeDialog}>
195+
Cancel
196+
</Button>
197+
<Button
198+
variant="primary"
199+
size="sm"
200+
onClick={handleSave}
201+
disabled={isLoading || !value.trim()}>
202+
{isLoading ? 'Saving…' : 'Save'}
203+
</Button>
204+
</div>
205+
</div>
206+
)
207+
}
208+
209+
// ---------------------------------------------------------------------------
210+
// MobilePadTitle – sticky header for the mobile document view
211+
// ---------------------------------------------------------------------------
212+
131213
const MobilePadTitle = () => {
132214
const user = useAuthStore((state) => state.profile)
133215
const {
134-
editor: { isEditable, instance: editor }
216+
editor: { isEditable, instance: editor },
217+
metadata,
218+
hocuspocusProvider
135219
} = useStore((state) => state.settings)
220+
const setWorkspaceSetting = useStore((state) => state.setWorkspaceSetting)
221+
const openDialog = useStore((state) => state.openDialog)
136222
const [isProfileModalOpen, setProfileModalOpen] = useState(false)
137223

224+
// Keep the store title in sync with remote changes from other users.
225+
// On desktop this is handled by the always-mounted DocTitle component;
226+
// on mobile we need our own listener since DocTitle is not rendered.
227+
//
228+
// A ref is used so the handler always reads the latest metadata without
229+
// causing the effect to re-subscribe on every metadata change.
230+
const metadataRef = useRef(metadata)
231+
metadataRef.current = metadata
232+
233+
useEffect(() => {
234+
if (!hocuspocusProvider) return
235+
236+
const handler = ({ payload }: any) => {
237+
try {
238+
const msg = JSON.parse(payload)
239+
if (msg.type === 'docTitle') {
240+
setWorkspaceSetting('metadata', { ...metadataRef.current, title: msg.state.title })
241+
}
242+
} catch {
243+
/* ignore malformed payloads */
244+
}
245+
}
246+
247+
hocuspocusProvider.on('stateless', handler)
248+
return () => hocuspocusProvider.off('stateless', handler)
249+
}, [hocuspocusProvider, setWorkspaceSetting])
250+
251+
const handleTitleClick = () => {
252+
openDialog(<TitleEditContent />, { size: 'sm', align: 'top', className: 'mt-14' })
253+
}
254+
138255
return (
139256
<>
140257
{/* Sticky mobile header - theme-aware */}
@@ -149,9 +266,12 @@ const MobilePadTitle = () => {
149266
{isEditable ? (
150267
<UndoRedoButtons editor={editor} className="ml-2" />
151268
) : (
152-
<div className="min-w-0 flex-1 overflow-hidden">
153-
<DocTitle className="truncate text-sm font-medium" />
154-
</div>
269+
<button
270+
type="button"
271+
className="min-w-0 flex-1 truncate text-left text-lg font-semibold"
272+
onClick={handleTitleClick}>
273+
{metadata?.title || 'Untitled'}
274+
</button>
155275
)}
156276
</div>
157277

packages/webapp/src/components/ui/Dialog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,14 @@ export function Modal({
7272

7373
type Props = {
7474
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | 'full'
75+
align?: 'center' | 'top'
7576
className?: string
7677
children: React.ReactNode
7778
} & Omit<React.HTMLProps<HTMLDivElement>, 'size'>
7879

7980
export const ModalContent = function ModalContent({
8081
size = 'md',
82+
align = 'center',
8183
className = '',
8284
children,
8385
...restProps
@@ -107,7 +109,12 @@ export const ModalContent = function ModalContent({
107109
<FloatingOverlay
108110
className="bg-base-content/40 fixed inset-0 z-50 backdrop-blur-sm"
109111
lockScroll>
110-
<div className="fixed inset-0 flex items-center justify-center p-4">
112+
<div
113+
className={`fixed inset-0 flex justify-center p-4 ${
114+
align === 'top'
115+
? 'items-start pt-[max(env(safe-area-inset-top,1rem),1rem)]'
116+
: 'items-center'
117+
}`}>
111118
<FloatingFocusManager context={context}>
112119
<div
113120
ref={ref}

packages/webapp/src/components/ui/GlobalDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function GlobalDialog() {
1616

1717
return (
1818
<Modal open={isOpen} onOpenChange={handleOpenChange}>
19-
<ModalContent size={config.size} className={config.className}>
19+
<ModalContent size={config.size} align={config.align} className={config.className}>
2020
{content}
2121
</ModalContent>
2222
</Modal>

packages/webapp/src/stores/dialogStore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { immer } from 'zustand/middleware/immer'
33

44
export interface DialogConfig {
55
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
6+
align?: 'center' | 'top'
67
className?: string
78
dismissible?: boolean
89
}

0 commit comments

Comments
 (0)