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
15 changes: 7 additions & 8 deletions components/board/BoardCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
setOffset({ x: newOffsetX, y: newOffsetY });
}, []);

// Focus a specific card when navigated to from the Outline. Waits until the
// Focus a specific card when navigated to from the Timeline. Waits until the
// board's cards have loaded and the target exists on this board, then centers
// on it and clears the request so it fires once.
useEffect(() => {
Expand Down Expand Up @@ -923,17 +923,16 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
[cards, saveCards],
);

// Send card to the Outline view
const handleSendToOutline = useCallback(
// Send card to the Timeline
const handleSendToTimeline = useCallback(
(card: BoardCardData) => {
repository?.addOutlineItem({
repository?.appendTimelineClip({
source: "card",
refDocId: docId,
refId: card.id,
title: card.title,
preview: card.description,
color: card.color,
parentId: null,
});
},
[repository, docId],
Expand Down Expand Up @@ -975,8 +974,8 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
{(card.type ?? "text") === "text" && (
<ContextMenuItem
icon={ListTree}
text={t("sendToOutline")}
action={() => handleSendToOutline(card)}
text={t("sendToTimeline")}
action={() => handleSendToTimeline(card)}
/>
)}
<ContextMenuItem
Expand All @@ -988,7 +987,7 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
),
});
},
[updateContextMenu, t, handleChangeCardColor, handleDuplicateCard, handleSendToOutline, handleDeleteCard],
[updateContextMenu, t, handleChangeCardColor, handleDuplicateCard, handleSendToTimeline, handleDeleteCard],
);

// Open the shared context-menu host for an arrow.
Expand Down
105 changes: 81 additions & 24 deletions components/editor/DocumentEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,10 @@ const DocumentEditorPanel = ({
repository,
} = projectCtx;
const { settings } = useSettings();
const { isEndlessScroll } = useViewContext();
const { isEndlessScroll, setChromeHidden } = useViewContext();
const { user } = useUser();
const isPhone = useIsPhone();

// Phones always render continuous (no discrete page rectangles): the compact
// "reading margins" applied on phone (--display-margin-scale in the CSS) reflow
// text to a different width than the canonical page, so the fixed-height page
// spacers would misalign. Page COUNT and numbering stay canonical — they are
// measured off-screen at full page width — so this is purely a visual mode.
const effectiveEndless = isEndlessScroll || isPhone;

const [isEditorReady, setIsEditorReady] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
// Phone-only draggable scroll handle: a fixed-size grab handle (styled like
Expand All @@ -113,6 +106,9 @@ const DocumentEditorPanel = ({
const [canScrollThumb, setCanScrollThumb] = useState(false);
const scrollIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDraggingThumb = useRef(false);
// Last scrollTop, to derive scroll direction for hiding/showing the mobile
// editor chrome (navbar + sidebar edge handles).
const lastScrollTop = useRef(0);
// 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<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -176,8 +172,8 @@ const DocumentEditorPanel = ({
useEffect(() => {
const el = editor?.view?.dom;
if (!el) return;
el.classList.toggle("endless-scroll", effectiveEndless);
}, [editor, effectiveEndless]);
el.classList.toggle("endless-scroll", isEndlessScroll);
}, [editor, isEndlessScroll]);

// Ready state
useEffect(() => {
Expand All @@ -187,14 +183,41 @@ const DocumentEditorPanel = ({
}
}, [editor, isYjsReady]);

// ---- Phone reading layout ----
// Phones no longer scale the whole page down to fit (which shrank the text).
// Instead the CSS applies a compact --display-margin-scale to reflow text at
// full size into the viewport width (see the max-width:767px block in
// EditorPanel.module.css). Because that reflow uses a narrower measure than
// the canonical page, the discrete page rectangles can't line up, so phones
// render continuous (effectiveEndless above). Pagination itself is measured
// off-screen at full page width, so page count / numbering are unchanged.
// ---- Phone view modes ----
// Endless (default on phone): the CSS reflows text into the viewport at full
// 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.
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
// shows the page at 1:1.
if (!isPhone || isEndlessScroll || !pageSize) {
container.style.removeProperty("--editor-zoom");
return;
}

const apply = () => {
const avail = container.clientWidth;
if (!avail) return;
// Fit the full canonical page width into the viewport; never upscale.
// Leaves a small gutter so the page edges aren't flush with the screen.
const ratio = Math.min(1, (avail - 8) / pageSize.pageWidth);
container.style.setProperty("--editor-zoom", `${ratio}`);
};

apply();
const ro = new ResizeObserver(apply);
ro.observe(container);
return () => ro.disconnect();
}, [containerEl, isPhone, isEndlessScroll, pageFormat]);

// ---- Orphaned comment cleanup ----
// Comments anchor to a node's data-id. When that node is deleted the comment
Expand Down Expand Up @@ -643,7 +666,7 @@ const DocumentEditorPanel = ({
}
}

// Detect a scene heading at the caret to offer "Send to outline".
// Detect a scene heading at the caret to offer "Send to timeline".
// Independent of `shelving` so it works in editor documents too.
let outlineScene: { refDocId: string; refId: string; title: string } | undefined;
if (config.documentId) {
Expand Down Expand Up @@ -707,9 +730,13 @@ const DocumentEditorPanel = ({
}, [commentOps]);

// Fixed handle height and inset of the track from the panel's top/bottom.
// (Keep HANDLE_HEIGHT in sync with .scroll_handle's height in the CSS.)
// (Keep HANDLE_HEIGHT in sync with .scroll_handle's height, and the insets
// in sync with .scroll_track's top/bottom, in the CSS.) The top inset clears
// the right sidebar edge toggle so the handle can't overlap it, and the
// bottom inset keeps it off the very bottom of the screen.
const HANDLE_HEIGHT = 44;
const TRACK_PAD = 6;
const TRACK_INSET_TOP = 60;
const TRACK_INSET_BOTTOM = 24;

// 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.
Expand All @@ -723,7 +750,7 @@ const DocumentEditorPanel = ({
return;
}
setCanScrollThumb(true);
const travel = Math.max(0, clientHeight - 2 * TRACK_PAD - HANDLE_HEIGHT);
const travel = Math.max(0, clientHeight - TRACK_INSET_TOP - TRACK_INSET_BOTTOM - HANDLE_HEIGHT);
setThumbTop((scrollTop / scrollable) * travel);
}, [containerEl]);

Expand All @@ -744,6 +771,20 @@ const DocumentEditorPanel = ({
if (isPhone) {
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.
const delta = scrollTop - lastScrollTop.current;
if (scrollTop <= 4) {
setChromeHidden(false);
} else if (delta > 6) {
setChromeHidden(true);
} else if (delta < -6) {
setChromeHidden(false);
}
lastScrollTop.current = scrollTop;
}
};

Expand All @@ -762,7 +803,7 @@ const DocumentEditorPanel = ({
const startY = e.clientY;
const startScrollTop = el.scrollTop;
const scrollable = el.scrollHeight - el.clientHeight;
const maxThumbTravel = el.clientHeight - 2 * TRACK_PAD - HANDLE_HEIGHT;
const maxThumbTravel = el.clientHeight - TRACK_INSET_TOP - TRACK_INSET_BOTTOM - HANDLE_HEIGHT;

const onMove = (ev: PointerEvent) => {
if (maxThumbTravel <= 0) return;
Expand Down Expand Up @@ -791,6 +832,22 @@ const DocumentEditorPanel = ({
};
}, []);

// Reveal the chrome again the moment the user starts writing. The native
// `input` event on the contenteditable fires only for real user edits — not
// 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;
}
const dom = editor?.view?.dom;
if (!dom) return;
const onInput = () => setChromeHidden(false);
dom.addEventListener("input", onInput);
return () => dom.removeEventListener("input", onInput);
}, [editor, isPhone, setChromeHidden]);

const focusType =
focusedTypeOverride ?? (config.type === "screenplay" ? "screenplay" : "title");

Expand Down Expand Up @@ -824,7 +881,7 @@ const DocumentEditorPanel = ({
}
>
<div
className={`${styles.editor_wrapper} ${effectiveEndless ? styles.endless_scroll : ""}`}
className={`${styles.editor_wrapper} ${isEndlessScroll ? styles.endless_scroll : ""}`}
style={wrapperStyle}
>
<div
Expand Down
119 changes: 57 additions & 62 deletions components/editor/EditorPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,15 @@
* DocumentEditorPanel; keep .scroll_handle's height in sync with HANDLE_HEIGHT. */
.scroll_track {
position: absolute;
top: 6px;
bottom: 6px;
/* 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;
bottom: 24px;
/* Right offset matches .right_sidebar_toggle (ProjectWorkspace) so the handle
* lines up vertically with the sidebar edge toggle. */
* lines up horizontally with the sidebar edge toggle. */
right: 8px;
width: 28px;
z-index: 52;
Expand Down Expand Up @@ -180,68 +185,58 @@
display: none !important;
}

/* Phone: reflow the screenplay into the viewport at full size instead of
* shrinking the whole page. The editor fills the screen width and the wide
* screenplay margins are compressed via --display-margin-scale (consumed by
* every element padding in scriptio.css), so text wraps to a narrow measure at
* real font size — much more readable than the old fit-to-page zoom.
*
* This is purely visual: the offscreen pagination measurement div
* (#pagination-test-div, appended to <body>) is not matched by the
* `.editor_wrapper` selector below, so it keeps --display-margin-scale: 1 and
* measures at the canonical page width. Page count, page numbers, scene
* numbering and PDF export are therefore unchanged from desktop. Phones also
* render continuous (endless-scroll) — see effectiveEndless in
* DocumentEditorPanel — because the reflowed text can't align to the fixed
* page rectangles. */
/* Phone has two view modes, tied to the endless-scroll toggle (default endless
* on phone — see ViewContext):
* - Endless: reflow text into the viewport at full size (compact margins), no
* page rectangles, so nothing shifts while writing.
* - Paged: the real desktop page (breaks/headers/footers) scaled to fit with
* `zoom` — a fixed rectangle, so its boundaries never move while writing.
* Both are purely visual; pagination is measured off-screen at the canonical page
* width, so page count / numbering / PDF export match desktop in either mode. */
@media (max-width: 767px) {
.container {
-webkit-overflow-scrolling: touch;
/* Hide the native scrollbar on phone: the draggable scroll handle
* (.scroll_track) is the touch scroll affordance here, and the two
* rendered together look redundant and can visually collide at the right
* edge. Drop the reserved gutter too so the page uses the full width. */
scrollbar-gutter: auto;
scrollbar-width: none;
}

.container::-webkit-scrollbar {
display: none;
}

.editor_wrapper {
max-width: none;
padding-bottom: 40%;
}

/* width:100% !important overrides the pagination extension's injected
* `.pagination { width: var(--page-width) !important }` (this two-class
* selector has higher specificity, so !important vs !important resolves in
* our favour). --display-margin-scale drives the compact reading margins. */
.editor_wrapper :global(.ProseMirror) {
/* ENDLESS mode (default on phone): reflow the screenplay into the viewport at
* full size instead of shrinking the page. The editor fills the width and the
* wide screenplay margins compress via --display-margin-scale (consumed by
* every element padding in scriptio.css), so text wraps to a narrow measure at
* real font size. Continuous — inter-page breaks are hidden by the
* `.endless_scroll .pagination-page-break { display:none }` rule above — so
* there are no page rectangles to shift while writing.
*
* Purely visual: the offscreen pagination measurement div
* (#pagination-test-div, appended to <body>) isn't matched by this selector,
* so it keeps --display-margin-scale: 1 and measures at the canonical page
* width. Page count, numbering and PDF export are unchanged from desktop.
*
* width:100% !important overrides the pagination extension's injected
* `.pagination { width: var(--page-width) !important }` (this three-class
* selector wins on specificity). */
.editor_wrapper.endless_scroll :global(.ProseMirror) {
width: 100% !important;
--display-margin-scale: 0.3;
}

/* Render each page boundary as a footer + gap + header band that carries the
* configured headers/footers (page numbers, title, etc.) at full height, just
* like desktop but without the page-fill whitespace. Un-hide the widget (the
* base `.endless_scroll .pagination-page-break { display:none }` rule collapses
* it in endless mode) and reset content-visibility so an off-screen band
* doesn't reserve a full canonical page height. */
.editor_wrapper.endless_scroll :global(.pagination-page-break) {
display: block !important;
height: auto !important;
content-visibility: visible !important;
contain-intrinsic-size: auto !important;
}

/* Drop only the freespace: the spacer/overlay are `freespace + marginBottom +
* gap + marginTop`. Collapsing them to `marginBottom + gap + marginTop` keeps
* the footer of the ending page, the inter-page gap, and the header of the
* next page at full height — so headers/footers show — while removing the
* blank page fill. */
.editor_wrapper.endless_scroll :global(.pagination-page-break > .pagination-spacer),
.editor_wrapper.endless_scroll :global(.pagination-page-break > .pagination-overlay) {
height: calc(var(--page-margin-bottom) + var(--page-gap) + var(--page-margin-top)) !important;
/* Clip anything taller than the collapsed band — e.g. the orphan-locked
* "empty page" area, whose full page-height block would otherwise spill. */
overflow: hidden !important;
}

/* The last-page widget's spacer is `freespace + marginBottom`; collapse the
* freespace so the script ends with just its bottom margin (symmetric with the
* first page's top margin, no text stuck to the page's bottom edge). */
/* Endless shows only the first page's top margin and the last page's bottom
* margin (all inter-page breaks are hidden). Collapse the last page's
* freespace so the script ends on its margin, not a blank page tail. */
.editor_wrapper.endless_scroll :global(.pagination-last-page) {
content-visibility: visible !important;
contain-intrinsic-size: auto !important;
Expand All @@ -252,23 +247,23 @@
height: var(--page-margin-bottom) !important;
}

/* Header/footer text is inset by the page margins, which are set in the
* injected pagination stylesheet at their canonical (full-page) size. Scale
* that inset by --display-margin-scale so the header/footer fits the viewport
* exactly the way the screenplay content margins do. Applies to first-page,
* last-page and inter-page widgets alike. */
/* Scale the first-page header / last-page footer inset by
* --display-margin-scale so those fit the viewport like the content does. */
.editor_wrapper.endless_scroll :global(.pagination-header-area),
.editor_wrapper.endless_scroll :global(.pagination-footer-area) {
padding-left: calc(var(--page-margin-left) * var(--display-margin-scale)) !important;
padding-right: calc(var(--page-margin-right) * var(--display-margin-scale)) !important;
}

/* (MORE) / (CONT'D) overlays are positioned against the canonical text column,
* so they'd sit at the wrong offset once the text reflows — hide them (the
* straddling dialogue reads continuously across the band instead). */
.editor_wrapper.endless_scroll :global(.page-more-overlay),
.editor_wrapper.endless_scroll :global(.page-contd-overlay) {
display: none !important;
/* 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. */
.editor_wrapper:not(.endless_scroll) :global(.ProseMirror) {
zoom: var(--editor-zoom, 1);
}

.editor_shadow {
Expand Down
Loading
Loading