From 05c6345cadaa5549125f2c87ca71d639cc98ab1e Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 13 Jul 2026 12:58:55 +0200 Subject: [PATCH] improved mobile experience --- components/editor/DocumentEditorPanel.tsx | 128 ++++++++-- .../editor/MobileFormatToolbar.module.css | 114 ++++++++- components/editor/MobileFormatToolbar.tsx | 232 +++++++++++++----- components/navbar/ProjectNavbar.module.css | 82 +++++-- components/navbar/ProjectNavbar.tsx | 54 +++- .../project/ProjectWorkspace.module.css | 63 ++++- components/project/ProjectWorkspace.tsx | 48 +++- .../project/SplitPanelContainer.module.css | 18 +- components/project/SplitPanelContainer.tsx | 3 +- src-tauri/gen/apple/Sources/Scriptio/main.mm | 30 +++ src/context/ViewContext.tsx | 24 +- src/lib/editor/document-editor-config.ts | 20 ++ src/lib/editor/use-active-editor.ts | 32 +++ src/lib/editor/use-document-editor.ts | 8 +- styles/globals.css | 2 +- 15 files changed, 705 insertions(+), 153 deletions(-) create mode 100644 src/lib/editor/use-active-editor.ts diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 9f3a049c..04822d0c 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -22,7 +22,7 @@ import Loading from "@components/utils/Loading"; import { TextSelection, Transaction } from "@tiptap/pm/state"; import { EditorView } from "@tiptap/pm/view"; -import { DocumentEditorConfig } from "@src/lib/editor/document-editor-config"; +import { DocumentEditorConfig, EDITOR_INPUT_ATTRIBUTES } from "@src/lib/editor/document-editor-config"; import { useDocumentComments } from "@src/lib/editor/use-document-comments"; import { getNodeIdAtPos, transactionDeletesNode } from "@src/lib/screenplay/comment-anchors"; import { useDocumentEditor } from "@src/lib/editor/use-document-editor"; @@ -47,6 +47,10 @@ export interface DocumentEditorPanelProps { focusedTypeOverride?: "screenplay" | "title" | "draft"; } +// Scroll distance (px) that fully hides the mobile chrome. Roughly the navbar +// height so the bar slides away at content speed — a 1:1 feel with the scroll. +const CHROME_HIDE_RANGE = 64; + const DocumentEditorPanel = ({ config, isVisible, @@ -90,7 +94,7 @@ const DocumentEditorPanel = ({ repository, } = projectCtx; const { settings } = useSettings(); - const { isEndlessScroll, setChromeHidden } = useViewContext(); + const { isEndlessScroll, setChromeHidden, mobileEditMode, setMobileEditMode } = useViewContext(); const { user } = useUser(); const isPhone = useIsPhone(); @@ -109,6 +113,13 @@ const DocumentEditorPanel = ({ // Last scrollTop, to derive scroll direction for hiding/showing the mobile // editor chrome (navbar + sidebar edge handles). const lastScrollTop = useRef(0); + // Continuous 0→1 progress for hiding that chrome, tracked so it follows the + // scroll gesture linearly rather than snapping at a threshold (see + // applyChromeHide). Mirrored into the --chrome-hide CSS variable. + const chromeHideRef = useRef(0); + // Pending single-tap timer for the phone reader. A tap arms it; a second tap + // within the window cancels it and counts as a double tap (see handleReaderTap). + const tapTimer = useRef | null>(null); // Callback ref stored in state so the zoom effect re-runs when the scroll // container actually mounts (it may render after a Loading fallback). const [containerEl, setContainerEl] = useState(null); @@ -154,16 +165,24 @@ const DocumentEditorPanel = ({ }; }, [editor, onEditorCreated]); - // Read-only enforcement for VIEWER role. + // Editability gate. + // + // Read-only enforcement for VIEWER role: the server already drops doc writes + // from viewers (see protocol.ts), but disabling tiptap locally avoids a + // confusing "I typed but nothing happened" experience — keystrokes are + // blocked at the editor level and collaboration carets/awareness still render. // - // The server already drops doc writes from viewers (see protocol.ts), but - // disabling tiptap locally avoids a confusing "I typed but nothing - // happened" experience: keystrokes are blocked at the editor level and - // collaboration carets/awareness still render normally. + // Phone reader mode: on phone the editor stays non-editable until the user + // enters edit mode via the pen button, so the default experience is a + // keyboard-free reader. Off phone, mobileEditMode is ignored. Turning it off + // (contenteditable=false) also dismisses the on-screen keyboard; blur too so + // focus doesn't linger. useEffect(() => { if (!editor || editor.isDestroyed) return; - editor.setEditable(!isReadOnly); - }, [editor, isReadOnly]); + const editable = !isReadOnly && (!isPhone || mobileEditMode); + editor.setEditable(editable); + if (!editable && editor.isFocused) editor.commands.blur(); + }, [editor, isReadOnly, isPhone, mobileEditMode]); // Marker class on the editor DOM so global CSS (scriptio.css) can drop the // first-of-page top-margin reset in endless-scroll mode. There the page-break @@ -452,6 +471,10 @@ const DocumentEditorPanel = ({ editor.setOptions({ editorProps: { + // Re-apply the base contenteditable attributes: setOptions replaces + // editorProps wholesale, so without this the autocorrect/predictive + // suppression set at mount would be dropped for screenplay editors. + attributes: EDITOR_INPUT_ATTRIBUTES, handleKeyDown(view: EditorView, event: KeyboardEvent) { const selection = view.state.selection; const node = selection.$anchor.parent; @@ -764,6 +787,63 @@ const DocumentEditorPanel = ({ } }, []); + // Drive the mobile chrome hide (navbar + sidebar edge handles + pen button) + // as a continuous 0→1 progress written straight to a CSS variable, so it + // tracks the scroll gesture linearly instead of snapping at a threshold and + // easing over a fixed duration. Written imperatively (no React state) to keep + // it frame-tight. `chromeHidden` is kept in sync only as a coarse flag for + // logic that needs a discrete "mostly hidden" state. + const applyChromeHide = useCallback( + (progress: number) => { + const clamped = progress < 0 ? 0 : progress > 1 ? 1 : progress; + if (clamped === chromeHideRef.current) return; + const wasHidden = chromeHideRef.current > 0.5; + chromeHideRef.current = clamped; + document.documentElement.style.setProperty("--chrome-hide", clamped.toFixed(4)); + const isHidden = clamped > 0.5; + if (isHidden !== wasHidden) setChromeHidden(isHidden); + }, + [setChromeHidden], + ); + + // Reset the chrome to fully shown whenever it can't/shouldn't be hidden: + // leaving phone layout, entering edit mode, or on unmount (so the next screen + // doesn't inherit a half-hidden bar). + useEffect(() => { + if (!isPhone || mobileEditMode) applyChromeHide(0); + return () => applyChromeHide(0); + }, [isPhone, mobileEditMode, applyChromeHide]); + + // Phone reader taps. The reader is not editable, so taps don't place a caret + // and are free to drive chrome: a single tap brings back the chrome the user + // scrolled away; a double tap enters edit mode and focuses the editor (same + // as the pen button), bringing up the keyboard. Off phone, in edit mode, or + // for read-only viewers this is inert so normal caret/selection behaviour is + // untouched. + const handleReaderTap = useCallback(() => { + if (!isPhone || mobileEditMode) return; + + if (tapTimer.current) { + // Second tap inside the window → double tap: enter edit mode. + clearTimeout(tapTimer.current); + tapTimer.current = null; + if (isReadOnly) return; + setMobileEditMode(true); + // setEditable flips in an effect after this render, so defer the + // focus a tick until the editor is actually editable. + const ed = editor; + if (ed) setTimeout(() => ed.commands.focus(), 0); + return; + } + + // First tap: wait briefly to see if a second one follows. If not, treat + // it as a single tap and reveal the chrome (a no-op when already shown). + tapTimer.current = setTimeout(() => { + tapTimer.current = null; + applyChromeHide(0); + }, 280); + }, [isPhone, mobileEditMode, isReadOnly, editor, setMobileEditMode, applyChromeHide]); + const onScroll = (e: React.UIEvent) => { if (suggestions.length > 0) updateSuggestions?.([]); const scrollTop = e.currentTarget.scrollTop; @@ -772,17 +852,16 @@ const DocumentEditorPanel = ({ updateThumb(); revealScrollThumb(); - // Hide the floating chrome (navbar + sidebar edge handles) while - // scrolling down into the script so it doesn't cover the page; bring - // it back on scroll-up or when near the top. A small threshold keeps - // momentum jitter from flickering it. + // Map scroll movement onto the hide progress 1:1 over CHROME_HIDE_RANGE + // px: scrolling down reveals more of the script by sliding the chrome + // away at content speed; scrolling up brings it straight back. Snap + // fully open at the very top. In edit mode the navbar carries the + // exit/undo/redo controls, so keep it pinned open there. const delta = scrollTop - lastScrollTop.current; - if (scrollTop <= 4) { - setChromeHidden(false); - } else if (delta > 6) { - setChromeHidden(true); - } else if (delta < -6) { - setChromeHidden(false); + if (mobileEditMode || scrollTop <= 4) { + applyChromeHide(0); + } else { + applyChromeHide(chromeHideRef.current + delta / CHROME_HIDE_RANGE); } lastScrollTop.current = scrollTop; } @@ -829,6 +908,7 @@ const DocumentEditorPanel = ({ useEffect(() => { return () => { if (scrollIdleTimer.current) clearTimeout(scrollIdleTimer.current); + if (tapTimer.current) clearTimeout(tapTimer.current); }; }, []); @@ -837,16 +917,13 @@ const DocumentEditorPanel = ({ // for programmatic/collaboration changes or the pagination height updates — // so it won't fight the scroll-hide. Off phone this is a no-op. useEffect(() => { - if (!isPhone) { - setChromeHidden(false); - return; - } + if (!isPhone) return; const dom = editor?.view?.dom; if (!dom) return; - const onInput = () => setChromeHidden(false); + const onInput = () => applyChromeHide(0); dom.addEventListener("input", onInput); return () => dom.removeEventListener("input", onInput); - }, [editor, isPhone, setChromeHidden]); + }, [editor, isPhone, applyChromeHide]); const focusType = focusedTypeOverride ?? (config.type === "screenplay" ? "screenplay" : "title"); @@ -869,6 +946,7 @@ const DocumentEditorPanel = ({ ref={setContainerEl} className={styles.container} onScroll={onScroll} + onClick={handleReaderTap} onMouseDown={handleContainerMouseDown} onFocus={() => setFocusedEditorType(focusType)} onPasteCapture={ diff --git a/components/editor/MobileFormatToolbar.module.css b/components/editor/MobileFormatToolbar.module.css index a4aca354..ee52d862 100644 --- a/components/editor/MobileFormatToolbar.module.css +++ b/components/editor/MobileFormatToolbar.module.css @@ -9,8 +9,8 @@ display: flex; flex-direction: row; align-items: center; - justify-content: center; - gap: 6px; + justify-content: flex-start; + gap: 8px; padding: 6px 12px; padding-bottom: max(6px, env(safe-area-inset-bottom, 0px)); @@ -31,6 +31,8 @@ flex-direction: row; align-items: center; gap: 4px; + /* Don't compress inside the scrollable row — overflow and scroll instead. */ + flex-shrink: 0; } .btn { @@ -39,6 +41,7 @@ justify-content: center; width: 40px; height: 40px; + flex-shrink: 0; padding: 0; border: none; border-radius: 8px; @@ -63,6 +66,113 @@ .separator { width: 1px; height: 22px; + flex-shrink: 0; background-color: var(--separator); margin-inline: 4px; } + +/* Grouped B/I/U + alignment. Takes the width left of the pinned element + * selector and scrolls horizontally when it doesn't fit, so we never have to + * worry about the controls being clipped on narrow phones. Scrollbar hidden — + * it's a touch-drag surface. */ +.format_group { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.format_group::-webkit-scrollbar { + display: none; +} + +/* Element-type selector (Scene, Action, Character, …). Pinned (never scrolls) + * so its upward menu is never clipped by the scroll container, and the primary + * control stays put. */ +.element { + position: relative; + display: flex; + flex-shrink: 0; +} + +.element_trigger { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + height: 40px; + padding: 0 10px; + border: none; + border-radius: 8px; + background-color: var(--secondary-hover); + color: var(--primary-text); + font-size: 14px; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; +} + +.element_label { + white-space: nowrap; +} + +.chevron { + flex-shrink: 0; + color: var(--secondary-text); + transition: transform 0.15s ease; +} + +.chevron_open { + transform: rotate(180deg); +} + +/* Menu opens upward, above the toolbar, since the bar sits on the keyboard. */ +.element_menu { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + z-index: 10; + + display: flex; + flex-direction: column; + gap: 2px; + + min-width: 180px; + max-height: 260px; + overflow-y: auto; + padding: 6px; + + background-color: var(--secondary); + border: 1px solid var(--separator); + border-radius: 12px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); +} + +.element_item { + display: flex; + align-items: center; + padding: 10px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--primary-text); + font-size: 15px; + text-align: left; + cursor: pointer; + touch-action: manipulation; + white-space: nowrap; +} + +.element_item:active { + background-color: var(--secondary-hover); +} + +.element_item_active { + background-color: var(--secondary-hover); + font-weight: 600; +} diff --git a/components/editor/MobileFormatToolbar.tsx b/components/editor/MobileFormatToolbar.tsx index 1b35ae7c..5086ad83 100644 --- a/components/editor/MobileFormatToolbar.tsx +++ b/components/editor/MobileFormatToolbar.tsx @@ -1,13 +1,14 @@ "use client"; -import { useCallback, useContext, useEffect, useState } from "react"; -import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, Underline } from "lucide-react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { AlignCenter, AlignLeft, AlignRight, Bold, ChevronUp, Italic, Underline } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; import { useIsPhone } from "@src/lib/utils/hooks"; -import { applyMarkToggle } from "@src/lib/screenplay/editor"; -import { applyTitlePageMarkToggle } from "@src/lib/titlepage/editor"; -import { Style } from "@src/lib/utils/enums"; +import { applyElement, applyMarkToggle } from "@src/lib/screenplay/editor"; +import { applyTitlePageElement, applyTitlePageMarkToggle } from "@src/lib/titlepage/editor"; +import { ScreenplayElement, Style, TitlePageElement } from "@src/lib/utils/enums"; import { join } from "@src/lib/utils/misc"; import styles from "./MobileFormatToolbar.module.css"; @@ -16,6 +17,24 @@ import styles from "./MobileFormatToolbar.module.css"; // open on-screen keyboard. const KEYBOARD_THRESHOLD = 120; +const SCREENPLAY_ELEMENTS_ORDER: ScreenplayElement[] = [ + ScreenplayElement.Scene, + ScreenplayElement.Action, + ScreenplayElement.Character, + ScreenplayElement.Dialogue, + ScreenplayElement.Parenthetical, + ScreenplayElement.Transition, + ScreenplayElement.Section, + ScreenplayElement.Note, +]; + +const TITLEPAGE_ELEMENTS_ORDER: TitlePageElement[] = [ + TitlePageElement.Title, + TitlePageElement.Author, + TitlePageElement.Date, + TitlePageElement.None, +]; + /** * Distance in px the on-screen keyboard covers at the bottom of the layout * viewport, tracked via the VisualViewport API. 0 when no keyboard is up. @@ -48,11 +67,12 @@ const useKeyboardInset = (enabled: boolean): number => { /** * Phone-only formatting bar that rides just above the on-screen keyboard while a - * screenplay/title editor is focused. Surfaces the inline styling (bold, italic, - * underline) and alignment controls that are dropped from the narrow navbar - * (see the max-width:767px block in ScreenplayFormatDropdown.module.css). + * screenplay/title editor is focused. Surfaces the element-type selector (moved + * here from the navbar so it's within thumb reach while writing) plus the inline + * styling (bold, italic, underline) and alignment controls. */ const MobileFormatToolbar = () => { + const t = useTranslations("formatDropdown"); const isPhone = useIsPhone(); const { editor, @@ -61,6 +81,10 @@ const MobileFormatToolbar = () => { focusedEditorType, selectedStyles, setSelectedStyles, + selectedElement, + setSelectedElement, + selectedTitlePageElement, + setSelectedTitlePageElement, isReadOnly, } = useContext(ProjectContext); @@ -71,6 +95,26 @@ const MobileFormatToolbar = () => { const activeEditor = isTitleContext ? titlePageEditor : isDraftContext ? draftEditor : editor; const [selectedAlign, setSelectedAlign] = useState("left"); + const [elementMenuOpen, setElementMenuOpen] = useState(false); + const elementRef = useRef(null); + + const ELEMENT_LABELS: Record = { + [ScreenplayElement.Scene]: t("elements.scene"), + [ScreenplayElement.Action]: t("elements.action"), + [ScreenplayElement.Character]: t("elements.character"), + [ScreenplayElement.Dialogue]: t("elements.dialogue"), + [ScreenplayElement.Parenthetical]: t("elements.parenthetical"), + [ScreenplayElement.Transition]: t("elements.transition"), + [ScreenplayElement.Section]: t("elements.section"), + [ScreenplayElement.Note]: t("elements.note"), + [TitlePageElement.Title]: t("titlePageElements.title"), + [TitlePageElement.Author]: t("titlePageElements.author"), + [TitlePageElement.Date]: t("titlePageElements.date"), + [TitlePageElement.None]: t("titlePageElements.none"), + }; + + const elementOrder = isTitleContext ? TITLEPAGE_ELEMENTS_ORDER : SCREENPLAY_ELEMENTS_ORDER; + const currentElement = isTitleContext ? selectedTitlePageElement : selectedElement; // Keep the alignment highlight in sync with the caret's block. useEffect(() => { @@ -88,6 +132,19 @@ const MobileFormatToolbar = () => { }; }, [activeEditor]); + // Close the element menu on any pointer-down outside it (without stealing the + // editor focus the way a normal outside tap would blur it). + useEffect(() => { + if (!elementMenuOpen) return; + const onDown = (e: PointerEvent) => { + if (elementRef.current && !elementRef.current.contains(e.target as Node)) { + setElementMenuOpen(false); + } + }; + document.addEventListener("pointerdown", onDown); + return () => document.removeEventListener("pointerdown", onDown); + }, [elementMenuOpen]); + const toggleStyle = useCallback( (style: Style) => { if (isReadOnly || !activeEditor) return; @@ -101,6 +158,21 @@ const MobileFormatToolbar = () => { [activeEditor, isTitleContext, isReadOnly, setSelectedStyles], ); + const selectElement = useCallback( + (element: ScreenplayElement | TitlePageElement) => { + setElementMenuOpen(false); + if (isReadOnly || !activeEditor) return; + if (isTitleContext) { + setSelectedTitlePageElement(element as TitlePageElement); + applyTitlePageElement(activeEditor, element as TitlePageElement); + } else { + setSelectedElement(element as ScreenplayElement); + applyElement(activeEditor, element as ScreenplayElement); + } + }, + [activeEditor, isTitleContext, isReadOnly, setSelectedElement, setSelectedTitlePageElement], + ); + const setAlignment = useCallback( (align: string) => { if (isReadOnly || !activeEditor) return; @@ -134,66 +206,104 @@ const MobileFormatToolbar = () => { return (
-
+ {/* Element-type selector — the primary control, opens a menu upward. */} +
+ {elementMenuOpen && ( +
+ {elementOrder.map((element) => ( + + ))} +
+ )} - -
-
+
+
+ + + +
-
- - - +
+ +
+ + + +
); diff --git a/components/navbar/ProjectNavbar.module.css b/components/navbar/ProjectNavbar.module.css index 66e6ba50..d9ad5303 100644 --- a/components/navbar/ProjectNavbar.module.css +++ b/components/navbar/ProjectNavbar.module.css @@ -42,40 +42,86 @@ padding-right: calc(10px + var(--safe-right)); } -.mobile_center { - flex: 1; +/* Both navbar clusters render as floating rounded islands (same treatment as + * .navbar_island) so the phone navbar reads as detached, rounded chrome that + * matches the sidebar handles and keyboard bar. The container row keeps the + * page background; only these pills stand out. */ +.mobile_left, +.mobile_right { display: flex; - justify-content: flex-start; + flex-direction: row; align-items: center; - min-width: 0; - height: calc(100% - 12px); + gap: 2px; + padding: 4px; + background-color: var(--secondary); + border-radius: 999px; + box-shadow: var(--panel-shadow); } +/* Always hug the right edge — the only other cluster (edit-mode controls) is on + * the left, and in reader mode this is the sole cluster, so it can't rely on a + * sibling to push it over. */ .mobile_right { - display: flex; - flex-direction: row; - align-items: center; - gap: 4px; + margin-left: auto; +} + +/* Icon buttons sit flush on the island (ghost fill), so the pill reads as one + * unit rather than buttons-within-a-button. Higher specificity than the shared + * NavbarIconButton .button rule so it wins regardless of stylesheet order. */ +.mobile_bar .mobile_icon { + background-color: transparent; + border-color: transparent; +} + +.mobile_bar .mobile_icon:hover { + border-color: transparent; +} + +.mobile_bar .mobile_icon:active { + background-color: var(--secondary-hover); +} + +/* The "leave edit mode" checkmark: a filled pill that reads as the primary + * action of the edit-mode island, standing apart from the ghost undo/redo icons + * beside it via the inverted text colour. */ +.edit_done { + color: var(--main-bg); + background-color: var(--primary-text); +} + +/* The global `svg { color: var(--primary-text) }` rule targets the icon element + * directly, so it overrides the inverted `color` inherited from this pill and + * paints the checkmark the same shade as the pill's background (invisible in + * dark mode). Re-inherit the button's colour so the icon stays legible. */ +.edit_done svg { + color: inherit; +} + +.edit_done:hover { + background-color: var(--primary-text); + opacity: 0.85; } @media (max-width: 767px) { .container { /* Push the bar below the status bar / Dynamic Island. --navbar-height - * already reserves this space, so the content box stays 56px tall. */ + * already reserves this space, so the content box stays 64px tall — the + * pill clusters (48px) then centre with an even ~8px above and below, + * clear of the bar's bottom edge. */ padding-block: 0; padding-top: var(--safe-top); overflow: hidden; - transition: - height 0.25s ease, - opacity 0.25s ease; + /* Collapse continuously as the reader scrolls down: --chrome-hide runs + * 0 (shown) → 1 (hidden), driven straight from scrollTop in + * DocumentEditorPanel. Height reclaims the space (the workspace is a flex + * column, so this reflows cleanly) and there is intentionally no + * transition — the bar tracks the gesture rather than easing after it. */ + height: calc(var(--navbar-height) * (1 - var(--chrome-hide, 0))); + opacity: calc(1 - var(--chrome-hide, 0)); } - /* Collapsed while the reader scrolls down (see chromeHidden in ViewContext). - * Height animates to 0 so the editor below reclaims the space; the workspace - * is a flex column, so this reflows cleanly without a fixed overlay. */ + /* Fully hidden: drop hit-testing so the collapsed sliver can't catch taps. */ .container_hidden { - height: 0; - opacity: 0; pointer-events: none; } } diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index aed59a13..4c48def2 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -19,6 +19,7 @@ import { DashboardContext } from "@src/context/DashboardContext"; import { AudioLines, BarChart2, + Check, CircleArrowLeft, CircleCheckBig, CloudUpload, @@ -28,7 +29,9 @@ import { Lock, Menu, Monitor, + Redo2, Settings, + Undo2, WifiOff, WifiSync, } from "lucide-react"; @@ -43,6 +46,7 @@ import mobileMenu from "./ProjectNavbarMobileMenu.module.css"; import navBtn from "@components/utils/NavbarIconButton.module.css"; import ScreenplayFormatDropdown from "./ScreenplayFormatDropdown"; import ScreenplaySearch from "./ScreenplaySearch"; +import { useActiveEditor } from "@src/lib/editor/use-active-editor"; /** Human-readable byte size, e.g. 1.4 GB. */ const formatBytes = (bytes: number): string => { @@ -193,7 +197,25 @@ const ProjectNavbar = () => { const isLocalEdit = useRef(false); const isPhone = useIsPhone(); - const { setLeftSidebarOpen, setRightSidebarOpen, chromeHidden } = useViewContext(); + const { setLeftSidebarOpen, setRightSidebarOpen, chromeHidden, mobileEditMode, setMobileEditMode } = + useViewContext(); + const activeEditor = useActiveEditor(); + + // Undo/redo are backed by the collaboration UndoManager (registered by the + // Collaboration extension). Guard on the command existing so calling before + // Yjs is ready can't throw; an empty history no-ops harmlessly. + const runHistory = (action: "undo" | "redo") => { + if (activeEditor && typeof activeEditor.commands[action] === "function") { + activeEditor.chain().focus()[action]().run(); + } + }; + + // Leave edit mode: drop focus so the keyboard dismisses (setEditable(false) + // in the panel also does this, but blur here makes it immediate). + const exitEditMode = () => { + activeEditor?.commands.blur(); + setMobileEditMode(false); + }; // Return to the projects list by clearing the ?projectId query. Use the same // redirect() primitive that opens a project (ProjectItem → redirectScreenplay): @@ -288,19 +310,37 @@ const ProjectNavbar = () => { return (