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
23 changes: 20 additions & 3 deletions components/board/BoardCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
useContext(ProjectContext);
const { updateContextMenu } = useContext(UserContext);
const t = useTranslations("board");
// Used only to name the default lanes when the board seeds them (below).
const tTimeline = useTranslations("timeline");
const projectState = repository?.getState();
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -962,6 +964,20 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
return out;
}, [timelineLayers]);

/**
* Lanes to offer in the "Send to timeline" submenu. The default lanes are
* otherwise only seeded when the Timeline panel is first opened, so a
* board-first user got an empty list (and a flat menu item) until they'd
* either opened the timeline or sent a card once — `appendTimelineClip`
* seeds too. Seeding here as well keeps the lanes listed on the very first
* right-click. Idempotent, and a no-op on a read-only project.
*/
const resolveTimelineLayers = useCallback(() => {
if (orderedLayers.length > 0) return orderedLayers;
const seeded = repository?.ensureTimelineLayers(2, (i) => `${tTimeline("layer")} ${i + 1}`) ?? [];
return seeded.map((layer) => ({ layer, depth: 0 }));
}, [orderedLayers, repository, tTimeline]);

// Delete arrow
const handleDeleteArrow = useCallback(
(id: string) => {
Expand All @@ -975,6 +991,7 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
// Open the shared context-menu host for a card.
const handleCardContextMenu = useCallback(
(e: React.MouseEvent, card: BoardCardData) => {
const layers = resolveTimelineLayers();
updateContextMenu({
position: { x: e.clientX, y: e.clientY },
content: (
Expand All @@ -996,9 +1013,9 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
action={() => handleDuplicateCard(card)}
/>
{(card.type ?? "text") === "text" &&
(orderedLayers.length > 0 ? (
(layers.length > 0 ? (
<ContextMenuSubmenu icon={ListTree} text={t("sendToTimeline")}>
{orderedLayers.map(({ layer, depth }) => (
{layers.map(({ layer, depth }) => (
<ContextMenuItem
key={layer.id}
icon={Layers}
Expand All @@ -1024,7 +1041,7 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
),
});
},
[updateContextMenu, t, handleChangeCardColor, handleDuplicateCard, handleSendToTimeline, handleDeleteCard, orderedLayers],
[updateContextMenu, t, handleChangeCardColor, handleDuplicateCard, handleSendToTimeline, handleDeleteCard, resolveTimelineLayers],
);

// Open the shared context-menu host for an arrow.
Expand Down
24 changes: 12 additions & 12 deletions components/dashboard/project/ExportProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,14 @@ import styles from "./ExportProject.module.css";
import optionCard from "./OptionCard.module.css";
import { importFilePopup } from "@src/lib/screenplay/popup";
import { UserContext } from "@src/context/UserContext";
import { getAdapterByExtension } from "@src/lib/adapters/registry";
import { getExportAdapter, getSupportedImportExtensions } from "@src/lib/adapters/registry";
import { ExportFormat } from "@src/lib/utils/enums";
import { BaseExportOptions } from "@src/lib/adapters/screenplay-adapter";
import Dropdown, { DropdownOption } from "@components/utils/Dropdown";
import { PDFExportOptions, RevisionExportMode } from "@src/lib/adapters/pdf/pdf-adapter";
import { ScriptioExportOptions } from "@src/lib/adapters/scriptio/scriptio-adapter";
import { importFileIntoProject, getSupportedImportExtensions } from "@src/lib/import/import-project";

export enum ExportFormat {
PDF = "pdf",
FOUNTAIN = "fountain",
FDX = "fdx",
SCRIPTIO = "scriptio",
}
import { TextExportOptions } from "@src/lib/adapters/text/text-adapter";
import { importFileIntoProject } from "@src/lib/import/import-project";

/** Which pages the user wants to export — a mutually exclusive choice. */
type PageMode = "all" | "ranges" | "revisions";
Expand Down Expand Up @@ -164,7 +159,7 @@ const ExportProject = () => {
onProgress: (p) => { console.log("progress: ", p);setProgress(p) },
};

const adapter = getAdapterByExtension(format);
const adapter = getExportAdapter(format);
if (!adapter) {
console.error("Unsupported file type");
return;
Expand Down Expand Up @@ -202,6 +197,9 @@ const ExportProject = () => {
pageSelection,
};
await adapter.export(ydoc, pdfOptions as BaseExportOptions);
} else if (format === ExportFormat.TEXT) {
const textOptions: TextExportOptions = { ...baseOptions, includeTitlePage };
await adapter.export(ydoc, textOptions as BaseExportOptions);
} else if (format === ExportFormat.SCRIPTIO) {
const scriptioOptions: ScriptioExportOptions = {
...baseOptions,
Expand All @@ -221,6 +219,7 @@ const ExportProject = () => {
{ value: ExportFormat.PDF, label: t("formatOptions.pdf") },
{ value: ExportFormat.FOUNTAIN, label: t("formatOptions.fountain") },
{ value: ExportFormat.FDX, label: t("formatOptions.fdx") },
{ value: ExportFormat.TEXT, label: t("formatOptions.text") },
{ value: ExportFormat.SCRIPTIO, label: t("formatOptions.scriptio") },
];

Expand Down Expand Up @@ -289,6 +288,7 @@ const ExportProject = () => {
{format === ExportFormat.PDF && t("formatHelp.pdf")}
{format === ExportFormat.FOUNTAIN && t("formatHelp.fountain")}
{format === ExportFormat.FDX && t("formatHelp.fdx")}
{format === ExportFormat.TEXT && t("formatHelp.text")}
{format === ExportFormat.SCRIPTIO && t("formatHelp.scriptio")}
</p>
</div>
Expand All @@ -309,8 +309,8 @@ const ExportProject = () => {
<span className={optionCard.optionTitle}>{t("notes")}</span>
</div>

{/* Title page is a PDF-only concept. */}
{format === ExportFormat.PDF && (
{/* Only the formats that lay out a title page of their own. */}
{(format === ExportFormat.PDF || format === ExportFormat.TEXT) && (
<div
className={`${optionCard.optionCard} ${styles.includeCard} ${includeTitlePage ? optionCard.active : ""}`}
onClick={() => setIncludeTitlePage(!includeTitlePage)}
Expand Down
67 changes: 45 additions & 22 deletions components/editor/DocumentEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ 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 { useViewModeScrollAnchor } from "@src/lib/editor/use-view-mode-scroll-anchor";
import { useKeyboardCaretVisibility } from "@src/lib/editor/use-keyboard-caret-visibility";
import { captureZoomAnchor, settleZoomAnchor } from "@src/lib/editor/zoom-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";
Expand Down Expand Up @@ -57,9 +59,18 @@ 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;
// view. Bounds are clamped so text stays legible.
//
// The floor also keeps WebKit's minimum-font-size clamp out of reach. WebKit
// multiplies computed font sizes by `zoom` and then floors the result at a
// rendered 9px (minimumLogicalFontSize; see the paged-mode rule in
// EditorPanel.module.css). The screenplay body is 12pt = 16px, so anything below
// 9/16 = 0.5625 gets floored — and a floored font wraps text differently from
// the canonical layout the pagination is measured against, which would put the
// page breaks shown on screen out of step with the document. 0.6 lands at 9.6px
// rendered and clears it. Levels stored by an older build are re-clamped on
// load, so a saved 0.5 comes back as 0.6.
const MIN_ZOOM = 0.6;
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
Expand Down Expand Up @@ -352,6 +363,16 @@ const DocumentEditorPanel = ({
onBeforeChange: onBeforeEndlessScrollChange,
});

// ---- Keyboard-safe writing area (phone) ----
// Reserve scroll room for the on-screen keyboard + format toolbar and keep the
// caret above them, so writing at the very end of the script isn't done blind
// behind the keyboard — see the hook for why neither half is automatic.
useKeyboardCaretVisibility({
container: containerEl,
editor,
enabled: isPhone && mobileEditMode,
});

// ---- 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
Expand All @@ -361,9 +382,10 @@ const DocumentEditorPanel = ({
}, []);

// 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
// the line the reader was on under the viewport centre as the page grows or
// shrinks around it (see zoom-scroll-anchor for why that is measured rather
// than computed from the zoom ratio). 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);
Expand All @@ -373,12 +395,16 @@ const DocumentEditorPanel = ({
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;
// Record what the reader is looking at BEFORE rescaling, while the
// outgoing layout is still measurable — the rescale itself can also clamp
// scrollTop/Left (zooming out shrinks the scrollable area), which would
// corrupt any position captured afterwards. Nothing to preserve on the
// first application (the document opens at the top), nor when this effect
// re-runs for a reason other than the level changing.
const anchor =
didInitZoomRef.current && prevZoomRef.current !== zoom
? captureZoomAnchor(container, editor?.view?.dom)
: null;
container.style.setProperty("--editor-user-zoom", `${zoom}`);

// First application (e.g. a zoom level restored from a previous session):
Expand All @@ -395,15 +421,12 @@ const DocumentEditorPanel = ({
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]);
if (!anchor) return;
// Scroll the anchored line back under the viewport centre, re-checking for
// a few frames while the rescaled layout settles.
return settleZoomAnchor(container, editor?.view?.dom, anchor);
}, [containerEl, zoom, isPhone, editor]);

// Persist the level and nudge resize-driven layout (e.g. the comment gutter,
// which only recomputes its icon coordinates on edits / resizes).
Expand Down Expand Up @@ -690,8 +713,8 @@ const DocumentEditorPanel = ({
editor.setOptions({
editorProps: {
// Re-apply the base contenteditable attributes: setOptions replaces
// editorProps wholesale, so without this the autocorrect/predictive
// suppression set at mount would be dropped for screenplay editors.
// editorProps wholesale, so without this the text-input traits set at
// mount (autocorrect, spellcheck) would be dropped for screenplay editors.
attributes: EDITOR_INPUT_ATTRIBUTES,
handleKeyDown(view: EditorView, event: KeyboardEvent) {
const selection = view.state.selection;
Expand Down
10 changes: 9 additions & 1 deletion components/editor/EditorPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,15 @@

.editor_wrapper {
max-width: none;
padding-bottom: 40%;
/* A fixed tail so the last page doesn't butt against the viewport edge,
* plus whatever the on-screen keyboard and the format toolbar riding on it
* currently cover (--keyboard-inset, set on the scroll container by
* useKeyboardCaretVisibility and inherited here; absent when no keyboard is
* up). Without that second term the end of the script has nothing below it
* to scroll up: the shell is pinned to the layout viewport, so the keyboard
* simply sits over the last lines and they can never be brought into view
* while writing them. */
padding-bottom: calc(40% + var(--keyboard-inset, 0px));
}

/* ENDLESS mode (default on phone): reflow the screenplay into the viewport at
Expand Down
3 changes: 2 additions & 1 deletion components/projects/ProjectPageContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
ExtendedProjectMembershipPayload,
} from "@src/lib/utils/hooks";
import { join } from "@src/lib/utils/misc";
import { importFileAsProject, getSupportedImportExtensions } from "@src/lib/import/import-project";
import { importFileAsProject } from "@src/lib/import/import-project";
import { getSupportedImportExtensions } from "@src/lib/adapters/registry";
import { useAppNavigation } from "@src/lib/utils/navigation";
import { FileDown, Plus, X } from "lucide-react";
import { useTranslations } from "next-intl";
Expand Down
10 changes: 8 additions & 2 deletions components/utils/ContextMenu.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
display: flex;
flex-direction: column;
width: 220px;
/* Never taller/wider than the viewport — long menus scroll instead of
overflowing off-screen (the JS clamps the top/left to match). */
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);

z-index: 1000;
position: fixed;
/* Hidden until the component has measured and placed it (before paint). */
visibility: hidden;
padding-block: 6px;
/* Horizontal padding so hovered items inset from the menu's left/right edges. */
padding-inline: 6px;
Expand Down Expand Up @@ -64,9 +70,9 @@
color: var(--secondary-text);
}

/* Flyout panel — reuses .menu, but capped in height so long layer lists scroll. */
/* Flyout panel — reuses .menu, but capped shorter so long layer lists scroll. */
.submenu_panel {
max-height: 320px;
max-height: min(320px, calc(100vh - 16px));
}

.menu_separator {
Expand Down
Loading
Loading