From cdfac7b3792d3813594daa36470465bd8c841ea9 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 24 Dec 2025 00:23:05 +0000 Subject: Add overwrite mechanism for conflicting writes of files --- .../frontend/src/components/files/BodyRenderer.tsx | 200 ++++++++++++++++++--- .../frontend/src/components/files/FileDetail.tsx | 9 + makima/frontend/src/routes/files.tsx | 10 +- 3 files changed, 187 insertions(+), 32 deletions(-) (limited to 'makima/frontend/src') diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx index 06b2b75..867fc4c 100644 --- a/makima/frontend/src/components/files/BodyRenderer.tsx +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -7,9 +7,12 @@ interface BodyRendererProps { isEditing?: boolean; onUpdate?: (index: number, element: BodyElement) => void; onReorder?: (fromIndex: number, toIndex: number) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; } -export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder }: BodyRendererProps) { +export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) { const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -99,6 +102,9 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder onUpdate(index, el) : undefined} + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} /> @@ -110,9 +116,15 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder function BodyElementRenderer({ element, onUpdate, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, }: { element: BodyElement; onUpdate?: (element: BodyElement) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; }) { switch (element.type) { case "heading": @@ -125,6 +137,9 @@ function BodyElementRenderer({ ? (text) => onUpdate({ ...element, text }) : undefined } + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} /> ); case "paragraph": @@ -136,6 +151,9 @@ function BodyElementRenderer({ ? (text) => onUpdate({ ...element, text }) : undefined } + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} /> ); case "chart": @@ -164,18 +182,27 @@ function HeadingElement({ level, text, onUpdate, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, }: { level: number; text: string; onUpdate?: (text: string) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; }) { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(text); const inputRef = useRef(null); useEffect(() => { - setEditText(text); - }, [text]); + // Only update editText if not currently editing + if (!isEditing) { + setEditText(text); + } + }, [text, isEditing]); useEffect(() => { if (isEditing && inputRef.current) { @@ -184,19 +211,52 @@ function HeadingElement({ } }, [isEditing]); + const startEditing = () => { + setIsEditing(true); + onEditingChange?.(true); + }; + + const stopEditing = () => { + setIsEditing(false); + onEditingChange?.(false); + }; + const handleSave = () => { + // Don't auto-save if there's a pending remote update + if (hasPendingRemoteUpdate) return; + if (onUpdate && editText.trim() !== text) { onUpdate(editText.trim()); } - setIsEditing(false); + stopEditing(); + }; + + const handleOverwrite = () => { + if (onUpdate && editText.trim() !== text) { + onUpdate(editText.trim()); + } + onOverwrite?.(); + stopEditing(); + }; + + const handleCancel = () => { + setEditText(text); + stopEditing(); }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + // Disable Enter save if there's a pending remote update + if (e.key === "Enter" && !hasPendingRemoteUpdate) { handleSave(); } else if (e.key === "Escape") { - setEditText(text); - setIsEditing(false); + handleCancel(); + } + }; + + const handleBlur = () => { + // Don't auto-save on blur if there's a pending remote update + if (!hasPendingRemoteUpdate) { + handleSave(); } }; @@ -213,15 +273,36 @@ function HeadingElement({ if (isEditing && onUpdate) { return ( - setEditText(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`} - /> +
+ setEditText(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`} + /> + {hasPendingRemoteUpdate && ( +
+ Remote update pending + + +
+ )} +
); } @@ -229,7 +310,7 @@ function HeadingElement({ return ( onUpdate && setIsEditing(true)} + onClick={() => onUpdate && startEditing()} > {text} @@ -239,17 +320,26 @@ function HeadingElement({ function ParagraphElement({ text, onUpdate, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, }: { text: string; onUpdate?: (text: string) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; }) { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(text); const textareaRef = useRef(null); useEffect(() => { - setEditText(text); - }, [text]); + // Only update editText if not currently editing + if (!isEditing) { + setEditText(text); + } + }, [text, isEditing]); useEffect(() => { if (isEditing && textareaRef.current) { @@ -260,20 +350,52 @@ function ParagraphElement({ } }, [isEditing]); + const startEditing = () => { + setIsEditing(true); + onEditingChange?.(true); + }; + + const stopEditing = () => { + setIsEditing(false); + onEditingChange?.(false); + }; + const handleSave = () => { + // Don't auto-save if there's a pending remote update + if (hasPendingRemoteUpdate) return; + if (onUpdate && editText.trim() !== text) { onUpdate(editText.trim()); } - setIsEditing(false); + stopEditing(); + }; + + const handleOverwrite = () => { + if (onUpdate && editText.trim() !== text) { + onUpdate(editText.trim()); + } + onOverwrite?.(); + stopEditing(); + }; + + const handleCancel = () => { + setEditText(text); + stopEditing(); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { - setEditText(text); - setIsEditing(false); + handleCancel(); + } + // Ctrl/Cmd + Enter to save - disabled if there's a pending remote update + if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) { + handleSave(); } - // Ctrl/Cmd + Enter to save - if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + }; + + const handleBlur = () => { + // Don't auto-save on blur if there's a pending remote update + if (!hasPendingRemoteUpdate) { handleSave(); } }; @@ -292,13 +414,33 @@ function ParagraphElement({ ref={textareaRef} value={editText} onChange={handleInput} - onBlur={handleSave} + onBlur={handleBlur} onKeyDown={handleKeyDown} className="font-mono text-sm text-white/80 leading-relaxed w-full bg-transparent border border-[#3f6fb3] outline-none p-2 resize-none min-h-[60px]" /> -
- Ctrl+Enter to save, Esc to cancel -
+ {hasPendingRemoteUpdate ? ( +
+ Remote update pending + + +
+ ) : ( +
+ Ctrl+Enter to save, Esc to cancel +
+ )} ); } @@ -306,7 +448,7 @@ function ParagraphElement({ return (

onUpdate && setIsEditing(true)} + onClick={() => onUpdate && startEditing()} > {text}

diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index 2bf4c03..29311b8 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -10,6 +10,9 @@ interface FileDetailProps { onDelete: (id: string) => void; onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void; onBodyReorder?: (fromIndex: number, toIndex: number) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; } export function FileDetail({ @@ -20,6 +23,9 @@ export function FileDetail({ onDelete, onBodyElementUpdate, onBodyReorder, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, }: FileDetailProps) { const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(file.name); @@ -152,6 +158,9 @@ export function FileDetail({ isEditing={isEditing} onUpdate={onBodyElementUpdate} onReorder={onBodyReorder} + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} /> diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 423baa1..037df7e 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -22,6 +22,7 @@ export default function FilesPage() { const [creating, setCreating] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState(null); const [hasLocalChanges, setHasLocalChanges] = useState(false); + const [isActivelyEditing, setIsActivelyEditing] = useState(false); const pendingUpdateRef = useRef(false); // Load file detail when URL has an id @@ -48,8 +49,8 @@ export default function FilesPage() { return; } - // If no local changes, auto-refresh - if (!hasLocalChanges) { + // If no local changes and not actively editing, auto-refresh + if (!hasLocalChanges && !isActivelyEditing) { const detail = await fetchFile(event.fileId); setFileDetail(detail); } else { @@ -57,7 +58,7 @@ export default function FilesPage() { setRemoteUpdate(event); } }, - [hasLocalChanges, fetchFile] + [hasLocalChanges, isActivelyEditing, fetchFile] ); // Subscribe to file updates @@ -247,6 +248,9 @@ export default function FilesPage() { onDelete={handleDelete} onBodyElementUpdate={handleBodyElementUpdate} onBodyReorder={handleBodyReorder} + onEditingChange={setIsActivelyEditing} + hasPendingRemoteUpdate={!!remoteUpdate} + onOverwrite={handleRemoteUpdateDismiss} />
-- cgit v1.2.3