diff --git a/components/analytics/AnalyticsModal.module.css b/components/analytics/AnalyticsModal.module.css index eaf77622..b7b2d099 100644 --- a/components/analytics/AnalyticsModal.module.css +++ b/components/analytics/AnalyticsModal.module.css @@ -211,71 +211,116 @@ font-size: 0.95rem; } -/* ── Phone ─────────────────────────────────────────────────── - Side-by-side sidebar/content squeezes the content column too - narrow on a phone, cropping the stat cards. Fill the screen and - stack vertically: the sidebar collapses to a horizontal tab strip - above the (now full-width, scrollable) content. */ -@media (max-width: 767px) { - .overlay { - padding: 0; - } +/* ── Phone ──────────────────────────────────────────────────── + A dedicated layout (rendered via a JS fork on phone viewports — see + AnalyticsModal) that matches the other navbar tool sheets: a full-width + panel pinned below the navbar with a compact header, a section dropdown, + and a scrollable body. These classes are only mounted on phone, so no + media query is needed to gate them. */ + +/* Transparent full-screen catcher for outside taps. Its z-index sits below + the portaled dropdown menu (.dropdown_menu, z-index 50) so the section + picker's menu renders above the sheet rather than behind it. */ +.mobileOverlay { + position: fixed; + inset: 0; + z-index: 40; +} - .modal { - width: 100vw; - height: 100dvh; - max-width: none; - max-height: none; - border-radius: 0; - flex-direction: column; - } +.mobilePanel { + position: fixed; + top: calc(var(--navbar-height) + 4px); + left: calc(12px + var(--safe-left)); + right: calc(12px + var(--safe-right)); + max-height: calc(100dvh - var(--navbar-height) - 16px - var(--safe-bottom)); + display: flex; + flex-direction: column; + background-color: var(--secondary); + border: 1px solid var(--separator); + border-radius: 16px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + overflow: hidden; +} - .sidebar { - flex: 0 0 auto; - padding: calc(12px + var(--safe-top)) 12px 12px; - border-right: none; - border-bottom: 1px solid var(--separator); - } +/* Header — mirrors the shared panel header (Production/Read-aloud/Saves). */ +.mobileHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--separator); + flex-shrink: 0; +} - .sidebarTitle { - padding: 0 4px; - margin-bottom: 12px; - } +.mobileTitle { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + font-weight: 600; + color: var(--primary-text); +} - /* Group sections and their items lay out inline as a single - horizontally-scrollable row of tabs. Group labels are dropped. */ - .navMenu { - display: flex; - gap: 8px; - padding: 0; - overflow-x: auto; - } +.mobileTitle svg { + color: var(--secondary-text); +} - .navMenu > div { - display: flex; - gap: 8px; - } +.mobileIconBtn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: var(--secondary-text); + cursor: pointer; + border-radius: 4px; +} - .groupLabel { - display: none; - } +.mobileIconBtn:hover { + background-color: var(--tertiary); + color: var(--primary-text); +} - .navItem { - width: auto; - white-space: nowrap; - } +/* Section picker row. */ +.mobileToolbar { + padding: 12px 16px; + border-bottom: 1px solid var(--separator); + flex-shrink: 0; +} - .content { - flex: 1; - min-height: 0; - padding: 20px 16px calc(20px + var(--safe-bottom)); - } +/* Constrains the dropdown so it doesn't stretch the full panel width. */ +.mobileTabField { + width: 200px; + max-width: 100%; +} - .scrollArea { - padding-right: 0; - } +.tabSelectTrigger { + padding: 8px 12px; + background: var(--primary); + border: 1px solid var(--separator); + border-radius: 8px; + color: var(--primary-text); + font-size: 0.9rem; +} - /* Single column: two-up grids can't fit a phone width. */ +.tabOption { + display: flex; + align-items: center; + gap: 12px; +} + +/* Scrollable body — padded on the right so content clears the scrollbar. */ +.mobileScroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 20px 16px 16px; +} + +/* Single column: two-up grids can't fit a phone-width panel. */ +@media (max-width: 767px) { .statsGridWide { grid-template-columns: 1fr; } diff --git a/components/analytics/AnalyticsModal.tsx b/components/analytics/AnalyticsModal.tsx index f89d45e8..d7198b88 100644 --- a/components/analytics/AnalyticsModal.tsx +++ b/components/analytics/AnalyticsModal.tsx @@ -1,8 +1,12 @@ "use client"; import { useEffect, useState, ReactNode } from "react"; +import { createPortal } from "react-dom"; import { BarChart2, Clapperboard, Users, MapPin, FileBarChart, X } from "lucide-react"; +import { useIsPhone } from "@src/lib/utils/hooks"; +import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; + import ScenesStats from "./stats/ScenesStats"; import CharactersStats from "./stats/CharactersStats"; import LocationsStats from "./stats/LocationsStats"; @@ -52,6 +56,19 @@ const TAB_TITLES: Record = { reports: "Reports", }; +// Flattened tabs for the mobile section dropdown (kept in sync with MENU). +const TAB_OPTIONS: DropdownOption[] = MENU.flatMap((section) => + section.items.map((item) => ({ + value: item.id, + label: ( + + {item.icon} + {item.label} + + ), + })), +); + // ── Reports placeholder ─────────────────────────────────────────────────────── function ReportsPlaceholder() { @@ -74,6 +91,10 @@ interface AnalyticsModalProps { export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps) { const [activeTab, setActiveTab] = useState("scenes"); + const [mounted, setMounted] = useState(false); + const isPhone = useIsPhone(); + + useEffect(() => setMounted(true), []); // ESC key closes the modal useEffect(() => { @@ -85,9 +106,62 @@ export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps) return () => window.removeEventListener("keydown", handleKey); }, [isOpen, onClose]); - if (!isOpen) return null; + if (!isOpen || !mounted) return null; - return ( + const activeStats = ( + <> + {activeTab === "scenes" && } + {activeTab === "characters" && } + {activeTab === "locations" && } + {activeTab === "reports" && } + + ); + + // ── Mobile ────────────────────────────────────────────────────────────── + // A distinct layout that matches the other navbar tool sheets (Production, + // Read-aloud, Saves): a full-width panel pinned below the navbar with a + // compact header, a section dropdown, and a scrollable body. Kept separate + // from the desktop two-pane modal because the structures don't overlap. + // The overlay carries a low z-index (below the portaled dropdown menu) so + // the section picker's menu renders above the sheet, not behind it. + if (isPhone) { + return createPortal( +
+
e.stopPropagation()}> +
+ + + Analytics + + +
+ +
+
+ setActiveTab(value as AnalyticsTab)} + options={TAB_OPTIONS} + className={styles.tabSelectTrigger} + fitContent + portal + /> +
+
+ +
{activeStats}
+
+
, + document.body, + ); + } + + // ── Desktop ───────────────────────────────────────────────────────────── + // Portal to so the overlay's fixed positioning escapes the navbar's + // transform/stacking context (which otherwise becomes its containing block). + return createPortal(
e.stopPropagation()}> @@ -124,15 +198,11 @@ export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps) -
- {activeTab === "scenes" && } - {activeTab === "characters" && } - {activeTab === "locations" && } - {activeTab === "reports" && } -
+
{activeStats}
- + , + document.body, ); } diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 022c0388..c02b3583 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; import { EditorContent } from "@tiptap/react"; @@ -26,7 +26,8 @@ import { DocumentEditorConfig, EDITOR_INPUT_ATTRIBUTES } from "@src/lib/editor/d 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"; -import { focusEditorAtCoords } from "@src/lib/editor/focus-in-viewport"; +import { useViewModeScrollAnchor } from "@src/lib/editor/use-view-mode-scroll-anchor"; +import { centerCaretInView, focusEditorAtCoords } from "@src/lib/editor/focus-in-viewport"; import { getSpellErrorAt } from "@src/lib/spellcheck/spellcheck-extension"; import type { SuggestionData } from "@components/editor/SuggestionMenu"; @@ -53,6 +54,35 @@ export interface DocumentEditorPanelProps { // rather than snapping shut after a flick — matches the pace of a natural scroll. const CHROME_HIDE_RANGE = 220; +// ---- Desktop editor zoom ---- +// Ctrl/⌘ +, Ctrl/⌘ -, Ctrl/⌘ 0 and Ctrl+wheel (also a trackpad pinch, which the +// browser delivers as a ctrlKey wheel on both platforms) scale the page in the +// view. Bounds are clamped; kept modest so text stays legible and the WebKit +// minimum-font-size clamp isn't hit at the low end. +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 3; +// Steps are multiplicative (a constant ratio), not additive: a fixed +0.2 would +// be 20% at 1× but only ~7% at 3×, so zooming-in felt progressively slower. A +// constant ratio makes every press feel the same size at any zoom level. +const ZOOM_STEP_RATIO = 1.2; +// Wheel/pinch scales exponentially with the delta for the same reason. A +// mouse-wheel notch (deltaY ≈ ±100) lands near the keyboard's ~1.2× ratio; +// trackpad pinches send small deltas for a smooth, fine-grained ramp. +const ZOOM_WHEEL_RATE = 0.002; +const ZOOM_STORAGE_KEY = "scriptio.editorZoom"; + +const clampZoom = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); + +// useLayoutEffect on the server warns; fall back to useEffect there. The zoom +// scroll-anchoring must run before paint, so it needs the layout variant. +const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +// macOS uses ⌘ for zoom shortcuts where Windows/Linux use Ctrl. The wheel gesture +// uses ctrlKey on every platform (that's what a trackpad pinch reports), so this +// only gates the keyboard shortcuts. +const isMacPlatform = () => + typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent); + const DocumentEditorPanel = ({ config, isVisible, @@ -96,12 +126,27 @@ const DocumentEditorPanel = ({ repository, } = projectCtx; const { settings } = useSettings(); - const { isEndlessScroll, setChromeHidden, mobileEditMode, setMobileEditMode, timelineOpen } = useViewContext(); + const { + isEndlessScroll, + onBeforeEndlessScrollChange, + setChromeHidden, + mobileEditMode, + setMobileEditMode, + timelineOpen, + } = useViewContext(); const { user } = useUser(); const isPhone = useIsPhone(); const [isEditorReady, setIsEditorReady] = useState(false); const [isScrolled, setIsScrolled] = useState(false); + // Desktop editor zoom (see the zoom effects below). Seeded from the last value + // the user set so it survives reloads; only affects a CSS variable applied in + // an effect, so there's no SSR/hydration markup to mismatch. + const [zoom, setZoom] = useState(() => { + if (typeof window === "undefined") return 1; + const stored = parseFloat(window.localStorage.getItem(ZOOM_STORAGE_KEY) ?? ""); + return Number.isFinite(stored) ? clampZoom(stored) : 1; + }); // Phone-only draggable scroll handle: a fixed-size grab handle (styled like // the sidebar edge toggles) that rides the right edge tracking scroll // position, so it's easy to grab and drag the page up/down. Shown while @@ -155,8 +200,7 @@ const DocumentEditorPanel = ({ // Resolve the comments Y.Map for this document const projectState = repository?.getState(); const commentsMap = useMemo( - () => - projectState && config.features.comments ? config.getCommentsMap(projectState) : null, + () => (projectState && config.features.comments ? config.getCommentsMap(projectState) : null), // Re-derive only when projectState identity changes (Yjs doc swap on project change) // eslint-disable-next-line react-hooks/exhaustive-deps [projectState], @@ -216,7 +260,12 @@ const DocumentEditorPanel = ({ // first-of-page top-margin reset in endless-scroll mode. There the page-break // widgets are hidden, so the reset would otherwise make each page's first // node stick to the previous page's content. - useEffect(() => { + // + // Layout effect, not a passive one: the wrapper's own mode class lands during + // the commit, so a passive toggle would leave one painted frame where the two + // disagree — and, more importantly, the scroll re-anchoring below has to + // measure the *finished* mode layout, not a half-applied one. + useIsoLayoutEffect(() => { const el = editor?.view?.dom; if (!el) return; el.classList.toggle("endless-scroll", isEndlessScroll); @@ -241,7 +290,9 @@ const DocumentEditorPanel = ({ // zoom-shrunk fonts to a 9px rendered minimum, inflating the screenplay font // — see the paged-mode rule in EditorPanel.module.css) and pagination is // measured off-screen, so page count / numbering are unaffected either way. - useEffect(() => { + // Layout effect so the paged scale is in place before the browser paints the + // new mode — and before the scroll re-anchoring below measures it. + useIsoLayoutEffect(() => { const container = containerEl; if (!container) return; @@ -289,6 +340,128 @@ const DocumentEditorPanel = ({ }; }, [containerEl, isPhone, isEndlessScroll, pageFormat, editor]); + // ---- Scroll anchoring across the endless-scroll toggle ---- + // Endless and paged render the same document at very different heights, so a + // switch would otherwise leave the reader pages away from what they were + // looking at. Declared after the two layout effects above because it measures + // the finished mode layout — see the hook for the full rationale. + useViewModeScrollAnchor({ + container: containerEl, + editor, + viewMode: isEndlessScroll, + onBeforeChange: onBeforeEndlessScrollChange, + }); + + // ---- Desktop editor zoom ---- + // Scale the zoom level by a ratio (>1 in, <1 out) so each step feels the same + // size at any level. Rounds to whole percents so repeated wheel/keys don't + // accumulate float drift, and clamps to [MIN_ZOOM, MAX_ZOOM]. + const zoomBy = useCallback((factor: number) => { + setZoom((z) => clampZoom(Math.round(z * factor * 100) / 100)); + }, []); + + // Push the level into the CSS variable the ProseMirror `zoom` reads, and keep + // the page growing from the viewport centre rather than pinning to the left as + // it scales past the view width. Runs before paint so the reposition is not + // seen as a jump. Phone owns its own scaling (paged transform / endless + // reflow), so this stays desktop-only — clear the variable there. + const prevZoomRef = useRef(1); + const didInitZoomRef = useRef(false); + useIsoLayoutEffect(() => { + const container = containerEl; + if (!container || isPhone) { + container?.style.removeProperty("--editor-user-zoom"); + return; + } + const prev = prevZoomRef.current; + // Capture the pre-zoom scroll BEFORE rescaling: applying the new zoom can + // clamp scrollLeft/Top (when zooming out shrinks the scrollable area), + // which would corrupt the centre we're trying to preserve. + const centreX = container.scrollLeft + container.clientWidth / 2; + const centreY = container.scrollTop + container.clientHeight / 2; + container.style.setProperty("--editor-user-zoom", `${zoom}`); + + // First application (e.g. a zoom level restored from a previous session): + // centre horizontally without touching the vertical scroll, so the page + // opens at the top like normal. + if (!didInitZoomRef.current) { + didInitZoomRef.current = true; + prevZoomRef.current = zoom; + if (zoom !== 1) { + // Reading scrollWidth flushes the pending zoom reflow before we + // centre against the new (scaled) content width. + container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2; + } + return; + } + + // Interactive change: keep whatever sat under the viewport centre put, so + // the page scales symmetrically around the middle of the view. + if (prev !== zoom) { + const factor = zoom / prev; + container.scrollLeft = centreX * factor - container.clientWidth / 2; + container.scrollTop = centreY * factor - container.clientHeight / 2; + } + prevZoomRef.current = zoom; + }, [containerEl, zoom, isPhone]); + + // Persist the level and nudge resize-driven layout (e.g. the comment gutter, + // which only recomputes its icon coordinates on edits / resizes). + useEffect(() => { + if (isPhone) return; + try { + window.localStorage.setItem(ZOOM_STORAGE_KEY, `${zoom}`); + } catch { + // Ignore storage failures (private mode / quota); zoom still applies. + } + window.dispatchEvent(new Event("resize")); + }, [zoom, isPhone]); + + // Wire the zoom gestures to the scroll container (not window) so that in split + // view each side zooms independently — the wheel fires on the container under + // the pointer, and keydown reaches the container the caret is focused in. The + // wheel listener must be non-passive to preventDefault the browser/webview's + // own page zoom, which React's synthetic onWheel can't guarantee. + useEffect(() => { + const container = containerEl; + if (!container || isPhone) return; + const mac = isMacPlatform(); + + const onWheel = (e: WheelEvent) => { + if (!e.ctrlKey) return; + e.preventDefault(); + zoomBy(Math.exp(-e.deltaY * ZOOM_WHEEL_RATE)); + }; + + const onKeyDown = (e: KeyboardEvent) => { + const mod = mac ? e.metaKey : e.ctrlKey; + if (!mod) return; + switch (e.key) { + case "+": + case "=": // same physical key as '+' without Shift + e.preventDefault(); + zoomBy(ZOOM_STEP_RATIO); + break; + case "-": + case "_": + e.preventDefault(); + zoomBy(1 / ZOOM_STEP_RATIO); + break; + case "0": + e.preventDefault(); + setZoom(1); + break; + } + }; + + container.addEventListener("wheel", onWheel, { passive: false }); + container.addEventListener("keydown", onKeyDown); + return () => { + container.removeEventListener("wheel", onWheel); + container.removeEventListener("keydown", onKeyDown); + }; + }, [containerEl, isPhone, zoomBy]); + // ---- Orphaned comment cleanup ---- // Comments anchor to a node's data-id. When that node is deleted the comment // is orphaned (no gutter icon, unreachable), so prune it from the project. @@ -391,14 +564,8 @@ const DocumentEditorPanel = ({ editorElement.style.setProperty(`--${key}-align`, s.align ?? "left"); editorElement.style.setProperty(`--${key}-weight`, s.bold ? "bold" : "normal"); editorElement.style.setProperty(`--${key}-style`, s.italic ? "italic" : "normal"); - editorElement.style.setProperty( - `--${key}-decoration`, - s.underline ? "underline" : "none", - ); - editorElement.style.setProperty( - `--${key}-transform`, - s.uppercase ? "uppercase" : "none", - ); + editorElement.style.setProperty(`--${key}-decoration`, s.underline ? "underline" : "none"); + editorElement.style.setProperty(`--${key}-transform`, s.uppercase ? "uppercase" : "none"); } // Compute startNewPage types from element styles @@ -536,8 +703,7 @@ const DocumentEditorPanel = ({ if (event.key === "Backspace") { // Inside a dual_dialogue_column: let the column node handle it. for (let d = selection.$anchor.depth; d >= 1; d--) { - if (selection.$anchor.node(d).type.name === DUAL_DIALOGUE_COLUMN) - return false; + if (selection.$anchor.node(d).type.name === DUAL_DIALOGUE_COLUMN) return false; } if (nodeSize === 1 && nodePos === 1) { const tr = view.state.tr.delete(selection.from - 1, selection.from); @@ -548,10 +714,7 @@ const DocumentEditorPanel = ({ } if (event.code === "Space") { - if ( - currNode === ScreenplayElement.Action && - node.textContent.match(/^\b(int|ext)\./gi) - ) { + if (currNode === ScreenplayElement.Action && node.textContent.match(/^\b(int|ext)\./gi)) { setActiveElementRef.current(ScreenplayElement.Scene); } return false; @@ -571,11 +734,7 @@ const DocumentEditorPanel = ({ if ($anchor.node(d).type.name === DUAL_DIALOGUE_COLUMN) return false; } - if ( - currNode === ScreenplayElement.Dialogue && - nodePos > 0 && - nodePos < nodeSize - ) { + if (currNode === ScreenplayElement.Dialogue && nodePos > 0 && nodePos < nodeSize) { const doc = view.state.doc; const $anchor = selection.$anchor; @@ -610,9 +769,7 @@ const DocumentEditorPanel = ({ tr.delete($anchor.pos, $anchor.end(1)); const insertPos = tr.mapping.map($anchor.after(1)); tr.insert(insertPos, [charNode, newDialogue]); - tr.setSelection( - TextSelection.create(tr.doc, insertPos + charNode.nodeSize + 1), - ); + tr.setSelection(TextSelection.create(tr.doc, insertPos + charNode.nodeSize + 1)); tr.scrollIntoView(); view.dispatch(tr); return true; @@ -703,6 +860,10 @@ const DocumentEditorPanel = ({ const onEditorContextMenu = useCallback( (e: React.MouseEvent) => { if (!editor) return; + // .page_shift spans the full editor column, but the page (editor.view.dom) + // is narrower and centred — ignore right-clicks in the surrounding gutter. + if (!editor.view.dom.contains(e.target as Node)) return; + e.preventDefault(); const { from, to } = editor.state.selection; @@ -774,9 +935,7 @@ const DocumentEditorPanel = ({ // Comments anchor to the node under the caret, not a text range. const commentNodeId = getNodeIdAtPos(editor.state, from); - const onAddComment = commentNodeId - ? () => addCommentToNode(commentNodeId) - : undefined; + const onAddComment = commentNodeId ? () => addCommentToNode(commentNodeId) : undefined; updateContextMenu({ type: ContextMenuType.EditorContextMenu, @@ -785,7 +944,17 @@ const DocumentEditorPanel = ({ // resolved against it, and ProjectContext.editor is always the // MAIN screenplay editor — so secondary editors (tree document, // draft, title page) must act on this instance, not that one. - typeSpecificProps: { editor, from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene, pageBreak }, + typeSpecificProps: { + editor, + from, + to, + onAddComment, + spellError, + nodePos, + nodeClass, + outlineScene, + pageBreak, + }, }); }, [ @@ -913,6 +1082,10 @@ const DocumentEditorPanel = ({ // The pen button, which has no tap point, aims at the viewport // instead (see focusEditorInViewport / ProjectWorkspace). focusEditorAtCoords(ed, e.clientX, e.clientY); + // Then scroll the tapped line to the middle of what's still + // visible with the keyboard up — a tap low on the screen would + // otherwise leave the caret hidden behind it. + centerCaretInView(ed); } return; } @@ -1068,8 +1241,7 @@ const DocumentEditorPanel = ({ return () => dom.removeEventListener("input", onInput); }, [editor, isPhone, applyChromeHide]); - const focusType = - focusedTypeOverride ?? (config.type === "screenplay" ? "screenplay" : "title"); + const focusType = focusedTypeOverride ?? (config.type === "screenplay" ? "screenplay" : "title"); const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS]; const wrapperStyle = pageSize @@ -1087,7 +1259,11 @@ const DocumentEditorPanel = ({
1 && !isPhone ? styles.zoomed_x : "", + )} onScroll={onScroll} onTouchStart={onReaderTouchStart} onTouchEnd={onReaderTouchEnd} @@ -1108,9 +1284,7 @@ const DocumentEditorPanel = ({ className={`${styles.editor_wrapper} ${isEndlessScroll ? styles.endless_scroll : ""}`} style={wrapperStyle} > -
+
{isReadOnly && (
@@ -1142,10 +1316,7 @@ const DocumentEditorPanel = ({ {isPhone && canScrollThumb && (
{ - const [inset, setInset] = useState(0); - - useEffect(() => { - // When disabled the consumer is hidden anyway, so leaving a stale inset is - // harmless — just don't subscribe. - if (!enabled || typeof window === "undefined" || !window.visualViewport) return; - const vv = window.visualViewport; - const update = () => { - // Layout-viewport height minus the visible visual viewport (and any - // offset from a scrolled visual viewport) is what the keyboard hides. - const covered = window.innerHeight - vv.height - vv.offsetTop; - setInset(covered > KEYBOARD_THRESHOLD ? covered : 0); - }; - update(); - vv.addEventListener("resize", update); - vv.addEventListener("scroll", update); - return () => { - vv.removeEventListener("resize", update); - vv.removeEventListener("scroll", update); - }; - }, [enabled]); - - return inset; -}; - /** * Phone-only formatting bar that rides just above the on-screen keyboard while a * screenplay/title editor is focused. Surfaces the element-type selector (moved diff --git a/components/navbar/ProjectNavbarMobile.tsx b/components/navbar/ProjectNavbarMobile.tsx index a8fb0b1f..7704b0ab 100644 --- a/components/navbar/ProjectNavbarMobile.tsx +++ b/components/navbar/ProjectNavbarMobile.tsx @@ -55,6 +55,8 @@ const ProjectNavbarMobile = () => { const { openDashboard, + closeDashboard, + isDashboardOpen, mobileMenuOpen, setMobileMenuOpen, membership, @@ -203,8 +205,17 @@ const ProjectNavbarMobile = () => {
{isInProject && }
{ + // The dashboard drawer sits below the navbar on phone, so the + // burger stays tappable while it's up — and it was reached + // *through* this menu. Tapping again dismisses that stack + // rather than stacking another menu behind it. + if (isDashboardOpen) { + closeDashboard(); + setMobileMenuOpen(false); + return; + } const next = !mobileMenuOpen; // The menu drawer opens on the right, same as the format // sidebar; close both side drawers so it opens cleanly on top. diff --git a/components/navbar/ScreenplaySearch.module.css b/components/navbar/ScreenplaySearch.module.css index 1a577a03..76d7e615 100644 --- a/components/navbar/ScreenplaySearch.module.css +++ b/components/navbar/ScreenplaySearch.module.css @@ -88,6 +88,19 @@ left: auto; right: calc(12px + var(--safe-right)); width: 220px; + /* Never taller than the gap between the navbar and the top of the + * on-screen keyboard (--keyboard-inset, set inline by ScreenplaySearch; + * 0 when no keyboard is up). Left unclamped, the filter list runs under + * the keyboard and off the screen, and dragging the panel scrolls the + * document instead of the panel — which shoves the whole layout above + * the top of the screen. Scroll the filters inside the panel instead, + * and don't chain that scroll to anything behind it. */ + max-height: calc( + 100dvh - var(--navbar-height) - 16px - var(--safe-bottom) - var(--keyboard-inset, 0px) + ); + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; /* Portaled to (see ScreenplaySearch), so it must out-rank the * navbar (z-index 50) and page content on its own. */ z-index: 60; diff --git a/components/navbar/ScreenplaySearch.tsx b/components/navbar/ScreenplaySearch.tsx index 0a2b06f1..e70bd7da 100644 --- a/components/navbar/ScreenplaySearch.tsx +++ b/components/navbar/ScreenplaySearch.tsx @@ -4,7 +4,7 @@ import { useContext, useRef, useEffect, useCallback, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; -import { useIsPhone } from "@src/lib/utils/hooks"; +import { useIsPhone, useKeyboardInset } from "@src/lib/utils/hooks"; import { ScreenplayElement } from "@src/lib/utils/enums"; import { scrollToMatch, SearchMatch } from "@src/lib/screenplay/extensions/search-highlight-extension"; import { Search, ChevronUp, ChevronDown, X, Replace, ReplaceAll } from "lucide-react"; @@ -54,6 +54,10 @@ const ScreenplaySearch = () => { const isPhone = useIsPhone(); const [isOpen, setIsOpen] = useState(false); + // Opening the panel focuses its input, which raises the on-screen keyboard. + // The panel is clamped to the space left above it (see the CSS max-height) + // so it never extends under the keyboard. + const keyboardInset = useKeyboardInset(isPhone && isOpen); const [replaceValue, setReplaceValue] = useState(""); const debounceRef = useRef | null>(null); const containerRef = useRef(null); @@ -121,6 +125,25 @@ const ScreenplaySearch = () => { return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen, handleClose]); + // iOS scrolls the whole document to bring a focused input into view when the + // keyboard opens. The app shell is pinned to the viewport (body is + // overflow: clip) so nothing scrolls the document legitimately — that scroll + // just drags the entire layout past the top of the screen and leaves it + // there, taking the navbar with it. Pin it back while the panel is open. + useEffect(() => { + if (!isOpen || !isPhone) return; + const pinToTop = () => { + if (window.scrollX !== 0 || window.scrollY !== 0) window.scrollTo(0, 0); + }; + pinToTop(); + window.addEventListener("scroll", pinToTop, { passive: true }); + window.visualViewport?.addEventListener("resize", pinToTop); + return () => { + window.removeEventListener("scroll", pinToTop); + window.visualViewport?.removeEventListener("resize", pinToTop); + }; + }, [isOpen, isPhone]); + // Use uncontrolled input with debounced updates to context const handleSearchChange = useCallback( (e: React.ChangeEvent) => { @@ -258,7 +281,11 @@ const ScreenplaySearch = () => { {isOpen && (() => { const panel = ( -
+
{isPhone && ( { - const { openDashboard, mobileMenuOpen, setMobileMenuOpen } = useContext(DashboardContext); + const { + openDashboard, + closeDashboard, + isOpen: isDashboardOpen, + mobileMenuOpen, + setMobileMenuOpen, + } = useContext(DashboardContext); const { project: membership, setProjectTitle: setContextTitle } = useContext(ProjectContext); const userCtx = useContext(UserContext); const { isPro } = useIsPro(); @@ -123,6 +129,8 @@ export const useProjectNavbar = () => { return { openDashboard, + closeDashboard, + isDashboardOpen, mobileMenuOpen, setMobileMenuOpen, membership, diff --git a/components/project/ProjectWorkspace.tsx b/components/project/ProjectWorkspace.tsx index c7f4dc52..ba05b944 100644 --- a/components/project/ProjectWorkspace.tsx +++ b/components/project/ProjectWorkspace.tsx @@ -4,7 +4,7 @@ import { useContext, useEffect, useState } from "react"; import { useViewContext } from "@src/context/ViewContext"; import { ProjectContext } from "@src/context/ProjectContext"; import { useActiveEditor } from "@src/lib/editor/use-active-editor"; -import { focusEditorInViewport } from "@src/lib/editor/focus-in-viewport"; +import { centerCaretInView, focusEditorInViewport } from "@src/lib/editor/focus-in-viewport"; import { useIsPhone } from "@src/lib/utils/hooks"; import EditorSidebarNavigation from "@components/editor/sidebar/EditorSidebarNavigation"; import EditorSidebarFormat from "@components/editor/sidebar/EditorSidebarFormat"; @@ -63,6 +63,9 @@ const ProjectWorkspace = () => { // Drop the caret on a character that's currently on screen rather than // at the old selection, so the view doesn't jump when the keyboard rises. focusEditorInViewport(editor); + // Then bring that caret to the middle of what stays visible once the + // keyboard is up, so the user can see where the focus landed. + centerCaretInView(editor); } }; diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index a22c465c..8e41316c 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -4,5 +4,13 @@ ITSAppUsesNonExemptEncryption + NSCameraUsageDescription + Scriptio uses the camera so you can photograph reference images and add them to your project boards. + NSMicrophoneUsageDescription + Scriptio uses the microphone to record voice notes for your boards and to dictate text into your screenplay. + NSSpeechRecognitionUsageDescription + Scriptio uses speech recognition to transcribe your dictation into the screenplay editor. + NSPhotoLibraryUsageDescription + Scriptio accesses your photo library so you can add existing images to your project boards. \ No newline at end of file diff --git a/src-tauri/gen/apple/Scriptio_iOS/Info.plist b/src-tauri/gen/apple/Scriptio_iOS/Info.plist index 1b41229b..a0c1450a 100644 --- a/src-tauri/gen/apple/Scriptio_iOS/Info.plist +++ b/src-tauri/gen/apple/Scriptio_iOS/Info.plist @@ -42,5 +42,13 @@ ITSAppUsesNonExemptEncryption + NSCameraUsageDescription + Scriptio uses the camera so you can photograph reference images and add them to your project boards. + NSMicrophoneUsageDescription + Scriptio uses the microphone to record voice notes for your boards and to dictate text into your screenplay. + NSSpeechRecognitionUsageDescription + Scriptio uses speech recognition to transcribe your dictation into the screenplay editor. + NSPhotoLibraryUsageDescription + Scriptio accesses your photo library so you can add existing images to your project boards. \ No newline at end of file diff --git a/src-tauri/gen/apple/project.yml b/src-tauri/gen/apple/project.yml index 47882bd8..653eabdb 100644 --- a/src-tauri/gen/apple/project.yml +++ b/src-tauri/gen/apple/project.yml @@ -55,6 +55,10 @@ targets: CFBundleShortVersionString: 2.1.0 CFBundleVersion: "2.1.0" CFBundleDisplayName: Scriptio (Staging) + NSCameraUsageDescription: Scriptio uses the camera so you can photograph reference images and add them to your project boards. + NSMicrophoneUsageDescription: Scriptio uses the microphone to record voice notes for your boards and to dictate text into your screenplay. + NSSpeechRecognitionUsageDescription: Scriptio uses speech recognition to transcribe your dictation into the screenplay editor. + NSPhotoLibraryUsageDescription: Scriptio accesses your photo library so you can add existing images to your project boards. entitlements: path: Scriptio_iOS/Scriptio_iOS.entitlements scheme: diff --git a/src/app/providers.tsx b/src/app/providers.tsx index e9693c2d..5f0881f9 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -3,6 +3,7 @@ import { ReactNode, useEffect } from "react"; import { ThemeProvider } from "next-themes"; import { NextIntlClientProvider } from "next-intl"; +import { isTauri } from "@tauri-apps/api/core"; import { SWRConfig } from "swr"; import { UserContextProvider } from "@src/context/UserContext"; import { DashboardContextProvider } from "@src/context/DashboardContext"; @@ -36,6 +37,23 @@ function LocaleSync() { return null; } +/** + * On the desktop app, suppress the native OS webview context menu so a stray + * right-click never breaks the immersive experience. Our own ContextMenu + * components call preventDefault() themselves and are unaffected; this only + * catches the right-clicks that would otherwise fall through to the webview. + * No-op on the web, where the browser's native menu is expected. + */ +function DesktopContextMenuGuard() { + useEffect(() => { + if (!isTauri()) return; + const suppress = (e: MouseEvent) => e.preventDefault(); + document.addEventListener("contextmenu", suppress); + return () => document.removeEventListener("contextmenu", suppress); + }, []); + return null; +} + export function Providers({ children }: { children: ReactNode }) { return ( + {children} diff --git a/src/context/ViewContext.tsx b/src/context/ViewContext.tsx index 0b8ecfde..0108b632 100644 --- a/src/context/ViewContext.tsx +++ b/src/context/ViewContext.tsx @@ -68,6 +68,15 @@ interface ViewContextType { closeDocument: (docId: string) => void; swapPanels: () => void; setIsEndlessScroll: (value: boolean | ((prev: boolean) => boolean)) => void; + /** + * Subscribe to be called synchronously *just before* `isEndlessScroll` + * flips — i.e. while the outgoing layout is still on screen and measurable. + * Editor panels use it to record which block sits at the top of their + * viewport, because the two modes render the same document at very + * different heights and a raw scrollTop would land somewhere else entirely + * (see DocumentEditorPanel's scroll anchoring). Returns an unsubscribe. + */ + onBeforeEndlessScrollChange: (callback: () => void) => () => void; setShowComments: (value: boolean | ((prev: boolean) => boolean)) => void; setLeftSidebarOpen: (value: boolean | ((prev: boolean) => boolean)) => void; setRightSidebarOpen: (value: boolean | ((prev: boolean) => boolean)) => void; @@ -90,7 +99,7 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { // Default to endless (continuous, reflowed) on phones: it renders text at a // readable size with no page rectangles to shift while writing. Desktop // defaults to the paged view. Users can toggle either way (EditorFooter). - const [isEndlessScroll, setIsEndlessScroll] = useState(isPhoneViewport); + const [isEndlessScroll, setIsEndlessScrollState] = useState(isPhoneViewport); const [showComments, setShowComments] = useState(true); const [leftSidebarOpen, setLeftSidebarOpenState] = useState(false); const [rightSidebarOpen, setRightSidebarOpenState] = useState(false); @@ -124,6 +133,35 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { if (next && isPhoneViewport()) setLeftSidebarOpenState(false); }, []); + // Endless-scroll listeners fired before the flip (see onBeforeEndlessScrollChange). + // Same ref-mirroring trick as the sidebars: the setter resolves a functional + // update itself so the listeners can run *outside* the state updater — React + // may call an updater twice (StrictMode / re-render), and these callbacks + // measure the DOM, so they must fire exactly once per real change. + const beforeEndlessChangeRef = useRef(new Set<() => void>()); + const endlessScrollRef = useRef(isEndlessScroll); + useEffect(() => { + endlessScrollRef.current = isEndlessScroll; + }, [isEndlessScroll]); + + const onBeforeEndlessScrollChange = useCallback((callback: () => void) => { + const listeners = beforeEndlessChangeRef.current; + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }, []); + + const setIsEndlessScroll = useCallback((value: boolean | ((prev: boolean) => boolean)) => { + const next = typeof value === "function" ? value(endlessScrollRef.current) : value; + if (next === endlessScrollRef.current) return; + // Still the old layout at this point — the state update below is what + // swaps it — so subscribers can measure where the reader is looking. + for (const listener of beforeEndlessChangeRef.current) listener(); + endlessScrollRef.current = next; + setIsEndlessScrollState(next); + }, []); + const isSplit = secondaryPanel !== null; const focusedPanel = useMemo(() => { @@ -324,11 +362,12 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { closeDocument, swapPanels, setIsEndlessScroll, + onBeforeEndlessScrollChange, setShowComments, setLeftSidebarOpen, setRightSidebarOpen, }), - [primaryPanel, secondaryPanel, primaryDocId, secondaryDocId, splitRatio, isSplit, visiblePanels, mountedPanels, focusedSide, focusedPanel, isEndlessScroll, showComments, leftSidebarOpen, rightSidebarOpen, timelineOpen, chromeHidden, mobileEditMode, setPrimaryPanel, setSecondaryPanel, setFocusedSide, setFocusedPanel, setSidePanel, setSideDocument, splitWithDocument, closeDocument, swapPanels, setIsEndlessScroll, setShowComments, setLeftSidebarOpen, setRightSidebarOpen], + [primaryPanel, secondaryPanel, primaryDocId, secondaryDocId, splitRatio, isSplit, visiblePanels, mountedPanels, focusedSide, focusedPanel, isEndlessScroll, showComments, leftSidebarOpen, rightSidebarOpen, timelineOpen, chromeHidden, mobileEditMode, setPrimaryPanel, setSecondaryPanel, setFocusedSide, setFocusedPanel, setSidePanel, setSideDocument, splitWithDocument, closeDocument, swapPanels, setIsEndlessScroll, onBeforeEndlessScrollChange, setShowComments, setLeftSidebarOpen, setRightSidebarOpen], ); return {children}; diff --git a/src/lib/editor/focus-in-viewport.ts b/src/lib/editor/focus-in-viewport.ts index 045178b9..69793961 100644 --- a/src/lib/editor/focus-in-viewport.ts +++ b/src/lib/editor/focus-in-viewport.ts @@ -13,6 +13,11 @@ import type { Editor } from "@tiptap/react"; * * Must be called synchronously inside the entering-edit tap gesture: iOS only * raises the keyboard when `focus()` runs in the same user-gesture turn. + * + * Callers follow this with {@link centerCaretInView}, which then eases the caret + * to the middle of the area left visible by the keyboard — a short, smooth scroll + * from an on-screen position, not the page-length jump a plain focus() would have + * made from the stale selection. */ export const focusEditorAtCoords = (editor: Editor, left: number, top: number) => { const coords = editor.view.posAtCoords({ left, top }); @@ -55,3 +60,94 @@ export const focusEditorInViewport = (editor: Editor) => { focusEditorAtCoords(editor, left, top); }; + +/** Quiet spell with no visual-viewport change that counts as "the keyboard is up". */ +const KEYBOARD_SETTLE_MS = 150; + +/** How long the scroll-padding below stays applied — past the smooth scroll's end. */ +const SCROLL_PADDING_MS = 1000; + +/** Nearest scrollable ancestor of the caret — the reader's scroll container. */ +const findScrollContainer = (from: HTMLElement): HTMLElement | null => { + for (let node = from.parentElement; node; node = node.parentElement) { + const overflowY = window.getComputedStyle(node).overflowY; + if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight + 1) { + return node; + } + } + return null; +}; + +/** + * Teach the scroller what's covering it, so `block: "center"` centers in the band + * the user can actually see rather than on the whole screen: the fixed navbar + * overlay at the top — exactly the scroller's own top padding, see + * EditorPanel.module.css .container — and the keyboard with its format toolbar at + * the bottom. Without the bottom one the caret lands behind the keyboard. + * + * Cleared again shortly after: the scene navigation and search highlight scroll + * this same container and must not inherit a keyboard-sized offset. + */ +const withVisibleBandPadding = (container: HTMLElement, scroll: () => void) => { + const vv = window.visualViewport; + const toolbar = document.querySelector('[role="toolbar"]'); + const covered = toolbar + ? window.innerHeight - toolbar.getBoundingClientRect().top + : window.innerHeight - (vv ? vv.offsetTop + vv.height : window.innerHeight); + + container.style.scrollPaddingTop = window.getComputedStyle(container).paddingTop; + container.style.scrollPaddingBottom = `${covered}px`; + scroll(); + setTimeout(() => { + container.style.removeProperty("scroll-padding-top"); + container.style.removeProperty("scroll-padding-bottom"); + }, SCROLL_PADDING_MS); +}; + +/** + * Smoothly bring the caret to the vertical center of the visible area after + * entering edit mode on phone, so the user can see at a glance where the focus + * landed. Call it right after the focus, in the same gesture. + * + * It can't scroll straight away: the on-screen keyboard rises over the next few + * hundred ms and halves the visible area as it does, so centering now would aim at + * a band that's about to change — and iOS scrolls the container itself to chase the + * caret meanwhile, which would fight (and cancel) a smooth scroll in flight. So it + * waits for the viewport to hold still, then scrolls once. + * + * A touch cancels it: the user has taken over the scroll and must not be yanked + * back. + */ +export const centerCaretInView = (editor: Editor) => { + if (typeof window === "undefined") return; + + const center = () => { + stop(); + if (editor.isDestroyed) return; + const { node } = editor.view.domAtPos(editor.state.selection.head); + const element = node instanceof HTMLElement ? node : node.parentElement; + if (!element) return; + const scroll = () => element.scrollIntoView({ behavior: "smooth", block: "center" }); + const container = findScrollContainer(element); + if (container) withVisibleBandPadding(container, scroll); + else scroll(); + }; + + // Each viewport change (the keyboard growing frame by frame) pushes the scroll + // back; a quiet spell means it's fully up. With no keyboard at all — a hardware + // one, or the reader already in edit mode — no event fires and the initial timer + // is the one that runs. + let timer = setTimeout(center, KEYBOARD_SETTLE_MS); + const restart = () => { + clearTimeout(timer); + timer = setTimeout(center, KEYBOARD_SETTLE_MS); + }; + const stop = () => { + clearTimeout(timer); + window.visualViewport?.removeEventListener("resize", restart); + window.removeEventListener("touchstart", stop); + }; + + window.visualViewport?.addEventListener("resize", restart); + window.addEventListener("touchstart", stop, { passive: true }); +}; diff --git a/src/lib/editor/use-dictation.ts b/src/lib/editor/use-dictation.ts index 1ab58645..abaf982d 100644 --- a/src/lib/editor/use-dictation.ts +++ b/src/lib/editor/use-dictation.ts @@ -137,7 +137,21 @@ export const useDictation = (editor: Editor | null, locale: string | null | unde const stop = useCallback(() => { wantListeningRef.current = false; - recognitionRef.current?.stop(); + const recognition = recognitionRef.current; + if (!recognition) return; + // Clear state up front: iOS WKWebView often doesn't fire `onend` after a + // user-initiated stop, which would otherwise leave `isListening` stuck on + // (the mic could never be toggled off). Dropping the ref and flag here + // means the UI reflects "stopped" immediately regardless. + recognitionRef.current = null; + setIsListening(false); + try { + // `abort()` ends the session more reliably than `stop()` on iOS, which + // may otherwise keep the recogniser (and the mic) alive. + recognition.abort(); + } catch { + // Already inactive — nothing to tear down. + } }, []); const start = useCallback((langOverride?: string | null) => { diff --git a/src/lib/editor/use-view-mode-scroll-anchor.ts b/src/lib/editor/use-view-mode-scroll-anchor.ts new file mode 100644 index 00000000..dfee3681 --- /dev/null +++ b/src/lib/editor/use-view-mode-scroll-anchor.ts @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import type { Editor } from "@tiptap/react"; + +// useLayoutEffect warns on the server; fall back to useEffect there. The +// correction must run before paint, so it needs the layout variant in the browser. +const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +/** + * Frame budget for re-applying the correction after the switch. The mode change + * doesn't necessarily finish settling in the commit that triggers it — a + * ResizeObserver-driven scale or a widget's deferred rendering can land a frame + * or two later — so a single pass could be measured against a half-settled + * layout. Each pass re-measures from scratch, so they converge rather than + * compound, and the loop stops as soon as the anchor holds still. + */ +const MAX_SETTLE_FRAMES = 5; + +/** Frames the anchor must hold still before the layout counts as settled. */ +const SETTLED_FRAMES = 2; + +/** Below this many px the anchor is where it should be; nothing to correct. */ +const SETTLED_EPSILON = 0.5; + +/** Class of the page-break widgets, which endless-scroll mode hides entirely. */ +const PAGE_BREAK_CLASS = "pagination-page-break"; + +interface ViewModeScrollAnchorOptions { + /** The scroll container whose reading position must be preserved. */ + container: HTMLElement | null; + editor: Editor | null; + /** The view mode. Any change re-anchors; the value itself is opaque. */ + viewMode: unknown; + /** + * Registers a callback fired synchronously *before* `viewMode` changes, while + * the outgoing layout is still on screen. Must return an unsubscribe. + */ + onBeforeChange: (callback: () => void) => () => void; +} + +/** + * Keeps the reader looking at the same place in the document across a view-mode + * switch (endless scroll ⇄ paged). + * + * The two modes render the same document at very different heights: paged keeps + * a page-break widget between every page (freespace + bottom margin + gap + top + * margin — easily an inch or two of spacer each), endless hides them, and on + * phone endless additionally reflows the text to a compact measure. So carrying + * the raw scrollTop across lands somewhere else entirely, and because the + * difference accumulates page by page the drift grows the deeper into the script + * you are — a switch near the end can be pages off. + * + * The fix is to preserve the *content* rather than a pixel offset: just before + * the switch, record which block sits at the top of the viewport and how far into + * it we are; afterwards, scroll by however far the new layout moved that block. + * Everything here runs on a toggle only — never during scrolling or typing. + * + * Both phases are layout-phase, so the correction lands before the browser paints + * and is never seen as a jump. Callers whose own effects change the mode's layout + * (a marker class, a scale variable) must therefore run them in the layout phase + * too, and declare them *before* this hook. + */ +export const useViewModeScrollAnchor = ({ + container, + editor, + viewMode, + onBeforeChange, +}: ViewModeScrollAnchorOptions) => { + const anchorRef = useRef<{ el: HTMLElement; offset: number } | null>(null); + + // Runs synchronously before the mode flips, so the outgoing layout is still + // on screen and measurable. + const capture = useCallback(() => { + anchorRef.current = null; + const dom = editor && !editor.isDestroyed ? (editor.view?.dom as HTMLElement | undefined) : undefined; + if (!container || !dom) return; + + // Only blocks that exist in BOTH modes make usable anchors: the page-break + // widgets are display:none in endless, so one picked here would have no + // position to come back to — and its collapsed rect would also break the + // monotonic ordering the search below relies on. + const blocks = (Array.from(dom.children) as HTMLElement[]).filter( + (child) => !child.classList.contains(PAGE_BREAK_CLASS), + ); + if (blocks.length === 0) return; + + // First block whose bottom edge is still below the top of the visible + // area. Binary search rather than a scan: rects are monotonic in document + // order, and a feature-length script has thousands of blocks. The first + // read flushes layout, so the ~12 that follow are cheap. + const containerTop = container.getBoundingClientRect().top; + let lo = 0; + let hi = blocks.length - 1; + let found = blocks.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (blocks[mid].getBoundingClientRect().bottom > containerTop) { + found = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + // A block split across a page boundary (a dialogue running over with + // MORE/CONT'D) hosts its break widget *inside* itself, so its own height + // changes with the mode and a position partway into it wouldn't map + // across. Step past those to the next unsplit block — the offset below is + // signed and relative to the viewport top, so anchoring to a block under + // the fold restores just as exactly as one at the fold. + while (found < blocks.length - 1 && blocks[found].querySelector(`.${PAGE_BREAK_CLASS}`)) found++; + + const el = blocks[found]; + // Signed: negative when the block starts above the fold (we're partway + // through it), so the same line — not merely the same block — comes back. + anchorRef.current = { el, offset: el.getBoundingClientRect().top - containerTop }; + }, [container, editor]); + + // Subscribe through a ref so a new container/editor identity doesn't churn the + // subscription — the callback is only ever invoked on a toggle anyway. + const captureRef = useRef(capture); + useEffect(() => { + captureRef.current = capture; + }, [capture]); + + useEffect(() => onBeforeChange(() => captureRef.current()), [onBeforeChange]); + + /** + * Put the anchored block back where it was, by scrolling the container by + * however far the new layout moved it. Returns whether it was already in + * place, i.e. there was nothing to correct. + */ + const restore = useCallback((): boolean => { + const anchor = anchorRef.current; + if (!container || !anchor || !anchor.el.isConnected) return true; + + const delta = anchor.el.getBoundingClientRect().top - container.getBoundingClientRect().top - anchor.offset; + if (Math.abs(delta) < SETTLED_EPSILON) return true; + const maxScroll = container.scrollHeight - container.clientHeight; + container.scrollTop = Math.max(0, Math.min(maxScroll, container.scrollTop + delta)); + return false; + }, [container]); + + const prevModeRef = useRef(viewMode); + useIsoLayoutEffect(() => { + // Only react to a real mode change — not to the first mount (nothing was + // captured then, and the document opens at the top anyway), and not to + // this effect re-running because the scroll container remounted. + if (prevModeRef.current === viewMode) return; + prevModeRef.current = viewMode; + if (!anchorRef.current) return; + restore(); + + let frames = 0; + let stillFrames = 0; + let raf = requestAnimationFrame(function settle() { + stillFrames = restore() ? stillFrames + 1 : 0; + // Held still for a couple of frames: the new layout has stopped + // shifting under us, so stop re-measuring rather than burning the + // whole budget on a heavy document. + if (stillFrames >= SETTLED_FRAMES || ++frames >= MAX_SETTLE_FRAMES) return; + raf = requestAnimationFrame(settle); + }); + return () => cancelAnimationFrame(raf); + }, [viewMode, restore]); +}; diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts index 01903520..26372e95 100644 --- a/src/lib/utils/hooks.ts +++ b/src/lib/utils/hooks.ts @@ -105,6 +105,40 @@ const useIsPhone = (): boolean => { }; +// Below this the visualViewport shrink is just browser chrome jitter, not an +// open on-screen keyboard. +const KEYBOARD_THRESHOLD = 120; + +/** + * 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. + */ +const useKeyboardInset = (enabled: boolean): number => { + const [inset, setInset] = useState(0); + + useEffect(() => { + // When disabled the consumer is hidden anyway, so leaving a stale inset is + // harmless — just don't subscribe. + if (!enabled || typeof window === "undefined" || !window.visualViewport) return; + const vv = window.visualViewport; + const update = () => { + // Layout-viewport height minus the visible visual viewport (and any + // offset from a scrolled visual viewport) is what the keyboard hides. + const covered = window.innerHeight - vv.height - vv.offsetTop; + setInset(covered > KEYBOARD_THRESHOLD ? covered : 0); + }; + update(); + vv.addEventListener("resize", update); + vv.addEventListener("scroll", update); + return () => { + vv.removeEventListener("resize", update); + vv.removeEventListener("scroll", update); + }; + }, [enabled]); + + return inset; +}; + const useProjectIdFromUrl = () => { const searchParams = useSearchParams(); return searchParams.get("projectId") ?? ""; @@ -587,6 +621,7 @@ export { usePage, useDesktop, useIsPhone, + useKeyboardInset, useCachedProjects, useCachedProjectInfo, useProjectIdFromUrl,