diff --git a/src/components/editable-filename.tsx b/src/components/editable-filename.tsx new file mode 100644 index 00000000..87342b58 --- /dev/null +++ b/src/components/editable-filename.tsx @@ -0,0 +1,157 @@ +import { useState, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from "react" +import { useHotkeys } from "react-hotkeys-hook" +import { TextInput } from "./text-input" +import { Tooltip } from "./tooltip" +import { Keys } from "./keys" +import { cx } from "../utils/cx" + +type EditableFilenameProps = { + noteId: string + isSignedOut: boolean + onRename: (newName: string) => boolean | Promise +} + +export interface EditableFilenameHandle { + startRename: () => void +} + +export const EditableFilename = forwardRef( + ({ noteId, isSignedOut, onRename }, ref) => { + const [isRenaming, setIsRenaming] = useState(false) + const [renameValue, setRenameValue] = useState(noteId) + const inputRef = useRef(null) + + // Sync internal state with noteId prop + useEffect(() => { + setRenameValue(noteId) + setIsRenaming(false) + }, [noteId]) + + const startRename = useCallback(() => { + if (isSignedOut || isRenaming) return + setIsRenaming(true) + // Focus and select the text for immediate editing + requestAnimationFrame(() => inputRef.current?.select()) + }, [isSignedOut, isRenaming]) + + // Expose startRename to parent via ref + useImperativeHandle( + ref, + () => ({ + startRename, + }), + [startRename], + ) + + // Global F2 Shortcut logic + useHotkeys( + "f2", + (e) => { + e.preventDefault() + startRename() + }, + { enabled: !isSignedOut && !isRenaming }, + ) + + const handleFinish = async () => { + const trimmed = renameValue.trim() + // If empty or unchanged, just close + if (!trimmed || trimmed === noteId) { + setIsRenaming(false) + setRenameValue(noteId) + return + } + + const success = await onRename(trimmed) + if (success) { + setIsRenaming(false) + } else { + // Keep input open and focus if rename failed (e.g. duplicate name) + inputRef.current?.focus() + } + } + + const handleCancel = () => { + setRenameValue(noteId) + setIsRenaming(false) + } + + if (isRenaming) { + return ( +
+ setRenameValue(e.target.value)} + onBlur={handleFinish} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleFinish() + } + if (e.key === "Escape") { + e.preventDefault() + handleCancel() + } + }} + className="h-7 min-w-0 grow text-sm sm:max-w-[300px]" + /> + .md +
+ ) + } + + return ( + + { + e.preventDefault() + startRename() + }} + onTouchStart={(e) => { + // Standard mobile double-tap detection + if (e.detail === 2) { + e.preventDefault() + startRename() + } + }} + onKeyDown={(e) => { + // Allow keyboard activation via Enter or Space + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + startRename() + } + }} + > + {noteId} + .md + + } + /> + {!isSignedOut && ( + +
+ Rename file +
+ +
+
+
+ )} +
+ ) + }, +) + +EditableFilename.displayName = "EditableFilename" diff --git a/src/routes/_appRoot.notes_.$.tsx b/src/routes/_appRoot.notes_.$.tsx index 9ca4d606..689d5651 100644 --- a/src/routes/_appRoot.notes_.$.tsx +++ b/src/routes/_appRoot.notes_.$.tsx @@ -54,6 +54,7 @@ import { githubRepoAtom, globalStateMachineAtom, isSignedOutAtom, + markdownFilesAtom, vimModeAtom, weeklyTemplateAtom, } from "../global-state" @@ -66,10 +67,11 @@ import { cx } from "../utils/cx" import { formatDate, formatWeek, isValidDateString, isValidWeekString } from "../utils/date" import { updateFrontmatterValue } from "../utils/frontmatter" import { clearNoteDraft, getNoteDraft, setNoteDraft } from "../utils/note-draft" -import { getInvalidNoteIdCharacters } from "../utils/note-id" +import { getInvalidNoteIdCharacters, isValidNoteId } from "../utils/note-id" import { parseNote } from "../utils/parse-note" import { pluralize } from "../utils/pluralize" import { notificationSound, playSound } from "../utils/sounds" +import { EditableFilename, EditableFilenameHandle } from "../components/editable-filename" type RouteSearch = { mode: "read" | "write" @@ -133,6 +135,7 @@ function NotePage() { const dailyTemplate = useAtomValue(dailyTemplateAtom) const weeklyTemplate = useAtomValue(weeklyTemplateAtom) const defaultFont = useAtomValue(defaultFontAtom) + const markdownFiles = useAtomValue(markdownFilesAtom) const { online } = useNetworkState() // Note data @@ -177,6 +180,9 @@ function NotePage() { const parsedWidthResult = widthSchema.safeParse(frontmatterWidth) const resolvedWidth = parsedWidthResult.success ? parsedWidthResult.data : "fixed" + // Handle for programmatic control of filename editing + const editableFilenameRef = React.useRef(null) + // Set the font React.useEffect(() => { document.documentElement.style.setProperty( @@ -198,6 +204,62 @@ function NotePage() { const renameNote = useRenameNote() const attachFile = useAttachFile() + const normalizeNoteId = React.useCallback((rawName: string) => { + return rawName.trim().replace(/\.md$/i, "").trim() + }, []) + + const attemptRename = React.useCallback( + (rawName: string): boolean => { + if (!noteId) return false + + const newNoteId = normalizeNoteId(rawName) + + if (!newNoteId || newNoteId === noteId) { + return true + } + + const result = renameNote({ + oldName: noteId, + newName: newNoteId, + content: editorValue, + }) + + if (!result.success) { + switch (result.reason) { + case "no-op": + return true + case "invalid": + { + const invalidCharacters = Array.from(new Set(getInvalidNoteIdCharacters(newNoteId))) + const invalidList = invalidCharacters.map((char) => `"${char}"`).join(", ") + const suffix = invalidList ? `: ${invalidList}` : "" + window.alert(`"${newNoteId}.md" contains invalid characters${suffix}`) + } + return false + case "duplicate": + window.alert(`"${newNoteId}.md" already exists.`) + return false + default: + result.reason satisfies never + } + return false + } + + clearNoteDraft({ githubRepo, noteId }) + clearNoteDraft({ githubRepo, noteId: newNoteId }) + + navigate({ + to: "/notes/$", + params: { _splat: newNoteId }, + search: (prev) => ({ ...prev, content: undefined }), + replace: true, + }) + + return true + }, + [noteId, normalizeNoteId, renameNote, editorValue, githubRepo, navigate], + ) + const handleSave = React.useCallback( (value: string) => { if (isSignedOut || !noteId) return @@ -231,57 +293,6 @@ function NotePage() { [noteId, editorValue, setEditorValue, handleSave], ) - const handleRename = React.useCallback(() => { - if (!noteId) return - - const oldNoteId = noteId - const newNoteIdRaw = window.prompt("Rename file", oldNoteId) - if (!newNoteIdRaw) return - - const newNoteIdTrimmed = newNoteIdRaw.trim() - if (!newNoteIdTrimmed) return - - const newNoteId = newNoteIdTrimmed.replace(/\.md$/i, "").trim() - if (!newNoteId || newNoteId === oldNoteId) return - - const result = renameNote({ - oldName: oldNoteId, - newName: newNoteId, - content: editorValue, - }) - - if (!result.success) { - switch (result.reason) { - case "no-op": - return - case "invalid": - { - const invalidCharacters = Array.from(new Set(getInvalidNoteIdCharacters(newNoteId))) - const invalidList = invalidCharacters.map((char) => `"${char}"`).join(", ") - const suffix = invalidList ? `: ${invalidList}` : "" - window.alert(`"${newNoteId}.md" contains invalid characters${suffix}`) - } - return - case "duplicate": - window.alert(`"${newNoteId}.md" already exists.`) - return - default: - result.reason satisfies never - } - return - } - - clearNoteDraft({ githubRepo, noteId: oldNoteId }) - clearNoteDraft({ githubRepo, noteId: newNoteId }) - - navigate({ - to: "/notes/$", - params: { _splat: newNoteId }, - search: (prev) => ({ ...prev, content: undefined }), - replace: true, - }) - }, [noteId, renameNote, editorValue, githubRepo, navigate]) - const switchToWriting = React.useCallback(() => { navigate({ search: (prev) => ({ ...prev, mode: "write" }), replace: true }) setTimeout(() => { @@ -508,9 +519,42 @@ function NotePage() { return ( + + {isDraft ? ( + + + + + } + /> + + } + variant="danger" + onClick={() => { + discardChanges() + editorRef.current?.view?.focus() + }} + > + Discard changes + + + + ) : null} +=======
{noteId}.md {isDraft ? : null} +>>>>>>> main
} icon={} @@ -659,7 +703,7 @@ function NotePage() { } disabled={isSignedOut} - onClick={handleRename} + onClick={() => editableFilenameRef.current?.startRename()} > Rename file