Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions components/board/BoardCanvas.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,15 @@
so typing/caret placement still works. The drag/resize/connection handles have
no hover on touch, so surface them and enlarge them into comfortable targets. */
@media (max-width: 767px) {
/* The navbar is a fixed overlay pinned to the top (see ProjectNavbar). The
* board doesn't drive the scroll-hide, so the bar stays shown over it — push
* the whole canvas down by one bar height so its top strip isn't trapped
* behind the bar. The canvas measures its own rect for panning, so shifting
* the wrapper leaves the pan/zoom math correct. */
.board_wrapper {
padding-top: var(--navbar-height);
}

.container {
touch-action: none;
/* The canvas owns all touch gestures. Without this, a double-tap or
Expand Down
6 changes: 6 additions & 0 deletions components/dashboard/DashboardModal.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@
padding: 30px;
background: var(--main-bg);
min-height: 0;
/* Without this, a panel containing a child with a fixed minimum width wider
* than the narrow phone drawer (e.g. Keybinds' .keyArea, Layout's margin-input
* pills) forces .content past the modal's `overflow: hidden` edge, clipping the
* right gutter and the close button. min-width:0 pins .content to the drawer
* width so any overflow scrolls inside .scrollArea and the header stays aligned. */
min-width: 0;
}

.contentHeader {
Expand Down
110 changes: 83 additions & 27 deletions components/editor/DocumentEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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 { focusEditorInViewport } from "@src/lib/editor/focus-in-viewport";
import { getSpellErrorAt } from "@src/lib/spellcheck/spellcheck-extension";
import type { SuggestionData } from "@components/editor/SuggestionMenu";

Expand Down Expand Up @@ -107,8 +108,14 @@ const DocumentEditorPanel = ({
// actively scrolling (or being dragged) and faded out shortly after, so it
// never sits on top of the writing while at rest.
const [showScrollThumb, setShowScrollThumb] = useState(false);
const [thumbTop, setThumbTop] = useState(0);
const [canScrollThumb, setCanScrollThumb] = useState(false);
// The handle's position is written straight to the DOM (not React state) so
// tracking the scroll gesture never re-renders this (heavy) panel — that
// per-event re-render is what made the scroll-linked chrome-hide stutter
// against the finger. thumbTopRef seeds the transform when the handle
// (re)mounts; updateThumb writes it imperatively thereafter.
const scrollHandleRef = useRef<HTMLDivElement | null>(null);
const thumbTopRef = useRef(0);
const scrollIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDraggingThumb = useRef(false);
// The draggable scroll track element, measured to derive the handle's travel
Expand All @@ -117,6 +124,10 @@ const DocumentEditorPanel = ({
// Last scrollTop, to derive scroll direction for hiding/showing the mobile
// editor chrome (navbar + sidebar edge handles).
const lastScrollTop = useRef(0);
// Coalesces all scroll-driven work into a single update per animation frame
// (see onScroll). A burst of scroll events then costs one layout, aligned to
// the paint cycle, so the chrome stays glued to the scroll instead of lagging.
const scrollRafRef = useRef<number | null>(null);
// 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.
Expand Down Expand Up @@ -211,19 +222,22 @@ const DocumentEditorPanel = ({
// size via a compact --display-margin-scale — no page rectangles, so nothing
// shifts while writing. Paged (endless off): render the real fixed-size page —
// page breaks, headers, footers, exactly like desktop — and scale the whole
// page down with `zoom` so it fits the viewport width. A fixed page rectangle
// means its boundaries never move as you type, so there are no layout shifts.
// `zoom` is a uniform visual scale and pagination is measured off-screen, so
// page count / numbering are unaffected either way.
// page down with transform: scale() so it fits the viewport width. A fixed
// page rectangle means its boundaries never move as you type, so there are no
// layout shifts. The scale is purely visual (NOT `zoom`: WebKit clamps
// 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(() => {
const container = containerEl;
if (!container) return;

const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS];
// Only the phone paged view is zoomed. Endless reflows (no zoom); desktop
// Only the phone paged view is scaled. Endless reflows (no scale); desktop
// shows the page at 1:1.
if (!isPhone || isEndlessScroll || !pageSize) {
container.style.removeProperty("--editor-zoom");
container.style.removeProperty("--editor-layout-height");
return;
}

Expand All @@ -239,8 +253,28 @@ const DocumentEditorPanel = ({
apply();
const ro = new ResizeObserver(apply);
ro.observe(container);
return () => ro.disconnect();
}, [containerEl, isPhone, isEndlessScroll, pageFormat]);

// transform: scale() doesn't shrink the layout box the way `zoom` did, so
// the CSS collapses the leftover (1 − scale) tail of the editor's layout
// height with a negative margin. Track the untransformed height here
// (offsetHeight ignores transforms) and expose it as a CSS var.
const editorDOM = editor?.view?.dom;
let heightObserver: ResizeObserver | undefined;
if (editorDOM) {
const applyHeight = () => {
container.style.setProperty("--editor-layout-height", `${editorDOM.offsetHeight}px`);
};
applyHeight();
heightObserver = new ResizeObserver(applyHeight);
heightObserver.observe(editorDOM);
}

return () => {
ro.disconnect();
heightObserver?.disconnect();
container.style.removeProperty("--editor-layout-height");
};
}, [containerEl, isPhone, isEndlessScroll, pageFormat, editor]);

// ---- Orphaned comment cleanup ----
// Comments anchor to a node's data-id. When that node is deleted the comment
Expand Down Expand Up @@ -763,7 +797,10 @@ const DocumentEditorPanel = ({
// bottom inset keeps it off the very bottom of the screen.
const HANDLE_HEIGHT = 44;
const TRACK_INSET_TOP = 60;
const TRACK_INSET_BOTTOM = 24;
// Base bottom gap only; the CSS adds --safe-bottom on top, which can't be read
// here. This is just the first-frame travel-range fallback before the track
// element mounts and is measured directly, so the missing inset is harmless.
const TRACK_INSET_BOTTOM = 8;

// How far the handle can travel down its track. Measured off the track element
// itself, which is fixed to the viewport (see .scroll_track) so the range stays
Expand Down Expand Up @@ -791,7 +828,10 @@ const DocumentEditorPanel = ({
return;
}
setCanScrollThumb(true);
setThumbTop((scrollTop / scrollable) * thumbTravel());
const top = (scrollTop / scrollable) * thumbTravel();
thumbTopRef.current = top;
const handle = scrollHandleRef.current;
if (handle) handle.style.transform = `translateY(${top}px)`;
}, [containerEl, thumbTravel]);

// Reveal the thumb and (re)arm the timer that hides it once scrolling has
Expand Down Expand Up @@ -854,7 +894,10 @@ const DocumentEditorPanel = ({
const ed = editor;
if (ed) {
ed.setEditable(true);
ed.commands.focus();
// Drop the caret on a character that's currently on screen rather
// than at the old selection, so entering edit mode doesn't scroll
// the view away to wherever the caret last was.
focusEditorInViewport(ed);
}
return;
}
Expand All @@ -867,33 +910,44 @@ const DocumentEditorPanel = ({
}, 280);
}, [isPhone, mobileEditMode, isReadOnly, editor, setMobileEditMode, applyChromeHide]);

const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
const onScroll = () => {
if (suggestions.length > 0) updateSuggestions?.([]);
const el = e.currentTarget;
// Clamp to the real scroll range. iOS rubber-band overscroll reports a
// scrollTop below 0 (top) or beyond the maximum (bottom) and then springs
// back, which would otherwise feed spurious up/down deltas into the chrome
// hide and make the navbar flicker as the bounce settles. Clamping pins
// the delta to 0 while overscrolling, so the bounce leaves the bar alone.
const maxScroll = el.scrollHeight - el.clientHeight;
const scrollTop = Math.max(0, Math.min(el.scrollTop, maxScroll));
setIsScrolled(scrollTop > 0);
if (isPhone) {
// Coalesce into one update per frame. iOS delivers scroll events on the
// main thread at an irregular cadence while the page scrolls on the
// compositor; doing the work per event (each a layout, plus a re-render)
// let the chrome drift behind the finger and stutter. Draining the latest
// scrollTop once per rAF keeps a single, paint-aligned update.
if (scrollRafRef.current != null) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null;
const el = containerEl;
if (!el) return;
// Clamp to the real scroll range. iOS rubber-band overscroll reports a
// scrollTop below 0 (top) or beyond the maximum (bottom) and then
// springs back, which would otherwise feed spurious up/down deltas
// into the chrome hide and make the navbar flicker as the bounce
// settles. Clamping pins the delta to 0 while overscrolling, so the
// bounce leaves the bar alone.
const maxScroll = el.scrollHeight - el.clientHeight;
const scrollTop = Math.max(0, Math.min(el.scrollTop, maxScroll));
setIsScrolled(scrollTop > 0);
if (!isPhone) return;
updateThumb();
revealScrollThumb();

// Accumulate scroll movement into the hide progress over
// CHROME_HIDE_RANGE px: scrolling down slides the chrome away, scrolling
// up brings it back. Snap fully open at the very top. In edit mode the
// navbar carries the exit/undo/redo controls, so keep it pinned open.
// CHROME_HIDE_RANGE px: scrolling down slides the chrome away,
// scrolling up brings it back (from anywhere in the doc). Snap fully
// open at the very top. In edit mode the navbar carries the
// exit/undo/redo controls, so keep it pinned open.
const delta = scrollTop - lastScrollTop.current;
if (mobileEditMode || scrollTop <= 4) {
applyChromeHide(0);
} else {
applyChromeHide(chromeHideRef.current + delta / CHROME_HIDE_RANGE);
}
lastScrollTop.current = scrollTop;
}
});
};

// Drag the thumb to scroll: map vertical pointer movement onto scrollTop via
Expand Down Expand Up @@ -938,6 +992,7 @@ const DocumentEditorPanel = ({
return () => {
if (scrollIdleTimer.current) clearTimeout(scrollIdleTimer.current);
if (tapTimer.current) clearTimeout(tapTimer.current);
if (scrollRafRef.current != null) cancelAnimationFrame(scrollRafRef.current);
};
}, []);

Expand Down Expand Up @@ -1031,8 +1086,9 @@ const DocumentEditorPanel = ({
)}
>
<div
ref={scrollHandleRef}
className={styles.scroll_handle}
style={{ transform: `translateY(${thumbTop}px)` }}
style={{ transform: `translateY(${thumbTopRef.current}px)` }}
onPointerDown={onThumbPointerDown}
>
<GripVertical size={16} />
Expand Down
65 changes: 55 additions & 10 deletions components/editor/EditorPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,14 @@
position: fixed;
/* --navbar-height positions the handle just below the (shown) navbar; the +60
* then clears the right sidebar edge toggle (top:8px, up to 44px tall on touch)
* plus a small gap. Bottom inset keeps it off the very bottom edge of the
* screen. Keep the +60 / bottom in sync with TRACK_INSET_TOP /
* TRACK_INSET_BOTTOM in DocumentEditorPanel (the travel-range fallback). */
* plus a small gap. The bottom sits 8px above the safe-area inset, so the
* handle's lower end lines up with the MobileFormatToolbar (which floats 8px
* above the keyboard) and clears the home indicator instead of dipping into it.
* Keep the +60 / bottom base in sync with TRACK_INSET_TOP / TRACK_INSET_BOTTOM
* in DocumentEditorPanel (the travel-range fallback; the safe-area part is
* measured, not part of the constant). */
top: calc(var(--navbar-height) + 60px);
bottom: 24px;
bottom: calc(var(--safe-bottom) + 8px);
/* Right offset matches .right_sidebar_toggle (ProjectWorkspace) so the handle
* lines up horizontally with the sidebar edge toggle. */
right: 8px;
Expand Down Expand Up @@ -210,6 +213,13 @@
* edge. Drop the reserved gutter too so the page uses the full width. */
scrollbar-gutter: auto;
scrollbar-width: none;
/* The mobile navbar is a fixed overlay pinned to the top (see ProjectNavbar
* .container:not(.home_container)), so this scroller fills the full height
* *behind* it. Pad the top by one navbar height so the first line clears the
* bar at rest, while the content still scrolls up UNDER the bar and is
* revealed as it slides away — the Google-Docs reclaim. The pad is constant,
* so the scroll viewport never resizes and content tracks the finger 1:1. */
padding-top: var(--navbar-height);
}

.container::-webkit-scrollbar {
Expand Down Expand Up @@ -265,16 +275,51 @@

/* PAGED mode (endless off): render exactly like desktop — real fixed-size
* pages, page breaks, headers, footers — and scale the whole page down to fit
* the viewport with `zoom` (--editor-zoom is computed in DocumentEditorPanel).
* Because the page is a fixed rectangle, its boundaries never move while
* writing, so there are no layout shifts — the tradeoff being smaller text
* than endless mode's reflow. The page keeps its canonical width (from the
* injected `.pagination` rule) and stays centred as it scales. */
* the viewport with transform: scale() (--editor-zoom is computed in
* DocumentEditorPanel). Because the page is a fixed rectangle, its boundaries
* never move while writing, so there are no layout shifts — the tradeoff
* being smaller text than endless mode's reflow.
*
* transform, NOT `zoom`: WebKit multiplies computed font sizes by `zoom`
* (usedZoom) and then applies its "smart minimum font size"
* (minimumLogicalFontSize = 9, StyleFontSizeFunctions.cpp): any absolute
* font-size ≥ 9px whose zoomed result lands below 9px is clamped back up to
* a rendered 9px. On a phone (zoom ≈ 0.48) the 16px screenplay font came out
* as 9px rendered — reported as 9 / zoom ≈ 18.7px computed — cramming
* oversized glyphs into the fixed 16px line boxes and wrapping text
* differently than the offscreen pagination measurement. There is NO CSS
* opt-out (text-size-adjust and text autosizing are different code paths and
* do not participate). transform leaves computed font sizes at their
* canonical values — layout is pixel-identical to the measurement pass — and
* only scales at paint time, so the clamp never triggers.
*
* Unlike zoom, transform doesn't shrink the layout box, so two compensations
* are needed (both !important to beat the injected
* `.pagination { margin: 0 auto !important }`; this three-class selector
* then wins on specificity — same trick as the endless width override):
* - centring: equal negative side margins centre the canonical-width box in
* the wrapper (margin auto would pin an overflowing box to the left);
* transform-origin top center then scales it in place.
* - height: negative margin-bottom collapses the (1 − zoom) tail of the
* unscaled layout height so the scroll extent matches what is visible.
* --editor-layout-height (the editor's untransformed offsetHeight) is fed
* by a ResizeObserver in DocumentEditorPanel. */
.editor_wrapper:not(.endless_scroll) :global(.ProseMirror) {
zoom: var(--editor-zoom, 1);
transform: scale(var(--editor-zoom, 1));
transform-origin: top center;
margin-left: calc((100% - var(--page-width)) / 2) !important;
margin-right: calc((100% - var(--page-width)) / 2) !important;
margin-bottom: calc((var(--editor-zoom, 1) - 1) * var(--editor-layout-height, 0px)) !important;
}

.editor_shadow {
max-width: none;
/* Stick the top shadow to the navbar's current bottom edge. The scroller is
* padded by a full navbar height (see .container above), which is also the
* sticky baseline — so `top: 0` already parks the shadow right below the
* shown bar. As the fixed bar slides up (--chrome-hide 0 → 1), offset the
* sticky point up by that much so the shadow rides the bar to the very top
* instead of hanging a navbar height too low, below the edge toggles. */
top: calc(var(--navbar-height) * var(--chrome-hide, 0) * -1);
}
}
9 changes: 9 additions & 0 deletions components/editor/timeline/TimelinePanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
-webkit-user-select: text;
}

/* Phone: the navbar is a fixed overlay pinned to the top (see ProjectNavbar), so
* the timeline strip — first in the column — would otherwise sit behind it. Push
* it below the bar. */
@media (max-width: 767px) {
.timeline {
margin-top: var(--navbar-height);
}
}

/* ---- Toolbar ---- */

.toolbar {
Expand Down
Loading
Loading