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
12 changes: 12 additions & 0 deletions components/board/BoardCanvas.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -623,12 +623,24 @@
@media (max-width: 767px) {
.container {
touch-action: none;
/* The canvas owns all touch gestures. Without this, a double-tap or
long-press on the board surface triggers iOS Safari's native text
selection across the whole board (it lacks the per-card
`user-select: none`), and that sticky selection makes every
subsequent interaction laggy until the selection is cleared. Suppress
native selection + the long-press callout on the whole subtree; the
editable text fields below opt back in so typing/caret still works. */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}

.card_description_input,
.card_header_title_input,
.audio_timeline {
touch-action: auto;
-webkit-user-select: text;
user-select: text;
}

.card_resize_handle {
Expand Down
27 changes: 22 additions & 5 deletions components/editor/DocumentEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ const DocumentEditorPanel = ({
const [canScrollThumb, setCanScrollThumb] = useState(false);
const scrollIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDraggingThumb = useRef(false);
// The draggable scroll track element, measured to derive the handle's travel
// range (see thumbTravel) independently of the container's changing height.
const scrollTrackRef = useRef<HTMLDivElement | null>(null);
// Last scrollTop, to derive scroll direction for hiding/showing the mobile
// editor chrome (navbar + sidebar edge handles).
const lastScrollTop = useRef(0);
Expand Down Expand Up @@ -762,6 +765,20 @@ const DocumentEditorPanel = ({
const TRACK_INSET_TOP = 60;
const TRACK_INSET_BOTTOM = 24;

// 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
// constant regardless of the mobile navbar's collapse — driving it off the
// container's clientHeight instead would make the handle jump as the navbar
// hides and the container grows. Falls back to the container-derived estimate
// for the first frame before the track has mounted.
const thumbTravel = useCallback(() => {
const track = scrollTrackRef.current;
const trackHeight = track
? track.clientHeight
: (containerEl?.clientHeight ?? 0) - TRACK_INSET_TOP - TRACK_INSET_BOTTOM;
return Math.max(0, trackHeight - HANDLE_HEIGHT);
}, [containerEl]);

// Recompute the handle's position from the container's scroll metrics: it's a
// fixed-size grab handle whose offset mirrors how far down we're scrolled.
const updateThumb = useCallback(() => {
Expand All @@ -774,9 +791,8 @@ const DocumentEditorPanel = ({
return;
}
setCanScrollThumb(true);
const travel = Math.max(0, clientHeight - TRACK_INSET_TOP - TRACK_INSET_BOTTOM - HANDLE_HEIGHT);
setThumbTop((scrollTop / scrollable) * travel);
}, [containerEl]);
setThumbTop((scrollTop / scrollable) * thumbTravel());
}, [containerEl, thumbTravel]);

// Reveal the thumb and (re)arm the timer that hides it once scrolling has
// been idle for a beat. While dragging, keep it pinned open (no auto-hide).
Expand Down Expand Up @@ -895,7 +911,7 @@ const DocumentEditorPanel = ({
const startY = e.clientY;
const startScrollTop = el.scrollTop;
const scrollable = el.scrollHeight - el.clientHeight;
const maxThumbTravel = el.clientHeight - TRACK_INSET_TOP - TRACK_INSET_BOTTOM - HANDLE_HEIGHT;
const maxThumbTravel = thumbTravel();

const onMove = (ev: PointerEvent) => {
if (maxThumbTravel <= 0) return;
Expand All @@ -914,7 +930,7 @@ const DocumentEditorPanel = ({
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
},
[containerEl, revealScrollThumb],
[containerEl, revealScrollThumb, thumbTravel],
);

// Clean up the idle timer on unmount.
Expand Down Expand Up @@ -1008,6 +1024,7 @@ const DocumentEditorPanel = ({
</div>
{isPhone && canScrollThumb && (
<div
ref={scrollTrackRef}
className={join(
styles.scroll_track,
showScrollThumb ? styles.scroll_track_visible : "",
Expand Down
22 changes: 15 additions & 7 deletions components/editor/EditorPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,21 @@
* faded out at rest so it never disrupts writing. Its offset is set inline by
* DocumentEditorPanel; keep .scroll_handle's height in sync with HANDLE_HEIGHT. */
.scroll_track {
position: absolute;
/* Top inset clears the right sidebar edge toggle (top:8px, up to 44px tall on
* touch) plus a small gap, so the handle starts just below it instead of
* overlapping. Bottom inset keeps it off the very bottom edge of the screen.
* Keep these in sync with TRACK_INSET_TOP / TRACK_INSET_BOTTOM in
* DocumentEditorPanel, which drive the handle's travel range. */
top: 60px;
/* Fixed to the viewport (not absolute inside .editor_panel) so the handle's
* position stays put regardless of the mobile navbar. The navbar collapses its
* height in the flex column as the reader scrolls (see ProjectNavbar), which
* would otherwise move .editor_panel's top edge up and drag an absolutely-
* positioned handle with it — a jarring shift right as scrolling begins. The
* static --navbar-height offset keeps the handle exactly where it sits while
* the navbar is shown, then holds that spot once the navbar is gone (same
* viewport-anchoring trick as .sidebar_backdrop in ProjectWorkspace). */
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). */
top: calc(var(--navbar-height) + 60px);
bottom: 24px;
/* Right offset matches .right_sidebar_toggle (ProjectWorkspace) so the handle
* lines up horizontally with the sidebar edge toggle. */
Expand Down
17 changes: 14 additions & 3 deletions components/editor/MobileFormatToolbar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,19 @@
.element_trigger {
display: flex;
align-items: center;
/* Push the label to the left and pin the chevron to the right edge so the
* fixed width stays stable regardless of label length. */
justify-content: space-between;
gap: 4px;
min-width: 0;
/* Fixed width so the bar's layout never reflows when the selected element
* (and thus its label length, "Scene" vs "Parenthetical") changes. */
width: 128px;
flex-shrink: 0;
height: 40px;
padding: 0 10px;
padding: 0 12px;
border: none;
border-radius: 8px;
/* Fully rounded pill, echoing the parent toolbar's rounded ends. */
border-radius: 20px;
background-color: var(--secondary-hover);
color: var(--primary-text);
font-size: 14px;
Expand All @@ -138,6 +145,10 @@
}

.element_label {
/* Truncate rather than grow — the trigger width is fixed. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

Expand Down
27 changes: 27 additions & 0 deletions components/navbar/ProjectNavbarSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { join } from "@src/lib/utils/misc";
import { useIsPhone } from "@src/lib/utils/hooks";

import navbar from "./ProjectNavbar.module.css";
import navBtn from "@components/utils/NavbarIconButton.module.css";
Expand All @@ -11,14 +12,40 @@ import skeleton from "./ProjectNavbarSkeleton.module.css";
* islands) while a project is resolving. Rendering it in the loading state keeps
* the navbar bar reserved, so the real navbar fills in place without the layout
* jumping down once loading finishes.
*
* Mirrors the same phone/desktop branch as [ProjectNavbar] so the skeleton
* matches whichever navbar mounts — on phone that's the two floating pill
* clusters of [ProjectNavbarMobile], not the desktop islands (which would
* overflow and stack on a narrow screen).
*/
const ProjectNavbarSkeleton = () => {
const isPhone = useIsPhone();

const iconBtn = (key: string) => (
<div key={key} className={navBtn.button}>
<div className={join(skeleton.icon, skeleton.pulse)} />
</div>
);

if (isPhone) {
// Flush icons on the pill, matching ProjectNavbarMobile's .mobile_icon.
const mobileIcon = (key: string) => (
<div key={key} className={`${navBtn.button} ${navbar.mobile_icon}`}>
<div className={join(skeleton.icon, skeleton.pulse)} />
</div>
);

return (
<nav className={navbar.container}>
<nav className={navbar.mobile_bar}>
{/* Left pill: back arrow. Right pill: search + burger. */}
<div className={navbar.mobile_left}>{mobileIcon("back")}</div>
<div className={navbar.mobile_right}>{["search", "menu"].map(mobileIcon)}</div>
</nav>
</nav>
);
}

return (
<nav className={navbar.container}>
{/* Left - back button, status/title island, panel buttons */}
Expand Down
14 changes: 10 additions & 4 deletions components/project/EditorFooter.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@
color 0.15s ease;
}

.action:hover {
opacity: 1;
background-color: var(--secondary-hover);
color: var(--primary-text);
/* Guard hover behind a hover-capable pointer. On touch, iOS keeps :hover applied
after a tap until the user touches elsewhere — and since the hover style is
identical to `.action_active`, a just-toggled-off button would keep looking
active. Restricting hover to real pointers avoids that misleading state. */
@media (hover: hover) {
.action:hover {
opacity: 1;
background-color: var(--secondary-hover);
color: var(--primary-text);
}
}

.action_active {
Expand Down
10 changes: 10 additions & 0 deletions components/project/EditorFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { useViewContext } from "@src/context/ViewContext";
import { useSpellcheck } from "@src/context/SpellcheckContext";
import { paginationKey } from "@src/lib/screenplay/extensions/pagination-extension";
import { join } from "@src/lib/utils/misc";
import { useIsPhone } from "@src/lib/utils/hooks";
import { useActiveEditor } from "@src/lib/editor/use-active-editor";
import WritingTimer from "./WritingTimer";

import styles from "./EditorFooter.module.css";
Expand Down Expand Up @@ -53,6 +55,10 @@ const EditorFooter = () => {
const { isZenMode, updateIsZenMode } = useContext(UserContext);
const { isEndlessScroll, setIsEndlessScroll, setLeftSidebarOpen, setRightSidebarOpen } = useViewContext();
const { spellcheckLang, setSpellcheckLang } = useSpellcheck();
const isPhone = useIsPhone();
// The footer's toggles all act on the active text editor. Board/statistics
// panels have none, so on phone (single-panel) the footer is hidden there.
const activeEditor = useActiveEditor();

const pageCount = useScreenplayPageCount(editor);

Expand Down Expand Up @@ -105,6 +111,10 @@ const EditorFooter = () => {
return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
}, [isZenMode, exitFocusMode]);

// On phone the workspace is single-panel: hide the footer when the shown
// panel has no text editor (board, statistics) — its controls don't apply.
if (isPhone && !activeEditor) return null;

return (
<div className={styles.bubble_right}>
{pageCount !== null && (
Expand Down
Loading